@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,1157 @@
|
|
|
1
|
+
// Veriff capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the target), authored top-down
|
|
2
|
+
// from what Veriff's Public API v1 actually does — NOT from what this twin has built. This is the
|
|
3
|
+
// honest denominator: many entries are `todo` and will stay that way until the twin reaches them.
|
|
4
|
+
// `verify()` (required to count as done) is ground truth; every `expected:'done'` is genuinely
|
|
5
|
+
// claimed, so a broken one shows as a regression.
|
|
6
|
+
//
|
|
7
|
+
// ── WHERE THE DENOMINATOR CAME FROM ─────────────────────────────────────────────────────────
|
|
8
|
+
// Veriff publishes `devdocs.veriff.com/llms.txt`, a machine-readable index of all 140 doc pages,
|
|
9
|
+
// and every `/apidocs/*` page EMBEDS its real OpenAPI 3.0.0 document. The surface below was
|
|
10
|
+
// enumerated from that index top-down: the 18 documented Public API v1 operations, the three
|
|
11
|
+
// Feedback/Fraud API operations, the sync-api registry operation, and the per-SOLUTION doc pages
|
|
12
|
+
// (Document+Selfie, Document-only, Biometric Authentication/Liveness, Selfie2Selfie, Age
|
|
13
|
+
// Estimation, Unstructured Docs, Proof of Address, AML screening, NFC/ePassport, UK DIATF, and the
|
|
14
|
+
// nine database verifications). Statuses, codes, error bodies and payload shapes are quoted from
|
|
15
|
+
// those documents; where two Veriff pages CONTRADICT each other, this manifest says so and files a
|
|
16
|
+
// todo rather than picking a side silently.
|
|
17
|
+
//
|
|
18
|
+
// ── NO UI CAPABILITIES ──────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Veriff is an API-first vendor for the party that integrates it: creating a session, embedding the
|
|
20
|
+
// hosted flow, receiving the decision webhook and reading the decision is ALL code. The only screen
|
|
21
|
+
// in the loop is Veriff's own HOSTED capture flow (camera, document capture, liveness), which is
|
|
22
|
+
// Veriff's own product surface rather than a customer-operated console; the twin returns a real
|
|
23
|
+
// session `url` so the redirect/embed path is exercised. The reference consumer settles it
|
|
24
|
+
// the same way: dub embeds Veriff's hosted frame and then built its OWN admin review screen rather
|
|
25
|
+
// than working in Veriff's. So this pack ships no mirror and has zero `ui` capabilities. See
|
|
26
|
+
// README.md `## Coverage` → `### No UI mirror`, and the ui-scope.json entry, which records the
|
|
27
|
+
// counter-argument rather than pretending the call was obvious.
|
|
28
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
29
|
+
import { tmpdir } from 'node:os';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { checkCapabilities, verifyBoundary, type CapabilityReport, type CapabilitySpec } from '@volter/world-tooling';
|
|
32
|
+
import { VERIFF_BUDGET_CEILING, VERIFF_CALL_WEIGHTS, VeriffBudget, VeriffBudgetError } from './veriff-budget.ts';
|
|
33
|
+
import {
|
|
34
|
+
liveVeriffExecute,
|
|
35
|
+
mapSessionAttempt,
|
|
36
|
+
mapSessionDecision,
|
|
37
|
+
mapSessionMedia,
|
|
38
|
+
pullVeriffAttempts,
|
|
39
|
+
pullVeriffDecisions,
|
|
40
|
+
pullVeriffMedia,
|
|
41
|
+
pushPendingVeriffActions,
|
|
42
|
+
signaturePayloadFor,
|
|
43
|
+
syncVeriffFromReal,
|
|
44
|
+
veriffRequestForAction,
|
|
45
|
+
type VeriffExecute,
|
|
46
|
+
} from './veriff-connector.ts';
|
|
47
|
+
import { buildSignedDelivery, verifyWebhook, VeriffWebhookVerificationError } from './veriff-events.ts';
|
|
48
|
+
import { createVeriffTwinServer, veriffResponseHeaders } from './veriff-server.ts';
|
|
49
|
+
import { AUTH_CLIENT_HEADER, HMAC_SIGNATURE_HEADER, veriffSignature, verifyVeriffSignature } from './veriff-signature.ts';
|
|
50
|
+
import {
|
|
51
|
+
handleVeriffTwinRequest,
|
|
52
|
+
TWIN_API_KEY,
|
|
53
|
+
TWIN_SHARED_SECRET,
|
|
54
|
+
VERIFF_DECISION_CODES,
|
|
55
|
+
type VeriffResponse,
|
|
56
|
+
} from './veriff-twin.ts';
|
|
57
|
+
|
|
58
|
+
// ── driving the twin: fresh root, correct auth, pinned-but-distinct instants ──
|
|
59
|
+
|
|
60
|
+
type Step = { m: string; p: string; b?: unknown; noAuth?: boolean; sig?: string };
|
|
61
|
+
type Body = Record<string, any>;
|
|
62
|
+
type H = (s: Step) => Promise<VeriffResponse>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Instants are pinned (deterministic ids/timestamps) but DISTINCT per write. The kernel dedupes an
|
|
66
|
+
* action by content + MILLISECOND, so a verify that repeats an identical transition under one fixed
|
|
67
|
+
* timestamp is a coin flip — same-ms lands as `replayed`, different-ms as a second write. Pinning a
|
|
68
|
+
* per-call ordinal makes the outcome chosen rather than rolled.
|
|
69
|
+
*/
|
|
70
|
+
let tick = 0;
|
|
71
|
+
const at = () => new Date(Date.UTC(2026, 7, 1, 0, 0, 0, tick++ % 1000)).toISOString();
|
|
72
|
+
|
|
73
|
+
/** Sign a request the way the vendor requires: body for POST/PATCH, path resource id for GET/DELETE. */
|
|
74
|
+
function signFor(m: string, p: string, raw: string): string {
|
|
75
|
+
const segments = (p.split('?')[0] ?? p).replace(/^\/v1\/?/, '').replace(/^\/+/, '').split('/');
|
|
76
|
+
return veriffSignature(m === 'GET' || m === 'DELETE' ? (segments[1] ?? '') : raw, TWIN_SHARED_SECRET);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function withRoot(steps: (h: H, root: string) => Promise<boolean>): Promise<boolean> {
|
|
80
|
+
const root = mkdtempSync(join(tmpdir(), 'veriff-cap-'));
|
|
81
|
+
const h: H = (s) => {
|
|
82
|
+
const raw = s.b === undefined ? '' : JSON.stringify(s.b);
|
|
83
|
+
const headers: Record<string, string> = s.noAuth ? {} : { [AUTH_CLIENT_HEADER]: TWIN_API_KEY, [HMAC_SIGNATURE_HEADER]: s.sig ?? signFor(s.m, s.p, raw) };
|
|
84
|
+
return handleVeriffTwinRequest({ method: s.m, path: s.p, ...(s.b === undefined ? {} : { body: raw }), headers, root, occurredAt: at() });
|
|
85
|
+
};
|
|
86
|
+
try { return await verifyBoundary('veriff.withRoot', () => steps(h, root)); } finally { rmSync(root, { recursive: true, force: true }); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ok = (r: VeriffResponse) => r.status >= 200 && r.status < 300;
|
|
90
|
+
const env = (r: VeriffResponse) => (r.body as { status?: string }).status;
|
|
91
|
+
const b = (r: VeriffResponse) => r.body as Body;
|
|
92
|
+
const failed = (r: VeriffResponse, status: number, code: string) => r.status === status && env(r) === 'fail' && b(r).code === code && typeof b(r).message === 'string' && b(r).message.length > 0;
|
|
93
|
+
|
|
94
|
+
/** Create a session and return its id (and the raw response, for the callers that assert on it). */
|
|
95
|
+
async function open(h: H, verification: Record<string, unknown> = {}): Promise<{ id: string; res: VeriffResponse }> {
|
|
96
|
+
const res = await h({ m: 'POST', p: '/v1/sessions', b: { verification } });
|
|
97
|
+
return { id: String(b(res).verification?.id ?? ''), res };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Drive a session all the way to a terminal decision, through the real PATCH + the twin-only arm. */
|
|
101
|
+
async function decide(h: H, id: string, status: string, extra: Record<string, unknown> = {}): Promise<VeriffResponse> {
|
|
102
|
+
await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: { verification: { status: 'submitted' } } });
|
|
103
|
+
return h({ m: 'POST', p: `/v1/_twin/sessions/${id}/decision`, b: { status, ...extra } });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const IMG = 'data:image/jpeg;base64,aGVsbG8td29ybGQ='; // "hello-world" → 11 decoded bytes
|
|
107
|
+
|
|
108
|
+
// ── shorthands ──
|
|
109
|
+
const done = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
110
|
+
const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec => ({ id, area, title, dimension, tier, expected: 'todo' });
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The AREA CENSUS — enumerated TOP-DOWN from Veriff's own documentation navigation (the
|
|
114
|
+
* `llms.txt` index: API reference groups + one page per solution), NOT derived from the manifest
|
|
115
|
+
* below. Deriving it from the manifest would make the bijection check a tautology; the point is
|
|
116
|
+
* that a whole Veriff product area cannot silently vanish from the denominator.
|
|
117
|
+
*/
|
|
118
|
+
export const VERIFF_AREAS = [
|
|
119
|
+
'sessions',
|
|
120
|
+
'decisions',
|
|
121
|
+
'attempts',
|
|
122
|
+
'person',
|
|
123
|
+
'media',
|
|
124
|
+
'auth',
|
|
125
|
+
'webhooks',
|
|
126
|
+
'errors',
|
|
127
|
+
'watchlist',
|
|
128
|
+
'idv',
|
|
129
|
+
'proof_of_address',
|
|
130
|
+
'database_verification',
|
|
131
|
+
'fraud',
|
|
132
|
+
'collected_data',
|
|
133
|
+
'faces',
|
|
134
|
+
'registry',
|
|
135
|
+
'platform',
|
|
136
|
+
'sdk',
|
|
137
|
+
'connector',
|
|
138
|
+
] as const;
|
|
139
|
+
|
|
140
|
+
export const VERIFF_CAPABILITIES: CapabilitySpec[] = [
|
|
141
|
+
// ── Sessions (the entry point of every integration) ───────────────────────
|
|
142
|
+
done('veriff.sessions.create', 'sessions', 'POST /v1/sessions — create a verification session (201 + the seven required verification fields)', 'api', 'core', () =>
|
|
143
|
+
withRoot(async (h) => {
|
|
144
|
+
const { id, res } = await open(h, { vendorData: 'partner_123', endUserId: 'eu_1' });
|
|
145
|
+
const v = b(res).verification;
|
|
146
|
+
return res.status === 201 && env(res) === 'success'
|
|
147
|
+
&& typeof id === 'string' && id.length === 36
|
|
148
|
+
&& v.status === 'created' && v.vendorData === 'partner_123' && v.endUserId === 'eu_1'
|
|
149
|
+
&& typeof v.sessionToken === 'string' && typeof v.host === 'string' && typeof v.url === 'string';
|
|
150
|
+
})),
|
|
151
|
+
done('veriff.sessions.create_minimal', 'sessions', 'POST /v1/sessions accepts the documented minimum body `{"verification":{}}`, returning vendorData/endUserId as null', 'api', 'core', () =>
|
|
152
|
+
withRoot(async (h) => {
|
|
153
|
+
const { res } = await open(h);
|
|
154
|
+
const v = b(res).verification;
|
|
155
|
+
return res.status === 201 && v.vendorData === null && v.endUserId === null && v.status === 'created';
|
|
156
|
+
})),
|
|
157
|
+
done('veriff.sessions.url_is_host_plus_token', 'sessions', 'verification.url is exactly host + "/v/" + sessionToken (the documented composition the hosted flow is opened with)', 'api', 'core', () =>
|
|
158
|
+
withRoot(async (h) => {
|
|
159
|
+
const { res } = await open(h);
|
|
160
|
+
const v = b(res).verification;
|
|
161
|
+
return v.url === `${v.host}/v/${v.sessionToken}` && String(v.sessionToken).split('.').length === 3;
|
|
162
|
+
})),
|
|
163
|
+
done('veriff.sessions.create_requires_verification', 'sessions', 'POST /v1/sessions without a `verification` object → 400 "Validation failed" (1101)', 'api', 'core', () =>
|
|
164
|
+
withRoot(async (h) => {
|
|
165
|
+
const miss = await h({ m: 'POST', p: '/v1/sessions', b: {} });
|
|
166
|
+
const wrong = await h({ m: 'POST', p: '/v1/sessions', b: { verification: 'nope' } });
|
|
167
|
+
return failed(miss, 400, '1101') && failed(wrong, 400, '1101');
|
|
168
|
+
})),
|
|
169
|
+
done('veriff.sessions.create_vendor_data_type', 'sessions', 'POST /v1/sessions rejects a non-string vendorData → 400 (1501)', 'api', 'common', () =>
|
|
170
|
+
withRoot(async (h) => {
|
|
171
|
+
const r = await h({ m: 'POST', p: '/v1/sessions', b: { verification: { vendorData: 42 } } });
|
|
172
|
+
// The message is asserted as a STRING LITERAL, quoted from Veriff's troubleshooting-codes
|
|
173
|
+
// table. Comparing against VERIFF_ERROR_MESSAGES — which is what the handler emits — would be
|
|
174
|
+
// two constants agreeing with each other: a §9 round-two review replaced all four constants
|
|
175
|
+
// with garbage and got ZERO regressions.
|
|
176
|
+
return failed(r, 400, '1501')
|
|
177
|
+
&& b(r).message === '`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)';
|
|
178
|
+
})),
|
|
179
|
+
done('veriff.sessions.create_vendor_data_length', 'sessions', 'POST /v1/sessions rejects a vendorData over the documented 1,000-character limit → 400 (1500)', 'api', 'common', () =>
|
|
180
|
+
withRoot(async (h) => {
|
|
181
|
+
const long = await h({ m: 'POST', p: '/v1/sessions', b: { verification: { vendorData: 'x'.repeat(1001) } } });
|
|
182
|
+
const edge = await h({ m: 'POST', p: '/v1/sessions', b: { verification: { vendorData: 'x'.repeat(1000) } } });
|
|
183
|
+
return failed(long, 400, '1500')
|
|
184
|
+
&& b(long).message === '`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)'
|
|
185
|
+
&& edge.status === 201;
|
|
186
|
+
})),
|
|
187
|
+
done('veriff.sessions.create_https_callback_only', 'sessions', 'POST /v1/sessions rejects a non-HTTPS callback → 400 "Only HTTPS return URLs are allowed." (1302)', 'api', 'common', () =>
|
|
188
|
+
withRoot(async (h) => {
|
|
189
|
+
const http = await h({ m: 'POST', p: '/v1/sessions', b: { verification: { callback: 'http://example.test/hook' } } });
|
|
190
|
+
const junk = await h({ m: 'POST', p: '/v1/sessions', b: { verification: { callback: 'not-a-url' } } });
|
|
191
|
+
const good = await h({ m: 'POST', p: '/v1/sessions', b: { verification: { callback: 'https://example.test/hook' } } });
|
|
192
|
+
return failed(http, 400, '1302') && b(http).message === 'Only HTTPS return URLs are allowed.'
|
|
193
|
+
&& failed(junk, 400, '1302') && good.status === 201;
|
|
194
|
+
})),
|
|
195
|
+
done('veriff.sessions.no_get_endpoint', 'sessions', 'Veriff has NO GET /v1/sessions/{id} — the twin refuses to invent one, and answers the vendor\'s 404', 'api', 'common', () =>
|
|
196
|
+
withRoot(async (h) => {
|
|
197
|
+
// The mirror image of "unmodeled ops fail like the vendor": a twin must not serve a convenient
|
|
198
|
+
// read the vendor does not have. Veriff's API reference index lists only POST/PATCH/DELETE on
|
|
199
|
+
// /v1/sessions/{id}; state is read from the webhooks and the decision endpoint, which is why
|
|
200
|
+
// the reference consumer stores its own veriffSessionId + status.
|
|
201
|
+
const { id } = await open(h, { vendorData: 'no-get' });
|
|
202
|
+
const invented = await h({ m: 'GET', p: `/v1/sessions/${id}` });
|
|
203
|
+
// …while the sub-resources the vendor DOES document all answer for the same live session, so
|
|
204
|
+
// this is a routing fact about one path, not a dead twin.
|
|
205
|
+
const decision = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
206
|
+
const attempts = await h({ m: 'GET', p: `/v1/sessions/${id}/attempts` });
|
|
207
|
+
const media = await h({ m: 'GET', p: `/v1/sessions/${id}/media` });
|
|
208
|
+
return failed(invented, 404, '1101')
|
|
209
|
+
&& ok(decision) && b(decision).verification === null
|
|
210
|
+
&& ok(attempts) && Array.isArray(b(attempts).verifications)
|
|
211
|
+
&& ok(media) && Array.isArray(b(media).images);
|
|
212
|
+
})),
|
|
213
|
+
done('veriff.sessions.unknown_session_404', 'sessions', 'Every session sub-resource answers 404 "Resource not found" (1101) for an unknown session id', 'api', 'core', () =>
|
|
214
|
+
withRoot(async (h) => {
|
|
215
|
+
const ghost = '00000000-0000-4000-8000-000000000000';
|
|
216
|
+
for (const p of [`/v1/sessions/${ghost}/decision`, `/v1/sessions/${ghost}/person`, `/v1/sessions/${ghost}/attempts`, `/v1/sessions/${ghost}/media`]) {
|
|
217
|
+
const r = await h({ m: 'GET', p });
|
|
218
|
+
if (!failed(r, 404, '1101') || b(r).message !== 'Resource not found') return false;
|
|
219
|
+
}
|
|
220
|
+
const patch = await h({ m: 'PATCH', p: `/v1/sessions/${ghost}`, b: { verification: { status: 'submitted' } } });
|
|
221
|
+
const del = await h({ m: 'DELETE', p: `/v1/sessions/${ghost}` });
|
|
222
|
+
return failed(patch, 404, '1101') && failed(del, 404, '1101');
|
|
223
|
+
})),
|
|
224
|
+
|
|
225
|
+
done('veriff.sessions.patch_submitted', 'sessions', 'PATCH /v1/sessions/{id} {verification:{status:"submitted"}} — the documented created→submitted transition', 'api', 'core', () =>
|
|
226
|
+
withRoot(async (h) => {
|
|
227
|
+
const { id } = await open(h);
|
|
228
|
+
const p = await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: { verification: { status: 'submitted' } } });
|
|
229
|
+
const g = await h({ m: 'GET', p: `/v1/_twin/sessions/${id}` });
|
|
230
|
+
return ok(p) && b(p).verification.status === 'submitted' && b(p).verification.id === id && b(g).verification.status === 'submitted';
|
|
231
|
+
})),
|
|
232
|
+
done('veriff.sessions.patch_rejects_other_statuses', 'sessions', 'PATCH /v1/sessions/{id} accepts ONLY "submitted" — any other value is 400 "Validation failed"', 'api', 'common', () =>
|
|
233
|
+
withRoot(async (h) => {
|
|
234
|
+
const { id } = await open(h);
|
|
235
|
+
const approved = await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: { verification: { status: 'approved' } } });
|
|
236
|
+
const empty = await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: { verification: {} } });
|
|
237
|
+
const g = await h({ m: 'GET', p: `/v1/_twin/sessions/${id}` });
|
|
238
|
+
return failed(approved, 400, '1101') && failed(empty, 400, '1101') && b(g).verification.status === 'created';
|
|
239
|
+
})),
|
|
240
|
+
done('veriff.sessions.patch_already_submitted', 'sessions', 'PATCH a session that is already submitted → 400 "Session has already been submitted"', 'api', 'common', () =>
|
|
241
|
+
withRoot(async (h) => {
|
|
242
|
+
const { id } = await open(h);
|
|
243
|
+
await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: { verification: { status: 'submitted' } } });
|
|
244
|
+
const again = await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: { verification: { status: 'submitted' } } });
|
|
245
|
+
return failed(again, 400, '1101') && b(again).message === 'Session has already been submitted';
|
|
246
|
+
})),
|
|
247
|
+
done('veriff.sessions.delete', 'sessions', 'DELETE /v1/sessions/{id} — id-only success body, and the session is gone afterwards', 'api', 'common', () =>
|
|
248
|
+
withRoot(async (h) => {
|
|
249
|
+
const { id } = await open(h);
|
|
250
|
+
const d = await h({ m: 'DELETE', p: `/v1/sessions/${id}` });
|
|
251
|
+
const g = await h({ m: 'GET', p: `/v1/_twin/sessions/${id}` });
|
|
252
|
+
// The published example is exactly `{status:'success', verification:{id}}` — nothing else.
|
|
253
|
+
return ok(d) && env(d) === 'success' && b(d).verification.id === id
|
|
254
|
+
&& Object.keys(b(d).verification).length === 1 && failed(g, 404, '1101');
|
|
255
|
+
})),
|
|
256
|
+
done('veriff.sessions.delete_in_progress', 'sessions', 'DELETE a submitted session → 400 "Session in progress." (1306), and the session survives', 'api', 'common', () =>
|
|
257
|
+
withRoot(async (h) => {
|
|
258
|
+
const { id } = await open(h);
|
|
259
|
+
await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: { verification: { status: 'submitted' } } });
|
|
260
|
+
const d = await h({ m: 'DELETE', p: `/v1/sessions/${id}` });
|
|
261
|
+
const g = await h({ m: 'GET', p: `/v1/_twin/sessions/${id}` });
|
|
262
|
+
return failed(d, 400, '1306') && b(d).message === 'Session in progress.' && ok(g) && b(g).verification.status === 'submitted';
|
|
263
|
+
})),
|
|
264
|
+
done('veriff.sessions.delete_not_completed', 'sessions', 'DELETE a session parked in review → 400 "Session is not in a completed status." (1305)', 'api', 'niche', () =>
|
|
265
|
+
withRoot(async (h) => {
|
|
266
|
+
const { id } = await open(h);
|
|
267
|
+
await decide(h, id, 'review');
|
|
268
|
+
const d = await h({ m: 'DELETE', p: `/v1/sessions/${id}` });
|
|
269
|
+
return failed(d, 400, '1305') && b(d).message === 'Session is not in a completed status.';
|
|
270
|
+
})),
|
|
271
|
+
done('veriff.sessions.delete_after_decision', 'sessions', 'DELETE is allowed from every completed status the endpoint lists (approved/declined/expired/abandoned)', 'api', 'niche', () =>
|
|
272
|
+
withRoot(async (h) => {
|
|
273
|
+
for (const status of ['approved', 'declined', 'expired', 'abandoned']) {
|
|
274
|
+
const { id } = await open(h);
|
|
275
|
+
await decide(h, id, status);
|
|
276
|
+
const d = await h({ m: 'DELETE', p: `/v1/sessions/${id}` });
|
|
277
|
+
if (!ok(d) || b(d).verification.id !== id) return false;
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
})),
|
|
281
|
+
|
|
282
|
+
// ── Decisions (what an integration is actually waiting for) ───────────────
|
|
283
|
+
done('veriff.decisions.pending_is_null', 'decisions', 'GET /v1/sessions/{id}/decision before a decision → 200 with `verification: null` (the documented poll-again answer)', 'api', 'core', () =>
|
|
284
|
+
withRoot(async (h) => {
|
|
285
|
+
const { id } = await open(h);
|
|
286
|
+
const before = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
287
|
+
await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: { verification: { status: 'submitted' } } });
|
|
288
|
+
const submitted = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
289
|
+
// Still null AFTER submission — the doc is explicit that a session can be `submitted` while
|
|
290
|
+
// the decision is still being processed.
|
|
291
|
+
return ok(before) && env(before) === 'success' && b(before).verification === null
|
|
292
|
+
&& ok(submitted) && b(submitted).verification === null;
|
|
293
|
+
})),
|
|
294
|
+
done('veriff.decisions.approved', 'decisions', 'An approved decision carries status "approved", code 9001, a decisionTime and an attemptId', 'api', 'core', () =>
|
|
295
|
+
withRoot(async (h) => {
|
|
296
|
+
const { id } = await open(h, { vendorData: 'partner_9' });
|
|
297
|
+
await decide(h, id, 'approved');
|
|
298
|
+
const d = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
299
|
+
const v = b(d).verification;
|
|
300
|
+
return ok(d) && v.id === id && v.status === 'approved' && v.code === 9001
|
|
301
|
+
&& v.vendorData === 'partner_9' && typeof v.decisionTime === 'string' && typeof v.attemptId === 'string' && v.reason === null;
|
|
302
|
+
})),
|
|
303
|
+
done('veriff.decisions.declined_with_reason', 'decisions', 'A declined decision carries code 9102 plus the granular reason/reasonCode pair', 'api', 'core', () =>
|
|
304
|
+
withRoot(async (h) => {
|
|
305
|
+
const { id } = await open(h);
|
|
306
|
+
await decide(h, id, 'declined', { reason: 'Suspected document tampering', reasonCode: 102 });
|
|
307
|
+
const d = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
308
|
+
const v = b(d).verification;
|
|
309
|
+
return v.status === 'declined' && v.code === 9102 && v.reason === 'Suspected document tampering' && v.reasonCode === 102;
|
|
310
|
+
})),
|
|
311
|
+
done('veriff.decisions.resubmission_requested', 'decisions', 'A resubmission decision carries code 9103 and re-opens the session for capture', 'api', 'core', () =>
|
|
312
|
+
withRoot(async (h) => {
|
|
313
|
+
const { id } = await open(h);
|
|
314
|
+
await decide(h, id, 'resubmission_requested', { reason: 'Face not clearly visible', reasonCode: 202 });
|
|
315
|
+
const d = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
316
|
+
// Re-opened: a second capture + submit is accepted, which is what "additional attempt needed"
|
|
317
|
+
// has to mean for the state machine to be usable.
|
|
318
|
+
const reupload = await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'face', content: IMG } } });
|
|
319
|
+
const resubmit = await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: { verification: { status: 'submitted' } } });
|
|
320
|
+
return b(d).verification.code === 9103 && b(d).verification.reasonCode === 202 && reupload.status === 200 && ok(resubmit);
|
|
321
|
+
})),
|
|
322
|
+
done('veriff.decisions.expired_and_abandoned_codes', 'decisions', 'expired carries 9104 and abandoned carries 9121 — the two codes the decision endpoint\'s own examples pair with those statuses', 'api', 'common', () =>
|
|
323
|
+
withRoot(async (h) => {
|
|
324
|
+
const a = await open(h); await decide(h, a.id, 'expired');
|
|
325
|
+
const c = await open(h); await decide(h, c.id, 'abandoned');
|
|
326
|
+
const da = await h({ m: 'GET', p: `/v1/sessions/${a.id}/decision` });
|
|
327
|
+
const dc = await h({ m: 'GET', p: `/v1/sessions/${c.id}/decision` });
|
|
328
|
+
// Distinct codes, not a shared one — the pairing an earlier revision of this pack got backwards.
|
|
329
|
+
return b(da).verification.code === 9104 && b(da).verification.status === 'expired'
|
|
330
|
+
&& b(dc).verification.code === 9121 && b(dc).verification.status === 'abandoned';
|
|
331
|
+
})),
|
|
332
|
+
done('veriff.decisions.review_has_no_invented_code', 'decisions', 'A session held for manual review reports status "review" with code NULL — Veriff publishes no code for it, and the twin refuses to borrow one', 'api', 'niche', () =>
|
|
333
|
+
withRoot(async (h) => {
|
|
334
|
+
// `review` is a real, opt-in status (the decision-webhook page lists it) but it appears in
|
|
335
|
+
// NEITHER the decision schema's `status` enum NOR its `code` enum — [9001,9102,9103,9104,9121]
|
|
336
|
+
// are all spoken for. Emitting 9121 here would tell a consumer branching on `code` that a
|
|
337
|
+
// manual-review case was abandoned.
|
|
338
|
+
const { id } = await open(h);
|
|
339
|
+
await decide(h, id, 'review');
|
|
340
|
+
const d = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
341
|
+
const v = b(d).verification;
|
|
342
|
+
const taken = [9001, 9102, 9103, 9104, 9121];
|
|
343
|
+
return v.status === 'review' && v.code === null && taken.every((c) => c !== v.code)
|
|
344
|
+
&& typeof v.decisionTime === 'string' && typeof v.attemptId === 'string';
|
|
345
|
+
})),
|
|
346
|
+
done('veriff.decisions.extracted_person_and_document', 'decisions', 'A decision carries the extracted `person` and `document` objects, distinct from the hints supplied at creation', 'api', 'core', () =>
|
|
347
|
+
withRoot(async (h) => {
|
|
348
|
+
// The caller SEEDS a hint at creation; the decision must report the EXTRACTED values, not
|
|
349
|
+
// echo the hint back — a twin that conflated the two would let a caller "verify" its own input.
|
|
350
|
+
const { id } = await open(h, { person: { firstName: 'hint', lastName: 'hint' }, document: { type: 'PASSPORT', country: 'EE' } });
|
|
351
|
+
const beforeDecision = await h({ m: 'GET', p: `/v1/sessions/${id}/person` });
|
|
352
|
+
await decide(h, id, 'approved', {
|
|
353
|
+
person: { firstName: 'SARAH', lastName: 'MORGAN', dateOfBirth: '1967-03-30', nationality: 'GB', idNumber: null },
|
|
354
|
+
document: { number: 'X1234567', type: 'PASSPORT', country: 'GB' },
|
|
355
|
+
});
|
|
356
|
+
const d = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
357
|
+
const v = b(d).verification;
|
|
358
|
+
return b(beforeDecision).person === null
|
|
359
|
+
&& v.person.firstName === 'SARAH' && v.person.dateOfBirth === '1967-03-30'
|
|
360
|
+
&& v.document.number === 'X1234567' && v.document.country === 'GB';
|
|
361
|
+
})),
|
|
362
|
+
done('veriff.decisions.risk_labels', 'decisions', 'A decision carries riskLabels[{label,category,sessionIds}] — the crosslink signal a fraud check reads', 'api', 'common', () =>
|
|
363
|
+
withRoot(async (h) => {
|
|
364
|
+
const { id } = await open(h);
|
|
365
|
+
await decide(h, id, 'approved', { riskLabels: [{ label: 'person_previously_approved', category: 'crosslinks', sessionIds: ['s1', 's2'] }] });
|
|
366
|
+
const d = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
367
|
+
const labels = b(d).verification.riskLabels;
|
|
368
|
+
return Array.isArray(labels) && labels[0].label === 'person_previously_approved' && labels[0].category === 'crosslinks' && labels[0].sessionIds.length === 2;
|
|
369
|
+
})),
|
|
370
|
+
done('veriff.decisions.transition_guard', 'decisions', 'A session cannot jump straight from created to a decision — 400 `Cannot transition to "…" status.` (1304)', 'api', 'core', () =>
|
|
371
|
+
withRoot(async (h) => {
|
|
372
|
+
const { id } = await open(h);
|
|
373
|
+
const early = await h({ m: 'POST', p: `/v1/_twin/sessions/${id}/decision`, b: { status: 'approved' } });
|
|
374
|
+
const stillPending = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
375
|
+
return failed(early, 400, '1304') && b(stillPending).verification === null;
|
|
376
|
+
})),
|
|
377
|
+
done('veriff.decisions.no_second_decision', 'decisions', 'A decided session is terminal — a second decision is refused and the first one stands', 'api', 'common', () =>
|
|
378
|
+
withRoot(async (h) => {
|
|
379
|
+
const { id } = await open(h);
|
|
380
|
+
await decide(h, id, 'approved');
|
|
381
|
+
const again = await h({ m: 'POST', p: `/v1/_twin/sessions/${id}/decision`, b: { status: 'declined' } });
|
|
382
|
+
const d = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
383
|
+
return failed(again, 400, '1304') && b(d).verification.status === 'approved' && b(d).verification.code === 9001;
|
|
384
|
+
})),
|
|
385
|
+
|
|
386
|
+
// ── Attempts ──────────────────────────────────────────────────────────────
|
|
387
|
+
done('veriff.attempts.list', 'attempts', 'GET /v1/sessions/{id}/attempts — the `verifications[]` array with id/status/userDefinedData/createdTime', 'api', 'common', () =>
|
|
388
|
+
withRoot(async (h) => {
|
|
389
|
+
const { id } = await open(h);
|
|
390
|
+
const decided = await decide(h, id, 'resubmission_requested');
|
|
391
|
+
const a = await h({ m: 'GET', p: `/v1/sessions/${id}/attempts` });
|
|
392
|
+
const rows = b(a).verifications;
|
|
393
|
+
return ok(a) && Array.isArray(rows) && rows.length === 1
|
|
394
|
+
&& rows[0].id === b(decided).verification.attemptId
|
|
395
|
+
&& rows[0].status === 'resubmission_requested'
|
|
396
|
+
&& Array.isArray(rows[0].userDefinedData) && typeof rows[0].createdTime === 'string';
|
|
397
|
+
})),
|
|
398
|
+
done('veriff.attempts.reverse_chronological', 'attempts', 'Attempts come back most-recent-first across a resubmission cycle', 'api', 'common', () =>
|
|
399
|
+
withRoot(async (h) => {
|
|
400
|
+
const { id } = await open(h);
|
|
401
|
+
await decide(h, id, 'resubmission_requested');
|
|
402
|
+
await decide(h, id, 'approved'); // second attempt, after the session re-opened
|
|
403
|
+
const a = await h({ m: 'GET', p: `/v1/sessions/${id}/attempts` });
|
|
404
|
+
const rows = b(a).verifications;
|
|
405
|
+
return rows.length === 2 && rows[0].status === 'approved' && rows[1].status === 'resubmission_requested'
|
|
406
|
+
&& String(rows[0].createdTime) > String(rows[1].createdTime);
|
|
407
|
+
})),
|
|
408
|
+
done('veriff.attempts.empty_before_decision', 'attempts', 'A session with no attempt yet returns an empty verifications array, not a 404', 'api', 'niche', () =>
|
|
409
|
+
withRoot(async (h) => {
|
|
410
|
+
const { id } = await open(h);
|
|
411
|
+
const a = await h({ m: 'GET', p: `/v1/sessions/${id}/attempts` });
|
|
412
|
+
const missing = await h({ m: 'GET', p: '/v1/sessions/00000000-0000-4000-8000-000000000000/attempts' });
|
|
413
|
+
return ok(a) && Array.isArray(b(a).verifications) && b(a).verifications.length === 0 && failed(missing, 404, '1101');
|
|
414
|
+
})),
|
|
415
|
+
|
|
416
|
+
// ── Person ────────────────────────────────────────────────────────────────
|
|
417
|
+
done('veriff.person.get', 'person', 'GET /v1/sessions/{id}/person — serves the session\'s extracted person once one exists, 404 for an unknown session', 'api', 'common', () =>
|
|
418
|
+
withRoot(async (h) => {
|
|
419
|
+
const { id } = await open(h);
|
|
420
|
+
const before = await h({ m: 'GET', p: `/v1/sessions/${id}/person` });
|
|
421
|
+
await decide(h, id, 'approved', { person: { firstName: 'ANA', lastName: 'LOPEZ', nationality: 'ES' } });
|
|
422
|
+
const after = await h({ m: 'GET', p: `/v1/sessions/${id}/person` });
|
|
423
|
+
const missing = await h({ m: 'GET', p: '/v1/sessions/00000000-0000-4000-8000-000000000000/person' });
|
|
424
|
+
return b(before).person === null && b(after).person.firstName === 'ANA' && b(after).person.nationality === 'ES' && failed(missing, 404, '1101');
|
|
425
|
+
})),
|
|
426
|
+
|
|
427
|
+
// ── Media ─────────────────────────────────────────────────────────────────
|
|
428
|
+
done('veriff.media.upload', 'media', 'POST /v1/sessions/{id}/media — 200 with the documented image object (id/name/context/size/mimetype/url/sessionId)', 'api', 'core', () =>
|
|
429
|
+
withRoot(async (h) => {
|
|
430
|
+
const { id } = await open(h);
|
|
431
|
+
const u = await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'document-front', content: IMG } } });
|
|
432
|
+
const img = b(u).image;
|
|
433
|
+
return u.status === 200 && env(u) === 'success'
|
|
434
|
+
&& typeof img.id === 'string' && img.context === 'document-front'
|
|
435
|
+
&& img.sessionId === id && img.mimetype === 'image/jpeg'
|
|
436
|
+
&& img.url === `https://stationapi.veriff.com/v1/media/${img.id}`;
|
|
437
|
+
})),
|
|
438
|
+
done('veriff.media.name_mirrors_context', 'media', 'The uploaded object\'s `name` mirrors its `context`, as every published example shows', 'api', 'niche', () =>
|
|
439
|
+
withRoot(async (h) => {
|
|
440
|
+
const { id } = await open(h);
|
|
441
|
+
const u = await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'document-back', content: IMG } } });
|
|
442
|
+
return b(u).image.name === 'document-back' && b(u).image.context === 'document-back';
|
|
443
|
+
})),
|
|
444
|
+
done('veriff.media.timestamp_always_null', 'media', 'The response `timestamp` is deprecated and always null — even when the caller supplies one', 'api', 'niche', () =>
|
|
445
|
+
withRoot(async (h) => {
|
|
446
|
+
const { id } = await open(h);
|
|
447
|
+
const u = await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'face', content: IMG, timestamp: '2025-01-01T10:00:00Z' } } });
|
|
448
|
+
return u.status === 200 && 'timestamp' in b(u).image && b(u).image.timestamp === null;
|
|
449
|
+
})),
|
|
450
|
+
done('veriff.media.size_is_decoded_bytes', 'media', '`size` is the DECODED byte count, not the base64 length or the data-URI length', 'api', 'niche', () =>
|
|
451
|
+
withRoot(async (h) => {
|
|
452
|
+
const { id } = await open(h);
|
|
453
|
+
const u = await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'face', content: IMG } } });
|
|
454
|
+
return b(u).image.size === 11; // "hello-world"
|
|
455
|
+
})),
|
|
456
|
+
done('veriff.media.upload_rejects_unknown_context', 'media', 'An unlisted media context → 400 (1402, "context not supported")', 'api', 'common', () =>
|
|
457
|
+
withRoot(async (h) => {
|
|
458
|
+
const { id } = await open(h);
|
|
459
|
+
const bad = await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'selfie-please', content: IMG } } });
|
|
460
|
+
const list = await h({ m: 'GET', p: `/v1/sessions/${id}/media` });
|
|
461
|
+
return failed(bad, 400, '1402') && b(bad).message === 'Image context is not supported.' && b(list).images.length === 0;
|
|
462
|
+
})),
|
|
463
|
+
done('veriff.media.upload_rejects_bad_base64', 'media', 'Content that is not valid base64 → 400 (1401)', 'api', 'common', () =>
|
|
464
|
+
withRoot(async (h) => {
|
|
465
|
+
const { id } = await open(h);
|
|
466
|
+
const bad = await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'face', content: 'data:image/jpeg;base64,!!!not base64!!!' } } });
|
|
467
|
+
const empty = await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'face', content: '' } } });
|
|
468
|
+
return failed(bad, 400, '1401') && b(bad).message === 'Image is not in valid `base64`.' && failed(empty, 400, '1401');
|
|
469
|
+
})),
|
|
470
|
+
done('veriff.media.upload_after_submit_409', 'media', 'Uploading after the session is submitted → 409 conflict, exactly as the endpoint documents', 'api', 'common', () =>
|
|
471
|
+
withRoot(async (h) => {
|
|
472
|
+
const { id } = await open(h);
|
|
473
|
+
await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: { verification: { status: 'submitted' } } });
|
|
474
|
+
const late = await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'face', content: IMG } } });
|
|
475
|
+
const list = await h({ m: 'GET', p: `/v1/sessions/${id}/media` });
|
|
476
|
+
return failed(late, 409, '1101') && b(list).images.length === 0;
|
|
477
|
+
})),
|
|
478
|
+
done('veriff.media.list_by_session', 'media', 'GET /v1/sessions/{id}/media — the images/videos/nfcDocuments split, scoped to that session', 'api', 'common', () =>
|
|
479
|
+
withRoot(async (h) => {
|
|
480
|
+
const a = await open(h); const c = await open(h);
|
|
481
|
+
await h({ m: 'POST', p: `/v1/sessions/${a.id}/media`, b: { image: { context: 'document-front', content: IMG } } });
|
|
482
|
+
await h({ m: 'POST', p: `/v1/sessions/${c.id}/media`, b: { image: { context: 'face', content: IMG } } });
|
|
483
|
+
const la = await h({ m: 'GET', p: `/v1/sessions/${a.id}/media` });
|
|
484
|
+
const lc = await h({ m: 'GET', p: `/v1/sessions/${c.id}/media` });
|
|
485
|
+
return ok(la) && Array.isArray(b(la).videos) && Array.isArray(b(la).nfcDocuments)
|
|
486
|
+
&& b(la).images.length === 1 && b(la).images[0].context === 'document-front' && b(la).images[0].sessionId === a.id
|
|
487
|
+
// …and scoped: the OTHER session's upload is not in this list, and is in its own.
|
|
488
|
+
&& b(lc).images.length === 1 && b(lc).images[0].context === 'face' && b(lc).images[0].sessionId === c.id;
|
|
489
|
+
})),
|
|
490
|
+
done('veriff.media.list_by_attempt', 'media', 'GET /v1/attempts/{id}/media — per-attempt metadata, 404 for an unknown attempt', 'api', 'niche', () =>
|
|
491
|
+
withRoot(async (h) => {
|
|
492
|
+
const { id } = await open(h);
|
|
493
|
+
await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'face', content: IMG } } });
|
|
494
|
+
const decided = await decide(h, id, 'approved');
|
|
495
|
+
const attemptId = b(decided).verification.attemptId;
|
|
496
|
+
const m = await h({ m: 'GET', p: `/v1/attempts/${attemptId}/media` });
|
|
497
|
+
const missing = await h({ m: 'GET', p: '/v1/attempts/00000000-0000-4000-8000-000000000000/media' });
|
|
498
|
+
return ok(m) && Array.isArray(b(m).images) && failed(missing, 404, '1101');
|
|
499
|
+
})),
|
|
500
|
+
done('veriff.media.download_bytes', 'media', 'GET /v1/media/{id} returns the media BYTES under its own mimetype (not JSON), 404 for an unknown id', 'api', 'common', () =>
|
|
501
|
+
withRoot(async (h) => {
|
|
502
|
+
const { id } = await open(h);
|
|
503
|
+
const u = await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'face', content: IMG } } });
|
|
504
|
+
const g = await h({ m: 'GET', p: `/v1/media/${b(u).image.id}` });
|
|
505
|
+
const missing = await h({ m: 'GET', p: '/v1/media/00000000-0000-4000-8000-000000000000' });
|
|
506
|
+
return ok(g) && b(g).mimetype === 'image/jpeg'
|
|
507
|
+
&& Buffer.from(String(b(g).content), 'base64').toString('utf8') === 'hello-world'
|
|
508
|
+
&& failed(missing, 404, '1101');
|
|
509
|
+
})),
|
|
510
|
+
|
|
511
|
+
// ── Auth: X-AUTH-CLIENT + the HMAC scheme (REAL crypto, not faked) ────────
|
|
512
|
+
done('veriff.auth.requires_auth_client', 'auth', 'A request without X-AUTH-CLIENT → 401 with the vendor\'s verbatim "Mandatory X-AUTH-CLIENT header…" message', 'api', 'core', () =>
|
|
513
|
+
withRoot(async (h) => {
|
|
514
|
+
const r = await h({ m: 'POST', p: '/v1/sessions', b: { verification: {} }, noAuth: true });
|
|
515
|
+
return failed(r, 401, '1101') && b(r).message === 'Mandatory X-AUTH-CLIENT header containing the API key is missing from the request.';
|
|
516
|
+
})),
|
|
517
|
+
done('veriff.auth.hmac_matches_published_vector', 'auth', 'The signing implementation reproduces Veriff\'s OWN published mock vector byte-for-byte', 'api', 'core', () => {
|
|
518
|
+
// devdocs.veriff.com/docs/hmac-authentication-and-endpoint-security, "Mock data to test
|
|
519
|
+
// signature generation locally". This is the strongest possible check on the primitive: it is
|
|
520
|
+
// the vendor's number, not ours, and a wrong encoding (base64, uppercase hex, the `sha256=`
|
|
521
|
+
// prefix the vendor's own Python sample wrongly emits) fails it instantly.
|
|
522
|
+
const secret = 'abcdef12-abcd-abcd-abcd-abcdef012345';
|
|
523
|
+
const payload = '{"verification":{"callback":"https://veriff.com","person":{"firstName":"John","lastName":"Smith"},"document":{"type":"PASSPORT","country":"EE"},"vendorData":"unique id of the end-user","timestamp":"2016-05-19T08:30:25.597Z"}}';
|
|
524
|
+
const sig = veriffSignature(payload, secret);
|
|
525
|
+
return sig === '0dcab73ddd20062616d104231c7439657546a5c24e4691977da93bb854c31e25'
|
|
526
|
+
&& verifyVeriffSignature(payload, sig, secret) === null
|
|
527
|
+
&& verifyVeriffSignature(payload, sig.toUpperCase(), secret) === 'mismatch'
|
|
528
|
+
&& verifyVeriffSignature(`${payload} `, sig, secret) === 'mismatch'
|
|
529
|
+
&& verifyVeriffSignature(payload, undefined, secret) === 'missing';
|
|
530
|
+
}),
|
|
531
|
+
done('veriff.auth.hmac_required_on_reads', 'auth', 'A read with a missing or wrong X-HMAC-SIGNATURE → 401 "Invalid HMAC signature" (1812)', 'api', 'core', () =>
|
|
532
|
+
withRoot(async (h, root) => {
|
|
533
|
+
const { id } = await open(h);
|
|
534
|
+
const wrong = await h({ m: 'GET', p: `/v1/sessions/${id}/decision`, sig: veriffSignature('some-other-session', TWIN_SHARED_SECRET) });
|
|
535
|
+
// Rooted even though it 401s before touching state: no verify in this file may be capable of
|
|
536
|
+
// writing outside its temp root, however it happens to short-circuit today.
|
|
537
|
+
const absent = await handleVeriffTwinRequest({ method: 'GET', path: `/v1/sessions/${id}/decision`, headers: { [AUTH_CLIENT_HEADER]: TWIN_API_KEY }, root, occurredAt: at() });
|
|
538
|
+
const good = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
539
|
+
return failed(wrong, 401, '1812') && b(wrong).message === 'Invalid HMAC signature'
|
|
540
|
+
&& failed(absent, 401, '1812') && ok(good);
|
|
541
|
+
})),
|
|
542
|
+
done('veriff.auth.create_session_is_signature_exempt', 'auth', 'POST /v1/sessions is the ONE endpoint that needs no signature — and every other one still does', 'api', 'core', () =>
|
|
543
|
+
withRoot(async (h, root) => {
|
|
544
|
+
// These three go around `h` because they must send NO signature header at all, which `h`
|
|
545
|
+
// always supplies. `root` and `occurredAt` are threaded BY HAND for exactly that reason: an
|
|
546
|
+
// omitted root does not error, it silently writes into the operator's real ~/.volter state
|
|
547
|
+
// dir — which is gitignored, so nothing catches it while it poisons later runs. A §9 review
|
|
548
|
+
// caught this here after measuring a `.volter/world/veriff/actions.jsonl` growing by one row
|
|
549
|
+
// per test run.
|
|
550
|
+
const bare = (method: string, path: string, body?: unknown) => handleVeriffTwinRequest({
|
|
551
|
+
method, path, ...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
552
|
+
headers: { [AUTH_CLIENT_HEADER]: TWIN_API_KEY }, root, occurredAt: at(),
|
|
553
|
+
});
|
|
554
|
+
const created = await bare('POST', '/v1/sessions', { verification: { vendorData: 'exempt' } });
|
|
555
|
+
const id = String((created.body as Body).verification?.id ?? '');
|
|
556
|
+
const unsignedRead = await bare('GET', `/v1/sessions/${id}/decision`);
|
|
557
|
+
// …and the same read WITH a correct signature works, so this is about the signature and not
|
|
558
|
+
// about the session being unreachable. That cross-check is ALSO what pins the root-threading
|
|
559
|
+
// above: `h` reads from the temp root, so if `bare()` stopped passing `root` the session would
|
|
560
|
+
// land in the ambient dir and this line would 404 — the capability reddens BY NAME rather than
|
|
561
|
+
// leaking silently. (Verified by reverting it: the baseline reports exactly this id.)
|
|
562
|
+
const signedRead = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
563
|
+
return created.status === 201 && (created.body as Body).verification.vendorData === 'exempt'
|
|
564
|
+
&& failed(unsignedRead, 401, '1812') && ok(signedRead) && b(signedRead).verification === null;
|
|
565
|
+
})),
|
|
566
|
+
done('veriff.auth.hmac_signs_body_on_writes', 'auth', 'POST/PATCH sign the REQUEST BODY — a signature over the session id is refused on a write', 'api', 'core', () =>
|
|
567
|
+
withRoot(async (h) => {
|
|
568
|
+
const { id } = await open(h);
|
|
569
|
+
const body = { verification: { status: 'submitted' } };
|
|
570
|
+
const wrongPayload = await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: body, sig: veriffSignature(id, TWIN_SHARED_SECRET) });
|
|
571
|
+
const rightPayload = await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: body, sig: veriffSignature(JSON.stringify(body), TWIN_SHARED_SECRET) });
|
|
572
|
+
return failed(wrongPayload, 401, '1812') && ok(rightPayload) && b(rightPayload).verification.status === 'submitted';
|
|
573
|
+
})),
|
|
574
|
+
done('veriff.auth.hmac_signs_path_resource_id', 'auth', 'GET signs the resource id IN THE PATH — the attempt id for /attempts/{id}/media, the media id for /media/{id}', 'api', 'common', () =>
|
|
575
|
+
withRoot(async (h) => {
|
|
576
|
+
const { id } = await open(h);
|
|
577
|
+
const u = await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'face', content: IMG } } });
|
|
578
|
+
const mediaId = String(b(u).image.id);
|
|
579
|
+
const decided = await decide(h, id, 'approved');
|
|
580
|
+
const attemptId = String(b(decided).verification.attemptId);
|
|
581
|
+
// Signing the SESSION id (the naive reading of "sign the session ID") must be refused here.
|
|
582
|
+
const wrongMedia = await h({ m: 'GET', p: `/v1/media/${mediaId}`, sig: veriffSignature(id, TWIN_SHARED_SECRET) });
|
|
583
|
+
const rightMedia = await h({ m: 'GET', p: `/v1/media/${mediaId}` });
|
|
584
|
+
const wrongAttempt = await h({ m: 'GET', p: `/v1/attempts/${attemptId}/media`, sig: veriffSignature(id, TWIN_SHARED_SECRET) });
|
|
585
|
+
const rightAttempt = await h({ m: 'GET', p: `/v1/attempts/${attemptId}/media` });
|
|
586
|
+
return failed(wrongMedia, 401, '1812') && ok(rightMedia) && failed(wrongAttempt, 401, '1812') && ok(rightAttempt);
|
|
587
|
+
})),
|
|
588
|
+
done('veriff.auth.response_signing_headers', 'auth', 'Responses carry X-AUTH-CLIENT and an X-HMAC-SIGNATURE over the response BODY, as every endpoint documents', 'api', 'common', async () => {
|
|
589
|
+
const root = mkdtempSync(join(tmpdir(), 'veriff-cap-srv-'));
|
|
590
|
+
const server = await createVeriffTwinServer({ root, port: 0, hostname: '127.0.0.1' }); // loopback-specific: port 0 can't land on a stranger's 127.0.0.1 listener
|
|
591
|
+
try {
|
|
592
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/v1/sessions`, {
|
|
593
|
+
method: 'POST',
|
|
594
|
+
headers: { [AUTH_CLIENT_HEADER]: TWIN_API_KEY, 'content-type': 'application/json' },
|
|
595
|
+
body: JSON.stringify({ verification: { vendorData: 'signed-response' } }),
|
|
596
|
+
});
|
|
597
|
+
const text = await res.text();
|
|
598
|
+
const parsed = JSON.parse(text) as Body;
|
|
599
|
+
// Data-coupled: the signature must cover the REAL body the handler produced, and that body
|
|
600
|
+
// must carry the seeded value — a dead handler's `{}` fails the second half.
|
|
601
|
+
return res.status === 201
|
|
602
|
+
&& parsed.verification?.vendorData === 'signed-response'
|
|
603
|
+
&& res.headers.get(AUTH_CLIENT_HEADER) === TWIN_API_KEY
|
|
604
|
+
&& res.headers.get(HMAC_SIGNATURE_HEADER) === veriffSignature(text, TWIN_SHARED_SECRET)
|
|
605
|
+
&& veriffResponseHeaders(text, { apiKey: TWIN_API_KEY, sharedSecret: TWIN_SHARED_SECRET })[HMAC_SIGNATURE_HEADER] === res.headers.get(HMAC_SIGNATURE_HEADER);
|
|
606
|
+
} finally {
|
|
607
|
+
server.stop();
|
|
608
|
+
rmSync(root, { recursive: true, force: true });
|
|
609
|
+
}
|
|
610
|
+
}),
|
|
611
|
+
|
|
612
|
+
// ── Webhooks ──────────────────────────────────────────────────────────────
|
|
613
|
+
done('veriff.webhooks.decision_delivery', 'webhooks', 'A decision pushes a signed decision webhook to the session callback, carrying the same document the decision endpoint serves', 'api', 'core', () =>
|
|
614
|
+
withRoot(async (h) => {
|
|
615
|
+
const { id } = await open(h, { callback: 'https://consumer.test/api/veriff/webhook', vendorData: 'partner_7' });
|
|
616
|
+
await decide(h, id, 'approved');
|
|
617
|
+
const d = await h({ m: 'GET', p: `/v1/_twin/deliveries?sessionId=${id}` });
|
|
618
|
+
const decision = (b(d).deliveries as Body[]).find((x) => x.kind === 'decision');
|
|
619
|
+
if (!decision) return false;
|
|
620
|
+
const payload = JSON.parse(String(decision.body)) as Body;
|
|
621
|
+
const endpoint = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
622
|
+
return decision.url === 'https://consumer.test/api/veriff/webhook'
|
|
623
|
+
&& payload.verification.id === id && payload.verification.status === 'approved'
|
|
624
|
+
&& payload.verification.code === 9001 && payload.verification.vendorData === 'partner_7'
|
|
625
|
+
&& JSON.stringify(payload.verification) === JSON.stringify(b(endpoint).verification);
|
|
626
|
+
})),
|
|
627
|
+
done('veriff.webhooks.decision_signature_verifies', 'webhooks', 'A real consumer\'s verification of the delivery passes — x-auth-client matched and the HMAC taken over the RAW body', 'api', 'core', () =>
|
|
628
|
+
withRoot(async (h) => {
|
|
629
|
+
const { id } = await open(h, { callback: 'https://consumer.test/hook' });
|
|
630
|
+
await decide(h, id, 'declined', { reason: 'Known fraud', reasonCode: 106 });
|
|
631
|
+
const d = await h({ m: 'GET', p: `/v1/_twin/deliveries?sessionId=${id}` });
|
|
632
|
+
// Two deliveries went out — the 7002 submitted EVENT and then the DECISION. Pick the decision
|
|
633
|
+
// explicitly rather than trusting an index; conflating them is how this verify first went red.
|
|
634
|
+
const delivery = (b(d).deliveries as Body[]).find((x) => x.kind === 'decision');
|
|
635
|
+
if (!delivery) return false;
|
|
636
|
+
const headers = delivery.headers as Record<string, string>;
|
|
637
|
+
const parsed = verifyWebhook(String(delivery.body), headers, { apiKey: TWIN_API_KEY, sharedSecret: TWIN_SHARED_SECRET }) as Body;
|
|
638
|
+
return (b(d).deliveries as Body[]).length === 2
|
|
639
|
+
&& parsed.verification.status === 'declined' && parsed.verification.reasonCode === 106
|
|
640
|
+
&& headers[AUTH_CLIENT_HEADER] === TWIN_API_KEY
|
|
641
|
+
&& headers[HMAC_SIGNATURE_HEADER] === veriffSignature(String(delivery.body), TWIN_SHARED_SECRET);
|
|
642
|
+
})),
|
|
643
|
+
done('veriff.webhooks.rejects_tampering', 'webhooks', 'Verification refuses a tampered body, a wrong x-auth-client, and a missing signature — each by its own reason', 'api', 'core', () =>
|
|
644
|
+
withRoot(async (h) => {
|
|
645
|
+
const { id } = await open(h, { callback: 'https://consumer.test/hook' });
|
|
646
|
+
await decide(h, id, 'approved');
|
|
647
|
+
const d = await h({ m: 'GET', p: `/v1/_twin/deliveries?sessionId=${id}` });
|
|
648
|
+
const delivery = (b(d).deliveries as Body[]).find((x) => x.kind === 'decision');
|
|
649
|
+
if (!delivery) return false;
|
|
650
|
+
const headers = delivery.headers as Record<string, string>;
|
|
651
|
+
const raw = String(delivery.body);
|
|
652
|
+
const reason = (fn: () => unknown): string => {
|
|
653
|
+
try { fn(); return 'NO-THROW'; } catch (e) { return e instanceof VeriffWebhookVerificationError ? e.reason : 'WRONG-ERROR'; }
|
|
654
|
+
};
|
|
655
|
+
const tampered = raw.replace('"approved"', '"declined"');
|
|
656
|
+
return tampered !== raw
|
|
657
|
+
&& reason(() => verifyWebhook(tampered, headers, { apiKey: TWIN_API_KEY, sharedSecret: TWIN_SHARED_SECRET })) === 'bad-signature'
|
|
658
|
+
&& reason(() => verifyWebhook(raw, { ...headers, [AUTH_CLIENT_HEADER]: 'someone-else' }, { apiKey: TWIN_API_KEY, sharedSecret: TWIN_SHARED_SECRET })) === 'bad-auth-client'
|
|
659
|
+
&& reason(() => verifyWebhook(raw, { [AUTH_CLIENT_HEADER]: TWIN_API_KEY }, { apiKey: TWIN_API_KEY, sharedSecret: TWIN_SHARED_SECRET })) === 'missing-signature'
|
|
660
|
+
&& reason(() => verifyWebhook(raw, {}, { apiKey: TWIN_API_KEY, sharedSecret: TWIN_SHARED_SECRET })) === 'missing-auth-client'
|
|
661
|
+
&& reason(() => verifyWebhook(raw, headers, { apiKey: TWIN_API_KEY, sharedSecret: 'a-different-secret' })) === 'bad-signature';
|
|
662
|
+
})),
|
|
663
|
+
done('veriff.webhooks.event_submitted_7002', 'webhooks', 'Submitting a session pushes the 7002 `submitted` EVENT webhook — no `verification` key, which is how a consumer tells the families apart', 'api', 'core', () =>
|
|
664
|
+
withRoot(async (h) => {
|
|
665
|
+
const { id } = await open(h, { callback: 'https://consumer.test/hook', vendorData: 'partner_3' });
|
|
666
|
+
await h({ m: 'PATCH', p: `/v1/sessions/${id}`, b: { verification: { status: 'submitted' } } });
|
|
667
|
+
const d = await h({ m: 'GET', p: `/v1/_twin/deliveries?sessionId=${id}` });
|
|
668
|
+
const rows = b(d).deliveries as Body[];
|
|
669
|
+
if (rows.length !== 1) return false;
|
|
670
|
+
const payload = JSON.parse(String(rows[0]!.body)) as Body;
|
|
671
|
+
return rows[0]!.kind === 'event' && payload.action === 'submitted' && payload.code === 7002
|
|
672
|
+
&& payload.id === id && payload.vendorData === 'partner_3' && !('verification' in payload);
|
|
673
|
+
})),
|
|
674
|
+
done('veriff.webhooks.event_started_7001', 'webhooks', 'The end-user entering the flow pushes the 7001 `started` EVENT webhook and moves the session to `started`', 'api', 'common', () =>
|
|
675
|
+
withRoot(async (h) => {
|
|
676
|
+
const { id } = await open(h, { callback: 'https://consumer.test/hook' });
|
|
677
|
+
const started = await h({ m: 'POST', p: `/v1/_twin/sessions/${id}/event`, b: { action: 'started' } });
|
|
678
|
+
const d = await h({ m: 'GET', p: `/v1/_twin/deliveries?sessionId=${id}` });
|
|
679
|
+
const payload = JSON.parse(String((b(d).deliveries as Body[])[0]!.body)) as Body;
|
|
680
|
+
return ok(started) && b(started).verification.status === 'started' && payload.action === 'started' && payload.code === 7001;
|
|
681
|
+
})),
|
|
682
|
+
done('veriff.webhooks.no_callback_no_delivery', 'webhooks', 'A session created without a callback produces no delivery at all — the twin never invents a destination', 'api', 'common', () =>
|
|
683
|
+
withRoot(async (h) => {
|
|
684
|
+
const { id } = await open(h);
|
|
685
|
+
await decide(h, id, 'approved');
|
|
686
|
+
const d = await h({ m: 'GET', p: '/v1/_twin/deliveries' });
|
|
687
|
+
const decision = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
688
|
+
// The decision still happened — this asserts the delivery is absent, not that nothing ran.
|
|
689
|
+
return b(decision).verification.status === 'approved' && (b(d).deliveries as Body[]).length === 0;
|
|
690
|
+
})),
|
|
691
|
+
done('veriff.webhooks.signed_delivery_is_pure', 'webhooks', 'buildSignedDelivery signs the exact bytes it emits — a re-serialized copy must not be assumed equal', 'api', 'niche', () => {
|
|
692
|
+
const payload = { status: 'success' as const, verification: { id: 'abc', status: 'approved', code: 9001 } };
|
|
693
|
+
const { body, headers } = buildSignedDelivery(payload, { apiKey: TWIN_API_KEY, sharedSecret: TWIN_SHARED_SECRET });
|
|
694
|
+
return headers[HMAC_SIGNATURE_HEADER] === veriffSignature(body, TWIN_SHARED_SECRET)
|
|
695
|
+
&& headers[AUTH_CLIENT_HEADER] === TWIN_API_KEY
|
|
696
|
+
&& headers['content-type'] === 'application/json'
|
|
697
|
+
&& JSON.parse(body).verification.code === 9001;
|
|
698
|
+
}),
|
|
699
|
+
|
|
700
|
+
// ── Errors + platform ─────────────────────────────────────────────────────
|
|
701
|
+
done('veriff.errors.envelope_shape', 'errors', 'Every failure is `{status:"fail", code, message}` with code a STRING (the schema\'s declared type), never a number', 'api', 'core', () =>
|
|
702
|
+
withRoot(async (h) => {
|
|
703
|
+
const notFound = await h({ m: 'GET', p: '/v1/sessions/00000000-0000-4000-8000-000000000000' });
|
|
704
|
+
const validation = await h({ m: 'POST', p: '/v1/sessions', b: {} });
|
|
705
|
+
const unauthorized = await h({ m: 'POST', p: '/v1/sessions', b: { verification: {} }, noAuth: true });
|
|
706
|
+
for (const r of [notFound, validation, unauthorized]) {
|
|
707
|
+
const body = b(r);
|
|
708
|
+
if (body.status !== 'fail' || typeof body.code !== 'string' || typeof body.message !== 'string') return false;
|
|
709
|
+
if (Object.keys(body).sort().join(',') !== 'code,message,status') return false;
|
|
710
|
+
}
|
|
711
|
+
return notFound.status === 404 && validation.status === 400 && unauthorized.status === 401;
|
|
712
|
+
})),
|
|
713
|
+
done('veriff.errors.unmodeled_route_404', 'errors', 'An endpoint this twin does not model fails like the vendor (404 "Resource not found") — never a fake success', 'api', 'core', () =>
|
|
714
|
+
withRoot(async (h) => {
|
|
715
|
+
const { id } = await open(h);
|
|
716
|
+
const watchlist = await h({ m: 'GET', p: `/v1/sessions/${id}/watchlist-screening` });
|
|
717
|
+
const faces = await h({ m: 'POST', p: '/v1/faces/import', b: { images: [] } });
|
|
718
|
+
const invented = await h({ m: 'POST', p: `/v1/sessions/${id}/NOPE`, b: {} });
|
|
719
|
+
return failed(watchlist, 404, '1101') && failed(faces, 404, '1101') && failed(invented, 404, '1101');
|
|
720
|
+
})),
|
|
721
|
+
done('veriff.platform.read_only_405', 'platform', 'A read-only twin serves reads and refuses every write with 405', 'api', 'common', async () => {
|
|
722
|
+
const root = mkdtempSync(join(tmpdir(), 'veriff-cap-ro-'));
|
|
723
|
+
try {
|
|
724
|
+
const call = (method: string, path: string, body?: unknown, readOnly = false) => {
|
|
725
|
+
const raw = body === undefined ? '' : JSON.stringify(body);
|
|
726
|
+
return handleVeriffTwinRequest({ method, path, ...(body === undefined ? {} : { body: raw }), headers: { [AUTH_CLIENT_HEADER]: TWIN_API_KEY, [HMAC_SIGNATURE_HEADER]: signFor(method, path, raw) }, root, readOnly, occurredAt: at() });
|
|
727
|
+
};
|
|
728
|
+
const created = await call('POST', '/v1/sessions', { verification: { vendorData: 'ro' } });
|
|
729
|
+
const id = String((created.body as Body).verification.id);
|
|
730
|
+
const blockedCreate = await call('POST', '/v1/sessions', { verification: {} }, true);
|
|
731
|
+
const blockedPatch = await call('PATCH', `/v1/sessions/${id}`, { verification: { status: 'submitted' } }, true);
|
|
732
|
+
const blockedDelete = await call('DELETE', `/v1/sessions/${id}`, undefined, true);
|
|
733
|
+
const stillReadable = await call('GET', `/v1/_twin/sessions/${id}`, undefined, true);
|
|
734
|
+
return blockedCreate.status === 405 && blockedPatch.status === 405 && blockedDelete.status === 405
|
|
735
|
+
&& stillReadable.status === 200 && (stillReadable.body as Body).verification.status === 'created';
|
|
736
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
737
|
+
}),
|
|
738
|
+
done('veriff.platform.session_ids_survive_delete_and_recreate', 'platform', 'DIRTY STATE: ids minted after deletes never collide with, or resurrect, an earlier session', 'api', 'common', () =>
|
|
739
|
+
withRoot(async (h) => {
|
|
740
|
+
// A deliberate dirty-state verify: build history up, delete out of the middle of it, then
|
|
741
|
+
// keep minting. A count- or index-derived id would re-issue a retired id here and a later
|
|
742
|
+
// read would silently return the WRONG session.
|
|
743
|
+
const seen: string[] = [];
|
|
744
|
+
for (let i = 0; i < 4; i += 1) {
|
|
745
|
+
const { id } = await open(h, { vendorData: `round-${i}` });
|
|
746
|
+
seen.push(id);
|
|
747
|
+
}
|
|
748
|
+
await h({ m: 'DELETE', p: `/v1/sessions/${seen[1]}` });
|
|
749
|
+
await h({ m: 'DELETE', p: `/v1/sessions/${seen[2]}` });
|
|
750
|
+
const fresh: string[] = [];
|
|
751
|
+
for (let i = 0; i < 3; i += 1) {
|
|
752
|
+
const { id } = await open(h, { vendorData: `after-${i}` });
|
|
753
|
+
fresh.push(id);
|
|
754
|
+
}
|
|
755
|
+
if (new Set([...seen, ...fresh]).size !== 7) return false; // no id ever reused
|
|
756
|
+
const revived = await h({ m: 'GET', p: `/v1/_twin/sessions/${seen[1]}` }); // deleted stays deleted
|
|
757
|
+
const survivor = await h({ m: 'GET', p: `/v1/_twin/sessions/${seen[3]}` }); // neighbours untouched
|
|
758
|
+
const newest = await h({ m: 'GET', p: `/v1/_twin/sessions/${fresh[2]}` });
|
|
759
|
+
return failed(revived, 404, '1101')
|
|
760
|
+
&& b(survivor).verification.vendorData === 'round-3'
|
|
761
|
+
&& b(newest).verification.vendorData === 'after-2';
|
|
762
|
+
})),
|
|
763
|
+
done('veriff.platform.decision_survives_media_churn', 'platform', 'DIRTY STATE: a decision reached after a resubmission cycle reports the LATEST attempt, not the first', 'api', 'common', () =>
|
|
764
|
+
withRoot(async (h) => {
|
|
765
|
+
const { id } = await open(h);
|
|
766
|
+
await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'document-front', content: IMG } } });
|
|
767
|
+
const first = await decide(h, id, 'resubmission_requested', { reason: 'Poor image quality', reasonCode: 204 });
|
|
768
|
+
const firstAttempt = String(b(first).verification.attemptId);
|
|
769
|
+
await h({ m: 'POST', p: `/v1/sessions/${id}/media`, b: { image: { context: 'document-front', content: 'data:image/png;base64,c2Vjb25kLXNob3Q=' } } });
|
|
770
|
+
const second = await decide(h, id, 'approved');
|
|
771
|
+
const secondAttempt = String(b(second).verification.attemptId);
|
|
772
|
+
const d = await h({ m: 'GET', p: `/v1/sessions/${id}/decision` });
|
|
773
|
+
const attempts = await h({ m: 'GET', p: `/v1/sessions/${id}/attempts` });
|
|
774
|
+
const media = await h({ m: 'GET', p: `/v1/sessions/${id}/media` });
|
|
775
|
+
return firstAttempt !== secondAttempt
|
|
776
|
+
&& b(d).verification.attemptId === secondAttempt
|
|
777
|
+
&& b(d).verification.status === 'approved' && b(d).verification.reason === null
|
|
778
|
+
&& (b(attempts).verifications as Body[]).length === 2
|
|
779
|
+
&& (b(media).images as Body[]).length === 2;
|
|
780
|
+
})),
|
|
781
|
+
|
|
782
|
+
// ── Connector (pull / push over an INJECTED client, offline) ──────────────
|
|
783
|
+
done('veriff.connector.pull_decisions', 'connector', 'Pull decisions for known session ids over an injected client and fold them into the projection', 'connector', 'core', () =>
|
|
784
|
+
withRoot(async (h, root) => {
|
|
785
|
+
const execute = fakeExecute();
|
|
786
|
+
const pulled = await pullVeriffDecisions(execute, [REMOTE_SESSION], root, PULL_AT);
|
|
787
|
+
const g = await h({ m: 'GET', p: `/v1/sessions/${REMOTE_SESSION}/decision` });
|
|
788
|
+
const v = b(g).verification;
|
|
789
|
+
return pulled === 1 && ok(g) && v.id === REMOTE_SESSION && v.status === 'approved' && v.code === 9001
|
|
790
|
+
&& v.person.firstName === 'REMOTE' && v.document.country === 'EE' && v.vendorData === 'remote-partner';
|
|
791
|
+
})),
|
|
792
|
+
done('veriff.connector.pull_is_idempotent', 'connector', 'A re-pull of identical state appends NOTHING (shadow-diff dedup): deltasAppended drops to 0', 'connector', 'core', () =>
|
|
793
|
+
withRoot(async (h, root) => {
|
|
794
|
+
const execute = fakeExecute();
|
|
795
|
+
const first = await syncVeriffFromReal(execute, { sessionIds: [REMOTE_SESSION], root, occurredAt: PULL_AT });
|
|
796
|
+
const second = await syncVeriffFromReal(execute, { sessionIds: [REMOTE_SESSION], root, occurredAt: PULL_AT });
|
|
797
|
+
const g = await h({ m: 'GET', p: `/v1/sessions/${REMOTE_SESSION}/decision` });
|
|
798
|
+
return first.observed === 3 && first.deltasAppended > 0 && second.deltasAppended === 0
|
|
799
|
+
&& b(g).verification.status === 'approved';
|
|
800
|
+
})),
|
|
801
|
+
done('veriff.connector.pull_attempts_and_media', 'connector', 'Pull folds the attempt history and media metadata, readable back over the twin\'s own endpoints', 'connector', 'common', () =>
|
|
802
|
+
withRoot(async (h, root) => {
|
|
803
|
+
const execute = fakeExecute();
|
|
804
|
+
// The attempt/media endpoints are SESSION-scoped (an unknown session is a 404 before any
|
|
805
|
+
// sub-resource is considered), so the session has to be observed before its children.
|
|
806
|
+
await pullVeriffDecisions(execute, [REMOTE_SESSION], root, PULL_AT);
|
|
807
|
+
const attempts = await pullVeriffAttempts(execute, [REMOTE_SESSION], root, PULL_AT);
|
|
808
|
+
const media = await pullVeriffMedia(execute, [REMOTE_SESSION], root, PULL_AT);
|
|
809
|
+
const a = await h({ m: 'GET', p: `/v1/sessions/${REMOTE_SESSION}/attempts` });
|
|
810
|
+
const m = await h({ m: 'GET', p: `/v1/sessions/${REMOTE_SESSION}/media` });
|
|
811
|
+
return attempts === 1 && media === 1
|
|
812
|
+
&& (b(a).verifications as Body[])[0]!.status === 'approved'
|
|
813
|
+
&& (b(m).images as Body[])[0]!.context === 'document-front'
|
|
814
|
+
&& (b(m).images as Body[])[0]!.sessionId === REMOTE_SESSION;
|
|
815
|
+
})),
|
|
816
|
+
done('veriff.connector.pull_then_local_create_never_collides', 'connector', 'A local create after a pull (and a pull after a local create) never lands on the other\'s id', 'connector', 'common', () =>
|
|
817
|
+
withRoot(async (h, root) => {
|
|
818
|
+
// Direction 1: pull first, then mint locally.
|
|
819
|
+
await syncVeriffFromReal(fakeExecute(), { sessionIds: [REMOTE_SESSION], root, occurredAt: PULL_AT });
|
|
820
|
+
const local = await open(h, { vendorData: 'local-after-pull' });
|
|
821
|
+
// Direction 2: mint locally, then pull a DIFFERENT remote id on top.
|
|
822
|
+
const local2 = await open(h, { vendorData: 'local-before-pull' });
|
|
823
|
+
await syncVeriffFromReal(fakeExecute(OTHER_REMOTE), { sessionIds: [OTHER_REMOTE], root, occurredAt: PULL_AT });
|
|
824
|
+
const pulled = await h({ m: 'GET', p: `/v1/sessions/${REMOTE_SESSION}/decision` });
|
|
825
|
+
const mine = await h({ m: 'GET', p: `/v1/_twin/sessions/${local.id}` });
|
|
826
|
+
const mine2 = await h({ m: 'GET', p: `/v1/_twin/sessions/${local2.id}` });
|
|
827
|
+
return local.id !== REMOTE_SESSION && local2.id !== OTHER_REMOTE
|
|
828
|
+
&& b(pulled).verification.vendorData === 'remote-partner'
|
|
829
|
+
&& b(mine).verification.vendorData === 'local-after-pull'
|
|
830
|
+
&& b(mine2).verification.vendorData === 'local-before-pull';
|
|
831
|
+
})),
|
|
832
|
+
done('veriff.connector.push_create_session', 'connector', 'Push turns a pending local session into a real POST /sessions and confirms it only on `status:"success"`', 'connector', 'core', () =>
|
|
833
|
+
withRoot(async (h, root) => {
|
|
834
|
+
await open(h, { vendorData: 'to-push', callback: 'https://consumer.test/hook' });
|
|
835
|
+
const calls: { method: string; path: string; body?: Record<string, unknown> }[] = [];
|
|
836
|
+
const succeeding: VeriffExecute = async (method, path, body) => { calls.push({ method, path, ...(body ? { body } : {}) }); return { status: 'success', verification: { id: 'remote-new' } }; };
|
|
837
|
+
const pushed = await pushPendingVeriffActions(succeeding, root, PULL_AT);
|
|
838
|
+
const again = await pushPendingVeriffActions(succeeding, root, PULL_AT); // already confirmed
|
|
839
|
+
return pushed === 1 && again === 0
|
|
840
|
+
&& calls[0]!.method === 'POST' && calls[0]!.path === '/sessions'
|
|
841
|
+
&& (calls[0]!.body as any).verification.vendorData === 'to-push'
|
|
842
|
+
&& (calls[0]!.body as any).verification.callback === 'https://consumer.test/hook';
|
|
843
|
+
})),
|
|
844
|
+
done('veriff.connector.push_does_not_confirm_on_failure', 'connector', 'A `status:"fail"` response leaves the local action PENDING — a failed push must never look confirmed', 'connector', 'core', () =>
|
|
845
|
+
withRoot(async (h, root) => {
|
|
846
|
+
await open(h, { vendorData: 'will-fail' });
|
|
847
|
+
let attempts = 0;
|
|
848
|
+
const failing: VeriffExecute = async () => { attempts += 1; return { status: 'fail', code: '1101', message: 'Validation failed' }; };
|
|
849
|
+
const pushed = await pushPendingVeriffActions(failing, root, PULL_AT);
|
|
850
|
+
const retried = await pushPendingVeriffActions(failing, root, PULL_AT);
|
|
851
|
+
return pushed === 0 && retried === 0 && attempts === 2;
|
|
852
|
+
})),
|
|
853
|
+
done('veriff.connector.request_for_action_mapping', 'connector', 'Pure action→request mapping: create → POST, submit → PATCH, delete → DELETE, vendor-authored types → null', 'connector', 'common', () => {
|
|
854
|
+
const act = (type: string, id: string, fields: Record<string, unknown>) => ({ id: 'a1', subject: { type, id }, fields } as any);
|
|
855
|
+
const create = veriffRequestForAction(act('session', 's1', { sessionId: 's1', status: 'created', vendorData: 'v', callback: 'https://x.test/h' }));
|
|
856
|
+
const submit = veriffRequestForAction(act('session', 's1', { sessionId: 's1', status: 'submitted' }));
|
|
857
|
+
const remove = veriffRequestForAction(act('session', 's1', { sessionId: 's1', deleted: true }));
|
|
858
|
+
return create?.method === 'POST' && create.path === '/sessions' && (create.body as any).verification.vendorData === 'v'
|
|
859
|
+
&& submit?.method === 'PATCH' && submit.path === '/sessions/s1' && (submit.body as any).verification.status === 'submitted'
|
|
860
|
+
&& remove?.method === 'DELETE' && remove.path === '/sessions/s1'
|
|
861
|
+
// Attempts, media and the twin's own delivery log are VENDOR-authored or twin-local: there is
|
|
862
|
+
// no endpoint to push them to, and inventing one would be a fake success.
|
|
863
|
+
&& veriffRequestForAction(act('attempt', 'a', {})) === null
|
|
864
|
+
&& veriffRequestForAction(act('media', 'm', {})) === null
|
|
865
|
+
&& veriffRequestForAction(act('delivery', 'd', {})) === null
|
|
866
|
+
// A decision is authored by Veriff — a local one has no push target either.
|
|
867
|
+
&& veriffRequestForAction(act('session', 's1', { sessionId: 's1', status: 'approved' })) === null;
|
|
868
|
+
}),
|
|
869
|
+
done('veriff.connector.signature_payload_rule', 'connector', 'The live executor signs the right half: body for POST/PATCH, the PATH resource id for GET/DELETE', 'connector', 'core', () => {
|
|
870
|
+
const body = '{"verification":{"status":"submitted"}}';
|
|
871
|
+
return signaturePayloadFor('POST', '/sessions', body) === body
|
|
872
|
+
&& signaturePayloadFor('PATCH', '/sessions/s1', body) === body
|
|
873
|
+
&& signaturePayloadFor('GET', '/sessions/s1/decision') === 's1'
|
|
874
|
+
&& signaturePayloadFor('GET', '/attempts/a9/media') === 'a9'
|
|
875
|
+
&& signaturePayloadFor('GET', '/media/m4') === 'm4'
|
|
876
|
+
&& signaturePayloadFor('DELETE', '/sessions/s1') === 's1';
|
|
877
|
+
}),
|
|
878
|
+
done('veriff.connector.live_client_signs_and_exempts_create', 'connector', 'liveVeriffExecute stamps X-AUTH-CLIENT on every call, signs correctly, and omits the signature on POST /sessions alone', 'connector', 'core', async () => {
|
|
879
|
+
const seen: { url: string; headers: Record<string, string>; body?: string }[] = [];
|
|
880
|
+
const fetchImpl = (async (url: any, init: any) => {
|
|
881
|
+
seen.push({ url: String(url), headers: init.headers as Record<string, string>, body: init.body as string | undefined });
|
|
882
|
+
return new Response(JSON.stringify({ status: 'success' }), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
883
|
+
}) as unknown as typeof fetch;
|
|
884
|
+
const execute = liveVeriffExecute('key-1', 'secret-1', { fetchImpl, budgetOptions: throwawayLedger() });
|
|
885
|
+
await execute('POST', '/sessions', { verification: { vendorData: 'x' } });
|
|
886
|
+
await execute('GET', '/sessions/abc/decision');
|
|
887
|
+
const create = seen[0]!; const read = seen[1]!;
|
|
888
|
+
return seen.length === 2
|
|
889
|
+
&& create.url === 'https://stationapi.veriff.com/v1/sessions'
|
|
890
|
+
&& create.headers[AUTH_CLIENT_HEADER] === 'key-1' && create.headers[HMAC_SIGNATURE_HEADER] === undefined
|
|
891
|
+
&& read.url === 'https://stationapi.veriff.com/v1/sessions/abc/decision'
|
|
892
|
+
&& read.headers[HMAC_SIGNATURE_HEADER] === veriffSignature('abc', 'secret-1');
|
|
893
|
+
}),
|
|
894
|
+
done('veriff.connector.budget_refuses_past_ceiling', 'connector', 'The rate budget REFUSES past the ceiling with the injected fetch\'s call count UNCHANGED — nothing reached the vendor', 'connector', 'core', async () => {
|
|
895
|
+
// "It threw" is not proof. The COUNT is what shows the refused call never went out.
|
|
896
|
+
let calls = 0;
|
|
897
|
+
const fetchImpl = (async () => { calls += 1; return new Response(JSON.stringify({ status: 'success' }), { status: 200 }); }) as unknown as typeof fetch;
|
|
898
|
+
const budget = new VeriffBudget({ ...throwawayLedger(), now: fixedClock() });
|
|
899
|
+
const execute = liveVeriffExecute('key-2', 'secret-2', { fetchImpl, budget });
|
|
900
|
+
const allowed = VERIFF_BUDGET_CEILING / VERIFF_CALL_WEIGHTS.other; // 30 calls at weight 2
|
|
901
|
+
for (let i = 0; i < allowed; i += 1) await execute('POST', '/sessions', { verification: {} });
|
|
902
|
+
if (calls !== allowed) return false;
|
|
903
|
+
let refused: unknown;
|
|
904
|
+
try { await execute('POST', '/sessions', { verification: {} }); } catch (e) { refused = e; }
|
|
905
|
+
return refused instanceof VeriffBudgetError && calls === allowed;
|
|
906
|
+
}),
|
|
907
|
+
done('veriff.connector.budget_prices_delete_higher', 'connector', 'DELETE /sessions/{id} is priced up so one window admits 5, not 30 — the delete-loop backstop', 'connector', 'common', async () => {
|
|
908
|
+
let calls = 0;
|
|
909
|
+
const fetchImpl = (async () => { calls += 1; return new Response(JSON.stringify({ status: 'success' }), { status: 200 }); }) as unknown as typeof fetch;
|
|
910
|
+
const budget = new VeriffBudget({ ...throwawayLedger(), now: fixedClock() });
|
|
911
|
+
const execute = liveVeriffExecute('key-3', 'secret-3', { fetchImpl, budget });
|
|
912
|
+
const allowed = Math.floor(VERIFF_BUDGET_CEILING / VERIFF_CALL_WEIGHTS.deleteSession); // 5
|
|
913
|
+
for (let i = 0; i < allowed; i += 1) await execute('DELETE', `/sessions/s${i}`);
|
|
914
|
+
if (calls !== allowed || allowed !== 5) return false;
|
|
915
|
+
let refused: unknown;
|
|
916
|
+
try { await execute('DELETE', '/sessions/s-final'); } catch (e) { refused = e; }
|
|
917
|
+
return refused instanceof VeriffBudgetError && calls === allowed;
|
|
918
|
+
}),
|
|
919
|
+
done('veriff.connector.mappers_rename_reserved_keys', 'connector', 'Every mapper renames the vendor `id` away from the kernel\'s reserved META keys, so no field is silently dropped', 'connector', 'common', () => {
|
|
920
|
+
const RESERVED = ['type', 'id', 'updatedAt'];
|
|
921
|
+
const decision = mapSessionDecision({ id: 's1', status: 'approved', code: 9001, attemptId: 'a1', vendorData: 'v' });
|
|
922
|
+
const attempt = mapSessionAttempt('s1', { id: 'a1', status: 'approved', createdTime: PULL_AT });
|
|
923
|
+
const media = mapSessionMedia('s1', { id: 'm1', name: 'face', context: 'face', mimetype: 'image/jpeg' }, 'image');
|
|
924
|
+
for (const r of [decision, attempt, media]) {
|
|
925
|
+
for (const key of RESERVED) if (key in r.fields) return false;
|
|
926
|
+
}
|
|
927
|
+
return decision.id === 's1' && decision.fields.sessionId === 's1' && decision.fields.decisionCode === 9001
|
|
928
|
+
&& attempt.fields.attemptId === 'a1' && attempt.fields.sessionId === 's1'
|
|
929
|
+
&& media.fields.mediaId === 'm1' && media.fields.kind === 'image';
|
|
930
|
+
}),
|
|
931
|
+
|
|
932
|
+
// ── SDK surface ───────────────────────────────────────────────────────────
|
|
933
|
+
done('veriff.sdk.js_sdk_wire_contract', 'sdk', '@veriff/js-sdk\'s exact wire contract holds: its request body shape, its `x-auth-client`-only headers, and the 201 it hard-checks', 'api', 'core', () =>
|
|
934
|
+
withRoot(async () => {
|
|
935
|
+
// `@veriff/js-sdk` v2.0.0 creates sessions FROM THE BROWSER: it XHRs `POST {host}/v1/sessions`
|
|
936
|
+
// with `x-auth-client` + `x-origin: js-sdk` and NO signature, sends
|
|
937
|
+
// `{verification:{callback,person:{firstName,lastName,idNumber},vendorData,timestamp}}`, and
|
|
938
|
+
// treats the call as successful ONLY on `201 === xhr.status`. Its `host` is a constructor
|
|
939
|
+
// option, so pointing it at this twin is configuration, not modification. The SDK itself is a
|
|
940
|
+
// DOM/XMLHttpRequest package (it mounts a form), so the twin is driven here over the same
|
|
941
|
+
// bytes rather than through a browser shim — the contract asserted is the SDK's, verbatim.
|
|
942
|
+
const root = mkdtempSync(join(tmpdir(), 'veriff-cap-jssdk-'));
|
|
943
|
+
try {
|
|
944
|
+
const body = JSON.stringify({
|
|
945
|
+
verification: {
|
|
946
|
+
callback: 'https://consumer.test/return',
|
|
947
|
+
person: { firstName: 'John', lastName: 'Smith', idNumber: '123' },
|
|
948
|
+
vendorData: 'js-sdk-user',
|
|
949
|
+
timestamp: new Date(Date.UTC(2026, 7, 1)).toISOString(),
|
|
950
|
+
},
|
|
951
|
+
});
|
|
952
|
+
const res = await handleVeriffTwinRequest({
|
|
953
|
+
method: 'POST', path: '/v1/sessions', body, root, occurredAt: at(),
|
|
954
|
+
headers: { 'content-type': 'application/json', [AUTH_CLIENT_HEADER]: TWIN_API_KEY, 'x-origin': 'js-sdk' },
|
|
955
|
+
});
|
|
956
|
+
const v = (res.body as Body).verification;
|
|
957
|
+
return res.status === 201 && v.status === 'created' && v.vendorData === 'js-sdk-user'
|
|
958
|
+
&& typeof v.sessionToken === 'string' && v.url === `${v.host}/v/${v.sessionToken}`;
|
|
959
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
960
|
+
})),
|
|
961
|
+
todo('veriff.sdk.incontext_frame', 'sdk', '@veriff/incontext-sdk embeds the session URL in an iframe and reports STARTED/SUBMITTED/FINISHED/CANCELED via postMessage', 'ui', 'common'),
|
|
962
|
+
todo('veriff.sdk.react_native', 'sdk', '@veriff/react-native-sdk / the Cordova plugin launch the native capture flow', 'ui', 'niche'),
|
|
963
|
+
|
|
964
|
+
// ═══ TODO: the documented surface this twin has NOT reached yet ═══════════
|
|
965
|
+
// Every entry below is a REAL, documented Veriff capability (each traceable to a devdocs page or
|
|
966
|
+
// an embedded OpenAPI operation), enumerated so coverage is measured against the product rather
|
|
967
|
+
// than against what was convenient to build.
|
|
968
|
+
|
|
969
|
+
// Watchlist screening / AML — a whole documented API area, entirely unmodeled.
|
|
970
|
+
// A THIRD vendor doc contradiction, recorded rather than silently resolved: the attempts
|
|
971
|
+
// endpoint's published `status` enum is ["created","started","submitted","resubmission_requested"]
|
|
972
|
+
// — which omits `approved`/`declined` — while its OWN response examples show attempts with
|
|
973
|
+
// `"status": "approved"` and `"status": "resubmission_requested"`. This twin follows the EXAMPLES
|
|
974
|
+
// (an attempt carries the decision it produced), because an attempt whose status can never be
|
|
975
|
+
// `approved` cannot express the two-attempt resubmission history the same page illustrates.
|
|
976
|
+
todo('veriff.attempts.status_enum', 'attempts', 'Pin the attempt `status` enum against a live account: the published schema lists created|started|submitted|resubmission_requested while the endpoint\'s own examples show `approved`', 'api', 'niche'),
|
|
977
|
+
|
|
978
|
+
todo('veriff.watchlist.get', 'watchlist', 'GET /v1/sessions/{id}/watchlist-screening — matchStatus/reviewStatus/monitorStatus + the hits[] array', 'api', 'common'),
|
|
979
|
+
todo('veriff.watchlist.hits_shape', 'watchlist', 'A hit carries matchedName, countries, dateOfBirth, matchTypes, aka, associates and listingsRelatedToMatch', 'api', 'common'),
|
|
980
|
+
todo('veriff.watchlist.search_term', 'watchlist', 'searchTerm{name,year,lists,countries,exactMatch,matchThreshold,excludeDeceased} is reported back with the screening', 'api', 'niche'),
|
|
981
|
+
todo('veriff.watchlist.patch_hit_status', 'watchlist', 'PATCH /v1/sessions/{id}/watchlist-screening {hit:{id,matchStatus,riskLevel}} — adjudicate one hit', 'api', 'common'),
|
|
982
|
+
todo('veriff.watchlist.patch_acknowledge', 'watchlist', 'PATCH {hasUnacknowledgedChanges:false} — acknowledge monitoring changes', 'api', 'niche'),
|
|
983
|
+
todo('veriff.watchlist.patch_monitor_status', 'watchlist', 'PATCH {monitorStatus:"disabled"} — stop ongoing monitoring for a screened person', 'api', 'niche'),
|
|
984
|
+
todo('veriff.watchlist.in_progress_202', 'watchlist', 'A screening still running answers 202, not 200 — the documented poll-again signal', 'api', 'common'),
|
|
985
|
+
todo('veriff.watchlist.not_enabled_402', 'watchlist', 'An integration without the feature gets 402, the vendor\'s "not enabled for your integration" status', 'api', 'common'),
|
|
986
|
+
todo('veriff.watchlist.webhook', 'webhooks', 'The watchlist-screening webhook family (its own documented payload)', 'api', 'common'),
|
|
987
|
+
todo('veriff.watchlist.pep_sanction_match', 'person', 'person.pepSanctionMatch / pepSanctionMatches[] on the person + decision payloads, incl. the provider matchTypes/matchTypesDetails/score fields', 'api', 'common'),
|
|
988
|
+
// The person ENDPOINT has its own documented shape — `id`, `idCode` (not the decision payload's
|
|
989
|
+
// `idNumber`), `placeOfBirth`, `citizenships`, `pepSanctionMatches[]` — which this twin does not
|
|
990
|
+
// yet reproduce: it serves the decision's `person` object verbatim. Filed rather than glossed.
|
|
991
|
+
todo('veriff.person.own_field_set', 'person', 'GET /v1/sessions/{id}/person\'s OWN field set (id, idCode, placeOfBirth, citizenships, pepSanctionMatches) rather than the decision payload\'s person object', 'api', 'common'),
|
|
992
|
+
todo('veriff.person.available_after_submit', 'person', 'Person data becomes available once the session is SUBMITTED, not only once a decision exists (the endpoint documents the earlier availability point)', 'api', 'common'),
|
|
993
|
+
|
|
994
|
+
// Collected data / device intelligence.
|
|
995
|
+
todo('veriff.collected_data.post', 'collected_data', 'POST /v1/sessions/{id}/collected-data — providerName + network.ip + device.fingerprint', 'api', 'niche'),
|
|
996
|
+
todo('veriff.collected_data.technical_data', 'decisions', 'verification.technicalData.ip on the decision payload', 'api', 'niche'),
|
|
997
|
+
|
|
998
|
+
// Faces / registry / database verification.
|
|
999
|
+
todo('veriff.faces.bulk_import', 'faces', 'POST /v1/faces/import — bulk face-image import, 202 accepted', 'api', 'niche'),
|
|
1000
|
+
// A FIFTH in-document divergence: Veriff spells this endpoint two ways in its own material —
|
|
1001
|
+
// "POST v1/sessions/validate-registry" in the API reference index, "/v1/validate-registry" in
|
|
1002
|
+
// the embedded OpenAPI document. Neither is modeled, so the twin 404s both today (the
|
|
1003
|
+
// conformance sweep drives /v1/validate-registry as a negative control); both spellings are
|
|
1004
|
+
// named here so whoever builds it knows there is a path question to settle first.
|
|
1005
|
+
todo('veriff.registry.validate', 'registry', 'Registry validation on the Public API — spelled BOTH "POST /v1/validate-registry" (embedded OpenAPI) and "POST /v1/sessions/validate-registry" (reference index); settle which the live API serves', 'api', 'niche'),
|
|
1006
|
+
todo('veriff.registry.validate_sync', 'registry', 'POST /v1/validate-registry on sync-api.veriff.me — the SYNCHRONOUS variant (a different host)', 'api', 'niche'),
|
|
1007
|
+
todo('veriff.registry.ine_decision', 'registry', 'GET /v1/sessions/{id}/decision/ine-registry — the legacy Mexican INE registry decision', 'api', 'niche'),
|
|
1008
|
+
todo('veriff.registry.curp_decision', 'registry', 'GET /v1/sessions/{id}/decision/curp-registry — the legacy CURP registry decision', 'api', 'niche'),
|
|
1009
|
+
todo('veriff.registry.combined_ine_curp_decision', 'registry', 'GET /v1/sessions/{id}/decision/combined-ine-curp-registry — the legacy combined decision', 'api', 'niche'),
|
|
1010
|
+
todo('veriff.database_verification.ine', 'database_verification', 'Mexico INE database verification (matchData MATCH/NO_MATCH/NO_INPUT/NO_DATA)', 'api', 'niche'),
|
|
1011
|
+
todo('veriff.database_verification.ine_biometric', 'database_verification', 'Mexico INE BIOMETRIC database verification (registryValidations)', 'api', 'niche'),
|
|
1012
|
+
todo('veriff.database_verification.curp', 'database_verification', 'Mexico CURP database verification + registryResponse', 'api', 'niche'),
|
|
1013
|
+
todo('veriff.database_verification.cadastro_unico', 'database_verification', 'Brazil Cadastro Único database verification', 'api', 'niche'),
|
|
1014
|
+
todo('veriff.database_verification.probet', 'database_verification', 'Brazil Pro Bet database verification', 'api', 'niche'),
|
|
1015
|
+
todo('veriff.database_verification.cpf_biometric', 'database_verification', 'Brazil CPF biometric database check', 'api', 'niche'),
|
|
1016
|
+
todo('veriff.database_verification.registraduria', 'database_verification', 'Colombia Registraduría database verification', 'api', 'niche'),
|
|
1017
|
+
todo('veriff.database_verification.us_ssn', 'database_verification', 'US database verification — the validationResults[] array', 'api', 'niche'),
|
|
1018
|
+
todo('veriff.database_verification.match', 'database_verification', 'Match database verification', 'api', 'niche'),
|
|
1019
|
+
todo('veriff.database_verification.metadata', 'database_verification', 'Metadata database verification', 'api', 'niche'),
|
|
1020
|
+
|
|
1021
|
+
// IDV solutions — the verification products a session is configured with.
|
|
1022
|
+
todo('veriff.idv.document_and_selfie', 'idv', 'Document + Selfie IDV — the flagship flow\'s decision payload fields', 'api', 'core'),
|
|
1023
|
+
todo('veriff.idv.document_only', 'idv', 'Document-only IDV (no selfie) — its distinct decision shape', 'api', 'common'),
|
|
1024
|
+
todo('veriff.idv.biometric_authentication', 'idv', 'Biometric Authentication — verification.biometricAuthentication{matchedSessionId, matchedSessionVendorData, details}', 'api', 'common'),
|
|
1025
|
+
todo('veriff.idv.biometric_liveness', 'idv', 'Biometric Liveness — the standalone liveness solution\'s decision payload', 'api', 'common'),
|
|
1026
|
+
todo('veriff.idv.selfie2selfie', 'idv', 'Selfie2Selfie — additionalVerifiedData.faceMatch', 'api', 'niche'),
|
|
1027
|
+
todo('veriff.idv.age_estimation', 'idv', 'Age Estimation — its decision payload and thresholds', 'api', 'niche'),
|
|
1028
|
+
todo('veriff.idv.unstructured_docs', 'idv', 'Unstructured Docs — the `udocs` object and its extraction fields', 'api', 'niche'),
|
|
1029
|
+
todo('veriff.idv.nfc_epassport', 'idv', 'NFC / ePassport chip verification — document.nfcValidated and the document-nfc media context', 'api', 'niche'),
|
|
1030
|
+
todo('veriff.idv.full_auto', 'idv', 'Full Auto / Essential Plan — its distinct decision and webhook behaviour', 'api', 'niche'),
|
|
1031
|
+
todo('veriff.idv.uk_diatf', 'idv', 'UK DIATF-certified flow', 'api', 'niche'),
|
|
1032
|
+
todo('veriff.idv.risk_score', 'decisions', 'verification.riskScore{score} and verification.highRisk', 'api', 'common'),
|
|
1033
|
+
todo('veriff.idv.additional_verified_data', 'decisions', 'The full additionalVerifiedData object (proofOfAddress, officialDatabaseVerification, faceMatch, …)', 'api', 'common'),
|
|
1034
|
+
todo('veriff.decisions.comments', 'decisions', 'verification.comments[] — reviewer commentary on a decision', 'api', 'niche'),
|
|
1035
|
+
todo('veriff.decisions.submission_time', 'decisions', 'verification.submissionTime is reported on the decision payload distinctly from decisionTime/acceptanceTime', 'api', 'niche'),
|
|
1036
|
+
todo('veriff.decisions.tag', 'decisions', 'verification.tag — the ≤64-character session tag, round-tripped from creation to decision', 'api', 'niche'),
|
|
1037
|
+
todo('veriff.decisions.granular_decline_codes', 'decisions', 'The documented decline reasonCode tables (101-113, 120-128, 501-587, 643, 655, 901-906)', 'api', 'common'),
|
|
1038
|
+
todo('veriff.decisions.granular_resubmission_codes', 'decisions', 'The documented resubmission reasonCode tables (201-218, 602-697)', 'api', 'common'),
|
|
1039
|
+
// A FOURTH vendor doc contradiction: the decision schema declares `code` (and `reasonCode`)
|
|
1040
|
+
// "type": "integer", and the decision WEBHOOK sample emits `"code": 9001` as a number — but the
|
|
1041
|
+
// decision ENDPOINT's own response examples quote them ("code": "9121", "reasonCode": "102"). This
|
|
1042
|
+
// twin emits numbers on both, following the declared type and the webhook sample.
|
|
1043
|
+
todo('veriff.decisions.code_wire_type', 'decisions', 'Pin whether the live decision ENDPOINT serves `code`/`reasonCode` as JSON numbers or strings — the schema declares integer and the webhook sample agrees, while the endpoint\'s own examples quote them', 'api', 'niche'),
|
|
1044
|
+
todo('veriff.decisions.review_code', 'decisions', 'Pin what `code` (if any) a live account sends alongside a `review` decision — the published schema\'s code enum has no entry for it, so this twin emits null', 'api', 'niche'),
|
|
1045
|
+
todo('veriff.sessions.delete_refusal_codes', 'sessions', 'Pin the 1305/1306 split against a live account: the endpoint documents ONE blanket refusal (1305) yet also publishes a 1306 "Session in progress." whose stated trigger (`started`) is on the deletable list, so routing `submitted`→1306 is this twin\'s disambiguation, not a documented rule', 'api', 'niche'),
|
|
1046
|
+
todo('veriff.decisions.code_numbering', 'decisions', 'Read the dedicated verification-session-status-codes table (linked from the decision endpoint but not fetched during this build) and reconcile it with the endpoint document this twin followed', 'api', 'niche'),
|
|
1047
|
+
todo('veriff.decisions.person_full_field_set', 'decisions', 'The full person field set (addresses, nameComponents, parentNames, citizenship, electorNumber, eyeColor, …)', 'api', 'common'),
|
|
1048
|
+
todo('veriff.decisions.document_full_field_set', 'decisions', 'The full document field set (state, placeOfIssue, issuedBy, remarks, residencePermitType, specimen, …)', 'api', 'common'),
|
|
1049
|
+
|
|
1050
|
+
// Proof of Address.
|
|
1051
|
+
todo('veriff.proof_of_address.session', 'proof_of_address', 'A proofOfAddress session — the `address-front` capture context and its decision payload', 'api', 'common'),
|
|
1052
|
+
todo('veriff.proof_of_address.address_validation', 'proof_of_address', 'additionalVerifiedData.proofOfAddress.addressValidationResult{status,…}', 'api', 'niche'),
|
|
1053
|
+
todo('veriff.proof_of_address.address_matching', 'proof_of_address', 'additionalVerifiedData.proofOfAddress.addressMatching', 'api', 'niche'),
|
|
1054
|
+
todo('veriff.proof_of_address.fraud_check', 'proof_of_address', 'additionalVerifiedData.proofOfAddress.fraud{reasonDescription,…}', 'api', 'niche'),
|
|
1055
|
+
|
|
1056
|
+
// Media — the parts of the media surface this twin does not model.
|
|
1057
|
+
todo('veriff.media.pdf_upload', 'media', 'PDF proof-of-address upload (≤20MB, not enabled by default)', 'api', 'niche'),
|
|
1058
|
+
todo('veriff.media.nfc_upload', 'media', 'The NFC media variant — com/sod/dg1/dg2 base64 payloads, `document-nfc` context, application/octet-stream', 'api', 'niche'),
|
|
1059
|
+
todo('veriff.media.video_objects', 'media', 'Video media objects and their `duration` field (native SDK capture only)', 'api', 'niche'),
|
|
1060
|
+
todo('veriff.media.size_limits', 'media', 'The documented upload limits (24MB base64 images, 20MB PDFs) enforced as the vendor enforces them', 'api', 'niche'),
|
|
1061
|
+
todo('veriff.media.pre_capture_contexts', 'media', 'The `-pre` (first-capture) context family and how it pairs with the final capture', 'api', 'niche'),
|
|
1062
|
+
todo('veriff.media.most_recent_attempt_only', 'media', 'GET /v1/sessions/{id}/media returns the MOST RECENT attempt\'s media only, as documented', 'api', 'common'),
|
|
1063
|
+
|
|
1064
|
+
// Webhooks — the families and behaviours not modeled.
|
|
1065
|
+
todo('veriff.webhooks.flow_events', 'webhooks', 'The web-flow event actions and codes: waiting_continued 7008, waiting_complete 7007, flow_finished 7009, flow_cancelled 7010, document_type_other_selected 7011', 'api', 'common'),
|
|
1066
|
+
todo('veriff.webhooks.event_context', 'webhooks', 'The optional event `context{reason,state}` object and its documented value tables', 'api', 'niche'),
|
|
1067
|
+
todo('veriff.webhooks.user_defined_statuses', 'webhooks', 'The user-defined-statuses webhook family (custom status/statusCode/reason/reasonCode)', 'api', 'niche'),
|
|
1068
|
+
todo('veriff.webhooks.full_auto', 'webhooks', 'The Full Auto webhook family', 'api', 'niche'),
|
|
1069
|
+
todo('veriff.webhooks.registry_families', 'webhooks', 'The INE / CURP / combined INE+CURP webhook families', 'api', 'niche'),
|
|
1070
|
+
todo('veriff.webhooks.retry_policy', 'webhooks', 'Veriff\'s delivery retry/redelivery behaviour for a failing receiver', 'api', 'common'),
|
|
1071
|
+
todo('veriff.webhooks.master_signature_key', 'auth', 'Multiple shared secrets (up to 5) with ONE designated master signing key, and API responses signed with the key active when the request was made', 'api', 'niche'),
|
|
1072
|
+
todo('veriff.webhooks.https_only', 'webhooks', 'Veriff calls HTTPS callback URLs only', 'api', 'niche'),
|
|
1073
|
+
|
|
1074
|
+
// Fraud / Feedback API — a separate host with its own VRF-* header scheme.
|
|
1075
|
+
todo('veriff.fraud.report', 'fraud', 'POST /v1/feedback/fraud-reports on feedback.api.veriff.com', 'api', 'niche'),
|
|
1076
|
+
todo('veriff.fraud.categories', 'fraud', 'GET /v1/feedback/fraud-categories', 'api', 'niche'),
|
|
1077
|
+
todo('veriff.fraud.retrieve', 'fraud', 'POST /v1/feedback/fraud-reports/retrieve (signs the full request path only, despite being a POST)', 'api', 'niche'),
|
|
1078
|
+
todo('veriff.fraud.vrf_header_scheme', 'auth', 'The Feedback API\'s VRF-AUTH-CLIENT / VRF-HMAC-SIGNATURE scheme, where a POST signs "path\\nbody"', 'api', 'niche'),
|
|
1079
|
+
|
|
1080
|
+
// Platform behaviours.
|
|
1081
|
+
todo('veriff.platform.session_expiry', 'platform', 'Sessions expire after 7 days: `created` → expired, `started` → abandoned, without a client call', 'api', 'common'),
|
|
1082
|
+
todo('veriff.platform.resubmission_limit', 'platform', 'The 9-resubmission cap: the 10th attempt auto-declines with reasonCode 539 "Resubmission limit exceeded"', 'api', 'common'),
|
|
1083
|
+
todo('veriff.platform.rate_limit_429', 'platform', 'The documented 429 (`{"status":"fail","code":"1004","message":"Too many requests."}`) served by the twin against the published per-minute session ceiling', 'api', 'common'),
|
|
1084
|
+
todo('veriff.platform.delete_purge_semantics', 'platform', 'DELETE\'s documented after-effects: an expired/abandoned decision webhook for a live session, and data purged within 12 hours', 'api', 'niche'),
|
|
1085
|
+
todo('veriff.platform.credential_code_taxonomy', 'errors', 'The precise credential/authorization codes (1801-1804, 1812-1819) rather than the single 1812 this twin returns for every signature failure', 'api', 'common'),
|
|
1086
|
+
todo('veriff.platform.troubleshooting_codes', 'errors', 'The remaining documented validation codes (1001-1003, 1102, 1104, 1201-1203, 1301, 1303, 1308-1310, 1403, 2003, 2101-2104)', 'api', 'common'),
|
|
1087
|
+
todo('veriff.platform.timestamp_freshness', 'errors', 'Code 1201: "Invalid timestamp. Timestamp must not be older than one hour."', 'api', 'niche'),
|
|
1088
|
+
todo('veriff.platform.account_base_url', 'platform', 'The account-specific BaseURL: this twin serves the paths and the injection map routes both stationapi.veriff.com and api.veriff.me, but the per-account host is not modeled', 'api', 'niche'),
|
|
1089
|
+
todo('veriff.platform.legacy_vrf_headers', 'platform', 'The legacy `vrf-auth-client` / `vrf-hmac-sigature` (the vendor\'s own typo) / `vrf-integration-id` response headers, removed Oct 2 2025', 'api', 'niche'),
|
|
1090
|
+
todo('veriff.platform.egress_allowlist', 'platform', 'The documented per-zone egress IP ranges a receiver allowlists', 'api', 'niche'),
|
|
1091
|
+
|
|
1092
|
+
// Connector gaps — filed against the twin's own denominator (see pull-audit.json).
|
|
1093
|
+
todo('veriff.connector.pull_media_bytes', 'connector', 'Pull the media BYTES (GET /v1/media/{id}) alongside the metadata, so a pulled session\'s images are locally servable', 'connector', 'common'),
|
|
1094
|
+
todo('veriff.connector.pull_watchlist', 'connector', 'Pull watchlist-screening results for a session (grounded in veriff.watchlist.get)', 'connector', 'common'),
|
|
1095
|
+
todo('veriff.connector.pull_person', 'connector', 'Pull GET /v1/sessions/{id}/person as its own observed resource (grounded in veriff.person.get)', 'connector', 'niche'),
|
|
1096
|
+
todo('veriff.connector.push_media', 'connector', 'Push locally captured media to a real session (grounded in veriff.media.upload)', 'connector', 'niche'),
|
|
1097
|
+
// An honest limitation of push, found by reading the code rather than by a failing test: a session
|
|
1098
|
+
// created AND submitted locally yields two pending actions, and the PATCH one addresses the LOCAL
|
|
1099
|
+
// session id — which the real account has never seen, because the POST that preceded it minted a
|
|
1100
|
+
// different id vendor-side. Push therefore converges a create, but not a create-then-submit pair,
|
|
1101
|
+
// until local ids are remapped to the ids the vendor returned.
|
|
1102
|
+
todo('veriff.connector.push_id_remapping', 'connector', 'Remap a locally-minted session id to the id the real account returns, so a create-then-submit pair pushes as one converging pair rather than a PATCH against an id the vendor never issued', 'connector', 'common'),
|
|
1103
|
+
todo('veriff.connector.pull_backoff', 'connector', 'Honour a live 429\'s cooldown across a multi-session pull rather than refusing the whole batch', 'connector', 'niche'),
|
|
1104
|
+
];
|
|
1105
|
+
|
|
1106
|
+
// ── the injected fake connector client (offline, deterministic) ──────────────
|
|
1107
|
+
|
|
1108
|
+
const REMOTE_SESSION = '7f9a1c2e-0b3d-4e5f-8a91-2c3d4e5f6a7b';
|
|
1109
|
+
const OTHER_REMOTE = '1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d';
|
|
1110
|
+
const PULL_AT = '2026-08-01T00:00:00.000Z';
|
|
1111
|
+
|
|
1112
|
+
/** A fake Veriff executor — the connector's injected boundary. No network, no SDK, no credential. */
|
|
1113
|
+
function fakeExecute(sessionId: string = REMOTE_SESSION): VeriffExecute {
|
|
1114
|
+
return async (method, path) => {
|
|
1115
|
+
if (method === 'GET' && path === `/sessions/${sessionId}/decision`) {
|
|
1116
|
+
return {
|
|
1117
|
+
status: 'success',
|
|
1118
|
+
verification: {
|
|
1119
|
+
id: sessionId, attemptId: `${sessionId}-att`, vendorData: 'remote-partner', endUserId: null,
|
|
1120
|
+
status: 'approved', code: 9001, reason: null, reasonCode: null,
|
|
1121
|
+
decisionTime: PULL_AT, acceptanceTime: PULL_AT, submissionTime: PULL_AT,
|
|
1122
|
+
person: { firstName: 'REMOTE', lastName: 'PERSON', dateOfBirth: '1990-01-01' },
|
|
1123
|
+
document: { number: 'AB1234', type: 'PASSPORT', country: 'EE' },
|
|
1124
|
+
riskLabels: null,
|
|
1125
|
+
},
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
if (method === 'GET' && path === `/sessions/${sessionId}/attempts`) {
|
|
1129
|
+
return { status: 'success', verifications: [{ id: `${sessionId}-att`, status: 'approved', userDefinedData: [], createdTime: PULL_AT }] };
|
|
1130
|
+
}
|
|
1131
|
+
if (method === 'GET' && path === `/sessions/${sessionId}/media`) {
|
|
1132
|
+
return {
|
|
1133
|
+
status: 'success',
|
|
1134
|
+
images: [{ id: `${sessionId}-img`, name: 'document-front', context: 'document-front', timestamp: null, size: 4242, mimetype: 'image/jpeg', url: `https://stationapi.veriff.com/v1/media/${sessionId}-img` }],
|
|
1135
|
+
videos: [],
|
|
1136
|
+
nfcDocuments: [],
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
return { status: 'success' };
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** Every budget test gets its OWN throwaway ledger file — a suite that spent against the operator's
|
|
1144
|
+
* real `~/.volter/veriff` ledger would poison every later run on the machine. */
|
|
1145
|
+
function throwawayLedger(): { path: string } {
|
|
1146
|
+
return { path: join(mkdtempSync(join(tmpdir(), 'veriff-ledger-')), 'budget.json') };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** A frozen clock, so a window can be filled without wall-clock time pruning spend mid-test. */
|
|
1150
|
+
function fixedClock(): () => number {
|
|
1151
|
+
const t = Date.UTC(2026, 7, 1, 12, 0, 0);
|
|
1152
|
+
return () => t;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
export function veriffCapabilities(): Promise<CapabilityReport> {
|
|
1156
|
+
return checkCapabilities('veriff', VERIFF_CAPABILITIES);
|
|
1157
|
+
}
|