@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,264 @@
1
+ // Veriff conformance (dev-only; lazy-imported by the CLI, NEVER from index.ts/runtime — E2).
2
+ //
3
+ // The bar this check is written to (ADDING_A_TWIN.md §6, "the conformance check needs the same
4
+ // teeth"): ONE REAL REQUEST per claimed endpoint, asserting the OUTCOME a live handler produces —
5
+ // an expected status plus a predicate over the body — against a throwaway root, with the endpoint
6
+ // census a two-way bijection with the handler's own `VERIFF_IMPLEMENTED_ENDPOINTS`.
7
+ //
8
+ // Two failure shapes this deliberately does NOT fall for, both recorded because both are checks
9
+ // that look plausible and cannot fail:
10
+ //
11
+ // • Two constants asserting about each other. A hand-written "implemented" array compared against
12
+ // hand-written expectations in the same file stays green when the entire router is deleted.
13
+ // • Probing, but grading only "not the router's own miss". Sub-handlers fall through to their own
14
+ // 404, so a whole method branch can be deleted while the probe still looks dispatched.
15
+ //
16
+ // So every probe SEEDS the subjects its path params need — against its OWN fresh root, because the
17
+ // inventory contains destructive routes (PATCH submits, DELETE removes, media upload is refused
18
+ // after submit) and a shared root would have the sweep destroy its own fixtures partway through and
19
+ // reinstate exactly the blind spot the seeding exists to remove. A resource-404 is therefore never
20
+ // a legitimate outcome here: every 404 means the route (or its method branch) is gone.
21
+ //
22
+ // NEGATIVE CONTROLS close the other direction. Without them a behaviourally-dead twin that answered
23
+ // a flat 200 to everything would satisfy "every declared route dispatched" perfectly. Each control
24
+ // is a REAL Veriff endpoint this pack deliberately does not model yet (plus one invented path), and
25
+ // each must report as unmodeled.
26
+ //
27
+ // The honest limit: this proves the routing table and each branch's outcome shape are intact. It
28
+ // does NOT prove the payloads match Veriff field-for-field — that is what the capability manifest's
29
+ // failable verifies and the real-transport fidelity test are for.
30
+ import { mkdtempSync, rmSync } from 'node:fs';
31
+ import { tmpdir } from 'node:os';
32
+ import { join } from 'node:path';
33
+ import { handleVeriffTwinRequest, TWIN_API_KEY, TWIN_SHARED_SECRET, VERIFF_IMPLEMENTED_ENDPOINTS, VERIFF_RESOURCE_TYPES, type VeriffRequest, type VeriffResponse } from './veriff-twin.ts';
34
+ import { AUTH_CLIENT_HEADER, HMAC_SIGNATURE_HEADER, veriffSignature } from './veriff-signature.ts';
35
+
36
+ export type VeriffConformanceReport = {
37
+ ok: boolean;
38
+ endpointsChecked: number;
39
+ resourceTypesChecked: number;
40
+ routesDispatched: number;
41
+ controlsRejected: number;
42
+ violations: string[];
43
+ };
44
+
45
+ /** The seeded fixture ids a probe can address. */
46
+ type Seed = { root: string; sessionId: string; decidedSessionId: string; attemptId: string; mediaId: string };
47
+
48
+ /** A monotonic instant source. The kernel dedupes actions by content + MILLISECOND, so re-seeding
49
+ * with one fixed timestamp would have later seeds silently swallowed as replays. */
50
+ let tick = 0;
51
+ function nextInstant(): string {
52
+ return new Date(Date.UTC(2026, 7, 1, 0, 0, 0, tick++ % 1000)).toISOString();
53
+ }
54
+
55
+ const PNG = 'data:image/jpeg;base64,aGVsbG8td29ybGQ=';
56
+
57
+ /**
58
+ * The handler under test. Defaults to the pack's real one; a test injects a WRAPPER to prove this
59
+ * check goes red when a route (or a single method branch) is deleted, and when a route this pack
60
+ * does NOT model starts answering. Injecting a handler cannot weaken the check — the default is the
61
+ * real export, and the mutation gate's saboteur replaces that export, so a dead twin is still
62
+ * measured through this same seam.
63
+ *
64
+ * THREADED AS A PARAMETER, never held in a module-level variable. An earlier revision used a
65
+ * mutable module global reset at the end of the run, and a §9 review reproduced the consequence:
66
+ * two concurrent `checkVeriffConformance` calls — one clean, one carrying a saboteur that deletes a
67
+ * whole route branch — BOTH reported conformant. It failed OPEN, in the one check whose entire job
68
+ * is to prove teeth. Nothing calls it concurrently today, but a `test.concurrent` or a parallel
69
+ * harness would arm it, and a green that depends on nobody trying is not a green.
70
+ */
71
+ export type VeriffHandler = (req: VeriffRequest) => Promise<VeriffResponse>;
72
+
73
+ /** Drive one request with correct auth + a correctly-computed signature, so no probe can be masked
74
+ * by a 401 that has nothing to do with routing. */
75
+ async function call(handler: VeriffHandler, root: string, method: string, path: string, body?: unknown): Promise<VeriffResponse> {
76
+ const raw = body === undefined ? '' : JSON.stringify(body);
77
+ const bare = path.split('?')[0] ?? path;
78
+ const segments = bare.replace(/^\/v1\/?/, '').replace(/^\/+/, '').split('/');
79
+ const payload = method === 'GET' || method === 'DELETE' ? (segments[1] ?? '') : raw;
80
+ return handler({
81
+ method,
82
+ path,
83
+ ...(body === undefined ? {} : { body: raw }),
84
+ headers: { [AUTH_CLIENT_HEADER]: TWIN_API_KEY, [HMAC_SIGNATURE_HEADER]: veriffSignature(payload, TWIN_SHARED_SECRET) },
85
+ root,
86
+ occurredAt: nextInstant(),
87
+ });
88
+ }
89
+
90
+ /** Build a fresh root carrying every fixture the probes address. */
91
+ async function seed(handler: VeriffHandler): Promise<Seed> {
92
+ const root = mkdtempSync(join(tmpdir(), 'veriff-conf-'));
93
+ const open = await call(handler, root, 'POST', '/v1/sessions', { verification: { vendorData: 'conformance', callback: 'https://example.test/hook' } });
94
+ const sessionId = String((open.body as any)?.verification?.id ?? '');
95
+ const up = await call(handler, root, 'POST', `/v1/sessions/${sessionId}/media`, { image: { context: 'document-front', content: PNG } });
96
+ const mediaId = String((up.body as any)?.image?.id ?? '');
97
+
98
+ const second = await call(handler, root, 'POST', '/v1/sessions', { verification: { vendorData: 'conformance-decided' } });
99
+ const decidedSessionId = String((second.body as any)?.verification?.id ?? '');
100
+ await call(handler, root, 'PATCH', `/v1/sessions/${decidedSessionId}`, { verification: { status: 'submitted' } });
101
+ const decided = await call(handler, root, 'POST', `/v1/_twin/sessions/${decidedSessionId}/decision`, { status: 'approved', person: { firstName: 'JOHN', lastName: 'SMITH' } });
102
+ const attemptId = String((decided.body as any)?.verification?.attemptId ?? '');
103
+ return { root, sessionId, decidedSessionId, attemptId, mediaId };
104
+ }
105
+
106
+ type Probe = {
107
+ /** The census key — must match a `VERIFF_IMPLEMENTED_ENDPOINTS` entry exactly. */
108
+ endpoint: (typeof VERIFF_IMPLEMENTED_ENDPOINTS)[number];
109
+ run: (s: Seed, h: VeriffHandler) => Promise<VeriffResponse>;
110
+ /** The status a LIVE handler answers with. A 404 is never in this set (see the header). */
111
+ status: number;
112
+ /** A predicate over the body that a `return {}` handler cannot satisfy. */
113
+ ok: (body: any) => boolean;
114
+ };
115
+
116
+ const PROBES: Probe[] = [
117
+ {
118
+ endpoint: 'POST /v1/sessions',
119
+ run: (s, h) => call(h, s.root, 'POST', '/v1/sessions', { verification: { vendorData: 'probe' } }),
120
+ status: 201,
121
+ ok: (b) => b?.status === 'success' && typeof b?.verification?.id === 'string' && b.verification.vendorData === 'probe',
122
+ },
123
+ {
124
+ endpoint: 'PATCH /v1/sessions/{sessionId}',
125
+ run: (s, h) => call(h, s.root, 'PATCH', `/v1/sessions/${s.sessionId}`, { verification: { status: 'submitted' } }),
126
+ status: 200,
127
+ ok: (b) => b?.verification?.status === 'submitted',
128
+ },
129
+ {
130
+ endpoint: 'DELETE /v1/sessions/{sessionId}',
131
+ run: (s, h) => call(h, s.root, 'DELETE', `/v1/sessions/${s.sessionId}`),
132
+ status: 200,
133
+ ok: (b) => b?.status === 'success' && typeof b?.verification?.id === 'string',
134
+ },
135
+ {
136
+ endpoint: 'GET /v1/sessions/{sessionId}/decision',
137
+ run: (s, h) => call(h, s.root, 'GET', `/v1/sessions/${s.decidedSessionId}/decision`),
138
+ status: 200,
139
+ ok: (b) => b?.verification?.status === 'approved' && b.verification.code === 9001,
140
+ },
141
+ {
142
+ endpoint: 'GET /v1/sessions/{sessionId}/person',
143
+ run: (s, h) => call(h, s.root, 'GET', `/v1/sessions/${s.decidedSessionId}/person`),
144
+ status: 200,
145
+ ok: (b) => b?.person?.firstName === 'JOHN',
146
+ },
147
+ {
148
+ endpoint: 'GET /v1/sessions/{sessionId}/attempts',
149
+ run: (s, h) => call(h, s.root, 'GET', `/v1/sessions/${s.decidedSessionId}/attempts`),
150
+ status: 200,
151
+ ok: (b) => Array.isArray(b?.verifications) && b.verifications.length === 1 && b.verifications[0].status === 'approved',
152
+ },
153
+ {
154
+ endpoint: 'GET /v1/sessions/{sessionId}/media',
155
+ run: (s, h) => call(h, s.root, 'GET', `/v1/sessions/${s.sessionId}/media`),
156
+ status: 200,
157
+ ok: (b) => Array.isArray(b?.images) && b.images.length === 1 && b.images[0].context === 'document-front',
158
+ },
159
+ {
160
+ endpoint: 'POST /v1/sessions/{sessionId}/media',
161
+ run: (s, h) => call(h, s.root, 'POST', `/v1/sessions/${s.sessionId}/media`, { image: { context: 'face', content: PNG } }),
162
+ // 200 — the media endpoint declares exactly one success response and it is not 201 (the
163
+ // status-code table's 201 row is scoped to SESSION creation).
164
+ status: 200,
165
+ ok: (b) => b?.image?.context === 'face' && typeof b?.image?.id === 'string',
166
+ },
167
+ {
168
+ endpoint: 'GET /v1/attempts/{attemptId}/media',
169
+ run: (s, h) => call(h, s.root, 'GET', `/v1/attempts/${s.attemptId}/media`),
170
+ status: 200,
171
+ ok: (b) => Array.isArray(b?.images) && Array.isArray(b?.videos) && Array.isArray(b?.nfcDocuments),
172
+ },
173
+ {
174
+ endpoint: 'GET /v1/media/{mediaId}',
175
+ run: (s, h) => call(h, s.root, 'GET', `/v1/media/${s.mediaId}`),
176
+ status: 200,
177
+ ok: (b) => typeof b?.content === 'string' && b.mimetype === 'image/jpeg',
178
+ },
179
+ ];
180
+
181
+ /**
182
+ * REAL Veriff endpoints this pack does not model yet (each a filed `todo` in the manifest), plus one
183
+ * invented path. Every one must come back as the unmodeled 404 — if any answers, the sweep above is
184
+ * measuring nothing.
185
+ */
186
+ const CONTROLS: { label: string; run: (s: Seed, h: VeriffHandler) => Promise<VeriffResponse> }[] = [
187
+ // Veriff has NO `GET /v1/sessions/{id}` — the reference index lists only POST/PATCH/DELETE on
188
+ // that path. Serving one would be an INVENTED op, so it is a control, not a probe.
189
+ { label: 'GET /v1/sessions/{id} (no such Veriff endpoint)', run: (s, h) => call(h, s.root, 'GET', `/v1/sessions/${s.sessionId}`) },
190
+ { label: 'GET /v1/sessions/{id}/watchlist-screening', run: (s, h) => call(h, s.root, 'GET', `/v1/sessions/${s.decidedSessionId}/watchlist-screening`) },
191
+ { label: 'PATCH /v1/sessions/{id}/watchlist-screening', run: (s, h) => call(h, s.root, 'PATCH', `/v1/sessions/${s.decidedSessionId}/watchlist-screening`, { monitorStatus: 'disabled' }) },
192
+ { label: 'POST /v1/sessions/{id}/collected-data', run: (s, h) => call(h, s.root, 'POST', `/v1/sessions/${s.sessionId}/collected-data`, { providerName: 'x' }) },
193
+ { label: 'POST /v1/faces/import', run: (s, h) => call(h, s.root, 'POST', '/v1/faces/import', { images: [] }) },
194
+ { label: 'POST /v1/validate-registry', run: (s, h) => call(h, s.root, 'POST', '/v1/validate-registry', {}) },
195
+ // The reference index spells the same unmodeled endpoint differently; BOTH must be unmodeled,
196
+ // or the twin half-answers a surface it does not implement. Note this one also has to survive
197
+ // the router's session lookup (`/sessions/<x>` with x='validate-registry'), which is exactly the
198
+ // kind of accidental dispatch a control is for.
199
+ { label: 'POST /v1/sessions/validate-registry (index spelling)', run: (s, h) => call(h, s.root, 'POST', '/v1/sessions/validate-registry', {}) },
200
+ { label: 'GET /v1/sessions/{id}/decision/ine-registry', run: (s, h) => call(h, s.root, 'GET', `/v1/sessions/${s.decidedSessionId}/decision/ine-registry`) },
201
+ { label: 'POST /v1/sessions/{id}/TOTALLY-FAKE (invented)', run: (s, h) => call(h, s.root, 'POST', `/v1/sessions/${s.sessionId}/TOTALLY-FAKE`, {}) },
202
+ ];
203
+
204
+ export async function checkVeriffConformance(opts: { root?: string; handler?: VeriffHandler } = {}): Promise<VeriffConformanceReport> {
205
+ void opts.root; // every probe runs against its OWN throwaway root — see the header
206
+ const handler: VeriffHandler = opts.handler ?? handleVeriffTwinRequest;
207
+ const violations: string[] = [];
208
+
209
+ // ── census: a two-way bijection with the handler's own inventory ───────────
210
+ const declared = new Set<string>(VERIFF_IMPLEMENTED_ENDPOINTS);
211
+ const probed = new Set(PROBES.map((p) => p.endpoint));
212
+ for (const e of declared) if (!probed.has(e as any)) violations.push(`endpoint declared in VERIFF_IMPLEMENTED_ENDPOINTS but never probed: ${e}`);
213
+ for (const e of probed) if (!declared.has(e)) violations.push(`probe for an endpoint the handler does not declare: ${e}`);
214
+ if (probed.size !== PROBES.length) violations.push('duplicate endpoint in PROBES');
215
+
216
+ // ── the live sweep ────────────────────────────────────────────────────────
217
+ let routesDispatched = 0;
218
+ for (const probe of PROBES) {
219
+ const s = await seed(handler);
220
+ try {
221
+ const res = await probe.run(s, handler);
222
+ if (res.status !== probe.status) {
223
+ violations.push(`${probe.endpoint}: expected status ${probe.status}, got ${res.status} (${JSON.stringify(res.body).slice(0, 160)})`);
224
+ continue;
225
+ }
226
+ if (!probe.ok(res.body as any)) {
227
+ violations.push(`${probe.endpoint}: status ${res.status} but the body predicate failed (${JSON.stringify(res.body).slice(0, 160)})`);
228
+ continue;
229
+ }
230
+ routesDispatched += 1;
231
+ } finally {
232
+ rmSync(s.root, { recursive: true, force: true });
233
+ }
234
+ }
235
+
236
+ // ── negative controls ─────────────────────────────────────────────────────
237
+ let controlsRejected = 0;
238
+ const controlSeed = await seed(handler);
239
+ try {
240
+ for (const control of CONTROLS) {
241
+ const res = await control.run(controlSeed, handler);
242
+ const body = res.body as { status?: string; code?: string };
243
+ if (res.status === 404 && body?.status === 'fail' && body?.code === '1101') controlsRejected += 1;
244
+ else violations.push(`negative control ANSWERED (this twin does not model it): ${control.label} → ${res.status} ${JSON.stringify(res.body).slice(0, 120)}`);
245
+ }
246
+ } finally {
247
+ rmSync(controlSeed.root, { recursive: true, force: true });
248
+ }
249
+
250
+ // ── structural: every projected resource type is produced by some route ───
251
+ const produced = new Set(['session', 'attempt', 'media', 'delivery']);
252
+ for (const t of VERIFF_RESOURCE_TYPES) {
253
+ if (!produced.has(t)) violations.push(`resource type ${t} is projected but no modeled route produces it`);
254
+ }
255
+
256
+ return {
257
+ ok: violations.length === 0,
258
+ endpointsChecked: PROBES.length,
259
+ resourceTypesChecked: VERIFF_RESOURCE_TYPES.length,
260
+ routesDispatched,
261
+ controlsRejected,
262
+ violations,
263
+ };
264
+ }