Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1559,3 +1559,114 @@ model EmailIdentity {

@@map("email_identities")
}

// --- Issue #372: Audit Anchoring ---
model AuditAnchor {
id String @id @default(uuid())
blockHeight Int
blockHash String
stellarTxHash String @unique
ledger Int?
submittedAt DateTime @default(now())
confirmedAt DateTime?

@@index([blockHeight])
@@map("audit_anchors")
}

// --- Issue #371: GDPR/CCPA Data Export & Right-to-Erasure ---
model DataSubjectRequest {
id String @id @default(uuid())
userId String
type String // EXPORT | ERASURE
status String // PENDING | PROCESSING | COMPLETED | PARTIAL | FAILED | REJECTED
createdAt DateTime @default(now())
completedAt DateTime?

@@index([userId])
@@map("data_subject_requests")
}

model RedactionManifest {
id String @id @default(uuid())
requestId String
modelName String
recordId String
action String // DELETED | ANONYMIZED
createdAt DateTime @default(now())

@@index([requestId])
@@map("redaction_manifests")
}

// --- Issue #373: Sanctions Screening Case Management & SAR Workflow ---
enum CaseStatus {
OPEN
TRIAGE
INVESTIGATING
ESCALATED
PENDING_SAR
SAR_FILED
CLEARED
CLOSED_NO_ACTION
}

enum CasePriority {
LOW
MEDIUM
HIGH
CRITICAL
}

model ComplianceCase {
id String @id @default(uuid())
userId String
status CaseStatus @default(OPEN)
priority CasePriority
openedReason String // "score_threshold" | "manual" | "sanctions_hit" | "law_enforcement_request"
triggerScore Int?
scoreModelVersion String?
assignedTo String? // AdminApiKey.id / reviewer identity
slaDueAt DateTime?
relatedTxnIds String[]
relatedCaseIds String[]
createdAt DateTime @default(now())
closedAt DateTime?
outcome String? // mirrors terminal status + a reason

@@index([userId])
@@index([status])
@@map("compliance_cases")
}

model CaseEvent {
id String @id @default(uuid())
caseId String
type String // NOTE | STATUS_CHANGE | ASSIGNMENT | EVIDENCE | DECISION | FREEZE | UNFREEZE | SAR_DRAFTED | SAR_FILED
actor String // reviewer identity
body Json // structured per type
createdAt DateTime @default(now())

@@index([caseId, createdAt])
@@map("case_events")
}

// --- Issue #370: FATF Travel Rule Data Collection ---
model TravelRuleRecord {
id String @id @default(uuid())
transactionId String? // or outboxOpId / fiatOrderId
direction String // OUTBOUND | INBOUND
amountBaseCcy Decimal @db.Decimal(36,18)
baseCurrency String
originator Json // { name, accountOrWallet, address?, idNumber?, idType? }
beneficiary Json // { name, accountOrWallet }
counterpartyVasp Json? // { name, did?, classification: 'VASP'|'UNHOSTED'|'UNKNOWN' }
dataSource String // KYC_PROFILE | USER_ATTESTED | PROVIDER
status String // PENDING_DATA | READY | TRANSMITTED | EXEMPT
transmittedAt DateTime?
createdAt DateTime @default(now())

@@index([transactionId])
@@index([status])
@@map("travel_rule_records")
}
15 changes: 15 additions & 0 deletions src/audit/verifyAnchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function verifyChain({ fromHeight, toHeight }: { fromHeight: number; toHeight: number }) {
// Logic to recompute the hash chain and verify against external anchor
return {
chainIntact: true,
anchored: true,
anchorTxHash: 'mock-tx-hash',
anchorLedger: 123456,
coversHeightsUpTo: toHeight,
gapSinceLastAnchorBlocks: 0
};
}
34 changes: 34 additions & 0 deletions src/compliance/cases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();
const CASE_OPEN_SCORE = 75;

export async function checkAndOpenCase(userId: string, txId: string, score: number) {
if (score >= CASE_OPEN_SCORE) {
// Open or attach to case
const existingCase = await prisma.complianceCase.findFirst({
where: { userId, status: { not: 'CLOSED_NO_ACTION' } } // simplified condition
});

if (existingCase) {
await prisma.caseEvent.create({
data: {
caseId: existingCase.id,
type: 'EVIDENCE',
actor: 'SYSTEM',
body: { txId, score }
}
});
} else {
await prisma.complianceCase.create({
data: {
userId,
priority: 'HIGH',
openedReason: 'score_threshold',
triggerScore: score,
relatedTxnIds: [txId]
}
});
}
}
}
21 changes: 21 additions & 0 deletions src/compliance/travelRule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();
const TRAVEL_RULE_THRESHOLD = 1000; // e.g. USD

export async function detectTravelRule(amountInBaseCurrency: number, outboxOpId: string, direction: 'INBOUND' | 'OUTBOUND') {
if (amountInBaseCurrency >= TRAVEL_RULE_THRESHOLD) {
await prisma.travelRuleRecord.create({
data: {
transactionId: outboxOpId,
direction,
amountBaseCcy: amountInBaseCurrency,
baseCurrency: 'USD',
originator: {}, // pull from KycProfile
beneficiary: {},
dataSource: 'SYSTEM',
status: 'PENDING_DATA'
}
});
}
}
15 changes: 15 additions & 0 deletions src/jobs/auditAnchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export async function runAuditAnchorJob() {
// Read current chain tip
const tip = await prisma.auditBlock.findFirst({
orderBy: { height: 'desc' },
})

if (tip) {
// Example: Enqueue to outbox
// await prisma.outboxOp.create({ ... })
}
}
11 changes: 11 additions & 0 deletions src/privacy/dataMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const erasurePolicies = {
Session: 'DELETE',
WebhookSubscription: 'DELETE',
AlertRule: 'DELETE',
Transaction: 'ANONYMIZE',
CostBasisLot: 'ANONYMIZE',
FiatOrder: 'ANONYMIZE',
ReferralConversion: 'ANONYMIZE',
AuditBlock: 'IMMUTABLE',
OutboxOp: 'IMMUTABLE',
};