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']), + ), + ); });