@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,406 @@
|
|
|
1
|
+
// Veriff CONNECTOR — the live-vendor pull/push path that gives the Veriff twin the full
|
|
2
|
+
// "git for SaaS" lifecycle.
|
|
3
|
+
//
|
|
4
|
+
// PULL (real → twin): fetch real Veriff decisions / attempts / media metadata, map them to
|
|
5
|
+
// SyncResource[], fold into the event log via observeResources (the kernel dedupes —
|
|
6
|
+
// a re-pull of identical state appends nothing).
|
|
7
|
+
// PUSH (twin → real): for every PENDING local action, call the real Veriff API and confirmAction
|
|
8
|
+
// on success.
|
|
9
|
+
//
|
|
10
|
+
// ── PULL IS HANDLE-DRIVEN, AND THAT IS THE VENDOR'S DOING ───────────────────────────────────
|
|
11
|
+
// Veriff publishes NO account-wide list endpoint: there is no `GET /v1/sessions`, and every read in
|
|
12
|
+
// the reference is scoped to a session/attempt/media id you already hold. So `syncVeriffFromReal`
|
|
13
|
+
// takes the session ids to pull, exactly as a real integration does — the reference consumer (dub)
|
|
14
|
+
// stores `veriffSessionId` on its own partner row and polls
|
|
15
|
+
// `GET /v1/sessions/{id}/decision` from there. An enumeration-driven pull is not "not built yet";
|
|
16
|
+
// it is not offerable against this API, and pull-audit.json records that rather than filing a gap
|
|
17
|
+
// for surface the vendor does not expose.
|
|
18
|
+
//
|
|
19
|
+
// The vendor I/O is an INJECTED executor (the auth boundary): the kernel and this pack hold NO
|
|
20
|
+
// Veriff credential and import NO network client. Offline/tests pass a fake executor; live runs pass
|
|
21
|
+
// `liveVeriffExecute(apiKey, sharedSecret)`. Same code path either way.
|
|
22
|
+
import { assertBudgetGuardIntact, confirmAction, deployableEntries, observeResources, ownFields, twinResources } from '@volter/world-core';
|
|
23
|
+
import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
|
|
24
|
+
import { VeriffBudget, VeriffBudgetError, veriffCallWeight, type VeriffBudgetOptions } from './veriff-budget.ts';
|
|
25
|
+
import { AUTH_CLIENT_HEADER, HMAC_SIGNATURE_HEADER, veriffSignature } from './veriff-signature.ts';
|
|
26
|
+
|
|
27
|
+
const SERVICE = 'veriff';
|
|
28
|
+
|
|
29
|
+
/** Subject types that are twin-internal and never pushed to real Veriff. `delivery` is the twin's
|
|
30
|
+
* own outbound-webhook log; `attempt` and `media` are vendor-authored — a client can read them
|
|
31
|
+
* but never create them, so a local action on one has no real call to become. */
|
|
32
|
+
const INTERNAL_SUBJECT_TYPES = new Set(['delivery', 'attempt', 'media']);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The injected real-Veriff boundary. `execute` issues ONE Veriff API call and returns the parsed
|
|
36
|
+
* JSON body (the flat `{ status, ... }` envelope). A real client (a raw fetch wrapper — which is
|
|
37
|
+
* what an integrator writes, since Veriff ships no server SDK) is structurally assignable; tests
|
|
38
|
+
* pass a fake.
|
|
39
|
+
*/
|
|
40
|
+
export type VeriffExecute = (
|
|
41
|
+
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
|
42
|
+
path: string,
|
|
43
|
+
body?: Record<string, unknown>,
|
|
44
|
+
) => Promise<{ status?: string; code?: string; message?: string; [k: string]: unknown }>;
|
|
45
|
+
|
|
46
|
+
/** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
|
|
47
|
+
export type LiveVeriffOptions = {
|
|
48
|
+
/** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
|
|
49
|
+
fetchImpl?: typeof fetch;
|
|
50
|
+
/** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
|
|
51
|
+
budget?: VeriffBudget;
|
|
52
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
53
|
+
budgetOptions?: VeriffBudgetOptions;
|
|
54
|
+
/** The API base. Veriff's URL is ACCOUNT-SPECIFIC (read it off the Customer Portal); the two
|
|
55
|
+
* hosts that appear in official material are `stationapi.veriff.com` (the reference consumer's,
|
|
56
|
+
* and the media samples') and `api.veriff.me` (`@veriff/js-sdk`'s compiled default). */
|
|
57
|
+
base?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A live executor against the real Veriff Public API v1.
|
|
62
|
+
*
|
|
63
|
+
* THIS IS THE ONE PLACE this pack issues a live Veriff request, and therefore the one place the
|
|
64
|
+
* rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the request
|
|
65
|
+
* goes out (`checkBudget`, which THROWS `VeriffBudgetError` instead of returning when the ceiling or
|
|
66
|
+
* a cooldown says stop) and the response is fed back (`recordCall`) so a 429 becomes a persisted
|
|
67
|
+
* cooldown that makes every later call fail fast WITHOUT touching Veriff. There is deliberately no
|
|
68
|
+
* OPTION to disable the guard, and no value a caller can pass for `budget` that yields an unguarded
|
|
69
|
+
* client. That is NOT immunity from a caller who WANTS one: a fresh `budgetOptions.path` per
|
|
70
|
+
* construction, or an injected clock, restores the allowance, because the seam tests need cannot be
|
|
71
|
+
* denied to a determined caller in the same process. The kernel header states that limit and this
|
|
72
|
+
* does not upgrade it.
|
|
73
|
+
*
|
|
74
|
+
* It also OWNS THE SIGNING, which is the other reason raw calls must not bypass it: Veriff's payload
|
|
75
|
+
* rule is asymmetric (POST/PATCH sign the body, GET/DELETE sign the resource id in the path), and a
|
|
76
|
+
* hand-rolled call that signs the wrong half fails authentication at the vendor for reasons that
|
|
77
|
+
* look nothing like a signing bug.
|
|
78
|
+
*/
|
|
79
|
+
export function liveVeriffExecute(
|
|
80
|
+
apiKey: string,
|
|
81
|
+
sharedSecret: string,
|
|
82
|
+
opts: LiveVeriffOptions = {},
|
|
83
|
+
): VeriffExecute {
|
|
84
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
85
|
+
const base = opts.base ?? 'https://stationapi.veriff.com';
|
|
86
|
+
// `null`/`undefined` (or omitting it) build the default budget. Anything else must be an
|
|
87
|
+
// UNMODIFIED VeriffBudget: a duck-typed stand-in, a SUBCLASS that overrides `checkBudget`, and a
|
|
88
|
+
// Proxy that traps it are all refused, because all three are one-liners that would otherwise hand
|
|
89
|
+
// back a client with no ceiling at all. The default ledger is keyed by a hash of THIS key —
|
|
90
|
+
// Veriff limits per integration, so a cwd-scoped ledger would hand the same key a fresh allowance
|
|
91
|
+
// per checkout/worktree/CI leg.
|
|
92
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
93
|
+
? assertBudgetGuardIntact(opts.budget, VeriffBudget, 'liveVeriffExecute')
|
|
94
|
+
: new VeriffBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
|
|
95
|
+
return async (method, path, body) => {
|
|
96
|
+
const serialized = method !== 'GET' && method !== 'DELETE' && body !== undefined ? JSON.stringify(body) : undefined;
|
|
97
|
+
const headers: Record<string, string> = { [AUTH_CLIENT_HEADER]: apiKey };
|
|
98
|
+
// POST /sessions is the ONE endpoint the vendor exempts from signing.
|
|
99
|
+
const isCreateSession = method === 'POST' && path === '/sessions';
|
|
100
|
+
if (!isCreateSession) {
|
|
101
|
+
headers[HMAC_SIGNATURE_HEADER] = veriffSignature(signaturePayloadFor(method, path, serialized), sharedSecret);
|
|
102
|
+
}
|
|
103
|
+
const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
|
|
104
|
+
if (serialized !== undefined) {
|
|
105
|
+
headers['content-type'] = 'application/json';
|
|
106
|
+
init.body = serialized;
|
|
107
|
+
}
|
|
108
|
+
const weight = veriffCallWeight(method, path);
|
|
109
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
110
|
+
const reservation = budget.checkBudget(weight);
|
|
111
|
+
const res = await doFetch(`${base}/v1${path}`, init);
|
|
112
|
+
const resHeaders: Record<string, string> = {};
|
|
113
|
+
res.headers.forEach((v: string, k: string) => { resHeaders[k.toLowerCase()] = v; });
|
|
114
|
+
const parsed = (await res.json()) as { status?: string };
|
|
115
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
|
|
116
|
+
// `Retry-After` beyond the cap is not something to sleep off) — the cooldown is persisted first
|
|
117
|
+
// either way, so the refusal survives the throw.
|
|
118
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
119
|
+
// call that louder refusal wins; an answer Veriff ACCEPTED is kept, so a write that landed is
|
|
120
|
+
// never recorded as failed and performed again on retry.
|
|
121
|
+
try {
|
|
122
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (!(error instanceof VeriffBudgetError) || !res.ok) throw error;
|
|
125
|
+
}
|
|
126
|
+
return parsed;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Which bytes `X-HMAC-SIGNATURE` must cover for this call. The vendor's per-endpoint docs refine
|
|
132
|
+
* "GET/DELETE sign the session ID" into "sign the RESOURCE id in the path" — `/attempts/{id}/media`
|
|
133
|
+
* signs the attempt id, `/media/{id}` signs the media id — so this reads the id positionally rather
|
|
134
|
+
* than assuming a session.
|
|
135
|
+
*/
|
|
136
|
+
export function signaturePayloadFor(method: string, path: string, serializedBody?: string): string {
|
|
137
|
+
if (method !== 'GET' && method !== 'DELETE') return serializedBody ?? '';
|
|
138
|
+
const segments = path.replace(/^\/+/, '').split('/');
|
|
139
|
+
return segments[1] ?? '';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── mappers (pure — no client, no network) ──────────────────────────────────
|
|
143
|
+
// Every mapper renames the vendor's `id` to a type-specific key, because the kernel's projection
|
|
144
|
+
// RESERVES `type`, `id` and `updatedAt` as META and silently DROPS a resource field with one of
|
|
145
|
+
// those names. Field names match what veriff-twin.ts stores, so a pulled session and a locally
|
|
146
|
+
// created one project identically.
|
|
147
|
+
|
|
148
|
+
/** Map a decision document → a `session` SyncResource. */
|
|
149
|
+
export function mapSessionDecision(v: Record<string, any>): SyncResource {
|
|
150
|
+
return {
|
|
151
|
+
type: 'session',
|
|
152
|
+
id: String(v.id),
|
|
153
|
+
fields: {
|
|
154
|
+
sessionId: v.id,
|
|
155
|
+
status: v.status,
|
|
156
|
+
decisionCode: v.code ?? null,
|
|
157
|
+
decisionAttemptId: v.attemptId ?? null,
|
|
158
|
+
decisionTime: v.decisionTime ?? null,
|
|
159
|
+
acceptanceTime: v.acceptanceTime ?? null,
|
|
160
|
+
submissionTime: v.submissionTime ?? null,
|
|
161
|
+
decisionReason: v.reason ?? null,
|
|
162
|
+
decisionReasonCode: v.reasonCode ?? null,
|
|
163
|
+
decidedPerson: v.person ?? null,
|
|
164
|
+
decidedDocument: v.document ?? null,
|
|
165
|
+
riskLabels: v.riskLabels ?? null,
|
|
166
|
+
vendorData: v.vendorData ?? null,
|
|
167
|
+
endUserId: v.endUserId ?? null,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Map one attempt row (`GET /v1/sessions/{id}/attempts` → `verifications[]`) → a SyncResource. */
|
|
173
|
+
export function mapSessionAttempt(sessionId: string, a: Record<string, any>): SyncResource {
|
|
174
|
+
return {
|
|
175
|
+
type: 'attempt',
|
|
176
|
+
id: String(a.id),
|
|
177
|
+
fields: { attemptId: a.id, sessionId, status: a.status, userDefinedData: a.userDefinedData ?? [], createdTime: a.createdTime ?? null },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Map one media object → a SyncResource. `kind` separates the images/videos/nfcDocuments buckets
|
|
182
|
+
* the media endpoints split their response into. */
|
|
183
|
+
export function mapSessionMedia(sessionId: string, m: Record<string, any>, kind: 'image' | 'video' | 'nfc'): SyncResource {
|
|
184
|
+
return {
|
|
185
|
+
type: 'media',
|
|
186
|
+
id: String(m.id),
|
|
187
|
+
fields: {
|
|
188
|
+
mediaId: m.id,
|
|
189
|
+
sessionId,
|
|
190
|
+
attemptId: m.attemptId ?? null,
|
|
191
|
+
kind,
|
|
192
|
+
name: m.name ?? null,
|
|
193
|
+
context: m.context ?? null,
|
|
194
|
+
timestamp: m.timestamp ?? null,
|
|
195
|
+
size: m.size ?? null,
|
|
196
|
+
mimetype: m.mimetype ?? null,
|
|
197
|
+
url: m.url ?? null,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A pinned occurredAt keeps a pull deterministic/idempotent under test.
|
|
203
|
+
const PULL_AT = '2026-08-01T00:00:00.000Z';
|
|
204
|
+
|
|
205
|
+
/** Fetch + map decisions for the given session ids (no fold) — shared by pull + syncFromReal. */
|
|
206
|
+
async function collectDecisions(execute: VeriffExecute, sessionIds: readonly string[]): Promise<SyncResource[]> {
|
|
207
|
+
const out: SyncResource[] = [];
|
|
208
|
+
for (const id of sessionIds) {
|
|
209
|
+
const res = await execute('GET', `/sessions/${id}/decision`);
|
|
210
|
+
const v = res.verification as Record<string, any> | null | undefined;
|
|
211
|
+
// `verification: null` is the documented "no decision yet" answer — not an error, and not
|
|
212
|
+
// something to fold as an empty session either.
|
|
213
|
+
if (v && v.id) out.push(mapSessionDecision(v));
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function collectAttempts(execute: VeriffExecute, sessionIds: readonly string[]): Promise<SyncResource[]> {
|
|
219
|
+
const out: SyncResource[] = [];
|
|
220
|
+
for (const id of sessionIds) {
|
|
221
|
+
const res = await execute('GET', `/sessions/${id}/attempts`);
|
|
222
|
+
const rows = Array.isArray(res.verifications) ? (res.verifications as Record<string, any>[]) : [];
|
|
223
|
+
for (const a of rows) if (a?.id) out.push(mapSessionAttempt(id, a));
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function collectMedia(execute: VeriffExecute, sessionIds: readonly string[]): Promise<SyncResource[]> {
|
|
229
|
+
const out: SyncResource[] = [];
|
|
230
|
+
for (const id of sessionIds) {
|
|
231
|
+
const res = await execute('GET', `/sessions/${id}/media`);
|
|
232
|
+
for (const [key, kind] of [['images', 'image'], ['videos', 'video'], ['nfcDocuments', 'nfc']] as const) {
|
|
233
|
+
const rows = Array.isArray(res[key]) ? (res[key] as Record<string, any>[]) : [];
|
|
234
|
+
for (const m of rows) if (m?.id) out.push(mapSessionMedia(id, m, kind));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** PULL decisions for known session ids into the twin's observed log (idempotent). */
|
|
241
|
+
export async function pullVeriffDecisions(execute: VeriffExecute, sessionIds: readonly string[], root?: string, occurredAt = PULL_AT): Promise<number> {
|
|
242
|
+
const resources = await collectDecisions(execute, sessionIds);
|
|
243
|
+
observeResources(SERVICE, resources, { ...(root !== undefined ? { root } : {}), at: occurredAt, batch: `obs:${SERVICE}:${occurredAt}` });
|
|
244
|
+
return resources.length;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** PULL the attempt history for known session ids (idempotent). */
|
|
248
|
+
export async function pullVeriffAttempts(execute: VeriffExecute, sessionIds: readonly string[], root?: string, occurredAt = PULL_AT): Promise<number> {
|
|
249
|
+
const resources = await collectAttempts(execute, sessionIds);
|
|
250
|
+
observeResources(SERVICE, resources, { ...(root !== undefined ? { root } : {}), at: occurredAt, batch: `obs:${SERVICE}:${occurredAt}` });
|
|
251
|
+
return resources.length;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** PULL media METADATA for known session ids (idempotent). The bytes are not pulled — see the
|
|
255
|
+
* `veriff.media.pull_bytes` todo. */
|
|
256
|
+
export async function pullVeriffMedia(execute: VeriffExecute, sessionIds: readonly string[], root?: string, occurredAt = PULL_AT): Promise<number> {
|
|
257
|
+
const resources = await collectMedia(execute, sessionIds);
|
|
258
|
+
observeResources(SERVICE, resources, { ...(root !== undefined ? { root } : {}), at: occurredAt, batch: `obs:${SERVICE}:${occurredAt}` });
|
|
259
|
+
return resources.length;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* D7 consumer-facing pull entry point: pull from real Veriff (decisions + attempts + media
|
|
264
|
+
* metadata) and fold into the twin in ONE observation, returning the standard
|
|
265
|
+
* `{ observed, deltasAppended }` result. Idempotent — a re-pull of identical state appends nothing
|
|
266
|
+
* (deltasAppended drops to 0).
|
|
267
|
+
*
|
|
268
|
+
* `sessionIds` is REQUIRED in substance (an empty list pulls nothing and says so by returning
|
|
269
|
+
* zeroes) because Veriff exposes no account-wide enumeration — see the module header.
|
|
270
|
+
*/
|
|
271
|
+
export async function syncVeriffFromReal(
|
|
272
|
+
execute: VeriffExecute,
|
|
273
|
+
opts: { sessionIds?: readonly string[]; root?: string; occurredAt?: string } = {},
|
|
274
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
275
|
+
const occurredAt = opts.occurredAt ?? PULL_AT;
|
|
276
|
+
const sessionIds = opts.sessionIds ?? [];
|
|
277
|
+
const decisions = await collectDecisions(execute, sessionIds);
|
|
278
|
+
const attempts = await collectAttempts(execute, sessionIds);
|
|
279
|
+
const media = await collectMedia(execute, sessionIds);
|
|
280
|
+
const resources = [...decisions, ...attempts, ...media];
|
|
281
|
+
const result = observeResources(SERVICE, resources, { ...(opts.root !== undefined ? { root: opts.root } : {}), at: occurredAt, batch: `obs:${SERVICE}:${occurredAt}` });
|
|
282
|
+
return { observed: resources.length, deltasAppended: result.appended };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Translate one pending local action into the real Veriff API call it represents. Returns `null`
|
|
286
|
+
* for anything the vendor gives a client no way to write. */
|
|
287
|
+
export function veriffRequestForAction(action: TwinAction): { method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; path: string; body?: Record<string, unknown> } | null {
|
|
288
|
+
if (INTERNAL_SUBJECT_TYPES.has(action.subject.type)) return null;
|
|
289
|
+
if (action.subject.type !== 'session') return null;
|
|
290
|
+
const fields = (action.fields ?? {}) as Record<string, unknown>;
|
|
291
|
+
const status = fields.status;
|
|
292
|
+
// A session whose local action only moved it to `submitted` is a PATCH, not a re-create.
|
|
293
|
+
if (status === 'submitted') return { method: 'PATCH', path: `/sessions/${String(fields.sessionId ?? action.subject.id)}`, body: { verification: { status: 'submitted' } } };
|
|
294
|
+
if (fields.deleted === true) return { method: 'DELETE', path: `/sessions/${String(fields.sessionId ?? action.subject.id)}` };
|
|
295
|
+
if (status === 'created') {
|
|
296
|
+
const verification: Record<string, unknown> = {};
|
|
297
|
+
if (fields.vendorData != null) verification.vendorData = fields.vendorData;
|
|
298
|
+
if (fields.endUserId != null) verification.endUserId = fields.endUserId;
|
|
299
|
+
if (fields.callback != null) verification.callback = fields.callback;
|
|
300
|
+
if (fields.hintPerson != null) verification.person = fields.hintPerson;
|
|
301
|
+
if (fields.hintDocument != null) verification.document = fields.hintDocument;
|
|
302
|
+
return { method: 'POST', path: '/sessions', body: { verification } };
|
|
303
|
+
}
|
|
304
|
+
// A decision status is authored by VERIFF, never by a client — there is no endpoint to push it to.
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* PUSH every pending local action to the real account; confirm each on success.
|
|
310
|
+
*
|
|
311
|
+
* HONEST LIMIT: local ids are not remapped. A session created AND submitted locally produces two
|
|
312
|
+
* pending actions — the create pushes as `POST /sessions` and the real account mints its OWN id,
|
|
313
|
+
* while the submit pushes as `PATCH /sessions/{LOCAL id}`, which that account has never seen. So a
|
|
314
|
+
* create converges; a create-then-submit pair does not, until `veriff.connector.push_id_remapping`
|
|
315
|
+
* is built. Saying so beats a push that silently half-lands.
|
|
316
|
+
*/
|
|
317
|
+
export async function pushPendingVeriffActions(execute: VeriffExecute, root?: string, occurredAt = PULL_AT): Promise<number> {
|
|
318
|
+
const pending = deployableEntries(SERVICE, root);
|
|
319
|
+
let pushed = 0;
|
|
320
|
+
for (const action of pending) {
|
|
321
|
+
const req = veriffRequestForAction(action);
|
|
322
|
+
if (!req) continue;
|
|
323
|
+
const res = await execute(req.method, req.path, req.body);
|
|
324
|
+
// Veriff signals success with status:"success" and failure with status:"fail". Confirm ONLY on
|
|
325
|
+
// an explicit success — a `fail` envelope (which still parses as JSON and still has a `status`)
|
|
326
|
+
// must NOT confirm the local action.
|
|
327
|
+
if (res.status === 'success') {
|
|
328
|
+
confirmAction({ service: SERVICE, actionId: action.id, subject: action.subject, fields: (action.fields ?? {}) as Record<string, unknown>, occurredAt, ...(root !== undefined ? { root } : {}) });
|
|
329
|
+
pushed += 1;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return pushed;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
/** A `VeriffExecute` over the kernel's executor. At a REAL boundary the kernel sets the sealed
|
|
338
|
+
* credential over these headers (executor.ts); at the twin's own wire any credential is one. */
|
|
339
|
+
export function veriffExecuteOver(execute: RemoteExecute): VeriffExecute {
|
|
340
|
+
return async (method, path, body) => {
|
|
341
|
+
const res = await execute({
|
|
342
|
+
method,
|
|
343
|
+
path: path.startsWith('/v1/') ? path : `/v1${path}`,
|
|
344
|
+
headers: { accept: 'application/json', 'content-type': 'application/json', 'x-auth-client': 'twin' },
|
|
345
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
346
|
+
});
|
|
347
|
+
try { return JSON.parse(res.body || '{}'); } catch { return { status: res.status < 300 ? 'success' : 'fail' }; }
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The refresh adapter.
|
|
353
|
+
*
|
|
354
|
+
* Veriff exposes NO account-wide enumeration — a session exists because a client created it and is
|
|
355
|
+
* then addressed by the id that client was handed. So "refresh this world from the real vendor"
|
|
356
|
+
* means re-reading the sessions the WORLD already holds: its decisions, its attempt history, and
|
|
357
|
+
* its media metadata. This is not a gap standing in for a list endpoint; it is the shape of the
|
|
358
|
+
* vendor, and the module header says so.
|
|
359
|
+
*/
|
|
360
|
+
export async function syncVeriffFromRemote(
|
|
361
|
+
execute: RemoteExecute,
|
|
362
|
+
opts: { root?: string; origin?: string; occurredAt?: string; sessionIds?: readonly string[] } = {},
|
|
363
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
364
|
+
const held: string[] = [];
|
|
365
|
+
for (const resource of twinResources(SERVICE, opts.root)) {
|
|
366
|
+
if (resource.type !== 'session') continue;
|
|
367
|
+
const fields = ownFields(resource);
|
|
368
|
+
const sessionId = typeof fields.sessionId === 'string' ? fields.sessionId : resource.id;
|
|
369
|
+
if (sessionId) held.push(sessionId);
|
|
370
|
+
}
|
|
371
|
+
return syncVeriffFromReal(veriffExecuteOver(execute), {
|
|
372
|
+
sessionIds: [...new Set([...held, ...(opts.sessionIds ?? [])])].sort(),
|
|
373
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
374
|
+
occurredAt: opts.occurredAt ?? new Date().toISOString(),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The perform adapter.
|
|
380
|
+
*
|
|
381
|
+
* This is what closes the pack's standing `push_id_remapping` gap. Under protocol 1 a local session
|
|
382
|
+
* created AND submitted produced two pending actions: the create pushed as `POST /sessions` and the
|
|
383
|
+
* real account minted its OWN id, while the submit pushed as `PATCH /sessions/{LOCAL id}` — an id
|
|
384
|
+
* that account had never seen. Protocol 2's confirmation carries `vendorSubjectId`, so the kernel
|
|
385
|
+
* records what Veriff called the session and the submit addresses the REAL id.
|
|
386
|
+
*/
|
|
387
|
+
export async function performVeriffAction(execute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome> {
|
|
388
|
+
const client = veriffExecuteOver(execute);
|
|
389
|
+
const req = veriffRequestForAction(action);
|
|
390
|
+
if (!req) {
|
|
391
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
392
|
+
return { externalId: action.subject.id, data: { performed: false, reason: `${op} is authored by Veriff, never by a client — there is no endpoint to push it to` } };
|
|
393
|
+
}
|
|
394
|
+
// Address the session by whatever the account already called it, when the kernel knows.
|
|
395
|
+
const resolved = ctx.resolve(action.subject.type, action.subject.id);
|
|
396
|
+
const path = req.path.replace(/\/sessions\/[^/]+/, `/sessions/${encodeURIComponent(resolved)}`);
|
|
397
|
+
const res = await client(req.method, path, req.body);
|
|
398
|
+
// Veriff signals success with status:"success" and failure with status:"fail". A `fail` envelope
|
|
399
|
+
// still parses as JSON and still has a `status`, so anything but an explicit success is a throw.
|
|
400
|
+
if (res.status !== 'success') {
|
|
401
|
+
throw new Error(`veriff ${req.method} ${path} refused: ${String(res.status ?? 'no status')} ${String(res.message ?? '')}`.trim());
|
|
402
|
+
}
|
|
403
|
+
const verification = (res.verification ?? {}) as Record<string, unknown>;
|
|
404
|
+
const externalId = typeof verification.id === 'string' ? verification.id : resolved;
|
|
405
|
+
return { externalId, data: res as Record<string, unknown> };
|
|
406
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Veriff WEBHOOK emission + verification — the twin's outbound half of the integration.
|
|
2
|
+
//
|
|
3
|
+
// Veriff pushes two webhook families to the URLs configured on the account (or per-session via
|
|
4
|
+
// `verification.callback`). Both are signed with the SAME scheme (veriff-signature.ts):
|
|
5
|
+
//
|
|
6
|
+
// • DECISION webhook — sent once a session reaches a terminal decision. Body is the same
|
|
7
|
+
// `{ status, verification: {...} }` document `GET /v1/sessions/{id}/decision` serves.
|
|
8
|
+
// • EVENT webhook — sent as the end user moves through the hosted flow:
|
|
9
|
+
// `{ id, attemptId, feature, code, action, vendorData }` with `action` ∈ started | submitted.
|
|
10
|
+
//
|
|
11
|
+
// The real consumer distinguishes them exactly the way this twin emits them: dub's
|
|
12
|
+
// `app/api/veriff/webhook/route.ts` routes on `"verification" in body` — decision payloads carry
|
|
13
|
+
// it, event payloads do not.
|
|
14
|
+
//
|
|
15
|
+
// DELIVERY IS INJECTED. This module never opens a socket: the caller passes a
|
|
16
|
+
// `VeriffWebhookDelivery` (a real `fetch` POST in production, a recording fake in tests), so every
|
|
17
|
+
// verify in this pack proves the signing and payload shape OFFLINE. The twin's own handler records
|
|
18
|
+
// each delivery as a `delivery` subject so a test can read back what was sent.
|
|
19
|
+
import { AUTH_CLIENT_HEADER, HMAC_SIGNATURE_HEADER, veriffSignature, verifyVeriffSignature } from './veriff-signature.ts';
|
|
20
|
+
|
|
21
|
+
/** What a caller supplies to actually put a delivery on the wire. */
|
|
22
|
+
export type VeriffWebhookDelivery = (url: string, body: string, headers: Record<string, string>) => Promise<void> | void;
|
|
23
|
+
|
|
24
|
+
/** The `action` values Veriff's EVENT webhook carries. */
|
|
25
|
+
export const VERIFF_EVENT_ACTIONS = ['started', 'submitted'] as const;
|
|
26
|
+
export type VeriffEventAction = (typeof VERIFF_EVENT_ACTIONS)[number];
|
|
27
|
+
|
|
28
|
+
/** An event-webhook payload (no `verification` key — that is what makes it an event, not a decision). */
|
|
29
|
+
export type VeriffEventPayload = {
|
|
30
|
+
id: string;
|
|
31
|
+
attemptId: string;
|
|
32
|
+
feature: string;
|
|
33
|
+
code: number;
|
|
34
|
+
action: VeriffEventAction;
|
|
35
|
+
vendorData: string | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A decision-webhook payload — the same document the decision endpoint serves. */
|
|
39
|
+
export type VeriffDecisionPayload = {
|
|
40
|
+
status: 'success';
|
|
41
|
+
verification: Record<string, unknown>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type VeriffWebhookPayload = VeriffEventPayload | VeriffDecisionPayload;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the exact headers + body bytes for one delivery.
|
|
48
|
+
*
|
|
49
|
+
* The body is serialized ONCE and the signature is taken over THOSE bytes — signing a
|
|
50
|
+
* re-serialized copy is the classic way a webhook signature stops verifying against the payload
|
|
51
|
+
* the receiver actually got (key order alone is enough to break it).
|
|
52
|
+
*/
|
|
53
|
+
export function buildSignedDelivery(
|
|
54
|
+
payload: VeriffWebhookPayload,
|
|
55
|
+
opts: { apiKey: string; sharedSecret: string },
|
|
56
|
+
): { body: string; headers: Record<string, string> } {
|
|
57
|
+
const body = JSON.stringify(payload);
|
|
58
|
+
return {
|
|
59
|
+
body,
|
|
60
|
+
headers: {
|
|
61
|
+
'content-type': 'application/json',
|
|
62
|
+
[AUTH_CLIENT_HEADER]: opts.apiKey,
|
|
63
|
+
[HMAC_SIGNATURE_HEADER]: veriffSignature(body, opts.sharedSecret),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Thrown when an inbound delivery fails verification. `reason` says which check refused. */
|
|
69
|
+
export class VeriffWebhookVerificationError extends Error {
|
|
70
|
+
readonly reason: 'missing-signature' | 'bad-signature' | 'missing-auth-client' | 'bad-auth-client';
|
|
71
|
+
constructor(reason: VeriffWebhookVerificationError['reason'], message: string) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = 'VeriffWebhookVerificationError';
|
|
74
|
+
this.reason = reason;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Verify a delivery the way a real receiver does — BOTH checks dub performs, in dub's order:
|
|
80
|
+
* the `x-auth-client` header must equal the account's API key, then `x-hmac-signature` must be the
|
|
81
|
+
* HMAC of the RAW body. Returns the parsed payload; throws `VeriffWebhookVerificationError`
|
|
82
|
+
* otherwise. Header lookup is case-insensitive because HTTP header names are.
|
|
83
|
+
*/
|
|
84
|
+
export function verifyWebhook(
|
|
85
|
+
rawBody: string,
|
|
86
|
+
headers: Record<string, string | undefined>,
|
|
87
|
+
opts: { apiKey: string; sharedSecret: string },
|
|
88
|
+
): VeriffWebhookPayload {
|
|
89
|
+
const lower: Record<string, string> = {};
|
|
90
|
+
for (const [k, v] of Object.entries(headers)) if (v !== undefined) lower[k.toLowerCase()] = v;
|
|
91
|
+
|
|
92
|
+
const authClient = lower[AUTH_CLIENT_HEADER];
|
|
93
|
+
if (authClient === undefined || authClient === '') {
|
|
94
|
+
throw new VeriffWebhookVerificationError('missing-auth-client', `missing ${AUTH_CLIENT_HEADER} header`);
|
|
95
|
+
}
|
|
96
|
+
if (authClient !== opts.apiKey) {
|
|
97
|
+
throw new VeriffWebhookVerificationError('bad-auth-client', `${AUTH_CLIENT_HEADER} does not match this account's API key`);
|
|
98
|
+
}
|
|
99
|
+
const failure = verifyVeriffSignature(rawBody, lower[HMAC_SIGNATURE_HEADER], opts.sharedSecret);
|
|
100
|
+
if (failure === 'missing') throw new VeriffWebhookVerificationError('missing-signature', `missing ${HMAC_SIGNATURE_HEADER} header`);
|
|
101
|
+
if (failure === 'mismatch') throw new VeriffWebhookVerificationError('bad-signature', `${HMAC_SIGNATURE_HEADER} does not match the delivered body`);
|
|
102
|
+
return JSON.parse(rawBody) as VeriffWebhookPayload;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sign and hand ONE delivery to the injected deliverer. Returns the bytes + headers that went out,
|
|
107
|
+
* so a caller (and the twin's own delivery log) can assert on exactly what a receiver would see.
|
|
108
|
+
*/
|
|
109
|
+
export async function emitVeriffWebhook(
|
|
110
|
+
url: string,
|
|
111
|
+
payload: VeriffWebhookPayload,
|
|
112
|
+
opts: { apiKey: string; sharedSecret: string; deliver: VeriffWebhookDelivery },
|
|
113
|
+
): Promise<{ url: string; body: string; headers: Record<string, string> }> {
|
|
114
|
+
const { body, headers } = buildSignedDelivery(payload, opts);
|
|
115
|
+
await opts.deliver(url, body, headers);
|
|
116
|
+
return { url, body, headers };
|
|
117
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Veriff twin HTTP server — serve the full Veriff Public API v1 twin over HTTP so an unmodified
|
|
2
|
+
// client works against it. Veriff publishes no server-side SDK, so a real integration hand-rolls a
|
|
3
|
+
// `fetch` client (the reference consumer, dub, does exactly that in `apps/web/lib/veriff/client.ts`)
|
|
4
|
+
// and that is what the fidelity test drives.
|
|
5
|
+
//
|
|
6
|
+
// Two things happen HERE rather than in the handler, because both are wire-level:
|
|
7
|
+
//
|
|
8
|
+
// 1. RESPONSE SIGNING. Every documented endpoint declares two response headers — `X-AUTH-CLIENT`
|
|
9
|
+
// ("API key echoed back in response") and `X-HMAC-SIGNATURE` ("Response body signed with the
|
|
10
|
+
// shared secret key. Required to authenticate the response sender."). A consumer that verifies
|
|
11
|
+
// the sender gets the real thing from the twin.
|
|
12
|
+
// 2. BINARY MEDIA. `GET /v1/media/{id}` answers with the media BYTES under the object's own
|
|
13
|
+
// mimetype, not JSON. The handler's `{status, body}` contract carries the base64 + mimetype;
|
|
14
|
+
// this is where it becomes a real binary body.
|
|
15
|
+
//
|
|
16
|
+
// Auth is FAKED LOCALLY: `X-AUTH-CLIENT` is required (the vendor 401s without it) but never checked
|
|
17
|
+
// against a real Veriff account. `X-HMAC-SIGNATURE`, by contrast, is verified for real.
|
|
18
|
+
// Writable by default; pass readOnly to reject writes (R4).
|
|
19
|
+
//
|
|
20
|
+
// FETCH-FIRST (runtime contract R12b): the surface is the plain `createVeriffTwinFetch` and the
|
|
21
|
+
// SERVER is one line of `Bun.serve` around it. This is a CUSTOM fetch, not the kernel adapter
|
|
22
|
+
// (`createTwinFetchFromHandler`): every reply is HMAC-signed OVER ITS OWN BODY (a per-response
|
|
23
|
+
// header the adapter's static `responseHeaders` cannot express) and the media route answers raw
|
|
24
|
+
// BYTES — openai-server.ts is the hand-written-fetch reference.
|
|
25
|
+
import { serveHttp } from '@volter/world-core';
|
|
26
|
+
import { handleVeriffTwinRequest, TWIN_API_KEY, TWIN_SHARED_SECRET } from './veriff-twin.ts';
|
|
27
|
+
import { worldNow, statefulTwinManifest} from '@volter/world-core';
|
|
28
|
+
import { AUTH_CLIENT_HEADER, HMAC_SIGNATURE_HEADER, veriffSignature, veriffSignatureBytes } from './veriff-signature.ts';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The two response headers Veriff documents on every endpoint. Pure, so a test can assert the
|
|
32
|
+
* signing rule without a socket.
|
|
33
|
+
*/
|
|
34
|
+
export function veriffResponseHeaders(body: string, opts: { apiKey: string; sharedSecret: string }): Record<string, string> {
|
|
35
|
+
return {
|
|
36
|
+
'content-type': 'application/json',
|
|
37
|
+
[AUTH_CLIENT_HEADER]: opts.apiKey,
|
|
38
|
+
[HMAC_SIGNATURE_HEADER]: veriffSignature(body, opts.sharedSecret),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Is this the binary media-download response shape the handler hands back for `GET /v1/media/{id}`? */
|
|
43
|
+
function isMediaDownload(path: string, out: unknown): out is { mimetype: string; content: string } {
|
|
44
|
+
if (!/^\/(v1\/)?media\/[^/]+$/.test(path.split('?')[0] ?? '')) return false;
|
|
45
|
+
const o = out as { mimetype?: unknown; content?: unknown } | null;
|
|
46
|
+
return typeof o?.mimetype === 'string' && typeof o?.content === 'string';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Options every Veriff-twin HTTP surface needs, independent of who owns the socket. */
|
|
50
|
+
export interface VeriffTwinFetchOptions {
|
|
51
|
+
root?: string;
|
|
52
|
+
readOnly?: boolean;
|
|
53
|
+
apiKey?: string;
|
|
54
|
+
sharedSecret?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createVeriffTwinFetch(options: VeriffTwinFetchOptions = {}): (request: Request) => Promise<Response> {
|
|
58
|
+
const readOnly = options.readOnly ?? false;
|
|
59
|
+
const sharedSecret = options.sharedSecret ?? TWIN_SHARED_SECRET;
|
|
60
|
+
const apiKey = options.apiKey ?? TWIN_API_KEY;
|
|
61
|
+
return async function veriffTwinFetch(request: Request): Promise<Response> {
|
|
62
|
+
const url = new URL(request.url);
|
|
63
|
+
// GET /twin — the discovery manifest (education inside the twin).
|
|
64
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
65
|
+
return Response.json(statefulTwinManifest({ vendor: 'veriff', twinOf: "Veriff's Station API v1", stores: 'verification sessions and their decision lifecycle' }));
|
|
66
|
+
}
|
|
67
|
+
const body = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text();
|
|
68
|
+
const headers: Record<string, string> = {};
|
|
69
|
+
request.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
|
|
70
|
+
const path = url.pathname + (url.search || '');
|
|
71
|
+
const { status, body: out } = await handleVeriffTwinRequest({
|
|
72
|
+
method: request.method,
|
|
73
|
+
path,
|
|
74
|
+
body,
|
|
75
|
+
headers,
|
|
76
|
+
readOnly,
|
|
77
|
+
sharedSecret,
|
|
78
|
+
apiKey,
|
|
79
|
+
occurredAt: worldNow(),
|
|
80
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
81
|
+
});
|
|
82
|
+
if (status === 200 && isMediaDownload(path, out)) {
|
|
83
|
+
const bytes = new Uint8Array(Buffer.from(out.content, 'base64'));
|
|
84
|
+
return new Response(bytes, {
|
|
85
|
+
status,
|
|
86
|
+
headers: {
|
|
87
|
+
'content-type': out.mimetype,
|
|
88
|
+
[AUTH_CLIENT_HEADER]: apiKey,
|
|
89
|
+
// Over the BYTES that go on the wire, not over the base64 text the twin stores them as:
|
|
90
|
+
// the documented header authenticates "the response body", and a consumer re-computing
|
|
91
|
+
// it over what it received must match.
|
|
92
|
+
[HMAC_SIGNATURE_HEADER]: veriffSignatureBytes(bytes, sharedSecret),
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const text = JSON.stringify(out);
|
|
97
|
+
return new Response(text, { status, headers: veriffResponseHeaders(text, { apiKey, sharedSecret }) });
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function createVeriffTwinServer(options: { root?: string; port?: number; hostname?: string; readOnly?: boolean; apiKey?: string; sharedSecret?: string } = {}): Promise<{ port: number; stop: () => void }> {
|
|
102
|
+
const server = await serveHttp({
|
|
103
|
+
// Callers that fetch this server at 127.0.0.1 (the capability verifies) pass a loopback
|
|
104
|
+
// hostname so `port: 0` cannot collide with a stranger's 127.0.0.1 listener (the roving
|
|
105
|
+
// ui-verify flake mechanism — see any *-mirror-ui.ts). Default binding is unchanged.
|
|
106
|
+
...(options.hostname !== undefined ? { hostname: options.hostname } : {}),
|
|
107
|
+
port: options.port ?? 0,
|
|
108
|
+
idleTimeout: 60,
|
|
109
|
+
fetch: createVeriffTwinFetch(options),
|
|
110
|
+
});
|
|
111
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
112
|
+
}
|