@volter/twin-veriff 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,795 @@
1
+ // Veriff twin REQUEST HANDLER — the canonical Veriff Public API v1 surface for the twin.
2
+ // Contract: handleVeriffTwinRequest({method, path, body, headers}) -> {status, body}. Backed by the
3
+ // @volter/world-core event/action-log kernel; response SHAPES are faithful to the published Station API
4
+ // (devdocs.veriff.com/apidocs/*, whose pages embed the real OpenAPI 3.0.0 documents), not invented.
5
+ // HTTP wrapper: veriff-server.ts.
6
+ //
7
+ // State lives in the action log (R18): writes are local actions, reads fold the projection. No real
8
+ // Veriff is ever called.
9
+ //
10
+ // ── THE API URL IS ACCOUNT-SPECIFIC ─────────────────────────────────────────────────────────
11
+ // Veriff deliberately does NOT publish one canonical API host — the reference tells you to read
12
+ // your BaseURL off the Customer Portal, and its OpenAPI `servers` entry is the placeholder
13
+ // `https://example-base-url`. Two real hosts appear in official material: `stationapi.veriff.com`
14
+ // (hardcoded in the media-endpoint code samples, and the host the reference consumer dub uses) and
15
+ // `api.veriff.me` (the default `host` compiled into `@veriff/js-sdk` v2.0.0). The twin serves the
16
+ // PATHS, which are identical either way, and the injection map routes BOTH hosts here.
17
+ //
18
+ // ── AUTH ────────────────────────────────────────────────────────────────────────────────────
19
+ // `X-AUTH-CLIENT` (the public API key) is REQUIRED but faked locally — accepted, never checked
20
+ // against a real Veriff account. `X-HMAC-SIGNATURE` is a different matter: it is a pure local HMAC
21
+ // over a shared secret, so the twin verifies it FOR REAL (veriff-signature.ts) — and this pack's
22
+ // implementation is pinned against Veriff's own PUBLISHED mock vector. A consumer that signs the
23
+ // wrong payload fails here exactly as it would at the vendor, which is the single most valuable
24
+ // thing this twin can prove about a Veriff integration.
25
+ //
26
+ // ── THE DECISION IS NOT SOMETHING A LOCAL TWIN CAN MAKE ─────────────────────────────────────
27
+ // Real Veriff decides a session with document OCR, face matching, liveness and (for some flows) a
28
+ // human reviewer. What the twin models
29
+ // faithfully is the LIFECYCLE those decisions move through, driven deterministically by the caller:
30
+ //
31
+ // created ──PATCH {status:'submitted'}──> submitted ──POST /v1/_twin/…/decision──> approved
32
+ // │ declined
33
+ // └──POST /v1/_twin/…/decision (expired|abandoned)──────────────────────────────> resubmission_requested
34
+ // review / expired / abandoned
35
+ //
36
+ // `POST /v1/_twin/sessions/{id}/decision` is TWIN-ONLY SCAFFOLDING, namespaced out of the vendor's
37
+ // surface and deliberately absent from the capability manifest (the figma / openweather
38
+ // convention): a real client never calls it, and counting it as coverage would be padding.
39
+ import { createHash } from 'node:crypto';
40
+ import { applyTwinWrite, projectResources } from '@volter/world-core';
41
+ import { buildSignedDelivery, type VeriffDecisionPayload, type VeriffEventPayload } from './veriff-events.ts';
42
+ import { AUTH_CLIENT_HEADER, HMAC_SIGNATURE_HEADER, verifyVeriffSignature } from './veriff-signature.ts';
43
+
44
+ const SERVICE = 'veriff';
45
+
46
+ export type VeriffRequest = {
47
+ method: string;
48
+ path: string;
49
+ body?: string;
50
+ headers?: Record<string, string | undefined>;
51
+ occurredAt?: string;
52
+ root?: string;
53
+ readOnly?: boolean;
54
+ /** The shared secret this twin verifies `X-HMAC-SIGNATURE` against and signs webhooks with. */
55
+ sharedSecret?: string;
56
+ /** The API key this twin stamps into `x-auth-client` on outbound webhook deliveries. */
57
+ apiKey?: string;
58
+ };
59
+ export type VeriffResponse = { status: number; body: unknown };
60
+
61
+ /**
62
+ * The default local credentials. A twin fakes auth, so these are FIXTURES, not secrets: the point
63
+ * of pinning them is that a consumer's `.env` can point `VERIFF_API_KEY` / `VERIFF_SHARED_SECRET`
64
+ * at the twin and the HMAC round-trip works end to end with no per-run coordination. Shaped like
65
+ * the real thing — Veriff's API key and shared secret are both UUIDs.
66
+ */
67
+ export const TWIN_API_KEY = '11111111-2222-4333-8444-555555555555';
68
+ export const TWIN_SHARED_SECRET = 'abcdef12-abcd-abcd-abcd-abcdef012345';
69
+
70
+ /** The host Veriff serves the end-user verification flow from — `verification.host`, and the base
71
+ * of `verification.url`. Grounded in the published `PATCH /v1/sessions/{id}` response example
72
+ * (`"host": "https://alchemy.veriff.com"`). */
73
+ const FLOW_HOST = 'https://alchemy.veriff.com';
74
+
75
+ /** The base `url` a media object reports itself downloadable from. */
76
+ const MEDIA_HOST = 'https://stationapi.veriff.com';
77
+
78
+ // ── the Veriff envelope ──────────────────────────────────────────────────────
79
+ // Every response is a FLAT object whose `status` is the envelope discriminator ("success" on the
80
+ // happy path, "fail" on an error) with the payload hung off a sibling key named for the resource
81
+ // (`verification`, `image`, `images`, `person`, `verifications`). There is no `data` wrapper — the
82
+ // real consumer's zod schema (dub's `veriffCreateSessionOutputSchema`) reads
83
+ // `{ status: 'success', verification: {...} }` straight off the top level.
84
+ function success(payload: Record<string, unknown>, status = 200): VeriffResponse {
85
+ return { status, body: { status: 'success', ...payload } };
86
+ }
87
+
88
+ /**
89
+ * Veriff's failure envelope: `{ status: 'fail', code, message }`, all three REQUIRED.
90
+ *
91
+ * `code` is a STRING in the published schema (`"type": "string"`, examples `"1101"`, `"1812"`,
92
+ * `"1305"`) even though every value looks numeric — a detail that is easy to get wrong and that a
93
+ * consumer doing `code === '1812'` would notice immediately.
94
+ */
95
+ function failure(message: string, status: number, code: VeriffErrorCode): VeriffResponse {
96
+ return { status, body: { status: 'fail', code, message } };
97
+ }
98
+
99
+ /**
100
+ * The troubleshooting/credential codes this twin actually emits, each with the verbatim message
101
+ * from the endpoint's own published example. `1101` is heavily overloaded by the vendor — it is the
102
+ * generic code across 400 (validation), 401 (missing key), 404 (not found) and 500.
103
+ */
104
+ /**
105
+ * The verbatim message the vendor's troubleshooting-codes table pairs with each code this twin
106
+ * emits. Kept as data next to the codes so the two can never drift apart, and so a consumer that
107
+ * surfaces `message` to a support engineer sees the string Veriff's own docs use.
108
+ */
109
+ // NB the verifies in veriff-capabilities.ts assert these strings as LITERALS, never by importing
110
+ // this object. That asymmetry is deliberate: a §9 round-two review corrupted all five constants and
111
+ // got ZERO regressions back, because handler and verify were reading the same value — two constants
112
+ // agreeing with each other is not a check.
113
+ export const VERIFF_ERROR_MESSAGES = {
114
+ '1302': 'Only HTTPS return URLs are allowed.',
115
+ '1401': 'Image is not in valid `base64`.',
116
+ '1402': 'Image context is not supported.',
117
+ '1500': '`vendorData` field cannot be more than 1000 symbols. We require only non-semantic data to be submitted (UUID-s etc., that can not be resolved or used outside the customer\'s domain)',
118
+ '1501': '`vendorData` must be a string. We require only non-semantic data to be submitted (UUID-s etc., that can not be resolved or used outside the customer\'s domain)',
119
+ } as const;
120
+
121
+ export const VERIFF_ERROR_CODES = {
122
+ generic: '1101',
123
+ invalidHmac: '1812',
124
+ notCompleted: '1305',
125
+ inProgress: '1306',
126
+ badTransition: '1304',
127
+ invalidBase64: '1401',
128
+ unsupportedContext: '1402',
129
+ httpsOnlyCallback: '1302',
130
+ vendorDataLength: '1500',
131
+ vendorDataType: '1501',
132
+ } as const;
133
+ type VeriffErrorCode = (typeof VERIFF_ERROR_CODES)[keyof typeof VERIFF_ERROR_CODES];
134
+
135
+ /** A route this twin does not model. Fails like the vendor (404) — never a fake success. */
136
+ function unmodeled(method: string, path: string): VeriffResponse {
137
+ void method; void path;
138
+ return failure('Resource not found', 404, VERIFF_ERROR_CODES.generic);
139
+ }
140
+
141
+ // ── the session lifecycle ────────────────────────────────────────────────────
142
+
143
+ /** Every state a session can be in, across both halves of the lifecycle. */
144
+ export const VERIFF_SESSION_STATUSES = [
145
+ 'created',
146
+ 'started',
147
+ 'submitted',
148
+ 'approved',
149
+ 'declined',
150
+ 'resubmission_requested',
151
+ 'review',
152
+ 'expired',
153
+ 'abandoned',
154
+ ] as const;
155
+ export type VeriffSessionStatus = (typeof VERIFF_SESSION_STATUSES)[number];
156
+
157
+ /** The terminal decision statuses — the exact set the decision webhook documents, and the exact set
158
+ * the reference consumer's zod enum accepts (dub's `veriffDecisionEventSchema`). */
159
+ export const VERIFF_DECISION_STATUSES = [
160
+ 'approved',
161
+ 'declined',
162
+ 'resubmission_requested',
163
+ 'review',
164
+ 'expired',
165
+ 'abandoned',
166
+ ] as const;
167
+ export type VeriffDecisionStatus = (typeof VERIFF_DECISION_STATUSES)[number];
168
+
169
+ const DECISION_SET: ReadonlySet<string> = new Set<string>(VERIFF_DECISION_STATUSES);
170
+
171
+ /**
172
+ * The numeric `code` Veriff carries alongside a decision status.
173
+ *
174
+ * Grounded four ways in the decision endpoint's own published document, which is the endpoint this
175
+ * twin serves: its prose list ("`9104` - Expired", "`9121` - Abandoned"), its
176
+ * `session_abandoned_generic` example (`"code":"9121"` with `"status":"abandoned"`), its
177
+ * `session_expired_generic` example (`"code":"9104"` with `"status":"expired"`), and the schema's
178
+ * `code` enum, which is exactly [9001, 9102, 9103, 9104, 9121].
179
+ *
180
+ * `review` therefore gets **null**, not a borrowed code. It is a real status — the decision-webhook
181
+ * page lists it among `approved|declined|resubmission_requested|review|expired|abandoned`, and it is
182
+ * opt-in ("Only if previously agreed with Veriff") — but it appears in NEITHER the decision schema's
183
+ * `status` enum NOR its `code` enum, so no published number belongs to it. Handing it another
184
+ * status's code would be invention, and a consumer branching on `code` would silently treat a
185
+ * manual-review case as abandoned. `veriff.decisions.review_code` is the filed todo.
186
+ *
187
+ * (An earlier revision of this pack mapped `abandoned: 9104` / `review: 9121` on the strength of a
188
+ * status-codes table it had not actually read — a §9 review caught it. Recorded because the lesson
189
+ * generalises: prefer the document you can quote over the one you were told about.)
190
+ *
191
+ * Emitted as a NUMBER, matching the decision-webhook sample (`"code": 9001`) and the schema's
192
+ * declared `"type": "integer"`.
193
+ */
194
+ export const VERIFF_DECISION_CODES: Record<VeriffDecisionStatus, number | null> = {
195
+ approved: 9001,
196
+ declined: 9102,
197
+ resubmission_requested: 9103,
198
+ expired: 9104,
199
+ abandoned: 9121,
200
+ review: null,
201
+ };
202
+
203
+ /** The event-webhook `code` per action. 7001/7002 are the two the event-webhook page documents as
204
+ * needing no extra configuration; the 7007-7011 web-flow actions are a filed todo. */
205
+ export const VERIFF_EVENT_CODES: Record<'started' | 'submitted', number> = { started: 7001, submitted: 7002 };
206
+
207
+ /** `PATCH /v1/sessions/{id}` accepts exactly ONE target status — the published schema's
208
+ * `"Valid values": ["submitted"]`. Anything else is a validation failure, not a transition error. */
209
+ const PATCH_TARGET = 'submitted';
210
+
211
+ /**
212
+ * The statuses `DELETE /v1/sessions/{id}` accepts, verbatim from the endpoint's normative "Session
213
+ * deletion logic" list. `submitted` and `review` are the two that are NOT deletable.
214
+ *
215
+ * ⚠️ The SAME PAGE contradicts itself: its `session_not_completed` example says "Session must be in
216
+ * a completed state (`approved`, `declined`, `expired`, `abandoned`) before deletion", which would
217
+ * also exclude `created`, `started` and `resubmission_requested` — the three the prose list
218
+ * explicitly allows, and for which the same page describes an expired/abandoned decision webhook
219
+ * being fired. This twin follows the PROSE LIST, because it is the section whose job is to state the
220
+ * rule and because the webhook behaviour only makes sense if those states are deletable.
221
+ */
222
+ const DELETABLE: ReadonlySet<string> = new Set(['created', 'started', 'approved', 'declined', 'resubmission_requested', 'expired', 'abandoned']);
223
+
224
+ /** Legal transitions, keyed by the state being left. A move not listed here is a 400. */
225
+ const TRANSITIONS: Record<string, ReadonlySet<string>> = {
226
+ created: new Set(['started', 'submitted', 'expired', 'abandoned']),
227
+ started: new Set(['submitted', 'expired', 'abandoned']),
228
+ submitted: new Set([...VERIFF_DECISION_STATUSES]),
229
+ // A resubmission is an explicit invitation to try again, so the session re-opens for capture.
230
+ resubmission_requested: new Set(['started', 'submitted', 'expired', 'abandoned']),
231
+ };
232
+
233
+ function canTransition(from: string, to: string): boolean {
234
+ return TRANSITIONS[from]?.has(to) ?? false;
235
+ }
236
+
237
+ // ── kernel plumbing ──────────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * The subject types this twin projects.
241
+ *
242
+ * `delivery` is the twin's own outbound-webhook log: every decision/event webhook the twin would
243
+ * have PUSHED is recorded here, signed exactly as it would go on the wire, so a consumer can read
244
+ * one back and verify it offline instead of standing up a receiver.
245
+ */
246
+ export const VERIFF_RESOURCE_TYPES = ['session', 'attempt', 'media', 'delivery'] as const;
247
+
248
+ function list(type: string, root?: string): Record<string, unknown>[] {
249
+ return (projectResources(SERVICE, root).filter((r) => r.type === type) as unknown as Record<string, unknown>[])
250
+ .filter((r) => r.deleted !== true);
251
+ }
252
+ function getById(type: string, id: string, root?: string): Record<string, unknown> | undefined {
253
+ return list(type, root).find((r) => (r as { id: string }).id === id);
254
+ }
255
+
256
+ async function write(type: string, id: string, fields: Record<string, unknown>, root?: string, occurredAt?: string): Promise<void> {
257
+ await applyTwinWrite(SERVICE, { operation: `set.${type}`, subjectType: type, subjectId: id, fields, ...(occurredAt ? { occurredAt } : {}), actor: { kind: 'agent' } }, root);
258
+ }
259
+
260
+ /**
261
+ * Strip the kernel's three RESERVED projection keys before a record is rendered.
262
+ *
263
+ * `type`, `id` and `updatedAt` are META in `@volter/world-core`'s projection — a resource field with one
264
+ * of those names is silently DROPPED on the way in. Veriff's documents are full of `id`s
265
+ * (`verification.id`, `image.id`, an attempt's `id`), so every one of them is STORED under a
266
+ * renamed key (`sessionId`, `mediaId`, `attemptId`) and mapped back here at the boundary. Getting
267
+ * this wrong produces no error at all, just a missing field.
268
+ */
269
+ function emit(r: Record<string, unknown>): Record<string, unknown> {
270
+ const { type, updatedAt, id, deleted, ...rest } = r as Record<string, unknown>;
271
+ void type; void updatedAt; void id; void deleted;
272
+ return rest;
273
+ }
274
+
275
+ function parseBody(body?: string): Record<string, unknown> | null {
276
+ if (body === undefined || body === '') return {};
277
+ try {
278
+ const parsed = JSON.parse(body) as unknown;
279
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
280
+ } catch { return null; }
281
+ }
282
+
283
+ /**
284
+ * Mint a session/attempt/media/delivery id.
285
+ *
286
+ * UUID-v4 SHAPED, because that is what Veriff's ids ARE (`"format": "uuid"` on every id in the
287
+ * published schemas) — but DETERMINISTIC (R9, resource level): the bytes are a stable hash of
288
+ * `${type}:${occurredAt}:${ordinal}`, the world instant the write happens at plus the count of ids
289
+ * the type already holds. It used to be a fresh `crypto.randomUUID()` per call, so two identical
290
+ * worlds never agreed on a session, attempt, media or delivery id and no replay of this pack could
291
+ * be byte-identical.
292
+ *
293
+ * The two properties the old comment credited to entropy are kept by the PROBE, not by the mint:
294
+ * `taken` is every id this type has ever held, soft-deleted ones INCLUDED, and the attempt counter
295
+ * goes into the seed until the candidate lands clear. So a connector-pulled vendor id sitting where
296
+ * a local mint would land is skipped, and a delete→recreate cycle can never re-issue a retired id.
297
+ */
298
+ function mintId(type: string, ctx: Ctx): string {
299
+ const taken = new Set(
300
+ (projectResources(SERVICE, ctx.root) as unknown as Array<{ type: string; id: string }>)
301
+ .filter((r) => r.type === type).map((r) => r.id),
302
+ );
303
+ for (let attempt = 0; ; attempt += 1) {
304
+ const h = createHash('sha256').update(`${type}:${ctx.occurredAt ?? ''}:${taken.size}:${attempt}`).digest('hex');
305
+ const id = `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${((parseInt(h[16]!, 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`;
306
+ if (!taken.has(id)) return id;
307
+ }
308
+ }
309
+
310
+ // ── views ────────────────────────────────────────────────────────────────────
311
+
312
+ /** The `verification` object session create / get / patch answer with. All seven fields are
313
+ * `required` in the published `SessionCreatedVerification` schema, so none may be omitted —
314
+ * `endUserId` and `vendorData` are returned as `null` when they were not supplied. */
315
+ function sessionVerificationView(s: Record<string, unknown>): Record<string, unknown> {
316
+ return {
317
+ status: s.status,
318
+ id: s.sessionId,
319
+ url: s.url,
320
+ sessionToken: s.sessionToken,
321
+ host: s.host,
322
+ endUserId: s.endUserId ?? null,
323
+ vendorData: s.vendorData ?? null,
324
+ };
325
+ }
326
+
327
+ /** The `verification` object the decision endpoint and the decision webhook both carry. */
328
+ function decisionVerificationView(s: Record<string, unknown>): Record<string, unknown> {
329
+ return {
330
+ id: s.sessionId,
331
+ attemptId: s.decisionAttemptId ?? null,
332
+ vendorData: s.vendorData ?? null,
333
+ endUserId: s.endUserId ?? null,
334
+ status: s.status,
335
+ code: s.decisionCode ?? null,
336
+ reason: s.decisionReason ?? null,
337
+ reasonCode: s.decisionReasonCode ?? null,
338
+ decisionTime: s.decisionTime ?? null,
339
+ acceptanceTime: s.acceptanceTime ?? null,
340
+ submissionTime: s.submissionTime ?? null,
341
+ person: s.decidedPerson ?? null,
342
+ document: s.decidedDocument ?? null,
343
+ additionalVerifiedData: s.additionalVerifiedData ?? null,
344
+ riskLabels: s.riskLabels ?? null,
345
+ comments: s.comments ?? [],
346
+ };
347
+ }
348
+
349
+ function mediaView(m: Record<string, unknown>): Record<string, unknown> {
350
+ const { mediaId, kind, content, ...rest } = emit(m);
351
+ void kind; void content;
352
+ return { id: mediaId, ...rest };
353
+ }
354
+
355
+ /** An attempt row, as `GET /v1/sessions/{id}/attempts` renders it. */
356
+ function attemptView(a: Record<string, unknown>): Record<string, unknown> {
357
+ return {
358
+ id: a.attemptId,
359
+ status: a.status,
360
+ userDefinedData: a.userDefinedData ?? [],
361
+ createdTime: a.createdTime ?? null,
362
+ };
363
+ }
364
+
365
+ // ── the router ───────────────────────────────────────────────────────────────
366
+
367
+ /**
368
+ * Every endpoint this twin DISPATCHES, as a `METHOD /path` template. The conformance check drives
369
+ * one real request per entry and asserts the OUTCOME a live handler produces, so deleting a route —
370
+ * or a single method branch — from the switchboard below turns it red. Its census is a two-way
371
+ * bijection with this list, so adding a route here without implementing it is red too.
372
+ */
373
+ export const VERIFF_IMPLEMENTED_ENDPOINTS = [
374
+ // NOTE the absence of `GET /v1/sessions/{sessionId}`. Veriff HAS NO SUCH ENDPOINT — its API
375
+ // reference index lists POST/PATCH/DELETE on that path and GETs only on the sub-resources
376
+ // (/decision, /person, /attempts, /media, /watchlist-screening). An integration reads a session's
377
+ // state from the webhooks and the decision endpoint, which is exactly why the reference consumer
378
+ // stores its own `veriffSessionId` + status. Serving a convenient read here would be an INVENTED
379
+ // op — the mirror image of the "unmodeled ops fail like the vendor" rule — so a GET on that path
380
+ // answers the vendor's 404, and `veriff.sessions.no_get_endpoint` pins it. The twin-only
381
+ // `GET /v1/_twin/sessions/{id}` exists for test readback and is not vendor surface.
382
+ 'POST /v1/sessions',
383
+ 'PATCH /v1/sessions/{sessionId}',
384
+ 'DELETE /v1/sessions/{sessionId}',
385
+ 'GET /v1/sessions/{sessionId}/decision',
386
+ 'GET /v1/sessions/{sessionId}/person',
387
+ 'GET /v1/sessions/{sessionId}/attempts',
388
+ 'GET /v1/sessions/{sessionId}/media',
389
+ 'POST /v1/sessions/{sessionId}/media',
390
+ 'GET /v1/attempts/{attemptId}/media',
391
+ 'GET /v1/media/{mediaId}',
392
+ ] as const;
393
+
394
+ /**
395
+ * Does this request need `X-HMAC-SIGNATURE`?
396
+ *
397
+ * Everything does EXCEPT `POST /v1/sessions` — the vendor states the exception verbatim ("Exception
398
+ * is POST /sessions … which does not require the X-HMAC-SIGNATURE header") and `@veriff/js-sdk`
399
+ * corroborates it by sending only `x-auth-client` on that call.
400
+ */
401
+ function requiresSignature(method: string, parts: string[]): boolean {
402
+ return !(method === 'POST' && parts.length === 1 && parts[0] === 'sessions');
403
+ }
404
+
405
+ /**
406
+ * The payload `X-HMAC-SIGNATURE` must be computed over.
407
+ *
408
+ * The vendor's rule of thumb is "POST/PATCH sign the request body, GET/DELETE sign the session ID",
409
+ * but the per-endpoint header docs refine it to **the resource id in the path**: `GET
410
+ * /v1/attempts/{id}/media` signs the ATTEMPT id and `GET /v1/media/{id}` signs the MEDIA id. So the
411
+ * payload is the raw body for a body-bearing write, and `parts[1]` — whichever resource the path
412
+ * addresses — for a read or a delete.
413
+ */
414
+ function signaturePayload(method: string, parts: string[], rawBody: string): string {
415
+ if (method !== 'GET' && method !== 'DELETE') return rawBody;
416
+ return parts[1] ?? '';
417
+ }
418
+
419
+ export async function handleVeriffTwinRequest(req: VeriffRequest): Promise<VeriffResponse> {
420
+ const { method, root, occurredAt } = req;
421
+ const readOnly = req.readOnly ?? false;
422
+ const sharedSecret = req.sharedSecret ?? TWIN_SHARED_SECRET;
423
+ const apiKey = req.apiKey ?? TWIN_API_KEY;
424
+ const rawBody = req.body ?? '';
425
+
426
+ let url: URL;
427
+ try { url = new URL(req.path, 'http://twin.local'); } catch { return failure('Validation failed', 400, VERIFF_ERROR_CODES.generic); }
428
+ const parts = url.pathname.replace(/^\/v1(\/|$)/, '/').replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
429
+
430
+ const headers: Record<string, string> = {};
431
+ for (const [k, v] of Object.entries(req.headers ?? {})) if (v !== undefined) headers[k.toLowerCase()] = v;
432
+
433
+ const ctx: Ctx = { root, occurredAt, apiKey, sharedSecret };
434
+
435
+ // ── twin-only control plane (NOT vendor surface, NOT in the manifest) ──────
436
+ // Checked before auth: these routes are test scaffolding a real client never calls, and requiring
437
+ // a signature to arm a fixture would only make the scaffolding harder to use than the API.
438
+ if (parts[0] === '_twin') {
439
+ if (readOnly && method !== 'GET') return failure('this twin was started read-only; omit readOnly to accept writes', 405, VERIFF_ERROR_CODES.generic);
440
+ const twinBody = parseBody(rawBody);
441
+ if (twinBody === null) return failure('Validation failed', 400, VERIFF_ERROR_CODES.generic);
442
+ return twinControl(req, parts, twinBody, ctx);
443
+ }
444
+
445
+ // ── auth (faked locally, but STRUCTURALLY enforced) ────────────────────────
446
+ // The public API key must be PRESENT — the vendor 401s without it — but is never checked against
447
+ // a real account. The HMAC, by contrast, is verified for real.
448
+ if (!headers[AUTH_CLIENT_HEADER]) {
449
+ return failure('Mandatory X-AUTH-CLIENT header containing the API key is missing from the request.', 401, VERIFF_ERROR_CODES.generic);
450
+ }
451
+ if (requiresSignature(method, parts)) {
452
+ const outcome = verifyVeriffSignature(signaturePayload(method, parts, rawBody), headers[HMAC_SIGNATURE_HEADER], sharedSecret);
453
+ if (outcome !== null) return failure('Invalid HMAC signature', 401, VERIFF_ERROR_CODES.invalidHmac);
454
+ }
455
+
456
+ const isWrite = method !== 'GET' && method !== 'HEAD';
457
+ if (readOnly && isWrite) return failure('this twin was started read-only; omit readOnly to accept writes', 405, VERIFF_ERROR_CODES.generic);
458
+
459
+ const body = parseBody(rawBody);
460
+ if (body === null) return failure('Validation failed', 400, VERIFF_ERROR_CODES.generic);
461
+
462
+ // ── /v1/sessions ───────────────────────────────────────────────────────────
463
+ if (parts[0] === 'sessions') {
464
+ if (method === 'POST' && parts.length === 1) return createSession(body, ctx);
465
+ if (parts.length >= 2 && parts.length <= 3) {
466
+ const sessionId = parts[1]!;
467
+ const session = getById('session', sessionId, root);
468
+ if (!session) return failure('Resource not found', 404, VERIFF_ERROR_CODES.generic);
469
+
470
+ if (parts.length === 2) {
471
+ // Deliberately NO `GET` branch — see the note on VERIFF_IMPLEMENTED_ENDPOINTS.
472
+ if (method === 'PATCH') return patchSession(session, body, ctx);
473
+ if (method === 'DELETE') return deleteSession(session, ctx);
474
+ return unmodeled(method, req.path);
475
+ }
476
+ const sub = parts[2]!;
477
+ if (method === 'GET' && sub === 'decision') return getDecision(session);
478
+ if (method === 'GET' && sub === 'person') return success({ person: session.decidedPerson ?? null });
479
+ if (method === 'GET' && sub === 'attempts') {
480
+ // Reverse-chronological, as the endpoint documents ("most recent attempt first").
481
+ const rows = list('attempt', root)
482
+ .filter((a) => a.sessionId === sessionId)
483
+ .sort((a, b) => String(b.createdTime ?? '').localeCompare(String(a.createdTime ?? '')))
484
+ .map(attemptView);
485
+ return success({ verifications: rows });
486
+ }
487
+ if (sub === 'media') {
488
+ if (method === 'GET') return listMedia((m) => m.sessionId === sessionId, root);
489
+ if (method === 'POST') return uploadMedia(session, body, ctx);
490
+ }
491
+ return unmodeled(method, req.path);
492
+ }
493
+ return unmodeled(method, req.path);
494
+ }
495
+
496
+ // ── /v1/attempts/{attemptId}/media ─────────────────────────────────────────
497
+ if (parts[0] === 'attempts' && parts.length === 3 && parts[2] === 'media' && method === 'GET') {
498
+ const attemptId = parts[1]!;
499
+ if (!getById('attempt', attemptId, root)) return failure('Resource not found', 404, VERIFF_ERROR_CODES.generic);
500
+ return listMedia((m) => m.attemptId === attemptId, root);
501
+ }
502
+
503
+ // ── /v1/media/{mediaId} ────────────────────────────────────────────────────
504
+ if (parts[0] === 'media' && parts.length === 2 && method === 'GET') {
505
+ const found = getById('media', parts[1]!, root);
506
+ if (!found) return failure('Resource not found', 404, VERIFF_ERROR_CODES.generic);
507
+ // The real endpoint answers with the media BYTES under the object's own mimetype, not JSON.
508
+ // The handler contract here is `{status, body}`, so the bytes are surfaced as the base64
509
+ // `content` the upload supplied plus its `mimetype`; veriff-server.ts is what turns that back
510
+ // into a real binary body with the right Content-Type on the wire.
511
+ return { status: 200, body: { mimetype: found.mimetype, content: found.content } };
512
+ }
513
+
514
+ return unmodeled(method, req.path);
515
+ }
516
+
517
+ // ── handlers ─────────────────────────────────────────────────────────────────
518
+
519
+ type Ctx = { root?: string | undefined; occurredAt?: string | undefined; apiKey?: string; sharedSecret?: string };
520
+
521
+ /** `vendorData` is documented as max 1,000 characters and string-typed, with dedicated codes. */
522
+ const VENDOR_DATA_MAX = 1000;
523
+
524
+ async function createSession(body: Record<string, unknown>, ctx: Ctx): Promise<VeriffResponse> {
525
+ const verification = body.verification;
526
+ if (verification === undefined || verification === null || typeof verification !== 'object' || Array.isArray(verification)) {
527
+ return failure('Validation failed', 400, VERIFF_ERROR_CODES.generic);
528
+ }
529
+ const v = verification as Record<string, unknown>;
530
+ if (v.vendorData !== undefined && v.vendorData !== null) {
531
+ if (typeof v.vendorData !== 'string') return failure(VERIFF_ERROR_MESSAGES['1501'], 400, VERIFF_ERROR_CODES.vendorDataType);
532
+ if (v.vendorData.length > VENDOR_DATA_MAX) return failure(VERIFF_ERROR_MESSAGES['1500'], 400, VERIFF_ERROR_CODES.vendorDataLength);
533
+ }
534
+ if (v.callback !== undefined && v.callback !== null) {
535
+ // Documented as code 1302: "Only HTTPS return URLs are allowed."
536
+ if (typeof v.callback !== 'string') return failure('Validation failed', 400, VERIFF_ERROR_CODES.generic);
537
+ let parsed: URL;
538
+ try { parsed = new URL(v.callback); } catch { return failure(VERIFF_ERROR_MESSAGES['1302'], 400, VERIFF_ERROR_CODES.httpsOnlyCallback); }
539
+ if (parsed.protocol !== 'https:') return failure(VERIFF_ERROR_MESSAGES['1302'], 400, VERIFF_ERROR_CODES.httpsOnlyCallback);
540
+ }
541
+ const sessionId = mintId('session', ctx);
542
+ // A real sessionToken is a JWT. The twin mints a structurally-real one (three base64url segments)
543
+ // whose payload carries the session id, so `verification.url` is `host + '/v/' + sessionToken`
544
+ // exactly as the schema describes and a consumer splitting the token still finds its session.
545
+ const sessionToken = mintSessionToken(sessionId);
546
+ await write('session', sessionId, {
547
+ sessionId,
548
+ status: 'created' satisfies VeriffSessionStatus,
549
+ url: `${FLOW_HOST}/v/${sessionToken}`,
550
+ host: FLOW_HOST,
551
+ sessionToken,
552
+ vendorData: (v.vendorData as string | undefined) ?? null,
553
+ endUserId: (v.endUserId as string | undefined) ?? null,
554
+ callback: (v.callback as string | undefined) ?? null,
555
+ tag: (v.tag as string | undefined) ?? null,
556
+ // The person/document HINTS supplied at creation are NOT the decision's extracted data — they
557
+ // are stored separately so a decision can never be confused with the caller's own input.
558
+ hintPerson: (v.person as Record<string, unknown> | undefined) ?? null,
559
+ hintDocument: (v.document as Record<string, unknown> | undefined) ?? null,
560
+ createdTime: ctx.occurredAt ?? null,
561
+ }, ctx.root, ctx.occurredAt);
562
+ const created = getById('session', sessionId, ctx.root)!;
563
+ // 201, not 200. The published OpenAPI declares this response 200 while the vendor's own HTTP
564
+ // status-code table says 201 ("returned when session creation was a success") — and
565
+ // `@veriff/js-sdk` v2.0.0 hard-checks `201 === xhr.status`, so a twin that answered 200 would
566
+ // break the real SDK. The conflict is recorded in spec-sources.json and README ## Coverage.
567
+ return success({ verification: sessionVerificationView(created) }, 201);
568
+ }
569
+
570
+ /** Three base64url segments — structurally a JWT, deterministic in its payload, never signed with
571
+ * anything meaningful (a twin fakes auth; the token is a handle, not a credential). */
572
+ function mintSessionToken(sessionId: string): string {
573
+ const seg = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url');
574
+ return `${seg({ alg: 'HS256', typ: 'JWT' })}.${seg({ session_id: sessionId })}.${Buffer.from(sessionId).toString('base64url')}`;
575
+ }
576
+
577
+ async function patchSession(session: Record<string, unknown>, body: Record<string, unknown>, ctx: Ctx): Promise<VeriffResponse> {
578
+ const v = (body.verification ?? {}) as Record<string, unknown>;
579
+ const target = v.status;
580
+ // `submitted` is the schema's ONLY valid value; anything else is a validation failure.
581
+ if (typeof target !== 'string' || target !== PATCH_TARGET) {
582
+ return failure('Validation failed', 400, VERIFF_ERROR_CODES.generic);
583
+ }
584
+ const from = String(session.status);
585
+ if (from === PATCH_TARGET) return failure('Session has already been submitted', 400, VERIFF_ERROR_CODES.generic);
586
+ if (!canTransition(from, PATCH_TARGET)) {
587
+ return failure(`Cannot transition to "${PATCH_TARGET}" status.`, 400, VERIFF_ERROR_CODES.badTransition);
588
+ }
589
+ await write('session', String(session.sessionId), { status: PATCH_TARGET, submissionTime: ctx.occurredAt ?? null }, ctx.root, ctx.occurredAt);
590
+ const updated = getById('session', String(session.sessionId), ctx.root)!;
591
+ // Veriff pushes the 7002 `submitted` EVENT webhook at exactly this point — "before the end-user
592
+ // is directed to the callback URL specified on session creation".
593
+ await recordEventDelivery(updated, 'submitted', ctx);
594
+ return success({ verification: sessionVerificationView(updated) });
595
+ }
596
+
597
+ async function deleteSession(session: Record<string, unknown>, ctx: Ctx): Promise<VeriffResponse> {
598
+ const from = String(session.status);
599
+ if (!DELETABLE.has(from)) {
600
+ // ⚠️ A DISAMBIGUATION, not a documented rule — said plainly because the difference matters.
601
+ // The endpoint documents ONE blanket refusal for every non-deletable status ("Session is not in
602
+ // a completed status.", 1305) while ALSO publishing a 1306 "Session in progress." example whose
603
+ // stated trigger is "while the end-user is actively completing verification". Since `started` is
604
+ // on the DELETABLE list, 1306 has no documented trigger left, and `submitted` is the only state
605
+ // that fits its description — so this twin routes `submitted` there and everything else to 1305.
606
+ // `veriff.sessions.delete_refusal_codes` is the filed todo to pin it against a live account.
607
+ return from === 'submitted'
608
+ ? failure('Session in progress.', 400, VERIFF_ERROR_CODES.inProgress)
609
+ : failure('Session is not in a completed status.', 400, VERIFF_ERROR_CODES.notCompleted);
610
+ }
611
+ await write('session', String(session.sessionId), { deleted: true }, ctx.root, ctx.occurredAt);
612
+ // The success body is deliberately just the id — the endpoint's published example is
613
+ // `{"status":"success","verification":{"id":"…"}}` and nothing more.
614
+ return success({ verification: { id: session.sessionId } });
615
+ }
616
+
617
+ function getDecision(session: Record<string, unknown>): VeriffResponse {
618
+ // A session with no decision yet answers 200 with a NULL verification — the endpoint documents
619
+ // exactly this ("`null` = decision not yet available, try again later"), which is why it is safe
620
+ // to poll from the moment the session exists.
621
+ if (!DECISION_SET.has(String(session.status))) return success({ verification: null });
622
+ return success({ verification: decisionVerificationView(session) });
623
+ }
624
+
625
+ function listMedia(match: (m: Record<string, unknown>) => boolean, root?: string): VeriffResponse {
626
+ const rows = list('media', root).filter(match);
627
+ return success({
628
+ images: rows.filter((m) => m.kind === 'image').map(mediaView),
629
+ videos: rows.filter((m) => m.kind === 'video').map(mediaView),
630
+ nfcDocuments: rows.filter((m) => m.kind === 'nfc').map(mediaView),
631
+ });
632
+ }
633
+
634
+ /** The image/video contexts the upload schema enumerates. An unlisted one is code 1402. */
635
+ const MEDIA_CONTEXTS: ReadonlySet<string> = new Set([
636
+ 'address-front', 'document-and-face', 'document-and-face-pre', 'document-back',
637
+ 'document-back-barcode', 'document-back-barcode-pre', 'document-back-pre', 'document-back-qrcode',
638
+ 'document-back-qrcode-pre', 'document-front', 'document-front-face-cropped', 'document-front-pre',
639
+ 'document-front-qrcode', 'document-front-qrcode-pre', 'face', 'face-cropped', 'face-nfc',
640
+ 'face-pre', 'face-reference', 'registry-face',
641
+ ]);
642
+
643
+ /** `content` is documented as carrying the data-URI scheme prefix (`data:image/jpeg;base64,…`). */
644
+ const DATA_URI = /^data:([\w.+-]+\/[\w.+-]+);base64,(.*)$/s;
645
+
646
+ async function uploadMedia(session: Record<string, unknown>, body: Record<string, unknown>, ctx: Ctx): Promise<VeriffResponse> {
647
+ const image = body.image;
648
+ if (image === undefined || image === null || typeof image !== 'object' || Array.isArray(image)) {
649
+ return failure('Validation failed', 400, VERIFF_ERROR_CODES.generic);
650
+ }
651
+ const img = image as Record<string, unknown>;
652
+ if (typeof img.context !== 'string' || !MEDIA_CONTEXTS.has(img.context)) {
653
+ return failure(VERIFF_ERROR_MESSAGES['1402'], 400, VERIFF_ERROR_CODES.unsupportedContext);
654
+ }
655
+ if (typeof img.content !== 'string' || img.content === '') {
656
+ return failure(VERIFF_ERROR_MESSAGES['1401'], 400, VERIFF_ERROR_CODES.invalidBase64);
657
+ }
658
+ const parsed = DATA_URI.exec(img.content);
659
+ const mimetype = parsed?.[1] ?? 'image/jpeg';
660
+ const base64 = parsed?.[2] ?? img.content;
661
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64) || base64.length === 0) {
662
+ return failure(VERIFF_ERROR_MESSAGES['1401'], 400, VERIFF_ERROR_CODES.invalidBase64);
663
+ }
664
+ // "If you have already submitted the session, you may encounter the 409 - conflict error."
665
+ if (String(session.status) !== 'created' && String(session.status) !== 'started' && String(session.status) !== 'resubmission_requested') {
666
+ return failure('Session has already been submitted', 409, VERIFF_ERROR_CODES.generic);
667
+ }
668
+ const mediaId = mintId('media', ctx);
669
+ await write('media', mediaId, {
670
+ mediaId,
671
+ sessionId: session.sessionId,
672
+ attemptId: session.currentAttemptId ?? null,
673
+ kind: 'image',
674
+ // `name` mirrors `context` in every published example.
675
+ name: img.context,
676
+ context: img.context,
677
+ // Documented as DEPRECATED and always returned as null, whatever the caller sent.
678
+ timestamp: null,
679
+ size: Buffer.from(base64, 'base64').length,
680
+ mimetype,
681
+ content: base64,
682
+ url: `${MEDIA_HOST}/v1/media/${mediaId}`,
683
+ }, ctx.root, ctx.occurredAt);
684
+ // 200, NOT 201. The media endpoint's own document declares exactly one success response —
685
+ // "Responses 200 / Media uploaded successfully." — and 201 appears nowhere in it. The
686
+ // status-code table's 201 row is scoped specifically to SESSION creation, so it does not carry
687
+ // over here, and unlike POST /v1/sessions there is no shipped SDK asserting otherwise.
688
+ return success({ image: mediaView(getById('media', mediaId, ctx.root)!) });
689
+ }
690
+
691
+ // ── outbound webhook deliveries (recorded, signed, never on a socket) ────────
692
+
693
+ async function recordDelivery(session: Record<string, unknown>, payload: VeriffDecisionPayload | VeriffEventPayload, kind: 'decision' | 'event', ctx: Ctx): Promise<void> {
694
+ const callback = session.callback;
695
+ if (typeof callback !== 'string' || callback === '') return; // no callback configured → nothing to deliver
696
+ const signed = buildSignedDelivery(payload, { apiKey: ctx.apiKey ?? TWIN_API_KEY, sharedSecret: ctx.sharedSecret ?? TWIN_SHARED_SECRET });
697
+ const deliveryId = mintId('delivery', ctx);
698
+ await write('delivery', deliveryId, {
699
+ deliveryId,
700
+ sessionId: session.sessionId,
701
+ kind,
702
+ url: callback,
703
+ body: signed.body,
704
+ headers: signed.headers,
705
+ }, ctx.root, ctx.occurredAt);
706
+ }
707
+
708
+ async function recordEventDelivery(session: Record<string, unknown>, action: 'started' | 'submitted', ctx: Ctx): Promise<void> {
709
+ const payload: VeriffEventPayload = {
710
+ id: String(session.sessionId),
711
+ attemptId: String(session.currentAttemptId ?? session.sessionId),
712
+ feature: 'selfid',
713
+ code: VERIFF_EVENT_CODES[action],
714
+ action,
715
+ vendorData: (session.vendorData as string | null) ?? null,
716
+ };
717
+ await recordDelivery(session, payload, 'event', ctx);
718
+ }
719
+
720
+ // ── twin-only control plane ──────────────────────────────────────────────────
721
+
722
+ async function twinControl(req: VeriffRequest, parts: string[], body: Record<string, unknown>, ctx: Ctx): Promise<VeriffResponse> {
723
+ const { method } = req;
724
+ // POST /v1/_twin/sessions/{sessionId}/decision — drive the terminal transition a real Veriff
725
+ // reaches by OCR + face match + liveness + (sometimes) a human reviewer.
726
+ if (method === 'POST' && parts[1] === 'sessions' && parts[3] === 'decision' && parts.length === 4) {
727
+ const sessionId = parts[2]!;
728
+ const session = getById('session', sessionId, ctx.root);
729
+ if (!session) return failure('Resource not found', 404, VERIFF_ERROR_CODES.generic);
730
+ const target = body.status;
731
+ if (typeof target !== 'string' || !DECISION_SET.has(target)) {
732
+ return failure('Validation failed', 400, VERIFF_ERROR_CODES.generic);
733
+ }
734
+ const from = String(session.status);
735
+ if (!canTransition(from, target)) return failure(`Cannot transition to "${target}" status.`, 400, VERIFF_ERROR_CODES.badTransition);
736
+ const attemptId = mintId('attempt', ctx);
737
+ const decisionTime = ctx.occurredAt ?? null;
738
+ await write('attempt', attemptId, {
739
+ attemptId,
740
+ sessionId,
741
+ status: target,
742
+ userDefinedData: (body.userDefinedData as unknown[] | undefined) ?? [],
743
+ createdTime: decisionTime,
744
+ }, ctx.root, ctx.occurredAt);
745
+ await write('session', sessionId, {
746
+ status: target,
747
+ decisionCode: VERIFF_DECISION_CODES[target as VeriffDecisionStatus],
748
+ decisionAttemptId: attemptId,
749
+ currentAttemptId: attemptId,
750
+ decisionTime,
751
+ acceptanceTime: decisionTime,
752
+ decisionReason: (body.reason as string | undefined) ?? null,
753
+ decisionReasonCode: (body.reasonCode as number | undefined) ?? null,
754
+ ...(body.person !== undefined ? { decidedPerson: body.person } : {}),
755
+ ...(body.document !== undefined ? { decidedDocument: body.document } : {}),
756
+ ...(body.riskLabels !== undefined ? { riskLabels: body.riskLabels } : {}),
757
+ ...(body.additionalVerifiedData !== undefined ? { additionalVerifiedData: body.additionalVerifiedData } : {}),
758
+ }, ctx.root, ctx.occurredAt);
759
+ const decided = getById('session', sessionId, ctx.root)!;
760
+ const payload: VeriffDecisionPayload = { status: 'success', verification: decisionVerificationView(decided) };
761
+ await recordDelivery(decided, payload, 'decision', ctx);
762
+ return success({ verification: decisionVerificationView(decided) });
763
+ }
764
+ // POST /v1/_twin/sessions/{sessionId}/event — fire the 7001 `started` flow event.
765
+ if (method === 'POST' && parts[1] === 'sessions' && parts[3] === 'event' && parts.length === 4) {
766
+ const session = getById('session', parts[2]!, ctx.root);
767
+ if (!session) return failure('Resource not found', 404, VERIFF_ERROR_CODES.generic);
768
+ const action = body.action;
769
+ if (action !== 'started') return failure('Validation failed', 400, VERIFF_ERROR_CODES.generic);
770
+ if (!canTransition(String(session.status), 'started')) return failure(`Cannot transition to "started" status.`, 400, VERIFF_ERROR_CODES.badTransition);
771
+ await write('session', String(session.sessionId), { status: 'started' }, ctx.root, ctx.occurredAt);
772
+ const started = getById('session', String(session.sessionId), ctx.root)!;
773
+ await recordEventDelivery(started, 'started', ctx);
774
+ return success({ verification: sessionVerificationView(started) });
775
+ }
776
+ // GET /v1/_twin/sessions/{sessionId} — TEST READBACK ONLY. Veriff exposes no GET for a session,
777
+ // so a verify that wants to assert "the status really moved" has no vendor endpoint to use; this
778
+ // is the twin-only stand-in (the figma / openweather `_twin` convention). Namespaced out of the
779
+ // vendor's surface and deliberately absent from the capability manifest.
780
+ if (method === 'GET' && parts[1] === 'sessions' && parts.length === 3) {
781
+ const session = getById('session', parts[2]!, ctx.root);
782
+ if (!session) return failure('Resource not found', 404, VERIFF_ERROR_CODES.generic);
783
+ return success({ verification: sessionVerificationView(session) });
784
+ }
785
+ // GET /v1/_twin/deliveries[?sessionId=] — read back what the twin WOULD have pushed, signed
786
+ // exactly as it would go on the wire, so a consumer can verify a real delivery offline.
787
+ if (method === 'GET' && parts[1] === 'deliveries' && parts.length === 2) {
788
+ const wanted = new URL(req.path, 'http://twin.local').searchParams.get('sessionId');
789
+ const rows = list('delivery', ctx.root)
790
+ .filter((d) => wanted === null || d.sessionId === wanted)
791
+ .map((d) => emit(d));
792
+ return success({ deliveries: rows });
793
+ }
794
+ return unmodeled(method, req.path);
795
+ }