From 264af9c6079226852ffc443252f7776310fb1c5b Mon Sep 17 00:00:00 2001 From: Chijioke Joseph Date: Fri, 9 Oct 2026 11:07:13 +0100 Subject: [PATCH] feat(anchor): in-process Stellar signer; format code; fix permitted() and contact email The stellar-anchor job required an external signing service that did not exist. Add src/anchor.ts: manifest builders, the sidecar client, and an in-process Soroban signer that calls the registry's anchor function, selected from the environment. Share one implementation for evidence and attestations. Format all source with Prettier (no behavior change), deny role names that resolve through Object.prototype, and fix the misspelled security contact. Tests: 10 to 59. Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 20 +- .github/workflows/ci.yml | 2 + .prettierignore | 4 + .prettierrc.json | 7 + CHANGELOG.md | 29 ++ README.md | 18 +- SECURITY.md | 2 +- package.json | 33 +- pnpm-lock.yaml | 10 + scripts/seed.ts | 246 ++++++---- src/anchor.ts | 313 ++++++++++++ src/database.ts | 8 +- src/domain.ts | 146 ++++-- src/main.ts | 984 ++++++++++++++++++++++++++++---------- src/storage.ts | 74 ++- src/worker.ts | 460 +++++++++++++++++- test/anchor.test.ts | 365 ++++++++++++++ test/domain-extra.test.ts | 260 ++++++++++ test/domain.test.ts | 58 ++- test/integrity.spec.ts | 39 +- 20 files changed, 2609 insertions(+), 469 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 CHANGELOG.md create mode 100644 src/anchor.ts create mode 100644 test/anchor.test.ts create mode 100644 test/domain-extra.test.ts diff --git a/.env.example b/.env.example index 4661b0b..fbcdfda 100644 --- a/.env.example +++ b/.env.example @@ -19,13 +19,21 @@ REDIS_URL=redis://localhost:6379 AI_URL=http://localhost:8000 AI_SERVICE_TOKEN=change-me-to-a-32-char-plus-random-secret -# ─── Stellar ──────────────────────────────────────────────────────────────── -# STELLAR_NETWORK=testnet -# STELLAR_RPC_URL=https://soroban-testnet.stellar.org +# ─── Stellar anchoring ────────────────────────────────────────────────────── +# Evidence is anchored on the evidence_anchor_registry contract. Pick ONE way to sign. +# +# Option 1 — in-process signer (the worker signs and submits itself): +# STELLAR_SIGNER_SECRET= # testnet key. Must be the registry's controller. Never commit it. # STELLAR_EVIDENCE_ANCHOR_CONTRACT= -# STELLAR_CUSTODY_CONTRACT= -# STELLAR_SIGNER_SECRET= # never commit this -# STELLAR_ANCHOR_SERVICE_URL= # optional sidecar signing service +# STELLAR_RPC_URL=https://soroban-testnet.stellar.org +# STELLAR_NETWORK=testnet # only testnet is supported +# +# Option 2 — external signing service (takes priority if set): +# STELLAR_ANCHOR_SERVICE_URL= +# STELLAR_SIGNER_TOKEN= +# +# With neither configured, anchor jobs stay pending and are retried. +# STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org # used to confirm anchors # ─── Malware scanning (ClamAV) ────────────────────────────────────────────── CLAMAV_HOST=localhost diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14c382c..3e97a36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,5 +18,7 @@ jobs: - run: pnpm prisma generate env: DATABASE_URL: postgresql://user:pass@localhost:5432/ci + - run: pnpm format:check + - run: pnpm typecheck - run: pnpm build - run: pnpm test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..00610e2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +prisma/migrations +pnpm-lock.yaml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..bed7828 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": true, + "printWidth": 100, + "trailingComma": "all", + "arrowParens": "avoid" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5e14ee6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +## 0.2.0 — 2026-10-09 + +### Added +- **Stellar anchor signer** (`src/anchor.ts`). The worker can now build, sign, + and submit the `anchor` call on the evidence anchor registry itself, using + `STELLAR_SIGNER_SECRET` and `STELLAR_EVIDENCE_ANCHOR_CONTRACT`. Previously + anchoring required an external signing service that did not exist, so the + `stellar-anchor` job could never complete. The external service is still + supported and takes priority when configured. Retries are safe because the + contract treats an identical anchor as a no-op. +- 49 new tests (10 to 59): manifests, both anchor clients, environment + selection, wallet-signature verification, role permissions, hashing, and + claim validation. +- Prettier, with `format`, `format:check`, and a CI step. + +### Fixed +- `permitted()` threw a `TypeError` for role names such as `__proto__` or + `constructor`, which resolved through `Object.prototype`. It now denies them. +- The security contact address in the README and SECURITY.md had a typo + (`gmaill.com`), so vulnerability reports could not be delivered. + +### Changed +- Reformatted all source. The worker and API were written as single very long + lines (up to 800 characters), which made review impractical. No behavior + change: the same tests passed before and after. +- The duplicated evidence and attestation anchor code in the worker now shares + one implementation. diff --git a/README.md b/README.md index 689d2cc..c67d145 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,23 @@ pnpm worker # background job worker, separate process ``` ```bash -pnpm build # tsc -pnpm test # unit tests (node:test) +pnpm build # tsc +pnpm test # unit tests (node:test) pnpm typecheck +pnpm format:check # prettier ``` +## Stellar anchoring + +After evidence is processed, the worker records a SHA-256 of a manifest (digests and opaque references only, never evidence content) on the `evidence_anchor_registry` contract from [`afterprint-contracts`](https://github.com/Afterprint/afterprint-contracts). Choose a signing method in `.env`: + +| Method | Set | Notes | +|---|---|---| +| In-process | `STELLAR_SIGNER_SECRET`, `STELLAR_EVIDENCE_ANCHOR_CONTRACT` | The worker builds, signs, and submits the Soroban transaction. The key must be the registry's controller. Testnet only. | +| Signing service | `STELLAR_ANCHOR_SERVICE_URL`, `STELLAR_SIGNER_TOKEN` | Takes priority when set. | + +With neither set, anchor jobs stay pending and retry, so nothing is lost. Retrying is safe: the contract treats an identical anchor as a no-op and rejects a conflicting one. See [`src/anchor.ts`](./src/anchor.ts). + ## Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) for the workflow and coding standards, and [SECURITY.md](./SECURITY.md) to report a vulnerability privately. @@ -63,7 +75,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the workflow and coding standards, | | | |---|---| | **GitHub** | [@helloworld1-star](https://github.com/helloworld1-star) | -| **Email** | chijiokejoseph20242@gmaill.com | +| **Email** | devt14985@gmail.com | --- diff --git a/SECURITY.md b/SECURITY.md index 4386435..d29952c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ This service has **not** undergone an independent security audit. It's deployed ## Reporting a vulnerability -Email **chijiokejoseph20242@gmaill.com** with a description of the issue and reproduction steps. Do not open a public GitHub issue for security vulnerabilities. +Email **devt14985@gmail.com** with a description of the issue and reproduction steps. Do not open a public GitHub issue for security vulnerabilities. You should get an acknowledgement within 5 business days. Once a fix is available, we'll coordinate disclosure timing with you. diff --git a/package.json b/package.json index 4d1054a..1a9f0fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "afterprint-api", - "version": "0.1.0", + "version": "0.2.0", "private": true, "packageManager": "pnpm@10.17.1", "scripts": { @@ -13,34 +13,37 @@ "db:generate": "prisma generate", "db:migrate": "prisma migrate deploy", "seed": "node --import tsx scripts/seed.ts", - "worker": "node dist/worker.js" + "worker": "node dist/worker.js", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.ts\"" }, "dependencies": { + "@aws-sdk/client-s3": "^3.890.0", + "@aws-sdk/s3-request-presigner": "^3.890.0", + "@fastify/cookie": "^11.0.0", + "@fastify/rate-limit": "^10.0.0", + "@fastify/static": "^10.1.2", "@nestjs/common": "^12.0.0", "@nestjs/core": "^12.0.0", "@nestjs/platform-fastify": "^12.0.0", "@nestjs/swagger": "^12.0.0", - "@fastify/cookie": "^11.0.0", - "@fastify/rate-limit": "^10.0.0", - "@fastify/static": "^10.1.2", - "@prisma/client": "7.10.0", "@prisma/adapter-pg": "7.10.0", - "pg": "^8.16.0", + "@prisma/client": "7.10.0", + "@stellar/stellar-sdk": "^17.0.0", "bullmq": "^6.0.0", "ioredis": "^5.7.0", - "@aws-sdk/client-s3": "^3.890.0", - "@aws-sdk/s3-request-presigner": "^3.890.0", - "@stellar/stellar-sdk": "^17.0.0", - "zod": "^4.1.0", + "jose": "^6.1.0", + "pg": "^8.16.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", - "jose": "^6.1.0" + "zod": "^4.1.0" }, "devDependencies": { + "@types/node": "^24.0.0", + "@types/pg": "^8.15.0", + "prettier": "^3.9.9", "prisma": "7.10.0", "tsx": "^4.20.0", - "typescript": "^5.9.2", - "@types/node": "^24.0.0", - "@types/pg": "^8.15.0" + "typescript": "^5.9.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfb8763..03327fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@types/pg': specifier: ^8.15.0 version: 8.23.1 + prettier: + specifier: ^3.9.9 + version: 3.9.9 prisma: specifier: 7.10.0 version: 7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3) @@ -1418,6 +1421,11 @@ packages: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} + engines: {node: '>=14'} + hasBin: true + prisma@7.10.0: resolution: {integrity: sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw==} engines: {node: ^20.19 || ^22.12 || >=24.0} @@ -3076,6 +3084,8 @@ snapshots: postgres@3.4.7: {} + prettier@3.9.9: {} + prisma@7.10.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3): dependencies: '@prisma/config': 7.10.0 diff --git a/scripts/seed.ts b/scripts/seed.ts index 97628b3..3ee2099 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -3,131 +3,203 @@ * Run: pnpm tsx scripts/seed.ts * All data is fabricated. Never use real case evidence. */ -import {PrismaClient} from '@prisma/client'; -import {createHash,randomBytes} from 'node:crypto'; -const db=new PrismaClient(); -function sha256(s:string){return createHash('sha256').update(s).digest('hex')} +import { PrismaClient } from '@prisma/client'; +import { createHash, randomBytes } from 'node:crypto'; +const db = new PrismaClient(); +function sha256(s: string) { + return createHash('sha256').update(s).digest('hex'); +} // Synthetic Stellar public keys (G... format, 56 chars, valid base32) -const SYNTH_KEYS={ +const SYNTH_KEYS = { admin: 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', investigator: 'GBOVKZBEM2YYLOCDCUXJ4IMRKHN4LCJAE7WEAEA2KF562XFAGDBOB64T', - analyst: 'GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGZAK7VQO8N7ZTO5KYI4AF', - reviewer: 'GDFOHLMRJMAPFLY83ZJKJR3CGJMPKNG7JIFUVDNUJVVV4HQ7QW3BRNZD', + analyst: 'GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGZAK7VQO8N7ZTO5KYI4AF', + reviewer: 'GDFOHLMRJMAPFLY83ZJKJR3CGJMPKNG7JIFUVDNUJVVV4HQ7QW3BRNZD', }; -async function main(){ +async function main() { console.log('Seeding synthetic development data…'); // ── Organization ────────────────────────────────────────────────────────── - const org=await db.organization.upsert({ - where:{id:'org-synthetic-001'}, - update:{}, - create:{id:'org-synthetic-001',name:'Synthetic Investigations Ltd'}, + const org = await db.organization.upsert({ + where: { id: 'org-synthetic-001' }, + update: {}, + create: { id: 'org-synthetic-001', name: 'Synthetic Investigations Ltd' }, }); // ── Users (wallet addresses) ─────────────────────────────────────────────── - const admin=await db.user.upsert({ - where:{stellarPublicKey:SYNTH_KEYS.admin}, - update:{}, - create:{id:'user-admin-001',organizationId:org.id,stellarPublicKey:SYNTH_KEYS.admin,name:'Lead Investigator'}, + const admin = await db.user.upsert({ + where: { stellarPublicKey: SYNTH_KEYS.admin }, + update: {}, + create: { + id: 'user-admin-001', + organizationId: org.id, + stellarPublicKey: SYNTH_KEYS.admin, + name: 'Lead Investigator', + }, }); - const investigator=await db.user.upsert({ - where:{stellarPublicKey:SYNTH_KEYS.investigator}, - update:{}, - create:{id:'user-inv-001',organizationId:org.id,stellarPublicKey:SYNTH_KEYS.investigator,name:'Field Investigator'}, + const investigator = await db.user.upsert({ + where: { stellarPublicKey: SYNTH_KEYS.investigator }, + update: {}, + create: { + id: 'user-inv-001', + organizationId: org.id, + stellarPublicKey: SYNTH_KEYS.investigator, + name: 'Field Investigator', + }, }); - const analyst=await db.user.upsert({ - where:{stellarPublicKey:SYNTH_KEYS.analyst}, - update:{}, - create:{id:'user-ana-001',organizationId:org.id,stellarPublicKey:SYNTH_KEYS.analyst,name:'Forensic Analyst'}, + const analyst = await db.user.upsert({ + where: { stellarPublicKey: SYNTH_KEYS.analyst }, + update: {}, + create: { + id: 'user-ana-001', + organizationId: org.id, + stellarPublicKey: SYNTH_KEYS.analyst, + name: 'Forensic Analyst', + }, }); - const reviewer=await db.user.upsert({ - where:{stellarPublicKey:SYNTH_KEYS.reviewer}, - update:{}, - create:{id:'user-rev-001',organizationId:org.id,stellarPublicKey:SYNTH_KEYS.reviewer,name:'Legal Reviewer'}, + const reviewer = await db.user.upsert({ + where: { stellarPublicKey: SYNTH_KEYS.reviewer }, + update: {}, + create: { + id: 'user-rev-001', + organizationId: org.id, + stellarPublicKey: SYNTH_KEYS.reviewer, + name: 'Legal Reviewer', + }, }); // ── Case ────────────────────────────────────────────────────────────────── - const existingCase=await db.case.findFirst({where:{id:'case-synthetic-001'}}); - const syntheticCase=existingCase||await db.case.create({ - data:{ - id:'case-synthetic-001', - organizationId:org.id, - ref:'AP-2026-001', - title:'North Depot Delivery Incident — Synthetic', - classification:'CONFIDENTIAL', - status:'OPEN', - members:{ - create:[ - {userId:admin.id,role:'CASE_ADMIN'}, - {userId:investigator.id,role:'INVESTIGATOR'}, - {userId:analyst.id,role:'FORENSIC_ANALYST'}, - {userId:reviewer.id,role:'LEGAL_REVIEWER'}, - ], + const existingCase = await db.case.findFirst({ where: { id: 'case-synthetic-001' } }); + const syntheticCase = + existingCase || + (await db.case.create({ + data: { + id: 'case-synthetic-001', + organizationId: org.id, + ref: 'AP-2026-001', + title: 'North Depot Delivery Incident — Synthetic', + classification: 'CONFIDENTIAL', + status: 'OPEN', + members: { + create: [ + { userId: admin.id, role: 'CASE_ADMIN' }, + { userId: investigator.id, role: 'INVESTIGATOR' }, + { userId: analyst.id, role: 'FORENSIC_ANALYST' }, + { userId: reviewer.id, role: 'LEGAL_REVIEWER' }, + ], + }, }, - }, - }); + })); // ── Evidence items (metadata only — no real files) ───────────────────────── - const evidenceSeeds=[ - {id:'ev-001',ref:'E-CCTV-001',title:'North entrance CCTV — Camera 04',source:'Depot security system extract — synthetic',type:'VIDEO',mimeType:'video/mp4'}, - {id:'ev-002',ref:'E-AUDIO-001',title:'Dispatch radio recording',source:'Fleet operations — synthetic',type:'AUDIO',mimeType:'audio/wav'}, - {id:'ev-003',ref:'E-DOC-001',title:'Delivery manifesto — printed report',source:'Logistics provider — synthetic',type:'DOCUMENT',mimeType:'application/pdf'}, - {id:'ev-004',ref:'E-GPS-001',title:'Vehicle telemetry export',source:'Fleet management system — synthetic',type:'DOCUMENT',mimeType:'text/csv'}, + const evidenceSeeds = [ + { + id: 'ev-001', + ref: 'E-CCTV-001', + title: 'North entrance CCTV — Camera 04', + source: 'Depot security system extract — synthetic', + type: 'VIDEO', + mimeType: 'video/mp4', + }, + { + id: 'ev-002', + ref: 'E-AUDIO-001', + title: 'Dispatch radio recording', + source: 'Fleet operations — synthetic', + type: 'AUDIO', + mimeType: 'audio/wav', + }, + { + id: 'ev-003', + ref: 'E-DOC-001', + title: 'Delivery manifesto — printed report', + source: 'Logistics provider — synthetic', + type: 'DOCUMENT', + mimeType: 'application/pdf', + }, + { + id: 'ev-004', + ref: 'E-GPS-001', + title: 'Vehicle telemetry export', + source: 'Fleet management system — synthetic', + type: 'DOCUMENT', + mimeType: 'text/csv', + }, ]; - for(const seed of evidenceSeeds){ - const existing=await db.evidenceItem.findFirst({where:{id:seed.id}}); - if(existing)continue; + for (const seed of evidenceSeeds) { + const existing = await db.evidenceItem.findFirst({ where: { id: seed.id } }); + if (existing) continue; // Create a synthetic version with a deterministic fake hash - const fakeContent=`SYNTHETIC_EVIDENCE:${seed.id}:${seed.title}`; - const fakeSha=sha256(fakeContent); - const fakeObjectKey=`originals/case-synthetic-001/${seed.id}/synthetic-object`; - const version=await db.evidenceVersion.create({ - data:{ - id:`ver-${seed.id}`, - evidenceId:seed.id, - objectKey:fakeObjectKey, - objectVersionId:`synth-version-${seed.id}`, - sha256:fakeSha, - size:1024, - mimeType:seed.mimeType, + const fakeContent = `SYNTHETIC_EVIDENCE:${seed.id}:${seed.title}`; + const fakeSha = sha256(fakeContent); + const fakeObjectKey = `originals/case-synthetic-001/${seed.id}/synthetic-object`; + const version = await db.evidenceVersion.create({ + data: { + id: `ver-${seed.id}`, + evidenceId: seed.id, + objectKey: fakeObjectKey, + objectVersionId: `synth-version-${seed.id}`, + sha256: fakeSha, + size: 1024, + mimeType: seed.mimeType, }, }); await db.evidenceItem.create({ - data:{ - id:seed.id, - caseId:syntheticCase.id, - ref:seed.ref, - title:seed.title, - source:seed.source, - type:seed.type, - custodianId:admin.id, - status:'PROCESSED', + data: { + id: seed.id, + caseId: syntheticCase.id, + ref: seed.ref, + title: seed.title, + source: seed.source, + type: seed.type, + custodianId: admin.id, + status: 'PROCESSED', }, }); // Synthetic custody chain - const importHash=sha256(''+sha256(JSON.stringify({id:`synth-${seed.id}-import`,versionId:version.id,actor:admin.id,type:'IMPORTED',reason:'Synthetic seed import',occurredAt:'2026-09-08T10:00:00.000Z'}))); + const importHash = sha256( + '' + + sha256( + JSON.stringify({ + id: `synth-${seed.id}-import`, + versionId: version.id, + actor: admin.id, + type: 'IMPORTED', + reason: 'Synthetic seed import', + occurredAt: '2026-09-08T10:00:00.000Z', + }), + ), + ); await db.custodyEvent.create({ - data:{ - versionId:version.id, - actor:admin.id, - type:'IMPORTED', - reason:'Synthetic seed import', - occurredAt:new Date('2026-09-08T10:00:00Z'), - previousHash:'', - eventHash:importHash, - idempotencyKey:`seed-import-${seed.id}`, + data: { + versionId: version.id, + actor: admin.id, + type: 'IMPORTED', + reason: 'Synthetic seed import', + occurredAt: new Date('2026-09-08T10:00:00Z'), + previousHash: '', + eventHash: importHash, + idempotencyKey: `seed-import-${seed.id}`, }, }); } console.log('✓ Organization:', org.name); - console.log('✓ Users seeded:', [admin,investigator,analyst,reviewer].map(u=>u.name).join(', ')); + console.log( + '✓ Users seeded:', + [admin, investigator, analyst, reviewer].map(u => u.name).join(', '), + ); console.log('✓ Case:', syntheticCase.title); console.log('✓ Evidence items seeded:', evidenceSeeds.length); console.log('\nSeed complete. This is synthetic data — no real evidence files or proofs.'); } -main().catch(e=>{console.error(e);process.exit(1)}).finally(()=>db.$disconnect()); +main() + .catch(e => { + console.error(e); + process.exit(1); + }) + .finally(() => db.$disconnect()); diff --git a/src/anchor.ts b/src/anchor.ts new file mode 100644 index 0000000..2519dd1 --- /dev/null +++ b/src/anchor.ts @@ -0,0 +1,313 @@ +/** + * Stellar anchoring. + * + * Turns an evidence version (or an attestation) into a manifest, hashes it, and + * records the hash in the `evidence_anchor_registry` contract so anyone can + * verify it independently of Afterprint's servers. + * + * Two ways to sign and submit are supported, chosen from the environment: + * + * - **Sidecar**: `STELLAR_ANCHOR_SERVICE_URL` points at an external signing + * service. This is the original behavior. + * - **In-process**: `STELLAR_SIGNER_SECRET` and `STELLAR_EVIDENCE_ANCHOR_CONTRACT` + * let the worker build, sign, and submit the Soroban transaction itself. + * + * Retrying is always safe. The registry treats an identical repeat as a no-op + * and rejects a conflicting one, so a timed-out submission can simply be sent + * again with the same values. + */ + +import { + BASE_FEE, + Contract, + Keypair, + Networks, + TransactionBuilder, + nativeToScVal, + rpc, +} from '@stellar/stellar-sdk'; +import { canonical, sha256 } from './domain'; + +/* -------------------------------------------------------------------------- */ +/* Manifests */ +/* -------------------------------------------------------------------------- */ + +export interface EvidenceManifestInput { + caseId: string; + evidenceId: string; + sha256: string; + size: number; + mimeType: string; + importedAt: Date; + /** The evidence source description. Only its hash is anchored. */ + source: string; + /** Hash of the latest custody event, or empty if there is none yet. */ + custodyHeadHash: string; +} + +/** + * The document whose hash is anchored for an evidence version. It contains + * digests and opaque references only, never evidence content or case names. + */ +export function evidenceManifest(input: EvidenceManifestInput) { + return { + schemaVersion: '1.0', + caseRef: sha256(input.caseId), + evidenceRef: sha256(input.evidenceId), + version: 1, + sha256: input.sha256, + sizeBytes: input.size, + mimeType: input.mimeType, + importedAt: input.importedAt.toISOString(), + sourceMetadataHash: sha256(input.source), + custodyHeadHash: input.custodyHeadHash, + }; +} + +export interface AttestationManifestInput { + caseId: string; + attestationId: string; + versionId: string; + statementHash: string; +} + +/** The document whose hash is anchored for an attestation. */ +export function attestationManifest(input: AttestationManifestInput) { + return { + schemaVersion: '1.0', + caseRef: sha256(input.caseId), + attestationRef: sha256(input.attestationId), + subjectRef: sha256(input.versionId), + statementHash: input.statementHash, + }; +} + +/** SHA-256 of the canonical (sorted-key) JSON form of a manifest. */ +export function manifestHash(manifest: object): string { + return sha256(canonical(manifest)); +} + +/* -------------------------------------------------------------------------- */ +/* Anchor clients */ +/* -------------------------------------------------------------------------- */ + +export interface AnchorRequest { + /** 64 hex characters: the reference the anchor is stored under. */ + evidenceVersionRef: string; + /** 64 hex characters: SHA-256 of the manifest. */ + manifestHash: string; + /** Stable per logical anchor, so a sidecar can de-duplicate retries. */ + idempotencyKey: string; +} + +export interface AnchorResult { + transactionHash: string; + status: 'CONFIRMED'; +} + +export interface AnchorClient { + anchor(request: AnchorRequest): Promise; +} + +/** Thrown when no signing method is configured. The job stays pending. */ +export class AnchorNotConfiguredError extends Error { + constructor(message = 'Stellar signing is not configured; anchor remains pending') { + super(message); + this.name = 'AnchorNotConfiguredError'; + } +} + +const HEX_32_BYTES = /^[0-9a-f]{64}$/; + +function assertHash32(name: string, value: string) { + if (!HEX_32_BYTES.test(value)) { + throw new Error(`${name} must be 64 lowercase hex characters`); + } +} + +/* ---- Sidecar ---------------------------------------------------------------- */ + +export interface HttpAnchorOptions { + url: string; + token: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +/** Sends the anchor to an external signing service. */ +export function httpAnchorClient(options: HttpAnchorOptions): AnchorClient { + const doFetch = options.fetchImpl ?? fetch; + return { + async anchor(request) { + assertHash32('evidenceVersionRef', request.evidenceVersionRef); + assertHash32('manifestHash', request.manifestHash); + + const response = await doFetch(options.url, { + method: 'POST', + headers: { + Authorization: `Bearer ${options.token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': request.idempotencyKey, + }, + body: JSON.stringify({ + evidenceVersionRef: request.evidenceVersionRef, + manifestHash: request.manifestHash, + }), + signal: AbortSignal.timeout(options.timeoutMs ?? 60_000), + }); + if (!response.ok) throw new Error('Anchor service rejected request'); + + const result = (await response.json()) as { transactionHash?: string; status?: string }; + if (!result.transactionHash || result.status !== 'CONFIRMED') { + throw new Error('Anchor is not confirmed'); + } + return { transactionHash: result.transactionHash, status: 'CONFIRMED' }; + }, + }; +} + +/* ---- In-process Soroban signer ---------------------------------------------- */ + +/** The slice of the Soroban RPC server this module uses, so tests can fake it. */ +export interface SorobanServer { + getAccount(address: string): Promise; + simulateTransaction(tx: any): Promise; + sendTransaction(tx: any): Promise<{ status: string; hash: string; errorResult?: unknown }>; + getTransaction(hash: string): Promise<{ status: string }>; +} + +export interface SorobanAnchorOptions { + contractId: string; + signer: Keypair; + server: SorobanServer; + networkPassphrase?: string; + /** How long to wait for the transaction to be included. Default 60 seconds. */ + confirmTimeoutMs?: number; + /** How often to ask. Default 2 seconds. */ + pollIntervalMs?: number; + /** Replaceable in tests. */ + assemble?: (tx: any, simulation: any) => { build(): any }; + sleep?: (ms: number) => Promise; + now?: () => number; +} + +/** + * The reference stored alongside each anchor to say who submitted it: the + * SHA-256 of the signer's public key, so the key itself is not repeated. + */ +export function submitterRef(signer: Keypair): string { + return sha256(signer.publicKey()); +} + +const bytes32 = (hex: string) => nativeToScVal(Buffer.from(hex, 'hex'), { type: 'bytes' }); + +/** + * Builds, signs, and submits `anchor(evidence_version_ref, manifest_hash, + * submitter_ref)` on the registry, then waits for the result. The signer must be + * the registry's controller or the call is rejected on-chain. + */ +export function sorobanAnchorClient(options: SorobanAnchorOptions): AnchorClient { + const passphrase = options.networkPassphrase ?? Networks.TESTNET; + const assemble = options.assemble ?? ((tx, sim) => rpc.assembleTransaction(tx, sim)); + const sleep = options.sleep ?? ((ms: number) => new Promise(r => setTimeout(r, ms))); + const now = options.now ?? Date.now; + const timeoutMs = options.confirmTimeoutMs ?? 60_000; + const pollMs = options.pollIntervalMs ?? 2_000; + const contract = new Contract(options.contractId); + + return { + async anchor(request) { + assertHash32('evidenceVersionRef', request.evidenceVersionRef); + assertHash32('manifestHash', request.manifestHash); + + const account = await options.server.getAccount(options.signer.publicKey()); + const transaction = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: passphrase, + }) + .addOperation( + contract.call( + 'anchor', + bytes32(request.evidenceVersionRef), + bytes32(request.manifestHash), + bytes32(submitterRef(options.signer)), + ), + ) + .setTimeout(60) + .build(); + + const simulation = await options.server.simulateTransaction(transaction); + if (rpc.Api.isSimulationError(simulation)) { + throw new Error(`Anchor simulation failed: ${simulation.error}`); + } + + const prepared = assemble(transaction, simulation).build(); + prepared.sign(options.signer); + + const sent = await options.server.sendTransaction(prepared); + if (sent.status === 'ERROR') + throw new Error('Anchor transaction was rejected by the network'); + + // PENDING and DUPLICATE both mean "in flight"; wait for the outcome. + const deadline = now() + timeoutMs; + for (;;) { + const outcome = await options.server.getTransaction(sent.hash); + if (outcome.status === 'SUCCESS') { + return { transactionHash: sent.hash, status: 'CONFIRMED' }; + } + if (outcome.status === 'FAILED') throw new Error('Anchor transaction failed on-chain'); + if (now() >= deadline) { + throw new Error(`Anchor transaction ${sent.hash} not confirmed in time; retry is safe`); + } + await sleep(pollMs); + } + }, + }; +} + +/* ---- Selection from the environment ---------------------------------------- */ + +type Env = Record; + +/** + * Pick a signing method from configuration. Throws `AnchorNotConfiguredError` + * when neither is set, so the queue keeps the job and retries later. + */ +export function createAnchorClient(env: Env = process.env): AnchorClient { + if (env.STELLAR_ANCHOR_SERVICE_URL) { + if (!env.STELLAR_SIGNER_TOKEN) { + throw new AnchorNotConfiguredError( + 'STELLAR_SIGNER_TOKEN is required with STELLAR_ANCHOR_SERVICE_URL', + ); + } + return httpAnchorClient({ + url: env.STELLAR_ANCHOR_SERVICE_URL, + token: env.STELLAR_SIGNER_TOKEN, + }); + } + + if (env.STELLAR_SIGNER_SECRET && env.STELLAR_EVIDENCE_ANCHOR_CONTRACT) { + const network = env.STELLAR_NETWORK ?? 'testnet'; + if (network !== 'testnet') { + throw new AnchorNotConfiguredError( + 'Only STELLAR_NETWORK=testnet is supported until custody and asset decisions are made', + ); + } + let signer: Keypair; + try { + signer = Keypair.fromSecret(env.STELLAR_SIGNER_SECRET); + } catch { + // Never echo the value: it is a secret key. + throw new AnchorNotConfiguredError('STELLAR_SIGNER_SECRET is not a valid secret key'); + } + const server = new rpc.Server(env.STELLAR_RPC_URL ?? 'https://soroban-testnet.stellar.org'); + return sorobanAnchorClient({ + contractId: env.STELLAR_EVIDENCE_ANCHOR_CONTRACT, + signer, + server: server as unknown as SorobanServer, + networkPassphrase: Networks.TESTNET, + }); + } + + throw new AnchorNotConfiguredError(); +} diff --git a/src/database.ts b/src/database.ts index c825963..7d69f7a 100644 --- a/src/database.ts +++ b/src/database.ts @@ -1,3 +1,5 @@ -import {PrismaClient} from '@prisma/client'; -import {PrismaPg} from '@prisma/adapter-pg'; -export const db=new PrismaClient({adapter:new PrismaPg({connectionString:process.env.DATABASE_URL})}); +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +export const db = new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), +}); diff --git a/src/domain.ts b/src/domain.ts index f820773..e2daa6c 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -1,45 +1,113 @@ -import {createHash,randomBytes,timingSafeEqual} from 'node:crypto'; -import {Keypair} from '@stellar/stellar-sdk'; -export const roles=['CASE_ADMIN','INVESTIGATOR','FORENSIC_ANALYST','LEGAL_REVIEWER','EXTERNAL_REVIEWER','READ_ONLY'] as const; -export type Role=typeof roles[number]; -const basic=['CASE_VIEW','EVIDENCE_VIEW']; -export const capabilities:Record={ - CASE_ADMIN:[...basic,'EVIDENCE_UPLOAD','EVIDENCE_EXPORT','CUSTODY_TRANSFER','AI_QUERY','RECONSTRUCTION_CREATE','RECONSTRUCTION_FINALIZE','ATTEST','CASE_ADMIN'], - INVESTIGATOR:[...basic,'EVIDENCE_UPLOAD','CUSTODY_TRANSFER','AI_QUERY','RECONSTRUCTION_CREATE'], - FORENSIC_ANALYST:[...basic,'EVIDENCE_UPLOAD','AI_QUERY','RECONSTRUCTION_CREATE'], - LEGAL_REVIEWER:[...basic,'AI_QUERY','EVIDENCE_EXPORT','RECONSTRUCTION_FINALIZE','ATTEST'], - EXTERNAL_REVIEWER:[...basic], - READ_ONLY:basic, +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { Keypair } from '@stellar/stellar-sdk'; +export const roles = [ + 'CASE_ADMIN', + 'INVESTIGATOR', + 'FORENSIC_ANALYST', + 'LEGAL_REVIEWER', + 'EXTERNAL_REVIEWER', + 'READ_ONLY', +] as const; +export type Role = (typeof roles)[number]; +const basic = ['CASE_VIEW', 'EVIDENCE_VIEW']; +export const capabilities: Record = { + CASE_ADMIN: [ + ...basic, + 'EVIDENCE_UPLOAD', + 'EVIDENCE_EXPORT', + 'CUSTODY_TRANSFER', + 'AI_QUERY', + 'RECONSTRUCTION_CREATE', + 'RECONSTRUCTION_FINALIZE', + 'ATTEST', + 'CASE_ADMIN', + ], + INVESTIGATOR: [ + ...basic, + 'EVIDENCE_UPLOAD', + 'CUSTODY_TRANSFER', + 'AI_QUERY', + 'RECONSTRUCTION_CREATE', + ], + FORENSIC_ANALYST: [...basic, 'EVIDENCE_UPLOAD', 'AI_QUERY', 'RECONSTRUCTION_CREATE'], + LEGAL_REVIEWER: [...basic, 'AI_QUERY', 'EVIDENCE_EXPORT', 'RECONSTRUCTION_FINALIZE', 'ATTEST'], + EXTERNAL_REVIEWER: [...basic], + READ_ONLY: basic, }; -export function permitted(role:string,capability:string){return (capabilities[role as Role]||[]).includes(capability)} -export function canonical(value:unknown):string{ - if(value===null||typeof value!=='object')return JSON.stringify(value); - if(Array.isArray(value))return `[${value.map(canonical).join(',')}]`; - return `{${Object.keys(value).sort().map(k=>`${JSON.stringify(k)}:${canonical((value as Record)[k])}`).join(',')}}`; -} -export function sha256(data:string|Buffer){return createHash('sha256').update(data).digest('hex')} -export function chain(previous:string,event:unknown){return sha256(previous+canonical(event))} -export function generateNonce():string{return randomBytes(32).toString('hex')} -export function verifyStellarSignature(publicKey:string,message:string,signature:string):boolean{ - try{ - const keypair=Keypair.fromPublicKey(publicKey); +export function permitted(role: string, capability: string) { + // Own-property check: a role such as "__proto__" or "constructor" must be + // denied, not resolved through Object.prototype. + return Object.hasOwn(capabilities, role) && capabilities[role as Role].includes(capability); +} +export function canonical(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + return `{${Object.keys(value) + .sort() + .map(k => `${JSON.stringify(k)}:${canonical((value as Record)[k])}`) + .join(',')}}`; +} +export function sha256(data: string | Buffer) { + return createHash('sha256').update(data).digest('hex'); +} +export function chain(previous: string, event: unknown) { + return sha256(previous + canonical(event)); +} +export function generateNonce(): string { + return randomBytes(32).toString('hex'); +} +export function verifyStellarSignature( + publicKey: string, + message: string, + signature: string, +): boolean { + try { + const keypair = Keypair.fromPublicKey(publicKey); // SEP-53: sign SHA256("Stellar Signed Message:\n" + message), not the raw message. - const payload=Buffer.concat([Buffer.from('Stellar Signed Message:\n','utf8'),Buffer.from(message,'utf8')]); - const messageHash=createHash('sha256').update(payload).digest(); + const payload = Buffer.concat([ + Buffer.from('Stellar Signed Message:\n', 'utf8'), + Buffer.from(message, 'utf8'), + ]); + const messageHash = createHash('sha256').update(payload).digest(); // Freighter signMessage returns base64-encoded signature - const sigBuffer=Buffer.from(signature,'base64'); - if(sigBuffer.length!==64)return false; - return keypair.verify(messageHash,sigBuffer); - }catch{return false} -} -export function mediaType(mime:string){return mime.startsWith('video/')?'VIDEO':mime.startsWith('audio/')?'AUDIO':mime.startsWith('image/')?'IMAGE':'DOCUMENT'} -export const categories=['VERIFIED_FACT','CORROBORATED_CLAIM','INFERENCE','CONFLICT','UNKNOWN'] as const; -export function validateClaims(claims:any[],versions:Set){ - for(const c of claims){ - if(!categories.includes(c.category))throw new Error('Invalid claim category'); - if(['VERIFIED_FACT','CORROBORATED_CLAIM','CONFLICT'].includes(c.category)&&!c.citations?.length)throw new Error('Grounded claim missing citation'); - for(const ref of c.citations||[])if(!versions.has(ref.versionId)||!ref.span)throw new Error('Invalid citation'); - if(c.category==='CORROBORATED_CLAIM'&&new Set(c.citations.map((r:any)=>r.versionId)).size<2)throw new Error('Corroboration requires distinct sources'); + const sigBuffer = Buffer.from(signature, 'base64'); + if (sigBuffer.length !== 64) return false; + return keypair.verify(messageHash, sigBuffer); + } catch { + return false; + } +} +export function mediaType(mime: string) { + return mime.startsWith('video/') + ? 'VIDEO' + : mime.startsWith('audio/') + ? 'AUDIO' + : mime.startsWith('image/') + ? 'IMAGE' + : 'DOCUMENT'; +} +export const categories = [ + 'VERIFIED_FACT', + 'CORROBORATED_CLAIM', + 'INFERENCE', + 'CONFLICT', + 'UNKNOWN', +] as const; +export function validateClaims(claims: any[], versions: Set) { + for (const c of claims) { + if (!categories.includes(c.category)) throw new Error('Invalid claim category'); + if ( + ['VERIFIED_FACT', 'CORROBORATED_CLAIM', 'CONFLICT'].includes(c.category) && + !c.citations?.length + ) + throw new Error('Grounded claim missing citation'); + for (const ref of c.citations || []) + if (!versions.has(ref.versionId) || !ref.span) throw new Error('Invalid citation'); + if ( + c.category === 'CORROBORATED_CLAIM' && + new Set(c.citations.map((r: any) => r.versionId)).size < 2 + ) + throw new Error('Corroboration requires distinct sources'); } return claims; } diff --git a/src/main.ts b/src/main.ts index 1f9f6e3..8aef149 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,238 +1,559 @@ import 'reflect-metadata'; -import {NestFactory} from '@nestjs/core'; -import {Module,Controller,All,Req,Res,HttpException} from '@nestjs/common'; -import {FastifyAdapter,NestFastifyApplication} from '@nestjs/platform-fastify'; -import {SwaggerModule,DocumentBuilder} from '@nestjs/swagger'; +import { NestFactory } from '@nestjs/core'; +import { Module, Controller, All, Req, Res, HttpException } from '@nestjs/common'; +import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import cookie from '@fastify/cookie'; import rateLimit from '@fastify/rate-limit'; -import {z} from 'zod'; -import {randomUUID,randomBytes} from 'node:crypto'; -import {db} from './database'; -import {roles,permitted,sha256,chain,mediaType,canonical,validateClaims,generateNonce,verifyStellarSignature} from './domain'; -import {uploadUrl,inspect,readUrl} from './storage'; -const max=Number(process.env.MAX_UPLOAD_BYTES||104857600); -const CHALLENGE_PREFIX='Afterprint authentication challenge: '; -const CHALLENGE_TTL_MS=5*60*1000; -const schemas={ - case:z.object({title:z.string().min(1).max(160),ref:z.string().min(1).max(60),classification:z.enum(['INTERNAL','CONFIDENTIAL','RESTRICTED'])}).strict(), - upload:z.object({title:z.string().min(1).max(200),source:z.string().min(1).max(2000),mimeType:z.enum(['video/mp4','video/webm','audio/wav','audio/mpeg','audio/mp4','image/jpeg','image/png','application/pdf','text/plain','text/csv','application/json']),size:z.number().int().positive().max(max)}).strict(), - member:z.object({userId:z.string().uuid(),role:z.enum(roles)}).strict(), +import { z } from 'zod'; +import { randomUUID, randomBytes } from 'node:crypto'; +import { db } from './database'; +import { + roles, + permitted, + sha256, + chain, + mediaType, + canonical, + validateClaims, + generateNonce, + verifyStellarSignature, +} from './domain'; +import { uploadUrl, inspect, readUrl } from './storage'; +const max = Number(process.env.MAX_UPLOAD_BYTES || 104857600); +const CHALLENGE_PREFIX = 'Afterprint authentication challenge: '; +const CHALLENGE_TTL_MS = 5 * 60 * 1000; +const schemas = { + case: z + .object({ + title: z.string().min(1).max(160), + ref: z.string().min(1).max(60), + classification: z.enum(['INTERNAL', 'CONFIDENTIAL', 'RESTRICTED']), + }) + .strict(), + upload: z + .object({ + title: z.string().min(1).max(200), + source: z.string().min(1).max(2000), + mimeType: z.enum([ + 'video/mp4', + 'video/webm', + 'audio/wav', + 'audio/mpeg', + 'audio/mp4', + 'image/jpeg', + 'image/png', + 'application/pdf', + 'text/plain', + 'text/csv', + 'application/json', + ]), + size: z.number().int().positive().max(max), + }) + .strict(), + member: z.object({ userId: z.string().uuid(), role: z.enum(roles) }).strict(), }; -function fail(status:number,message:string):never{throw new HttpException(message,status)} -async function auth(req:any){ - const token=req.cookies?.afterprint_session; - if(!token)fail(401,'Sign in required'); - const session=await db.session.findUnique({where:{id:sha256(token)},include:{user:true}}); - if(!session||session.expiresAt{ - const v=e.versions[0]; - return {id:e.id,ref:e.ref,title:e.title,type:e.type,source:e.source,status:e.status,versionId:v?.id||'',sha256:v?.sha256||'',size:v?.size||0,mimeType:v?.mimeType||'',importedAt:v?.importedAt||null}; +async function evidenceList(caseId: string) { + const items = await db.evidenceItem.findMany({ + where: { caseId }, + include: { versions: { orderBy: { version: 'desc' }, take: 1 } }, + orderBy: { ref: 'asc' }, + }); + return items.map(e => { + const v = e.versions[0]; + return { + id: e.id, + ref: e.ref, + title: e.title, + type: e.type, + source: e.source, + status: e.status, + versionId: v?.id || '', + sha256: v?.sha256 || '', + size: v?.size || 0, + mimeType: v?.mimeType || '', + importedAt: v?.importedAt || null, + }; }); } -async function ai(caseId:string,operation:string,extra:object={}){ - const artifacts=await db.derivedArtifact.findMany({where:{version:{evidence:{caseId}}}}); - const sources=[]; - for(const artifact of artifacts.filter(a=>a.type==='AI_JSON')){ - const r=await db.record.findFirst({where:{caseId,kind:`analysis:${artifact.versionId}`}}); - if(r)sources.push(r.data); +async function ai(caseId: string, operation: string, extra: object = {}) { + const artifacts = await db.derivedArtifact.findMany({ + where: { version: { evidence: { caseId } } }, + }); + const sources = []; + for (const artifact of artifacts.filter(a => a.type === 'AI_JSON')) { + const r = await db.record.findFirst({ + where: { caseId, kind: `analysis:${artifact.versionId}` }, + }); + if (r) sources.push(r.data); } - const response=await fetch(`${process.env.AI_URL}/internal/v1/${operation}`,{method:'POST',headers:{Authorization:`Bearer ${process.env.AI_SERVICE_TOKEN}`,'Content-Type':'application/json'},body:JSON.stringify({caseId,sources,...extra}),signal:AbortSignal.timeout(120000)}); - if(!response.ok)fail(502,'Evidence analysis service unavailable'); + const response = await fetch(`${process.env.AI_URL}/internal/v1/${operation}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.AI_SERVICE_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ caseId, sources, ...extra }), + signal: AbortSignal.timeout(120000), + }); + if (!response.ok) fail(502, 'Evidence analysis service unavailable'); return response.json() as Promise; } -async function appendCustody(tx:any,versionId:string,actor:string,type:string,reason:string,key:string,toUserId?:string){ - await tx.$executeRawUnsafe('SELECT pg_advisory_xact_lock(hashtext($1))',versionId); - const old=await tx.custodyEvent.findUnique({where:{idempotencyKey:key}}); - if(old)return old; - const previous=await tx.custodyEvent.findFirst({where:{versionId},orderBy:[{occurredAt:'desc'},{id:'desc'}]}); - const event={id:randomUUID(),versionId,actor,type,reason,toUserId:toUserId||null,occurredAt:new Date().toISOString()}; - return tx.custodyEvent.create({data:{...event,previousHash:previous?.eventHash||'',eventHash:chain(previous?.eventHash||'',event),idempotencyKey:key}}); +async function appendCustody( + tx: any, + versionId: string, + actor: string, + type: string, + reason: string, + key: string, + toUserId?: string, +) { + await tx.$executeRawUnsafe('SELECT pg_advisory_xact_lock(hashtext($1))', versionId); + const old = await tx.custodyEvent.findUnique({ where: { idempotencyKey: key } }); + if (old) return old; + const previous = await tx.custodyEvent.findFirst({ + where: { versionId }, + orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }], + }); + const event = { + id: randomUUID(), + versionId, + actor, + type, + reason, + toUserId: toUserId || null, + occurredAt: new Date().toISOString(), + }; + return tx.custodyEvent.create({ + data: { + ...event, + previousHash: previous?.eventHash || '', + eventHash: chain(previous?.eventHash || '', event), + idempotencyKey: key, + }, + }); } @Controller('v1') class ApiController { - @All('*') async handle(@Req() req:any,@Res() reply:any){ - try{ - if(!['GET','HEAD'].includes(req.method)&&req.headers['x-afterprint-request']!=='1')fail(403,'Missing request origin protection'); - const origin=req.headers.origin; - if(origin&&origin!==process.env.WEB_ORIGIN)fail(403,'Origin denied'); - const path=req.url.split('?')[0].replace(/^\/v1\/?/,'').split('/'); - const method=req.method; - const body=req.body||{}; - const [root,caseId,section,id,op]=path; + @All('*') async handle(@Req() req: any, @Res() reply: any) { + try { + if (!['GET', 'HEAD'].includes(req.method) && req.headers['x-afterprint-request'] !== '1') + fail(403, 'Missing request origin protection'); + const origin = req.headers.origin; + if (origin && origin !== process.env.WEB_ORIGIN) fail(403, 'Origin denied'); + const path = req.url + .split('?')[0] + .replace(/^\/v1\/?/, '') + .split('/'); + const method = req.method; + const body = req.body || {}; + const [root, caseId, section, id, op] = path; // Health - if(root==='health')return reply.send({status:'ok',schemaVersion:'1.0'}); + if (root === 'health') return reply.send({ status: 'ok', schemaVersion: '1.0' }); // Auth — Freighter wallet challenge-response - if(root==='auth'){ - if(caseId==='challenge'&&method==='POST'){ - const {publicKey}=z.object({publicKey:z.string().min(40).max(60)}).strict().parse(body); + if (root === 'auth') { + if (caseId === 'challenge' && method === 'POST') { + const { publicKey } = z + .object({ publicKey: z.string().min(40).max(60) }) + .strict() + .parse(body); // Clean up expired challenges for this key - await db.authChallenge.deleteMany({where:{publicKey,expiresAt:{lt:new Date()}}}); - const nonce=randomBytes(32).toString('hex'); - await db.authChallenge.create({data:{publicKey,nonce,expiresAt:new Date(Date.now()+CHALLENGE_TTL_MS)}}); - return reply.send({challenge:`${CHALLENGE_PREFIX}${nonce}`}); + await db.authChallenge.deleteMany({ + where: { publicKey, expiresAt: { lt: new Date() } }, + }); + const nonce = randomBytes(32).toString('hex'); + await db.authChallenge.create({ + data: { publicKey, nonce, expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS) }, + }); + return reply.send({ challenge: `${CHALLENGE_PREFIX}${nonce}` }); } - if(caseId==='verify'&&method==='POST'){ - const {publicKey,signature}=z.object({publicKey:z.string().min(40).max(60),signature:z.string().min(1).max(512)}).strict().parse(body); - const challenge=await db.authChallenge.findFirst({where:{publicKey,expiresAt:{gt:new Date()}},orderBy:{expiresAt:'desc'}}); - if(!challenge)fail(401,'No valid challenge found — request a new one'); - const challengeText=`${CHALLENGE_PREFIX}${challenge.nonce}`; - if(!verifyStellarSignature(publicKey,challengeText,signature))fail(401,'Signature verification failed'); + if (caseId === 'verify' && method === 'POST') { + const { publicKey, signature } = z + .object({ + publicKey: z.string().min(40).max(60), + signature: z.string().min(1).max(512), + }) + .strict() + .parse(body); + const challenge = await db.authChallenge.findFirst({ + where: { publicKey, expiresAt: { gt: new Date() } }, + orderBy: { expiresAt: 'desc' }, + }); + if (!challenge) fail(401, 'No valid challenge found — request a new one'); + const challengeText = `${CHALLENGE_PREFIX}${challenge.nonce}`; + if (!verifyStellarSignature(publicKey, challengeText, signature)) + fail(401, 'Signature verification failed'); // Delete used challenge - await db.authChallenge.delete({where:{id:challenge.id}}); + await db.authChallenge.delete({ where: { id: challenge.id } }); // Upsert user — first wallet connection auto-provisions a personal org + user - let user=await db.user.findUnique({where:{stellarPublicKey:publicKey}}); - if(!user){ - const org=await db.organization.create({data:{name:`Personal — ${publicKey.slice(0,8)}`}}); - user=await db.user.create({data:{stellarPublicKey:publicKey,organizationId:org.id,name:publicKey.slice(0,8)}}); + let user = await db.user.findUnique({ where: { stellarPublicKey: publicKey } }); + if (!user) { + const org = await db.organization.create({ + data: { name: `Personal — ${publicKey.slice(0, 8)}` }, + }); + user = await db.user.create({ + data: { + stellarPublicKey: publicKey, + organizationId: org.id, + name: publicKey.slice(0, 8), + }, + }); } - if(!user.active)fail(403,'Account is deactivated'); - const token=randomBytes(32).toString('hex'); - await db.session.create({data:{id:sha256(token),userId:user.id,expiresAt:new Date(Date.now()+8*3600000)}}); - reply.setCookie('afterprint_session',token,{httpOnly:true,secure:process.env.NODE_ENV==='production',sameSite:process.env.NODE_ENV==='production'?'none':'lax',path:'/',maxAge:28800}); - return reply.send({id:user.id,name:user.name,publicKey:user.stellarPublicKey}); + if (!user.active) fail(403, 'Account is deactivated'); + const token = randomBytes(32).toString('hex'); + await db.session.create({ + data: { + id: sha256(token), + userId: user.id, + expiresAt: new Date(Date.now() + 8 * 3600000), + }, + }); + reply.setCookie('afterprint_session', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + path: '/', + maxAge: 28800, + }); + return reply.send({ id: user.id, name: user.name, publicKey: user.stellarPublicKey }); } - if(caseId==='logout'&&method==='POST'){ - const token=req.cookies?.afterprint_session; - if(token)await db.session.deleteMany({where:{id:sha256(token)}}); - reply.clearCookie('afterprint_session',{path:'/'}); + if (caseId === 'logout' && method === 'POST') { + const token = req.cookies?.afterprint_session; + if (token) await db.session.deleteMany({ where: { id: sha256(token) } }); + reply.clearCookie('afterprint_session', { path: '/' }); return reply.code(204).send(); } - if(caseId==='me'&&method==='GET'){ - const user=await auth(req); - return reply.send({id:user.id,name:user.name,publicKey:user.stellarPublicKey,organizationId:user.organizationId}); + if (caseId === 'me' && method === 'GET') { + const user = await auth(req); + return reply.send({ + id: user.id, + name: user.name, + publicKey: user.stellarPublicKey, + organizationId: user.organizationId, + }); } } - if(root!=='cases')fail(404,'Route not found'); + if (root !== 'cases') fail(404, 'Route not found'); // Cases list / create - if(!caseId){ - const user=await auth(req); - if(method==='GET')return reply.send(await db.case.findMany({where:{organizationId:user.organizationId,members:{some:{userId:user.id,active:true}}},orderBy:{openedAt:'desc'}})); - if(method==='POST'){ - const data=schemas.case.parse(body); - return reply.code(201).send(await db.case.create({data:{...data,organizationId:user.organizationId,members:{create:{userId:user.id,role:'CASE_ADMIN'}}}})); + if (!caseId) { + const user = await auth(req); + if (method === 'GET') + return reply.send( + await db.case.findMany({ + where: { + organizationId: user.organizationId, + members: { some: { userId: user.id, active: true } }, + }, + orderBy: { openedAt: 'desc' }, + }), + ); + if (method === 'POST') { + const data = schemas.case.parse(body); + return reply.code(201).send( + await db.case.create({ + data: { + ...data, + organizationId: user.organizationId, + members: { create: { userId: user.id, role: 'CASE_ADMIN' } }, + }, + }), + ); } - fail(405,'Method not allowed'); + fail(405, 'Method not allowed'); } - const permission=section==='members'?'CASE_ADMIN':section==='exports'?'EVIDENCE_EXPORT':section==='conversations'?'AI_QUERY':section==='attestations'?'ATTEST':section==='reconstructions'?(op==='finalize'||op==='review'?'RECONSTRUCTION_FINALIZE':method==='GET'?'CASE_VIEW':'RECONSTRUCTION_CREATE'):section==='conflicts'&&method==='PATCH'?'RECONSTRUCTION_CREATE':section==='evidence'?(id==='upload-url'||id==='finalize'?'EVIDENCE_UPLOAD':op==='custody'&&path[5]==='transfer'?'CUSTODY_TRANSFER':'EVIDENCE_VIEW'):method==='PATCH'&&!section?'CASE_ADMIN':'CASE_VIEW'; - const {user,member}=await access(req,caseId,permission); + const permission = + section === 'members' + ? 'CASE_ADMIN' + : section === 'exports' + ? 'EVIDENCE_EXPORT' + : section === 'conversations' + ? 'AI_QUERY' + : section === 'attestations' + ? 'ATTEST' + : section === 'reconstructions' + ? op === 'finalize' || op === 'review' + ? 'RECONSTRUCTION_FINALIZE' + : method === 'GET' + ? 'CASE_VIEW' + : 'RECONSTRUCTION_CREATE' + : section === 'conflicts' && method === 'PATCH' + ? 'RECONSTRUCTION_CREATE' + : section === 'evidence' + ? id === 'upload-url' || id === 'finalize' + ? 'EVIDENCE_UPLOAD' + : op === 'custody' && path[5] === 'transfer' + ? 'CUSTODY_TRANSFER' + : 'EVIDENCE_VIEW' + : method === 'PATCH' && !section + ? 'CASE_ADMIN' + : 'CASE_VIEW'; + const { user, member } = await access(req, caseId, permission); // Case detail - if(!section){ - if(method==='GET')return reply.send(member.case); - if(method==='PATCH'){ - const data=z.object({title:z.string().min(1).max(160).optional(),status:z.enum(['OPEN','CLOSED','ARCHIVED']).optional()}).strict().parse(body); - return reply.send(await db.case.update({where:{id:caseId},data})); + if (!section) { + if (method === 'GET') return reply.send(member.case); + if (method === 'PATCH') { + const data = z + .object({ + title: z.string().min(1).max(160).optional(), + status: z.enum(['OPEN', 'CLOSED', 'ARCHIVED']).optional(), + }) + .strict() + .parse(body); + return reply.send(await db.case.update({ where: { id: caseId }, data })); } } // Members - if(section==='members'){ - if(method==='GET'){ - const members=await db.caseMember.findMany({where:{caseId,active:true},include:{user:true}}); - return reply.send(members.map(m=>({id:m.id,userId:m.userId,name:m.user.name,role:m.role}))); + if (section === 'members') { + if (method === 'GET') { + const members = await db.caseMember.findMany({ + where: { caseId, active: true }, + include: { user: true }, + }); + return reply.send( + members.map(m => ({ id: m.id, userId: m.userId, name: m.user.name, role: m.role })), + ); } - if(method==='POST'){ - const data=schemas.member.parse(body); - const target=await db.user.findFirst({where:{id:data.userId,organizationId:user.organizationId,active:true}}); - if(!target)fail(400,'User must be an active organization member'); - return reply.code(201).send(await db.caseMember.upsert({where:{caseId_userId:{caseId,userId:data.userId}},create:{...data,caseId},update:{role:data.role,active:true}})); + if (method === 'POST') { + const data = schemas.member.parse(body); + const target = await db.user.findFirst({ + where: { id: data.userId, organizationId: user.organizationId, active: true }, + }); + if (!target) fail(400, 'User must be an active organization member'); + return reply.code(201).send( + await db.caseMember.upsert({ + where: { caseId_userId: { caseId, userId: data.userId } }, + create: { ...data, caseId }, + update: { role: data.role, active: true }, + }), + ); } - if(id&&['PATCH','DELETE'].includes(method)){ - const target=await db.caseMember.findFirst({where:{id,caseId}}); - if(!target)fail(404,'Member not found'); - if(target.userId===user.id)fail(400,'Ask another case administrator to change your own membership'); - const data=method==='DELETE'?{active:false}:z.object({role:z.enum(roles)}).strict().parse(body); - return reply.send(await db.caseMember.update({where:{id},data})); + if (id && ['PATCH', 'DELETE'].includes(method)) { + const target = await db.caseMember.findFirst({ where: { id, caseId } }); + if (!target) fail(404, 'Member not found'); + if (target.userId === user.id) + fail(400, 'Ask another case administrator to change your own membership'); + const data = + method === 'DELETE' + ? { active: false } + : z + .object({ role: z.enum(roles) }) + .strict() + .parse(body); + return reply.send(await db.caseMember.update({ where: { id }, data })); } } // Evidence - if(section==='evidence'){ - if(!id&&method==='GET')return reply.send(await evidenceList(caseId)); - if(id==='upload-url'&&method==='POST'){ - const data=schemas.upload.parse(body); - const sessionId=randomUUID(),evidenceId=randomUUID(),key=`originals/${caseId}/${evidenceId}/${randomUUID()}`; - await db.evidenceItem.create({data:{id:evidenceId,caseId,ref:`E-${evidenceId.slice(0,8)}`,title:data.title,source:data.source,type:mediaType(data.mimeType),custodianId:user.id,uploads:{create:{id:sessionId,objectKey:key,expectedSize:data.size,mimeType:data.mimeType,expiresAt:new Date(Date.now()+300000)}}}}); - return reply.send({sessionId,...await uploadUrl(key,data.mimeType)}); + if (section === 'evidence') { + if (!id && method === 'GET') return reply.send(await evidenceList(caseId)); + if (id === 'upload-url' && method === 'POST') { + const data = schemas.upload.parse(body); + const sessionId = randomUUID(), + evidenceId = randomUUID(), + key = `originals/${caseId}/${evidenceId}/${randomUUID()}`; + await db.evidenceItem.create({ + data: { + id: evidenceId, + caseId, + ref: `E-${evidenceId.slice(0, 8)}`, + title: data.title, + source: data.source, + type: mediaType(data.mimeType), + custodianId: user.id, + uploads: { + create: { + id: sessionId, + objectKey: key, + expectedSize: data.size, + mimeType: data.mimeType, + expiresAt: new Date(Date.now() + 300000), + }, + }, + }, + }); + return reply.send({ sessionId, ...(await uploadUrl(key, data.mimeType)) }); } - if(id==='finalize'&&method==='POST'){ - const {sessionId}=z.object({sessionId:z.string().uuid()}).strict().parse(body); - const session=await db.uploadSession.findUnique({where:{id:sessionId},include:{evidence:{include:{versions:true}}}}); - if(!session||session.evidence.caseId!==caseId)fail(404,'Upload session not found'); - if(session.status==='FINALIZED')return reply.send({id:session.evidence.id,versionId:session.evidence.versions[0]?.id}); - if(session.expiresAt{ - await tx.$executeRawUnsafe('SELECT pg_advisory_xact_lock(hashtext($1))',sessionId); - const previous=await tx.evidenceVersion.findUnique({where:{objectKey:session.objectKey}}); - if(previous)return previous; - const v=await tx.evidenceVersion.create({data:{evidenceId:session.evidenceId,objectKey:session.objectKey,...object}}); - await appendCustody(tx,v.id,user.id,'IMPORTED','Immutable original imported',`ingest-${sessionId}`); - await tx.uploadSession.update({where:{id:sessionId},data:{status:'FINALIZED'}}); - await tx.evidenceItem.update({where:{id:session.evidenceId},data:{status:'QUARANTINED'}}); - await tx.outbox.create({data:{id:`process-${v.id}`,queue:'evidence-process',data:{caseId,versionId:v.id,actorId:user.id}}}); + if (id === 'finalize' && method === 'POST') { + const { sessionId } = z.object({ sessionId: z.string().uuid() }).strict().parse(body); + const session = await db.uploadSession.findUnique({ + where: { id: sessionId }, + include: { evidence: { include: { versions: true } } }, + }); + if (!session || session.evidence.caseId !== caseId) fail(404, 'Upload session not found'); + if (session.status === 'FINALIZED') + return reply.send({ + id: session.evidence.id, + versionId: session.evidence.versions[0]?.id, + }); + if (session.expiresAt < new Date()) fail(410, 'Upload session expired'); + const object = await inspect(session.objectKey); + if (object.size !== session.expectedSize || object.mimeType !== session.mimeType) + fail(400, 'Stored object does not match upload metadata'); + const version = await db.$transaction(async tx => { + await tx.$executeRawUnsafe('SELECT pg_advisory_xact_lock(hashtext($1))', sessionId); + const previous = await tx.evidenceVersion.findUnique({ + where: { objectKey: session.objectKey }, + }); + if (previous) return previous; + const v = await tx.evidenceVersion.create({ + data: { evidenceId: session.evidenceId, objectKey: session.objectKey, ...object }, + }); + await appendCustody( + tx, + v.id, + user.id, + 'IMPORTED', + 'Immutable original imported', + `ingest-${sessionId}`, + ); + await tx.uploadSession.update({ + where: { id: sessionId }, + data: { status: 'FINALIZED' }, + }); + await tx.evidenceItem.update({ + where: { id: session.evidenceId }, + data: { status: 'QUARANTINED' }, + }); + await tx.outbox.create({ + data: { + id: `process-${v.id}`, + queue: 'evidence-process', + data: { caseId, versionId: v.id, actorId: user.id }, + }, + }); return v; }); - return reply.send({id:session.evidenceId,versionId:version.id}); + return reply.send({ id: session.evidenceId, versionId: version.id }); } - const e=await item(caseId,id); - const v=e.versions[0]; - if(!op&&method==='GET')return reply.send((await evidenceList(caseId)).find(x=>x.id===id)); - if(!v)fail(409,'Evidence upload is incomplete'); - if(op==='content'&&method==='GET'){ - if(e.status==='QUARANTINED'||e.status==='REJECTED')fail(423,'Evidence awaits a successful malware scan'); - return reply.send({url:await readUrl(v.objectKey,v.objectVersionId)}); + const e = await item(caseId, id); + const v = e.versions[0]; + if (!op && method === 'GET') + return reply.send((await evidenceList(caseId)).find(x => x.id === id)); + if (!v) fail(409, 'Evidence upload is incomplete'); + if (op === 'content' && method === 'GET') { + if (e.status === 'QUARANTINED' || e.status === 'REJECTED') + fail(423, 'Evidence awaits a successful malware scan'); + return reply.send({ url: await readUrl(v.objectKey, v.objectVersionId) }); } - if(op==='integrity-check'&&method==='POST'){ - const object=await inspect(v.objectKey,v.objectVersionId); - const valid=object.sha256===v.sha256&&object.objectVersionId===v.objectVersionId&&object.size===v.size; - await db.$transaction(tx=>appendCustody(tx,v.id,user.id,'INTEGRITY_CHECKED',valid?'Bytes match recorded hash':'INTEGRITY FAILURE',`check-${randomUUID()}`)); - return reply.send({valid,sha256:object.sha256}); + if (op === 'integrity-check' && method === 'POST') { + const object = await inspect(v.objectKey, v.objectVersionId); + const valid = + object.sha256 === v.sha256 && + object.objectVersionId === v.objectVersionId && + object.size === v.size; + await db.$transaction(tx => + appendCustody( + tx, + v.id, + user.id, + 'INTEGRITY_CHECKED', + valid ? 'Bytes match recorded hash' : 'INTEGRITY FAILURE', + `check-${randomUUID()}`, + ), + ); + return reply.send({ valid, sha256: object.sha256 }); } - if(op==='custody'&&method==='GET')return reply.send(await db.custodyEvent.findMany({where:{versionId:v.id},orderBy:{occurredAt:'asc'}})); - if(op==='custody'&&path[5]==='transfer'&&method==='POST'){ - const data=z.object({toUserId:z.string().uuid(),reason:z.string().min(5).max(2000)}).strict().parse(body); - const key=z.string().uuid().parse(req.headers['idempotency-key']); - const target=await db.caseMember.findUnique({where:{caseId_userId:{caseId,userId:data.toUserId}}}); - if(!target?.active)fail(400,'Recipient is not an active case member'); - const result=await db.$transaction(async tx=>{ - await tx.$executeRawUnsafe('SELECT pg_advisory_xact_lock(hashtext($1))',v.id); - const prior=await tx.custodyEvent.findUnique({where:{idempotencyKey:key}}); - if(prior){ - if(prior.versionId!==v.id||prior.actor!==user.id||prior.toUserId!==data.toUserId||prior.reason!==data.reason)fail(409,'Idempotency key reused with different operation'); + if (op === 'custody' && method === 'GET') + return reply.send( + await db.custodyEvent.findMany({ + where: { versionId: v.id }, + orderBy: { occurredAt: 'asc' }, + }), + ); + if (op === 'custody' && path[5] === 'transfer' && method === 'POST') { + const data = z + .object({ toUserId: z.string().uuid(), reason: z.string().min(5).max(2000) }) + .strict() + .parse(body); + const key = z.string().uuid().parse(req.headers['idempotency-key']); + const target = await db.caseMember.findUnique({ + where: { caseId_userId: { caseId, userId: data.toUserId } }, + }); + if (!target?.active) fail(400, 'Recipient is not an active case member'); + const result = await db.$transaction(async tx => { + await tx.$executeRawUnsafe('SELECT pg_advisory_xact_lock(hashtext($1))', v.id); + const prior = await tx.custodyEvent.findUnique({ where: { idempotencyKey: key } }); + if (prior) { + if ( + prior.versionId !== v.id || + prior.actor !== user.id || + prior.toUserId !== data.toUserId || + prior.reason !== data.reason + ) + fail(409, 'Idempotency key reused with different operation'); return prior; } - const fresh=await tx.evidenceItem.findUnique({where:{id:e.id}}); - if(fresh?.custodianId!==user.id&&member.role!=='CASE_ADMIN')fail(403,'Only current custodian or case administrator can transfer'); - const event=await appendCustody(tx,v.id,user.id,'TRANSFERRED',data.reason,key,data.toUserId); - await tx.evidenceItem.update({where:{id:e.id},data:{custodianId:data.toUserId}}); + const fresh = await tx.evidenceItem.findUnique({ where: { id: e.id } }); + if (fresh?.custodianId !== user.id && member.role !== 'CASE_ADMIN') + fail(403, 'Only current custodian or case administrator can transfer'); + const event = await appendCustody( + tx, + v.id, + user.id, + 'TRANSFERRED', + data.reason, + key, + data.toUserId, + ); + await tx.evidenceItem.update({ + where: { id: e.id }, + data: { custodianId: data.toUserId }, + }); return event; }); return reply.send(result); @@ -240,123 +561,264 @@ class ApiController { } // Case-level custody history - if(section==='custody'&&method==='GET'){ - const entries=await db.custodyEvent.findMany({where:{version:{evidence:{caseId}}},include:{version:true},orderBy:{occurredAt:'asc'}}); - return reply.send(entries.map(e=>({...e,evidenceId:e.version.evidenceId}))); + if (section === 'custody' && method === 'GET') { + const entries = await db.custodyEvent.findMany({ + where: { version: { evidence: { caseId } } }, + include: { version: true }, + orderBy: { occurredAt: 'asc' }, + }); + return reply.send(entries.map(e => ({ ...e, evidenceId: e.version.evidenceId }))); } // Timeline / graph / conflicts - if(['timeline','graph','conflicts'].includes(section)&&method==='GET'){ - const r=await db.record.findMany({where:{caseId,kind:section},orderBy:{createdAt:'asc'}}); - if(section==='graph')return reply.send(r.at(-1)?.data||{nodes:[],edges:[]}); - return reply.send(r.flatMap(x=>Array.isArray(x.data)?x.data:[{...(x.data as object),id:x.id}])); + if (['timeline', 'graph', 'conflicts'].includes(section) && method === 'GET') { + const r = await db.record.findMany({ + where: { caseId, kind: section }, + orderBy: { createdAt: 'asc' }, + }); + if (section === 'graph') return reply.send(r.at(-1)?.data || { nodes: [], edges: [] }); + return reply.send( + r.flatMap(x => (Array.isArray(x.data) ? x.data : [{ ...(x.data as object), id: x.id }])), + ); } - if(section==='conflicts'&&id&&method==='PATCH'){ - const data=z.object({status:z.enum(['OPEN','REVIEWED','RESOLVED','EXPECTED'])}).strict().parse(body); - const r=await record(caseId,id,'conflicts'); - await db.record.create({data:{caseId,kind:'conflict-review',data:{conflictId:id,prior:r.data,status:data.status,reviewer:user.id}}}); - return reply.send(await db.record.update({where:{id},data:{data:{...(r.data as object),...data},revision:{increment:1}}})); + if (section === 'conflicts' && id && method === 'PATCH') { + const data = z + .object({ status: z.enum(['OPEN', 'REVIEWED', 'RESOLVED', 'EXPECTED']) }) + .strict() + .parse(body); + const r = await record(caseId, id, 'conflicts'); + await db.record.create({ + data: { + caseId, + kind: 'conflict-review', + data: { conflictId: id, prior: r.data, status: data.status, reviewer: user.id }, + }, + }); + return reply.send( + await db.record.update({ + where: { id }, + data: { data: { ...(r.data as object), ...data }, revision: { increment: 1 } }, + }), + ); } // Conversations / Ask - if(section==='conversations'){ - if(!id&&method==='POST')return reply.code(201).send(await db.record.create({data:{caseId,kind:'conversation',data:{owner:user.id}}})); - if(id&&op==='messages'&&method==='POST'){ - const c=await record(caseId,id,'conversation'); - if((c.data as any).owner!==user.id)fail(403,'Conversation is private'); - const {message}=z.object({message:z.string().min(1).max(4000)}).strict().parse(body); - const answer=await ai(caseId,'query',{query:message}); - const versions=new Set((await evidenceList(caseId)).map(e=>e.versionId)); - validateClaims(answer.claims,versions); - await db.record.create({data:{caseId,kind:'message',data:{conversationId:id,query:message,answer}}}); + if (section === 'conversations') { + if (!id && method === 'POST') + return reply.code(201).send( + await db.record.create({ + data: { caseId, kind: 'conversation', data: { owner: user.id } }, + }), + ); + if (id && op === 'messages' && method === 'POST') { + const c = await record(caseId, id, 'conversation'); + if ((c.data as any).owner !== user.id) fail(403, 'Conversation is private'); + const { message } = z + .object({ message: z.string().min(1).max(4000) }) + .strict() + .parse(body); + const answer = await ai(caseId, 'query', { query: message }); + const versions = new Set((await evidenceList(caseId)).map(e => e.versionId)); + validateClaims(answer.claims, versions); + await db.record.create({ + data: { caseId, kind: 'message', data: { conversationId: id, query: message, answer } }, + }); return reply.send(answer); } } // Reconstructions - if(section==='reconstructions'){ - if(!id&&method==='POST'){ - const answer=await ai(caseId,'reconstruct'); - const versions=await evidenceList(caseId); - validateClaims(answer.claims,new Set(versions.map(e=>e.versionId))); - const r=await db.record.create({data:{caseId,kind:'reconstruction',data:{status:'DRAFT',claims:answer.claims,reviewedIds:[],sourceManifest:versions.map(e=>({evidenceId:e.id,versionId:e.versionId,sha256:e.sha256}))}}}); - return reply.code(201).send({id:r.id,...r.data as object}); + if (section === 'reconstructions') { + if (!id && method === 'POST') { + const answer = await ai(caseId, 'reconstruct'); + const versions = await evidenceList(caseId); + validateClaims(answer.claims, new Set(versions.map(e => e.versionId))); + const r = await db.record.create({ + data: { + caseId, + kind: 'reconstruction', + data: { + status: 'DRAFT', + claims: answer.claims, + reviewedIds: [], + sourceManifest: versions.map(e => ({ + evidenceId: e.id, + versionId: e.versionId, + sha256: e.sha256, + })), + }, + }, + }); + return reply.code(201).send({ id: r.id, ...(r.data as object) }); } - if(id){ - const r=await record(caseId,id,'reconstruction'); - if(!op&&method==='GET')return reply.send({id,...r.data as object}); - if(method==='POST'&&(op==='review'||op==='finalize')){ - const updated=await db.$transaction(async tx=>{ - await tx.$executeRawUnsafe('SELECT pg_advisory_xact_lock(hashtext($1))',id); - const fresh=await tx.record.findUniqueOrThrow({where:{id}}); - const data=fresh.data as any; - if(data.status==='FINALIZED')return fresh; - if(op==='review'){ - const input=z.object({reviewedIds:z.array(z.string()).max(10000)}).strict().parse(body); - if(input.reviewedIds.some(x=>!data.claims.some((c:any)=>c.id===x)))fail(400,'Unknown reviewed claim'); - data.reviewedIds=[...new Set(input.reviewedIds)]; - data.reviewer=user.id; - }else{ - if(!data.sourceManifest?.length||!data.claims.length||data.reviewer!==user.id||data.claims.some((c:any)=>!data.reviewedIds.includes(c.id)))fail(409,'Review every claim before finalization'); - data.reportHash=sha256(canonical(data)); - data.status='FINALIZED'; - data.finalizedAt=new Date().toISOString(); + if (id) { + const r = await record(caseId, id, 'reconstruction'); + if (!op && method === 'GET') return reply.send({ id, ...(r.data as object) }); + if (method === 'POST' && (op === 'review' || op === 'finalize')) { + const updated = await db.$transaction(async tx => { + await tx.$executeRawUnsafe('SELECT pg_advisory_xact_lock(hashtext($1))', id); + const fresh = await tx.record.findUniqueOrThrow({ where: { id } }); + const data = fresh.data as any; + if (data.status === 'FINALIZED') return fresh; + if (op === 'review') { + const input = z + .object({ reviewedIds: z.array(z.string()).max(10000) }) + .strict() + .parse(body); + if (input.reviewedIds.some(x => !data.claims.some((c: any) => c.id === x))) + fail(400, 'Unknown reviewed claim'); + data.reviewedIds = [...new Set(input.reviewedIds)]; + data.reviewer = user.id; + } else { + if ( + !data.sourceManifest?.length || + !data.claims.length || + data.reviewer !== user.id || + data.claims.some((c: any) => !data.reviewedIds.includes(c.id)) + ) + fail(409, 'Review every claim before finalization'); + data.reportHash = sha256(canonical(data)); + data.status = 'FINALIZED'; + data.finalizedAt = new Date().toISOString(); } - return tx.record.update({where:{id},data:{data,revision:{increment:1}}}); + return tx.record.update({ + where: { id }, + data: { data, revision: { increment: 1 } }, + }); }); - return reply.send({id,...updated.data as object}); + return reply.send({ id, ...(updated.data as object) }); } } } // Exports - if(section==='exports'){ - if(!id&&method==='POST'){ - const r=await db.$transaction(async tx=>{ - const r=await tx.record.create({data:{caseId,kind:'export',data:{status:'QUEUED',actorId:user.id}}}); - await tx.outbox.create({data:{id:`export-${r.id}`,queue:'export-build',data:{caseId,exportId:r.id,actorId:user.id}}}); + if (section === 'exports') { + if (!id && method === 'POST') { + const r = await db.$transaction(async tx => { + const r = await tx.record.create({ + data: { caseId, kind: 'export', data: { status: 'QUEUED', actorId: user.id } }, + }); + await tx.outbox.create({ + data: { + id: `export-${r.id}`, + queue: 'export-build', + data: { caseId, exportId: r.id, actorId: user.id }, + }, + }); return r; }); - return reply.code(202).send({id:r.id,status:'QUEUED'}); + return reply.code(202).send({ id: r.id, status: 'QUEUED' }); } - if(id&&method==='GET'){ - const r=await record(caseId,id,'export'); - const data=r.data as any; - return reply.send({id,...data,...(data.objectKey?{url:await readUrl(data.objectKey,data.objectVersionId)}:{})}); + if (id && method === 'GET') { + const r = await record(caseId, id, 'export'); + const data = r.data as any; + return reply.send({ + id, + ...data, + ...(data.objectKey ? { url: await readUrl(data.objectKey, data.objectVersionId) } : {}), + }); } } // Attestations - if(section==='attestations'&&method==='POST'){ - const input=z.object({subjectRef:z.string().uuid(),statementHash:z.string().regex(/^[a-f0-9]{64}$/)}).strict().parse(body); - const subject=await record(caseId,input.subjectRef,'reconstruction'); - if((subject.data as any).status!=='FINALIZED'||(subject.data as any).reportHash!==input.statementHash)fail(409,'Attestation must reference the exact finalized report hash'); - const att=await db.$transaction(async tx=>{const a=await tx.record.create({data:{caseId,kind:'attestation',data:{...input,issuer:user.id,status:'PENDING_CHAIN'}}});await tx.outbox.create({data:{id:`attest-stellar-${a.id}`,queue:'stellar-anchor',data:{caseId,attestationId:a.id,versionId:input.subjectRef,isAttestation:true}}});return a}); - return reply.code(201).send({id:att.id,...att.data as object}); + if (section === 'attestations' && method === 'POST') { + const input = z + .object({ + subjectRef: z.string().uuid(), + statementHash: z.string().regex(/^[a-f0-9]{64}$/), + }) + .strict() + .parse(body); + const subject = await record(caseId, input.subjectRef, 'reconstruction'); + if ( + (subject.data as any).status !== 'FINALIZED' || + (subject.data as any).reportHash !== input.statementHash + ) + fail(409, 'Attestation must reference the exact finalized report hash'); + const att = await db.$transaction(async tx => { + const a = await tx.record.create({ + data: { + caseId, + kind: 'attestation', + data: { ...input, issuer: user.id, status: 'PENDING_CHAIN' }, + }, + }); + await tx.outbox.create({ + data: { + id: `attest-stellar-${a.id}`, + queue: 'stellar-anchor', + data: { + caseId, + attestationId: a.id, + versionId: input.subjectRef, + isAttestation: true, + }, + }, + }); + return a; + }); + return reply.code(201).send({ id: att.id, ...(att.data as object) }); } - fail(404,'Route not found'); - }catch(error){ - if(error instanceof (await import('zod')).ZodError)return reply.code(400).send({message:'Invalid request',issues:(error as any).issues.map((i:any)=>({path:i.path,message:i.message}))}); - if(error instanceof HttpException)return reply.code(error.getStatus()).send({message:error.message}); - req.log.error({errType:(error as Error).name,requestId:req.id},'Request failed'); - return reply.code(500).send({message:'Operation failed; contact the administrator with request ID '+req.id}); + fail(404, 'Route not found'); + } catch (error) { + if (error instanceof (await import('zod')).ZodError) + return reply.code(400).send({ + message: 'Invalid request', + issues: (error as any).issues.map((i: any) => ({ path: i.path, message: i.message })), + }); + if (error instanceof HttpException) + return reply.code(error.getStatus()).send({ message: error.message }); + req.log.error({ errType: (error as Error).name, requestId: req.id }, 'Request failed'); + return reply + .code(500) + .send({ message: 'Operation failed; contact the administrator with request ID ' + req.id }); } } } -@Module({controllers:[ApiController]}) class AppModule{} -async function bootstrap(){ - for(const key of ['DATABASE_URL','WEB_ORIGIN','AI_URL','AI_SERVICE_TOKEN'])if(!process.env[key])throw new Error(`Missing ${key}`); - const app=await NestFactory.create(AppModule,new FastifyAdapter({logger:{redact:['req.headers.cookie','req.headers.authorization','req.body','res.headers.set-cookie']},bodyLimit:1024*1024})); +@Module({ controllers: [ApiController] }) +class AppModule {} +async function bootstrap() { + for (const key of ['DATABASE_URL', 'WEB_ORIGIN', 'AI_URL', 'AI_SERVICE_TOKEN']) + if (!process.env[key]) throw new Error(`Missing ${key}`); + const app = await NestFactory.create( + AppModule, + new FastifyAdapter({ + logger: { + redact: [ + 'req.headers.cookie', + 'req.headers.authorization', + 'req.body', + 'res.headers.set-cookie', + ], + }, + bodyLimit: 1024 * 1024, + }), + ); await app.register(cookie as any); - await app.register(rateLimit as any,{max:120,timeWindow:'1 minute'}); - app.enableCors({origin:process.env.WEB_ORIGIN,credentials:true,allowedHeaders:['Content-Type','X-Afterprint-Request','Idempotency-Key']}); + await app.register(rateLimit as any, { max: 120, timeWindow: '1 minute' }); + app.enableCors({ + origin: process.env.WEB_ORIGIN, + credentials: true, + allowedHeaders: ['Content-Type', 'X-Afterprint-Request', 'Idempotency-Key'], + }); // OpenAPI - const config=new DocumentBuilder().setTitle('Afterprint API').setDescription('Evidence intelligence platform API v1').setVersion('1.0').addCookieAuth('afterprint_session').build(); - const document=SwaggerModule.createDocument(app,config); - SwaggerModule.setup('docs',app,document); - await app.listen(Number(process.env.PORT||4000),process.env.HOST||'0.0.0.0'); - console.log(JSON.stringify({event:'api_started',port:process.env.PORT||4000,docs:`http://localhost:${process.env.PORT||4000}/docs`})); + const config = new DocumentBuilder() + .setTitle('Afterprint API') + .setDescription('Evidence intelligence platform API v1') + .setVersion('1.0') + .addCookieAuth('afterprint_session') + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('docs', app, document); + await app.listen(Number(process.env.PORT || 4000), process.env.HOST || '0.0.0.0'); + console.log( + JSON.stringify({ + event: 'api_started', + port: process.env.PORT || 4000, + docs: `http://localhost:${process.env.PORT || 4000}/docs`, + }), + ); } -if(require.main===module)bootstrap(); -export {ApiController,AppModule,appendCustody}; +if (require.main === module) bootstrap(); +export { ApiController, AppModule, appendCustody }; diff --git a/src/storage.ts b/src/storage.ts index a638893..1d04e01 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,8 +1,66 @@ -import {S3Client,PutObjectCommand,GetObjectCommand,HeadObjectCommand} from '@aws-sdk/client-s3'; -import {getSignedUrl} from '@aws-sdk/s3-request-presigner'; -import {createHash} from 'node:crypto'; -export const bucket=process.env.S3_BUCKET||'afterprint-originals'; -export const s3=new S3Client({region:process.env.S3_REGION||'us-east-1',endpoint:process.env.S3_ENDPOINT,forcePathStyle:true,credentials:process.env.S3_ACCESS_KEY?{accessKeyId:process.env.S3_ACCESS_KEY,secretAccessKey:process.env.S3_SECRET_KEY!}:undefined}); -export async function uploadUrl(key:string,mime:string){const headers:Record={'Content-Type':mime,'If-None-Match':'*'};const command=new PutObjectCommand({Bucket:bucket,Key:key,ContentType:mime,IfNoneMatch:'*',...(process.env.S3_KMS_KEY?{ServerSideEncryption:'aws:kms' as const,SSEKMSKeyId:process.env.S3_KMS_KEY}:{})});return {uploadUrl:await getSignedUrl(s3,command,{expiresIn:300}),headers}} -export async function inspect(key:string,version?:string){const head=await s3.send(new HeadObjectCommand({Bucket:bucket,Key:key,VersionId:version}));if(!head.VersionId||head.VersionId==='null')throw new Error('Original bucket must have object versioning enabled');const object=await s3.send(new GetObjectCommand({Bucket:bucket,Key:key,VersionId:head.VersionId}));const hash=createHash('sha256');let size=0;for await(const chunk of object.Body as any){size+=chunk.length;if(size>Number(process.env.MAX_UPLOAD_BYTES||104857600))throw new Error('Object too large');hash.update(chunk)}return {sha256:hash.digest('hex'),size,mimeType:head.ContentType||'application/octet-stream',objectVersionId:head.VersionId}} -export async function readUrl(key:string,version:string){return getSignedUrl(s3,new GetObjectCommand({Bucket:bucket,Key:key,VersionId:version,ResponseContentDisposition:'attachment'}),{expiresIn:120})} +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + HeadObjectCommand, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { createHash } from 'node:crypto'; +export const bucket = process.env.S3_BUCKET || 'afterprint-originals'; +export const s3 = new S3Client({ + region: process.env.S3_REGION || 'us-east-1', + endpoint: process.env.S3_ENDPOINT, + forcePathStyle: true, + credentials: process.env.S3_ACCESS_KEY + ? { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY! } + : undefined, +}); +export async function uploadUrl(key: string, mime: string) { + const headers: Record = { 'Content-Type': mime, 'If-None-Match': '*' }; + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: mime, + IfNoneMatch: '*', + ...(process.env.S3_KMS_KEY + ? { ServerSideEncryption: 'aws:kms' as const, SSEKMSKeyId: process.env.S3_KMS_KEY } + : {}), + }); + return { uploadUrl: await getSignedUrl(s3, command, { expiresIn: 300 }), headers }; +} +export async function inspect(key: string, version?: string) { + const head = await s3.send( + new HeadObjectCommand({ Bucket: bucket, Key: key, VersionId: version }), + ); + if (!head.VersionId || head.VersionId === 'null') + throw new Error('Original bucket must have object versioning enabled'); + const object = await s3.send( + new GetObjectCommand({ Bucket: bucket, Key: key, VersionId: head.VersionId }), + ); + const hash = createHash('sha256'); + let size = 0; + for await (const chunk of object.Body as any) { + size += chunk.length; + if (size > Number(process.env.MAX_UPLOAD_BYTES || 104857600)) + throw new Error('Object too large'); + hash.update(chunk); + } + return { + sha256: hash.digest('hex'), + size, + mimeType: head.ContentType || 'application/octet-stream', + objectVersionId: head.VersionId, + }; +} +export async function readUrl(key: string, version: string) { + return getSignedUrl( + s3, + new GetObjectCommand({ + Bucket: bucket, + Key: key, + VersionId: version, + ResponseContentDisposition: 'attachment', + }), + { expiresIn: 120 }, + ); +} diff --git a/src/worker.ts b/src/worker.ts index efbd2de..27a415e 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,26 +1,434 @@ -import {Queue,Worker} from 'bullmq'; -import {createConnection} from 'node:net'; -import {GetObjectCommand,PutObjectCommand} from '@aws-sdk/client-s3'; -import {db} from './database'; -import {s3,bucket,readUrl} from './storage'; -import {canonical,sha256,permitted} from './domain'; -const redis=new URL(process.env.REDIS_URL||'redis://localhost:6379'); -const connection={host:redis.hostname,port:Number(redis.port||6379),...(redis.password?{password:decodeURIComponent(redis.password)}:{}),...(redis.protocol==='rediss:'?{tls:{}}:{})}; -const names=['evidence-process','transcribe','frame-index','entity-event-extract','timeline-refresh','graph-refresh','conflict-refresh','reconstruction','integrity-check','stellar-anchor','stellar-confirm','export-build'] as const; -const queues=new Map(names.map(n=>[n,new Queue(n,{connection,defaultJobOptions:{attempts:5,backoff:{type:'exponential',delay:3000},removeOnComplete:false,removeOnFail:false}})])); -async function scan(key:string,version:string){if(!process.env.CLAMAV_HOST)throw new Error('Malware scanner not configured; evidence remains quarantined');const object=await s3.send(new GetObjectCommand({Bucket:bucket,Key:key,VersionId:version}));await new Promise((resolve,reject)=>{const socket=createConnection({host:process.env.CLAMAV_HOST!,port:Number(process.env.CLAMAV_PORT||3310)});let response='';socket.setTimeout(120000,()=>socket.destroy(new Error('Malware scan timeout')));socket.on('error',reject);socket.on('data',chunk=>response+=chunk.toString());socket.on('end',()=>response.includes('stream: OK')?resolve():reject(new Error('Malware scan failed')));socket.on('connect',async()=>{try{socket.write('zINSTREAM\0');for await(const chunk of object.Body as any){const header=Buffer.alloc(4);header.writeUInt32BE(chunk.length);socket.write(header);if(!socket.write(chunk))await new Promise(r=>socket.once('drain',r))}socket.write(Buffer.alloc(4))}catch(error){socket.destroy();reject(error)}})})} -async function callAi(operation:string,payload:object){const r=await fetch(`${process.env.AI_URL}/internal/v1/${operation}`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${process.env.AI_SERVICE_TOKEN}`},body:JSON.stringify(payload),signal:AbortSignal.timeout(240000)});if(!r.ok)throw new Error(`AI processing failed (${r.status})`);return r.json() as Promise} -async function storeObject(key:string,content:string){const digest=sha256(content);const r=await s3.send(new PutObjectCommand({Bucket:bucket,Key:key,Body:content,ContentType:'application/json',IfNoneMatch:'*'}));if(!r.VersionId)throw new Error('Versioned storage required');return {sha256:digest,objectVersionId:r.VersionId}} -async function processEvidence(data:any){const v=await db.evidenceVersion.findUniqueOrThrow({where:{id:data.versionId},include:{evidence:true}});if(v.evidence.caseId!==data.caseId)throw new Error('Case/version mismatch');await scan(v.objectKey,v.objectVersionId);await db.evidenceItem.update({where:{id:v.evidenceId},data:{status:'PROCESSING'}});const recordId=`analysis-${v.id}`;let analysis=(await db.record.findUnique({where:{id:recordId}}))?.data as any; -if(!analysis){analysis=await callAi('process-evidence',{caseId:data.caseId,evidenceId:v.evidenceId,versionId:v.id,sha256:v.sha256,mimeType:v.mimeType,url:await readUrl(v.objectKey,v.objectVersionId)});if(analysis.versionId!==v.id||analysis.evidenceId!==v.evidenceId||analysis.sha256!==v.sha256)throw new Error('AI provenance mismatch');const content=canonical(analysis),key=`derived/${data.caseId}/${v.id}/${sha256(content)}.json`;try{await storeObject(key,content)}catch(error){if((error as any).$metadata?.httpStatusCode!==412)throw error}await db.$transaction(async tx=>{await tx.derivedArtifact.upsert({where:{objectKey:key},create:{versionId:v.id,objectKey:key,sha256:sha256(content),type:'AI_JSON',toolName:'afterprint-ai',toolVersion:'0.1.0',parametersHash:sha256('conservative-source-extraction/0.1')},update:{}});await tx.record.upsert({where:{id:recordId},create:{id:recordId,caseId:data.caseId,kind:`analysis:${v.id}`,data:analysis},update:{}})})} -const all=await db.record.findMany({where:{caseId:data.caseId,kind:{startsWith:'analysis:'}}});const payload={caseId:data.caseId,sources:all.map(r=>r.data)};const timeline=await callAi('build-timeline',payload),graph=await callAi('build-graph',payload),conflicts=await callAi('detect-conflicts',payload); -await db.$transaction(async tx=>{for(const [kind,result] of [['timeline',timeline],['graph',graph]] as const){const id=`${kind}-${data.caseId}`;await tx.record.upsert({where:{id},create:{id,caseId:data.caseId,kind,data:result},update:{data:result,revision:{increment:1}}})}for(const conflict of conflicts){const id=`conflict-${data.caseId}-${conflict.id}`;await tx.record.upsert({where:{id},create:{id,caseId:data.caseId,kind:'conflicts',data:{...conflict,id}},update:{}})}await tx.evidenceItem.update({where:{id:v.evidenceId},data:{status:'PROCESSED'}});await tx.outbox.upsert({where:{id:`anchor-${v.id}`},create:{id:`anchor-${v.id}`,queue:'stellar-anchor',data:{caseId:data.caseId,versionId:v.id}},update:{}})})} -async function exportBundle(data:any){const existing=await db.record.findUniqueOrThrow({where:{id:data.exportId}});if((existing.data as any).status==='READY')return;const membership=await db.caseMember.findUnique({where:{caseId_userId:{caseId:data.caseId,userId:data.actorId}}});if(!membership?.active||!permitted(membership.role,'EVIDENCE_EXPORT'))throw new Error('Export permission revoked');const versions=await db.evidenceVersion.findMany({where:{evidence:{caseId:data.caseId}},include:{custody:{orderBy:{occurredAt:'asc'}},artifacts:true}});const reports=await db.record.findMany({where:{caseId:data.caseId,kind:'reconstruction'}});const payload={schemaVersion:'1.0',caseRef:sha256(data.caseId),evidence:versions.map(v=>({evidenceVersionId:v.id,sha256:v.sha256,sizeBytes:v.size,mimeType:v.mimeType,objectVersionId:v.objectVersionId,custody:v.custody,derivedArtifacts:v.artifacts.map(a=>({id:a.id,sha256:a.sha256,tool:a.toolName,toolVersion:a.toolVersion}))})),reports:reports.filter(r=>(r.data as any).status==='FINALIZED').map(r=>r.data),verification:'Canonicalize payload recursively with lexicographically sorted object keys, preserve array order, then compute UTF-8 SHA-256. Source files require separate authorized retrieval. No legal admissibility is asserted.'};const content=canonical({payload,payloadSha256:sha256(canonical(payload))}),key=`exports/${data.caseId}/${data.exportId}.json`;let stored;try{stored=await storeObject(key,content)}catch(error){if((error as any).$metadata?.httpStatusCode!==412)throw error;const {inspect}=await import('./storage.js');stored=await inspect(key);if(stored.sha256!==sha256(content))throw new Error('Existing export differs from retry')}await db.record.update({where:{id:data.exportId},data:{data:{status:'READY',actorId:data.actorId,manifestHash:sha256(canonical(payload)),objectKey:key,objectVersionId:stored.objectVersionId}}})} -async function anchor(data:any){if(!process.env.STELLAR_ANCHOR_SERVICE_URL)throw new Error('Stellar signing service not configured; anchor remains pending'); -if(data.isAttestation){const att=await db.record.findUniqueOrThrow({where:{id:data.attestationId}});const attData=att.data as any;const manifest={schemaVersion:'1.0',caseRef:sha256(data.caseId),attestationRef:sha256(data.attestationId),subjectRef:sha256(data.versionId),statementHash:attData.statementHash};const r=await fetch(process.env.STELLAR_ANCHOR_SERVICE_URL,{method:'POST',headers:{Authorization:`Bearer ${process.env.STELLAR_SIGNER_TOKEN}`,'Content-Type':'application/json','Idempotency-Key':data.attestationId},body:JSON.stringify({evidenceVersionRef:sha256(data.attestationId),manifestHash:sha256(canonical(manifest))}),signal:AbortSignal.timeout(60000)});if(!r.ok)throw new Error('Anchor service rejected request');const result=await r.json() as any;if(!result.transactionHash||result.status!=='CONFIRMED')throw new Error('Anchor is not confirmed');await db.record.upsert({where:{id:`attestation-anchor-${data.attestationId}`},create:{id:`attestation-anchor-${data.attestationId}`,caseId:data.caseId,kind:'attestation-anchor',data:{manifestHash:sha256(canonical(manifest)),transactionHash:result.transactionHash,status:'CONFIRMED'}},update:{data:{manifestHash:sha256(canonical(manifest)),transactionHash:result.transactionHash,status:'CONFIRMED'}}});await db.record.update({where:{id:data.attestationId},data:{data:{...attData,status:'ANCHORED'}}});return} -const v=await db.evidenceVersion.findUniqueOrThrow({where:{id:data.versionId},include:{evidence:true,custody:{orderBy:{occurredAt:'desc'},take:1}}});const manifest={schemaVersion:'1.0',caseRef:sha256(data.caseId),evidenceRef:sha256(v.evidenceId),version:1,sha256:v.sha256,sizeBytes:v.size,mimeType:v.mimeType,importedAt:v.importedAt.toISOString(),sourceMetadataHash:sha256(v.evidence.source),custodyHeadHash:v.custody[0]?.eventHash||''};const r=await fetch(process.env.STELLAR_ANCHOR_SERVICE_URL,{method:'POST',headers:{Authorization:`Bearer ${process.env.STELLAR_SIGNER_TOKEN}`,'Content-Type':'application/json','Idempotency-Key':v.id},body:JSON.stringify({evidenceVersionRef:sha256(v.id),manifestHash:sha256(canonical(manifest))}),signal:AbortSignal.timeout(60000)});if(!r.ok)throw new Error('Anchor service rejected request');const result=await r.json() as any;if(!result.transactionHash||result.status!=='CONFIRMED')throw new Error('Anchor is not confirmed');await db.record.upsert({where:{id:`anchor-${v.id}`},create:{id:`anchor-${v.id}`,caseId:data.caseId,kind:'anchor',data:{manifestHash:sha256(canonical(manifest)),transactionHash:result.transactionHash,status:'CONFIRMED'}},update:{}})} -async function confirmAnchor(data:any){const recordId=`anchor-${data.versionId}`;const existing=await db.record.findUnique({where:{id:recordId}});if(!existing)throw new Error('No anchor record found; stellar-anchor may not have run yet');const d=existing.data as any;if(d.status==='CONFIRMED')return;if(!d.transactionHash)throw new Error('Anchor record has no transaction hash yet');const horizon=process.env.STELLAR_HORIZON_URL||'https://horizon-testnet.stellar.org';const res=await fetch(`${horizon}/transactions/${d.transactionHash}`,{signal:AbortSignal.timeout(30000)});if(!res.ok)throw new Error(`Horizon returned ${res.status} for tx ${d.transactionHash}`);const tx=await res.json() as any;if(!tx.successful)throw new Error('Transaction is not successful on Stellar');await db.record.update({where:{id:recordId},data:{data:{...d,status:'CONFIRMED',ledger:tx.ledger,confirmedAt:new Date().toISOString()}}})} -async function refreshCase(data:any){const {caseId}=data;if(!caseId)throw new Error('caseId required for refresh');const all=await db.record.findMany({where:{caseId,kind:{startsWith:'analysis:'}}});if(!all.length)return;const payload={caseId,sources:all.map(r=>r.data)};const [timeline,graph,conflicts]=await Promise.all([callAi('build-timeline',payload),callAi('build-graph',payload),callAi('detect-conflicts',payload)]);await db.$transaction(async tx=>{for(const [kind,result] of [['timeline',timeline],['graph',graph]] as const){const id=`${kind}-${caseId}`;await tx.record.upsert({where:{id},create:{id,caseId,kind,data:result},update:{data:result,revision:{increment:1}}})}for(const conflict of conflicts){const id=`conflict-${caseId}-${conflict.id}`;await tx.record.upsert({where:{id},create:{id,caseId,kind:'conflicts',data:{...conflict,id}},update:{}})}})} -const workers=names.map(name=>{const w=new Worker(name,job=>{if(['evidence-process','transcribe','frame-index','entity-event-extract'].includes(name))return processEvidence(job.data);if(name==='export-build')return exportBundle(job.data);if(name==='stellar-anchor')return anchor(job.data);if(name==='stellar-confirm')return confirmAnchor(job.data);if(['timeline-refresh','graph-refresh','conflict-refresh'].includes(name))return refreshCase(job.data);return Promise.resolve()},{connection,concurrency:2});w.on('failed',(job,error)=>{console.error(JSON.stringify({event:'job_failed',jobId:job?.id,queue:name,attempt:job?.attemptsMade,errorType:error.name,deadLetter:job?.attemptsMade===5}))});return w}); -let polling=false;const interval=setInterval(async()=>{if(polling)return;polling=true;try{for(const row of await db.outbox.findMany({where:{sentAt:null},take:50})){await queues.get(row.queue)!.add(row.queue,row.data,{jobId:row.id});await db.outbox.update({where:{id:row.id},data:{sentAt:new Date()}})}}catch(error){console.error(JSON.stringify({event:'outbox_retry',errorType:(error as Error).name}))}finally{polling=false}},1000); -async function shutdown(){clearInterval(interval);await Promise.all(workers.map(w=>w.close()));await Promise.all([...queues.values()].map(q=>q.close()));await db.$disconnect();process.exit(0)}process.on('SIGTERM',shutdown);process.on('SIGINT',shutdown); +import { Queue, Worker } from 'bullmq'; +import { createConnection } from 'node:net'; +import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { db } from './database'; +import { s3, bucket, readUrl } from './storage'; +import { canonical, sha256, permitted } from './domain'; +import { attestationManifest, createAnchorClient, evidenceManifest, manifestHash } from './anchor'; +const redis = new URL(process.env.REDIS_URL || 'redis://localhost:6379'); +const connection = { + host: redis.hostname, + port: Number(redis.port || 6379), + ...(redis.password ? { password: decodeURIComponent(redis.password) } : {}), + ...(redis.protocol === 'rediss:' ? { tls: {} } : {}), +}; +const names = [ + 'evidence-process', + 'transcribe', + 'frame-index', + 'entity-event-extract', + 'timeline-refresh', + 'graph-refresh', + 'conflict-refresh', + 'reconstruction', + 'integrity-check', + 'stellar-anchor', + 'stellar-confirm', + 'export-build', +] as const; +const queues = new Map( + names.map(n => [ + n, + new Queue(n, { + connection, + defaultJobOptions: { + attempts: 5, + backoff: { type: 'exponential', delay: 3000 }, + removeOnComplete: false, + removeOnFail: false, + }, + }), + ]), +); +async function scan(key: string, version: string) { + if (!process.env.CLAMAV_HOST) + throw new Error('Malware scanner not configured; evidence remains quarantined'); + const object = await s3.send( + new GetObjectCommand({ Bucket: bucket, Key: key, VersionId: version }), + ); + await new Promise((resolve, reject) => { + const socket = createConnection({ + host: process.env.CLAMAV_HOST!, + port: Number(process.env.CLAMAV_PORT || 3310), + }); + let response = ''; + socket.setTimeout(120000, () => socket.destroy(new Error('Malware scan timeout'))); + socket.on('error', reject); + socket.on('data', chunk => (response += chunk.toString())); + socket.on('end', () => + response.includes('stream: OK') ? resolve() : reject(new Error('Malware scan failed')), + ); + socket.on('connect', async () => { + try { + socket.write('zINSTREAM\0'); + for await (const chunk of object.Body as any) { + const header = Buffer.alloc(4); + header.writeUInt32BE(chunk.length); + socket.write(header); + if (!socket.write(chunk)) await new Promise(r => socket.once('drain', r)); + } + socket.write(Buffer.alloc(4)); + } catch (error) { + socket.destroy(); + reject(error); + } + }); + }); +} +async function callAi(operation: string, payload: object) { + const r = await fetch(`${process.env.AI_URL}/internal/v1/${operation}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.AI_SERVICE_TOKEN}`, + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(240000), + }); + if (!r.ok) throw new Error(`AI processing failed (${r.status})`); + return r.json() as Promise; +} +async function storeObject(key: string, content: string) { + const digest = sha256(content); + const r = await s3.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: content, + ContentType: 'application/json', + IfNoneMatch: '*', + }), + ); + if (!r.VersionId) throw new Error('Versioned storage required'); + return { sha256: digest, objectVersionId: r.VersionId }; +} +async function processEvidence(data: any) { + const v = await db.evidenceVersion.findUniqueOrThrow({ + where: { id: data.versionId }, + include: { evidence: true }, + }); + if (v.evidence.caseId !== data.caseId) throw new Error('Case/version mismatch'); + await scan(v.objectKey, v.objectVersionId); + await db.evidenceItem.update({ where: { id: v.evidenceId }, data: { status: 'PROCESSING' } }); + const recordId = `analysis-${v.id}`; + let analysis = (await db.record.findUnique({ where: { id: recordId } }))?.data as any; + if (!analysis) { + analysis = await callAi('process-evidence', { + caseId: data.caseId, + evidenceId: v.evidenceId, + versionId: v.id, + sha256: v.sha256, + mimeType: v.mimeType, + url: await readUrl(v.objectKey, v.objectVersionId), + }); + if ( + analysis.versionId !== v.id || + analysis.evidenceId !== v.evidenceId || + analysis.sha256 !== v.sha256 + ) + throw new Error('AI provenance mismatch'); + const content = canonical(analysis), + key = `derived/${data.caseId}/${v.id}/${sha256(content)}.json`; + try { + await storeObject(key, content); + } catch (error) { + if ((error as any).$metadata?.httpStatusCode !== 412) throw error; + } + await db.$transaction(async tx => { + await tx.derivedArtifact.upsert({ + where: { objectKey: key }, + create: { + versionId: v.id, + objectKey: key, + sha256: sha256(content), + type: 'AI_JSON', + toolName: 'afterprint-ai', + toolVersion: '0.1.0', + parametersHash: sha256('conservative-source-extraction/0.1'), + }, + update: {}, + }); + await tx.record.upsert({ + where: { id: recordId }, + create: { id: recordId, caseId: data.caseId, kind: `analysis:${v.id}`, data: analysis }, + update: {}, + }); + }); + } + const all = await db.record.findMany({ + where: { caseId: data.caseId, kind: { startsWith: 'analysis:' } }, + }); + const payload = { caseId: data.caseId, sources: all.map(r => r.data) }; + const timeline = await callAi('build-timeline', payload), + graph = await callAi('build-graph', payload), + conflicts = await callAi('detect-conflicts', payload); + await db.$transaction(async tx => { + for (const [kind, result] of [ + ['timeline', timeline], + ['graph', graph], + ] as const) { + const id = `${kind}-${data.caseId}`; + await tx.record.upsert({ + where: { id }, + create: { id, caseId: data.caseId, kind, data: result }, + update: { data: result, revision: { increment: 1 } }, + }); + } + for (const conflict of conflicts) { + const id = `conflict-${data.caseId}-${conflict.id}`; + await tx.record.upsert({ + where: { id }, + create: { id, caseId: data.caseId, kind: 'conflicts', data: { ...conflict, id } }, + update: {}, + }); + } + await tx.evidenceItem.update({ where: { id: v.evidenceId }, data: { status: 'PROCESSED' } }); + await tx.outbox.upsert({ + where: { id: `anchor-${v.id}` }, + create: { + id: `anchor-${v.id}`, + queue: 'stellar-anchor', + data: { caseId: data.caseId, versionId: v.id }, + }, + update: {}, + }); + }); +} +async function exportBundle(data: any) { + const existing = await db.record.findUniqueOrThrow({ where: { id: data.exportId } }); + if ((existing.data as any).status === 'READY') return; + const membership = await db.caseMember.findUnique({ + where: { caseId_userId: { caseId: data.caseId, userId: data.actorId } }, + }); + if (!membership?.active || !permitted(membership.role, 'EVIDENCE_EXPORT')) + throw new Error('Export permission revoked'); + const versions = await db.evidenceVersion.findMany({ + where: { evidence: { caseId: data.caseId } }, + include: { custody: { orderBy: { occurredAt: 'asc' } }, artifacts: true }, + }); + const reports = await db.record.findMany({ + where: { caseId: data.caseId, kind: 'reconstruction' }, + }); + const payload = { + schemaVersion: '1.0', + caseRef: sha256(data.caseId), + evidence: versions.map(v => ({ + evidenceVersionId: v.id, + sha256: v.sha256, + sizeBytes: v.size, + mimeType: v.mimeType, + objectVersionId: v.objectVersionId, + custody: v.custody, + derivedArtifacts: v.artifacts.map(a => ({ + id: a.id, + sha256: a.sha256, + tool: a.toolName, + toolVersion: a.toolVersion, + })), + })), + reports: reports.filter(r => (r.data as any).status === 'FINALIZED').map(r => r.data), + verification: + 'Canonicalize payload recursively with lexicographically sorted object keys, preserve array order, then compute UTF-8 SHA-256. Source files require separate authorized retrieval. No legal admissibility is asserted.', + }; + const content = canonical({ payload, payloadSha256: sha256(canonical(payload)) }), + key = `exports/${data.caseId}/${data.exportId}.json`; + let stored; + try { + stored = await storeObject(key, content); + } catch (error) { + if ((error as any).$metadata?.httpStatusCode !== 412) throw error; + const { inspect } = await import('./storage.js'); + stored = await inspect(key); + if (stored.sha256 !== sha256(content)) throw new Error('Existing export differs from retry'); + } + await db.record.update({ + where: { id: data.exportId }, + data: { + data: { + status: 'READY', + actorId: data.actorId, + manifestHash: sha256(canonical(payload)), + objectKey: key, + objectVersionId: stored.objectVersionId, + }, + }, + }); +} +async function anchor(data: any) { + // Throws AnchorNotConfiguredError first, so the job stays pending and is retried later. + const client = createAnchorClient(); + + if (data.isAttestation) { + const att = await db.record.findUniqueOrThrow({ where: { id: data.attestationId } }); + const attData = att.data as any; + const hash = manifestHash( + attestationManifest({ + caseId: data.caseId, + attestationId: data.attestationId, + versionId: data.versionId, + statementHash: attData.statementHash, + }), + ); + const result = await client.anchor({ + evidenceVersionRef: sha256(data.attestationId), + manifestHash: hash, + idempotencyKey: data.attestationId, + }); + const anchorData = { + manifestHash: hash, + transactionHash: result.transactionHash, + status: 'CONFIRMED', + }; + const id = `attestation-anchor-${data.attestationId}`; + await db.record.upsert({ + where: { id }, + create: { id, caseId: data.caseId, kind: 'attestation-anchor', data: anchorData }, + update: { data: anchorData }, + }); + await db.record.update({ + where: { id: data.attestationId }, + data: { data: { ...attData, status: 'ANCHORED' } }, + }); + return; + } + + const v = await db.evidenceVersion.findUniqueOrThrow({ + where: { id: data.versionId }, + include: { evidence: true, custody: { orderBy: { occurredAt: 'desc' }, take: 1 } }, + }); + const hash = manifestHash( + evidenceManifest({ + caseId: data.caseId, + evidenceId: v.evidenceId, + sha256: v.sha256, + size: v.size, + mimeType: v.mimeType, + importedAt: v.importedAt, + source: v.evidence.source, + custodyHeadHash: v.custody[0]?.eventHash || '', + }), + ); + const result = await client.anchor({ + evidenceVersionRef: sha256(v.id), + manifestHash: hash, + idempotencyKey: v.id, + }); + const id = `anchor-${v.id}`; + await db.record.upsert({ + where: { id }, + create: { + id, + caseId: data.caseId, + kind: 'anchor', + data: { manifestHash: hash, transactionHash: result.transactionHash, status: 'CONFIRMED' }, + }, + update: {}, + }); +} +async function confirmAnchor(data: any) { + const recordId = `anchor-${data.versionId}`; + const existing = await db.record.findUnique({ where: { id: recordId } }); + if (!existing) throw new Error('No anchor record found; stellar-anchor may not have run yet'); + const d = existing.data as any; + if (d.status === 'CONFIRMED') return; + if (!d.transactionHash) throw new Error('Anchor record has no transaction hash yet'); + const horizon = process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org'; + const res = await fetch(`${horizon}/transactions/${d.transactionHash}`, { + signal: AbortSignal.timeout(30000), + }); + if (!res.ok) throw new Error(`Horizon returned ${res.status} for tx ${d.transactionHash}`); + const tx = (await res.json()) as any; + if (!tx.successful) throw new Error('Transaction is not successful on Stellar'); + await db.record.update({ + where: { id: recordId }, + data: { + data: { ...d, status: 'CONFIRMED', ledger: tx.ledger, confirmedAt: new Date().toISOString() }, + }, + }); +} +async function refreshCase(data: any) { + const { caseId } = data; + if (!caseId) throw new Error('caseId required for refresh'); + const all = await db.record.findMany({ where: { caseId, kind: { startsWith: 'analysis:' } } }); + if (!all.length) return; + const payload = { caseId, sources: all.map(r => r.data) }; + const [timeline, graph, conflicts] = await Promise.all([ + callAi('build-timeline', payload), + callAi('build-graph', payload), + callAi('detect-conflicts', payload), + ]); + await db.$transaction(async tx => { + for (const [kind, result] of [ + ['timeline', timeline], + ['graph', graph], + ] as const) { + const id = `${kind}-${caseId}`; + await tx.record.upsert({ + where: { id }, + create: { id, caseId, kind, data: result }, + update: { data: result, revision: { increment: 1 } }, + }); + } + for (const conflict of conflicts) { + const id = `conflict-${caseId}-${conflict.id}`; + await tx.record.upsert({ + where: { id }, + create: { id, caseId, kind: 'conflicts', data: { ...conflict, id } }, + update: {}, + }); + } + }); +} +const workers = names.map(name => { + const w = new Worker( + name, + job => { + if (['evidence-process', 'transcribe', 'frame-index', 'entity-event-extract'].includes(name)) + return processEvidence(job.data); + if (name === 'export-build') return exportBundle(job.data); + if (name === 'stellar-anchor') return anchor(job.data); + if (name === 'stellar-confirm') return confirmAnchor(job.data); + if (['timeline-refresh', 'graph-refresh', 'conflict-refresh'].includes(name)) + return refreshCase(job.data); + return Promise.resolve(); + }, + { connection, concurrency: 2 }, + ); + w.on('failed', (job, error) => { + console.error( + JSON.stringify({ + event: 'job_failed', + jobId: job?.id, + queue: name, + attempt: job?.attemptsMade, + errorType: error.name, + deadLetter: job?.attemptsMade === 5, + }), + ); + }); + return w; +}); +let polling = false; +const interval = setInterval(async () => { + if (polling) return; + polling = true; + try { + for (const row of await db.outbox.findMany({ where: { sentAt: null }, take: 50 })) { + await queues.get(row.queue)!.add(row.queue, row.data, { jobId: row.id }); + await db.outbox.update({ where: { id: row.id }, data: { sentAt: new Date() } }); + } + } catch (error) { + console.error(JSON.stringify({ event: 'outbox_retry', errorType: (error as Error).name })); + } finally { + polling = false; + } +}, 1000); +async function shutdown() { + clearInterval(interval); + await Promise.all(workers.map(w => w.close())); + await Promise.all([...queues.values()].map(q => q.close())); + await db.$disconnect(); + process.exit(0); +} +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); diff --git a/test/anchor.test.ts b/test/anchor.test.ts new file mode 100644 index 0000000..b38262e --- /dev/null +++ b/test/anchor.test.ts @@ -0,0 +1,365 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Account, Keypair, StrKey } from '@stellar/stellar-sdk'; +import { + AnchorNotConfiguredError, + attestationManifest, + createAnchorClient, + evidenceManifest, + httpAnchorClient, + manifestHash, + sorobanAnchorClient, + submitterRef, + type SorobanServer, +} from '../src/anchor'; +import { sha256 } from '../src/domain'; + +const REF = 'a'.repeat(64); +const HASH = 'b'.repeat(64); +const CONTRACT = StrKey.encodeContract(Buffer.alloc(32, 7)); + +/* -------------------------------------------------------------------------- */ +/* Manifests */ +/* -------------------------------------------------------------------------- */ + +const evidenceInput = () => ({ + caseId: 'case-1', + evidenceId: 'ev-1', + sha256: 'c'.repeat(64), + size: 1024, + mimeType: 'video/mp4', + importedAt: new Date('2026-09-14T10:00:00.000Z'), + source: 'Body camera, unit 12', + custodyHeadHash: 'd'.repeat(64), +}); + +test('evidence manifest holds digests and references, never raw identifiers or the source text', () => { + const manifest = evidenceManifest(evidenceInput()); + const text = JSON.stringify(manifest); + + assert.equal(manifest.schemaVersion, '1.0'); + assert.equal(manifest.caseRef, sha256('case-1')); + assert.equal(manifest.evidenceRef, sha256('ev-1')); + assert.equal(manifest.sourceMetadataHash, sha256('Body camera, unit 12')); + assert.equal(manifest.importedAt, '2026-09-14T10:00:00.000Z'); + assert.ok(!text.includes('case-1'), 'case id must not appear in the clear'); + assert.ok(!text.includes('ev-1'), 'evidence id must not appear in the clear'); + assert.ok(!text.includes('Body camera'), 'source text must not appear in the clear'); +}); + +test('manifest hash is a 64 character hex digest', () => { + assert.match(manifestHash(evidenceManifest(evidenceInput())), /^[0-9a-f]{64}$/); +}); + +test('manifest hash does not depend on property order', () => { + assert.equal( + manifestHash({ a: 1, b: { d: 4, c: 3 } }), + manifestHash({ b: { c: 3, d: 4 }, a: 1 }), + ); +}); + +test('changing any single evidence field changes the manifest hash', () => { + const base = manifestHash(evidenceManifest(evidenceInput())); + const variants: Array>> = [ + { caseId: 'case-2' }, + { evidenceId: 'ev-2' }, + { sha256: 'e'.repeat(64) }, + { size: 1025 }, + { mimeType: 'video/webm' }, + { importedAt: new Date('2026-09-14T10:00:01.000Z') }, + { source: 'Body camera, unit 13' }, + { custodyHeadHash: 'f'.repeat(64) }, + ]; + for (const change of variants) { + const changed = manifestHash(evidenceManifest({ ...evidenceInput(), ...change })); + assert.notEqual(changed, base, `changing ${Object.keys(change)[0]} must change the hash`); + } +}); + +test('attestation manifest commits to the case, attestation, subject, and statement', () => { + const input = { + caseId: 'case-1', + attestationId: 'att-1', + versionId: 'v-1', + statementHash: 'a'.repeat(64), + }; + const base = manifestHash(attestationManifest(input)); + assert.equal(attestationManifest(input).subjectRef, sha256('v-1')); + for (const change of [ + { caseId: 'case-2' }, + { attestationId: 'att-2' }, + { versionId: 'v-2' }, + { statementHash: 'b'.repeat(64) }, + ]) { + assert.notEqual(manifestHash(attestationManifest({ ...input, ...change })), base); + } +}); + +/* -------------------------------------------------------------------------- */ +/* Sidecar client */ +/* -------------------------------------------------------------------------- */ + +function fakeFetch(response: { ok: boolean; body?: unknown }) { + const calls: Array<{ url: string; init: any }> = []; + const impl = (async (url: string, init: any) => { + calls.push({ url, init }); + return { ok: response.ok, json: async () => response.body } as Response; + }) as unknown as typeof fetch; + return { impl, calls }; +} + +const request = { evidenceVersionRef: REF, manifestHash: HASH, idempotencyKey: 'version-1' }; + +test('sidecar client posts the refs with the token and the idempotency key', async () => { + const f = fakeFetch({ ok: true, body: { transactionHash: 'tx1', status: 'CONFIRMED' } }); + const client = httpAnchorClient({ + url: 'https://signer.test/anchor', + token: 'secret', + fetchImpl: f.impl, + }); + + const result = await client.anchor(request); + + assert.deepEqual(result, { transactionHash: 'tx1', status: 'CONFIRMED' }); + assert.equal(f.calls.length, 1); + assert.equal(f.calls[0].url, 'https://signer.test/anchor'); + assert.equal(f.calls[0].init.method, 'POST'); + assert.equal(f.calls[0].init.headers.Authorization, 'Bearer secret'); + assert.equal(f.calls[0].init.headers['Idempotency-Key'], 'version-1'); + assert.deepEqual(JSON.parse(f.calls[0].init.body), { + evidenceVersionRef: REF, + manifestHash: HASH, + }); +}); + +test('sidecar client fails when the service rejects the request', async () => { + const f = fakeFetch({ ok: false }); + const client = httpAnchorClient({ url: 'https://s.test', token: 't', fetchImpl: f.impl }); + await assert.rejects(client.anchor(request), /rejected request/); +}); + +test('sidecar client refuses an unconfirmed or hash-less answer', async () => { + for (const body of [{ transactionHash: 'tx1', status: 'PENDING' }, { status: 'CONFIRMED' }, {}]) { + const f = fakeFetch({ ok: true, body }); + const client = httpAnchorClient({ url: 'https://s.test', token: 't', fetchImpl: f.impl }); + await assert.rejects(client.anchor(request), /not confirmed/); + } +}); + +test('both clients reject malformed references before touching the network', async () => { + const f = fakeFetch({ ok: true, body: {} }); + const client = httpAnchorClient({ url: 'https://s.test', token: 't', fetchImpl: f.impl }); + for (const bad of ['', 'xyz', 'A'.repeat(64), 'a'.repeat(63), 'a'.repeat(65)]) { + await assert.rejects( + client.anchor({ ...request, evidenceVersionRef: bad }), + /64 lowercase hex/, + ); + await assert.rejects(client.anchor({ ...request, manifestHash: bad }), /64 lowercase hex/); + } + assert.equal(f.calls.length, 0); +}); + +/* -------------------------------------------------------------------------- */ +/* In-process Soroban signer */ +/* -------------------------------------------------------------------------- */ + +function setup(overrides: Partial = {}, outcomes: string[] = ['SUCCESS']) { + const signer = Keypair.random(); + const sent: any[] = []; + const polls: string[] = []; + const queue = [...outcomes]; + let clock = 0; + const server: SorobanServer = { + getAccount: async () => new Account(signer.publicKey(), '100'), + simulateTransaction: async () => ({}), + sendTransaction: async tx => { + sent.push(tx); + return { status: 'PENDING', hash: 'TXHASH' }; + }, + getTransaction: async hash => { + polls.push(hash); + return { status: queue.length > 1 ? queue.shift()! : queue[0] }; + }, + ...overrides, + }; + const client = sorobanAnchorClient({ + contractId: CONTRACT, + signer, + server, + assemble: tx => ({ build: () => tx }), + sleep: async ms => { + clock += ms; + }, + now: () => clock, + confirmTimeoutMs: 10_000, + pollIntervalMs: 1_000, + }); + return { signer, sent, polls, client }; +} + +test('signer calls anchor on the right contract with the three 32-byte arguments', async () => { + const { signer, sent, client } = setup(); + + const result = await client.anchor(request); + + assert.deepEqual(result, { transactionHash: 'TXHASH', status: 'CONFIRMED' }); + assert.equal(sent.length, 1); + const operation = sent[0].operations[0]; + assert.equal(operation.type, 'invokeHostFunction'); + // SDK 17 exposes the decoded XDR as plain tagged objects. + const invoke = operation.func.invokeContract; + assert.equal( + Buffer.from(invoke.contractAddress.contractId.value).toString('hex'), + Buffer.from(StrKey.decodeContract(CONTRACT)).toString('hex'), + ); + assert.equal(Buffer.from(invoke.functionName.bytes).toString('utf8'), 'anchor'); + const args = invoke.args.map((a: any) => Buffer.from(a.bytes.value).toString('hex')); + assert.deepEqual(args, [REF, HASH, submitterRef(signer)]); +}); + +test('signer signs the transaction with its own key', async () => { + const { signer, sent, client } = setup(); + await client.anchor(request); + + const tx = sent[0]; + assert.equal(tx.signatures.length, 1); + const signature: any = tx.signatures[0].signature; + assert.ok(signer.verify(tx.hash(), Buffer.from(signature.value ?? signature))); + assert.equal(tx.source, signer.publicKey()); +}); + +test('submitter ref is the hash of the public key, not the key itself', () => { + const signer = Keypair.random(); + assert.equal(submitterRef(signer), sha256(signer.publicKey())); + assert.match(submitterRef(signer), /^[0-9a-f]{64}$/); +}); + +test('signer waits while the transaction is not yet found', async () => { + const { polls, client } = setup({}, ['NOT_FOUND', 'NOT_FOUND', 'SUCCESS']); + const result = await client.anchor(request); + assert.equal(result.status, 'CONFIRMED'); + assert.equal(polls.length, 3); +}); + +test('signer fails without sending when simulation fails', async () => { + const { sent, client } = setup({ + simulateTransaction: async () => ({ error: 'HostError: auth' }), + }); + await assert.rejects(client.anchor(request), /simulation failed: HostError: auth/); + assert.equal(sent.length, 0); +}); + +test('signer fails when the network rejects the submission', async () => { + const { client } = setup({ sendTransaction: async () => ({ status: 'ERROR', hash: 'X' }) }); + await assert.rejects(client.anchor(request), /rejected by the network/); +}); + +test('signer fails when the transaction fails on-chain', async () => { + const { client } = setup({}, ['FAILED']); + await assert.rejects(client.anchor(request), /failed on-chain/); +}); + +test('signer gives up after the timeout and says a retry is safe', async () => { + const { polls, client } = setup({}, ['NOT_FOUND']); + await assert.rejects(client.anchor(request), /not confirmed in time; retry is safe/); + assert.ok(polls.length >= 2 && polls.length <= 12, `polled ${polls.length} times`); +}); + +test('signer rejects malformed references before contacting the network', async () => { + let touched = false; + const { client } = setup({ + getAccount: async () => { + touched = true; + throw new Error('should not be called'); + }, + }); + await assert.rejects(client.anchor({ ...request, manifestHash: 'nothex' }), /64 lowercase hex/); + assert.equal(touched, false); +}); + +/* -------------------------------------------------------------------------- */ +/* Selecting a client from the environment */ +/* -------------------------------------------------------------------------- */ + +test('with nothing configured the job stays pending', () => { + assert.throws(() => createAnchorClient({}), AnchorNotConfiguredError); + assert.throws(() => createAnchorClient({}), /anchor remains pending/); +}); + +test('a secret without a contract, or a contract without a secret, is not enough', () => { + const secret = Keypair.random().secret(); + assert.throws( + () => createAnchorClient({ STELLAR_SIGNER_SECRET: secret }), + AnchorNotConfiguredError, + ); + assert.throws( + () => createAnchorClient({ STELLAR_EVIDENCE_ANCHOR_CONTRACT: CONTRACT }), + AnchorNotConfiguredError, + ); +}); + +test('the sidecar needs its token', () => { + assert.throws( + () => createAnchorClient({ STELLAR_ANCHOR_SERVICE_URL: 'https://s.test' }), + /STELLAR_SIGNER_TOKEN is required/, + ); + assert.ok( + createAnchorClient({ STELLAR_ANCHOR_SERVICE_URL: 'https://s.test', STELLAR_SIGNER_TOKEN: 't' }), + ); +}); + +test('the in-process signer is built from a secret and a contract', () => { + const client = createAnchorClient({ + STELLAR_SIGNER_SECRET: Keypair.random().secret(), + STELLAR_EVIDENCE_ANCHOR_CONTRACT: CONTRACT, + }); + assert.equal(typeof client.anchor, 'function'); +}); + +test('an invalid secret is reported without echoing it', () => { + const bad = 'SNOTAREALSECRETKEYxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; + try { + createAnchorClient({ STELLAR_SIGNER_SECRET: bad, STELLAR_EVIDENCE_ANCHOR_CONTRACT: CONTRACT }); + assert.fail('should have thrown'); + } catch (error) { + assert.ok(error instanceof AnchorNotConfiguredError); + assert.ok(!(error as Error).message.includes(bad), 'the secret must not appear in the error'); + } +}); + +test('only testnet is allowed for now', () => { + assert.throws( + () => + createAnchorClient({ + STELLAR_SIGNER_SECRET: Keypair.random().secret(), + STELLAR_EVIDENCE_ANCHOR_CONTRACT: CONTRACT, + STELLAR_NETWORK: 'public', + }), + /Only STELLAR_NETWORK=testnet is supported/, + ); +}); + +test('the sidecar takes priority when both are configured', async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (url: string) => { + calls.push(String(url)); + return { + ok: true, + json: async () => ({ transactionHash: 'via-sidecar', status: 'CONFIRMED' }), + }; + }) as unknown as typeof fetch; + try { + const client = createAnchorClient({ + STELLAR_ANCHOR_SERVICE_URL: 'https://sidecar.test/anchor', + STELLAR_SIGNER_TOKEN: 't', + STELLAR_SIGNER_SECRET: Keypair.random().secret(), + STELLAR_EVIDENCE_ANCHOR_CONTRACT: CONTRACT, + }); + const result = await client.anchor(request); + assert.equal(result.transactionHash, 'via-sidecar'); + assert.deepEqual(calls, ['https://sidecar.test/anchor']); + } finally { + globalThis.fetch = realFetch; + } +}); diff --git a/test/domain-extra.test.ts b/test/domain-extra.test.ts new file mode 100644 index 0000000..aecb2c5 --- /dev/null +++ b/test/domain-extra.test.ts @@ -0,0 +1,260 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { Keypair } from '@stellar/stellar-sdk'; +import { + canonical, + capabilities, + categories, + generateNonce, + mediaType, + permitted, + roles, + sha256, + validateClaims, + verifyStellarSignature, +} from '../src/domain'; + +/** Sign the way a SEP-53 wallet (Freighter) does. */ +function signSep53(keypair: Keypair, message: string): string { + const payload = Buffer.concat([ + Buffer.from('Stellar Signed Message:\n', 'utf8'), + Buffer.from(message, 'utf8'), + ]); + // Buffer.from: SDK 17 returns a Uint8Array, whose toString() is not base64. + return Buffer.from(keypair.sign(createHash('sha256').update(payload).digest())).toString( + 'base64', + ); +} + +/* -------------------------------------------------------------------------- */ +/* Wallet signatures */ +/* -------------------------------------------------------------------------- */ + +test('a real SEP-53 signature from the wallet verifies', () => { + const wallet = Keypair.random(); + const message = 'Afterprint authentication challenge: abc123'; + assert.equal( + verifyStellarSignature(wallet.publicKey(), message, signSep53(wallet, message)), + true, + ); +}); + +test('a signature over a different message is rejected', () => { + const wallet = Keypair.random(); + const signature = signSep53(wallet, 'message one'); + assert.equal(verifyStellarSignature(wallet.publicKey(), 'message two', signature), false); +}); + +test("another wallet's signature is rejected", () => { + const wallet = Keypair.random(); + const impostor = Keypair.random(); + const message = 'challenge'; + assert.equal( + verifyStellarSignature(wallet.publicKey(), message, signSep53(impostor, message)), + false, + ); +}); + +test('a signature over the raw message, without the SEP-53 prefix, is rejected', () => { + // The login must only accept what a real wallet produces. + const wallet = Keypair.random(); + const message = 'challenge'; + const raw = Buffer.from(wallet.sign(Buffer.from(message, 'utf8'))).toString('base64'); + assert.equal(verifyStellarSignature(wallet.publicKey(), message, raw), false); +}); + +test('malformed signatures and keys fail closed instead of throwing', () => { + const wallet = Keypair.random(); + const good = signSep53(wallet, 'm'); + assert.equal(verifyStellarSignature(wallet.publicKey(), 'm', ''), false); + assert.equal(verifyStellarSignature(wallet.publicKey(), 'm', 'not base64!!'), false); + assert.equal( + verifyStellarSignature(wallet.publicKey(), 'm', Buffer.alloc(63).toString('base64')), + false, + ); + assert.equal( + verifyStellarSignature(wallet.publicKey(), 'm', Buffer.alloc(65).toString('base64')), + false, + ); + assert.equal(verifyStellarSignature('not-a-public-key', 'm', good), false); + assert.equal(verifyStellarSignature('', 'm', good), false); +}); + +test('flipping one bit of a valid signature makes it invalid', () => { + const wallet = Keypair.random(); + const bytes = Buffer.from(signSep53(wallet, 'm'), 'base64'); + bytes[10] ^= 1; + assert.equal(verifyStellarSignature(wallet.publicKey(), 'm', bytes.toString('base64')), false); +}); + +/* -------------------------------------------------------------------------- */ +/* Roles and capabilities */ +/* -------------------------------------------------------------------------- */ + +test('every role can at least view the case and evidence', () => { + for (const role of roles) { + assert.equal(permitted(role, 'CASE_VIEW'), true, `${role} should view cases`); + assert.equal(permitted(role, 'EVIDENCE_VIEW'), true, `${role} should view evidence`); + } +}); + +test('only the case admin can administer the case', () => { + for (const role of roles) { + assert.equal(permitted(role, 'CASE_ADMIN'), role === 'CASE_ADMIN', role); + } +}); + +test('only roles that can upload evidence may upload it', () => { + const uploaders = roles.filter(role => permitted(role, 'EVIDENCE_UPLOAD')); + assert.deepEqual([...uploaders].sort(), ['CASE_ADMIN', 'FORENSIC_ANALYST', 'INVESTIGATOR']); +}); + +test('only admins and legal reviewers can export or attest', () => { + for (const capability of ['EVIDENCE_EXPORT', 'ATTEST']) { + const allowed = roles.filter(role => permitted(role, capability)); + assert.deepEqual([...allowed].sort(), ['CASE_ADMIN', 'LEGAL_REVIEWER'], capability); + } +}); + +test('external reviewers and read-only users have no capability beyond viewing', () => { + assert.deepEqual(capabilities.EXTERNAL_REVIEWER, ['CASE_VIEW', 'EVIDENCE_VIEW']); + assert.deepEqual(capabilities.READ_ONLY, ['CASE_VIEW', 'EVIDENCE_VIEW']); +}); + +test('unknown roles and unknown capabilities are denied', () => { + assert.equal(permitted('SUPERUSER', 'CASE_VIEW'), false); + assert.equal(permitted('', 'CASE_VIEW'), false); + assert.equal(permitted('CASE_ADMIN', 'DELETE_EVERYTHING'), false); + assert.equal(permitted('__proto__', 'CASE_VIEW'), false); + assert.equal(permitted('constructor', 'CASE_VIEW'), false); + assert.equal(permitted('toString', 'CASE_VIEW'), false); +}); + +/* -------------------------------------------------------------------------- */ +/* Hashing and canonical form */ +/* -------------------------------------------------------------------------- */ + +test('sha256 matches a known vector', () => { + assert.equal(sha256('abc'), 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + assert.equal(sha256(Buffer.from('abc')), sha256('abc')); +}); + +test('canonical form is stable for nested values, nulls, and arrays', () => { + assert.equal( + canonical({ b: [1, { z: null, a: 'x' }], a: true }), + '{"a":true,"b":[1,{"a":"x","z":null}]}', + ); + assert.equal(canonical(null), 'null'); + assert.equal(canonical('text'), '"text"'); + assert.equal(canonical([]), '[]'); + assert.equal(canonical({}), '{}'); +}); + +test('canonical form keeps array order, so reordering is detectable', () => { + assert.notEqual(canonical([1, 2, 3]), canonical([3, 2, 1])); +}); + +test('nonces are 64 hex characters and do not repeat', () => { + const seen = new Set(); + for (let i = 0; i < 200; i++) { + const nonce = generateNonce(); + assert.match(nonce, /^[0-9a-f]{64}$/); + seen.add(nonce); + } + assert.equal(seen.size, 200); +}); + +/* -------------------------------------------------------------------------- */ +/* Media types */ +/* -------------------------------------------------------------------------- */ + +test('mime types map to the evidence media type', () => { + assert.equal(mediaType('video/mp4'), 'VIDEO'); + assert.equal(mediaType('audio/wav'), 'AUDIO'); + assert.equal(mediaType('image/png'), 'IMAGE'); + assert.equal(mediaType('application/pdf'), 'DOCUMENT'); + assert.equal(mediaType('text/plain'), 'DOCUMENT'); + assert.equal(mediaType('anything/else'), 'DOCUMENT'); +}); + +/* -------------------------------------------------------------------------- */ +/* Claim validation */ +/* -------------------------------------------------------------------------- */ + +const known = new Set(['v1', 'v2']); +const cite = (versionId: string) => ({ versionId, span: 'p1' }); + +test('an unknown claim category is rejected', () => { + assert.throws( + () => validateClaims([{ category: 'GUESS', citations: [] }], known), + /Invalid claim category/, + ); +}); + +test('every grounded category needs at least one citation', () => { + for (const category of ['VERIFIED_FACT', 'CORROBORATED_CLAIM', 'CONFLICT']) { + assert.throws(() => validateClaims([{ category }], known), /missing citation/, category); + } +}); + +test('inference and unknown claims may stand without citations', () => { + assert.doesNotThrow(() => + validateClaims([{ category: 'INFERENCE' }, { category: 'UNKNOWN' }], known), + ); +}); + +test('a citation must point at a version in this case and carry a span', () => { + assert.throws( + () => validateClaims([{ category: 'VERIFIED_FACT', citations: [cite('other-case')] }], known), + /Invalid citation/, + ); + assert.throws( + () => + validateClaims( + [{ category: 'VERIFIED_FACT', citations: [{ versionId: 'v1', span: '' }] }], + known, + ), + /Invalid citation/, + ); + assert.throws( + () => validateClaims([{ category: 'INFERENCE', citations: [{ versionId: 'v1' }] }], known), + /Invalid citation/, + ); +}); + +test('corroboration needs two distinct sources, not two spans of one', () => { + assert.throws( + () => + validateClaims( + [{ category: 'CORROBORATED_CLAIM', citations: [cite('v1'), cite('v1')] }], + known, + ), + /distinct sources/, + ); + assert.doesNotThrow(() => + validateClaims( + [{ category: 'CORROBORATED_CLAIM', citations: [cite('v1'), cite('v2')] }], + known, + ), + ); +}); + +test('one bad claim rejects the whole batch', () => { + assert.throws(() => + validateClaims( + [ + { category: 'VERIFIED_FACT', citations: [cite('v1')] }, + { category: 'VERIFIED_FACT', citations: [] }, + ], + known, + ), + ); +}); + +test('the five claim categories are exactly the documented ones', () => { + assert.deepEqual( + [...categories], + ['VERIFIED_FACT', 'CORROBORATED_CLAIM', 'INFERENCE', 'CONFLICT', 'UNKNOWN'], + ); +}); diff --git a/test/domain.test.ts b/test/domain.test.ts index a4d9a19..6f8975d 100644 --- a/test/domain.test.ts +++ b/test/domain.test.ts @@ -1,9 +1,51 @@ -import {test} from 'node:test'; +import { test } from 'node:test'; import assert from 'node:assert/strict'; -import {canonical,sha256,chain,permitted,validateClaims} from '../src/domain'; -test('canonical hashes do not depend on property insertion order',()=>{assert.equal(sha256(canonical({b:2,a:{d:4,c:3}})),sha256(canonical({a:{c:3,d:4},b:2})));assert.notEqual(sha256(canonical([1,2])),sha256(canonical([2,1])))}); -test('byte modification changes integrity hash',()=>assert.notEqual(sha256(Buffer.from('original')),sha256(Buffer.from('origina1')))); -test('custody chain commits to previous event and current event',()=>{const first=chain('',{type:'IMPORTED'});const second=chain(first,{type:'TRANSFERRED'});assert.notEqual(second,chain('',{type:'TRANSFERRED'}));assert.notEqual(second,chain(first,{type:'EXPORTED'}))}); -test('read only role cannot export, query or finalize',()=>{assert.equal(permitted('READ_ONLY','CASE_VIEW'),true);for(const c of ['EVIDENCE_EXPORT','AI_QUERY','RECONSTRUCTION_FINALIZE','CASE_ADMIN'])assert.equal(permitted('READ_ONLY',c),false);assert.equal(permitted('made-up-role','CASE_VIEW'),false)}); -test('legal reviewer can finalize but cannot upload',()=>{assert.equal(permitted('LEGAL_REVIEWER','RECONSTRUCTION_FINALIZE'),true);assert.equal(permitted('LEGAL_REVIEWER','EVIDENCE_UPLOAD'),false)}); -test('claims fail closed on missing or cross-case citations',()=>{assert.throws(()=>validateClaims([{category:'VERIFIED_FACT',citations:[]}],new Set()));assert.throws(()=>validateClaims([{category:'INFERENCE',citations:[{versionId:'secret-case',span:'p1'}]}],new Set(['v1'])));assert.throws(()=>validateClaims([{category:'CORROBORATED_CLAIM',citations:[{versionId:'v1',span:'p1'},{versionId:'v1',span:'p2'}]}],new Set(['v1'])))}); +import { canonical, sha256, chain, permitted, validateClaims } from '../src/domain'; +test('canonical hashes do not depend on property insertion order', () => { + assert.equal( + sha256(canonical({ b: 2, a: { d: 4, c: 3 } })), + sha256(canonical({ a: { c: 3, d: 4 }, b: 2 })), + ); + assert.notEqual(sha256(canonical([1, 2])), sha256(canonical([2, 1]))); +}); +test('byte modification changes integrity hash', () => + assert.notEqual(sha256(Buffer.from('original')), sha256(Buffer.from('origina1')))); +test('custody chain commits to previous event and current event', () => { + const first = chain('', { type: 'IMPORTED' }); + const second = chain(first, { type: 'TRANSFERRED' }); + assert.notEqual(second, chain('', { type: 'TRANSFERRED' })); + assert.notEqual(second, chain(first, { type: 'EXPORTED' })); +}); +test('read only role cannot export, query or finalize', () => { + assert.equal(permitted('READ_ONLY', 'CASE_VIEW'), true); + for (const c of ['EVIDENCE_EXPORT', 'AI_QUERY', 'RECONSTRUCTION_FINALIZE', 'CASE_ADMIN']) + assert.equal(permitted('READ_ONLY', c), false); + assert.equal(permitted('made-up-role', 'CASE_VIEW'), false); +}); +test('legal reviewer can finalize but cannot upload', () => { + assert.equal(permitted('LEGAL_REVIEWER', 'RECONSTRUCTION_FINALIZE'), true); + assert.equal(permitted('LEGAL_REVIEWER', 'EVIDENCE_UPLOAD'), false); +}); +test('claims fail closed on missing or cross-case citations', () => { + assert.throws(() => validateClaims([{ category: 'VERIFIED_FACT', citations: [] }], new Set())); + assert.throws(() => + validateClaims( + [{ category: 'INFERENCE', citations: [{ versionId: 'secret-case', span: 'p1' }] }], + new Set(['v1']), + ), + ); + assert.throws(() => + validateClaims( + [ + { + category: 'CORROBORATED_CLAIM', + citations: [ + { versionId: 'v1', span: 'p1' }, + { versionId: 'v1', span: 'p2' }, + ], + }, + ], + new Set(['v1']), + ), + ); +}); diff --git a/test/integrity.spec.ts b/test/integrity.spec.ts index 6648f19..ceec896 100644 --- a/test/integrity.spec.ts +++ b/test/integrity.spec.ts @@ -1,21 +1,36 @@ -import {test} from 'node:test'; +import { test } from 'node:test'; import assert from 'node:assert/strict'; -import {canonical,sha256,chain,validateClaims} from '../src/domain'; +import { canonical, sha256, chain, validateClaims } from '../src/domain'; -test('sha256(canonical(x)) is deterministic',()=>{ - const x={b:2,a:[1,{z:9,y:8}],c:'text'}; - assert.equal(sha256(canonical(x)),sha256(canonical(x))); +test('sha256(canonical(x)) is deterministic', () => { + const x = { b: 2, a: [1, { z: 9, y: 8 }], c: 'text' }; + assert.equal(sha256(canonical(x)), sha256(canonical(x))); }); -test('chain(prev,event) changes when event changes',()=>{ - const prev=chain('',{type:'IMPORTED'}); - assert.notEqual(chain(prev,{type:'TRANSFERRED'}),chain(prev,{type:'REVIEWED'})); +test('chain(prev,event) changes when event changes', () => { + const prev = chain('', { type: 'IMPORTED' }); + assert.notEqual(chain(prev, { type: 'TRANSFERRED' }), chain(prev, { type: 'REVIEWED' })); }); -test('validateClaims throws when a grounded claim has no citations',()=>{ - assert.throws(()=>validateClaims([{category:'VERIFIED_FACT',citations:[]}],new Set(['v1']))); +test('validateClaims throws when a grounded claim has no citations', () => { + assert.throws(() => + validateClaims([{ category: 'VERIFIED_FACT', citations: [] }], new Set(['v1'])), + ); }); -test('validateClaims throws when CORROBORATED_CLAIM has only one distinct versionId',()=>{ - assert.throws(()=>validateClaims([{category:'CORROBORATED_CLAIM',citations:[{versionId:'v1',span:'p1'},{versionId:'v1',span:'p2'}]}],new Set(['v1']))); +test('validateClaims throws when CORROBORATED_CLAIM has only one distinct versionId', () => { + assert.throws(() => + validateClaims( + [ + { + category: 'CORROBORATED_CLAIM', + citations: [ + { versionId: 'v1', span: 'p1' }, + { versionId: 'v1', span: 'p2' }, + ], + }, + ], + new Set(['v1']), + ), + ); });