@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.
- package/LICENSE +202 -0
- package/README.md +247 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +30 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +119 -0
- package/dist/src/veriff-budget.d.ts +55 -0
- package/dist/src/veriff-budget.js +151 -0
- package/dist/src/veriff-capabilities.d.ts +10 -0
- package/dist/src/veriff-capabilities.js +1079 -0
- package/dist/src/veriff-conformance.d.ts +28 -0
- package/dist/src/veriff-conformance.js +223 -0
- package/dist/src/veriff-connector.d.ts +133 -0
- package/dist/src/veriff-connector.js +372 -0
- package/dist/src/veriff-events.d.ts +62 -0
- package/dist/src/veriff-events.js +82 -0
- package/dist/src/veriff-server.d.ts +27 -0
- package/dist/src/veriff-server.js +101 -0
- package/dist/src/veriff-signature.d.ts +36 -0
- package/dist/src/veriff-signature.js +55 -0
- package/dist/src/veriff-twin.d.ts +104 -0
- package/dist/src/veriff-twin.js +759 -0
- package/package.json +65 -0
- package/src/cli.ts +29 -0
- package/src/index.ts +172 -0
- package/src/veriff-budget.ts +177 -0
- package/src/veriff-capabilities.ts +1157 -0
- package/src/veriff-conformance.ts +264 -0
- package/src/veriff-connector.ts +406 -0
- package/src/veriff-events.ts +117 -0
- package/src/veriff-server.ts +112 -0
- package/src/veriff-signature.ts +86 -0
- package/src/veriff-twin.ts +795 -0
|
@@ -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
|
+
}
|