@volter/twin-veriff 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ /** The header Veriff carries the public API key in (case-insensitive on the wire). */
2
+ export declare const AUTH_CLIENT_HEADER = "x-auth-client";
3
+ /** The header Veriff carries the HMAC in (case-insensitive on the wire). */
4
+ export declare const HMAC_SIGNATURE_HEADER = "x-hmac-signature";
5
+ /**
6
+ * Compute Veriff's signature for a payload: lowercase-hex HMAC-SHA256(sharedSecret, payload).
7
+ *
8
+ * `payload` is the raw request/webhook body for anything with a body, and the bare path parameter
9
+ * (session id, attempt id, media id) for a GET. Callers pass the exact bytes — this function never
10
+ * re-serializes, because a re-serialized body signs different bytes than the ones sent.
11
+ */
12
+ export declare function veriffSignature(payload: string, sharedSecret: string): string;
13
+ /**
14
+ * The same signature over RAW BYTES rather than a string.
15
+ *
16
+ * Needed because one response is not JSON: `GET /v1/media/{id}` answers with the media itself under
17
+ * its own mimetype, and the documented `X-HMAC-SIGNATURE` header is "Response body signed with the
18
+ * shared secret key" — the body a receiver actually got. Signing the base64 TEXT the twin happens to
19
+ * store it as produces a digest that verifies against nothing the consumer holds; a §9 round-two
20
+ * review measured exactly that mismatch before this existed.
21
+ */
22
+ export declare function veriffSignatureBytes(payload: Uint8Array, sharedSecret: string): string;
23
+ /**
24
+ * Constant-time compare of two signature strings.
25
+ *
26
+ * Length is compared first and NOT with `timingSafeEqual` — the node primitive THROWS on a length
27
+ * mismatch rather than returning false, so an unequal-length forgery would crash the handler
28
+ * instead of being rejected. The real consumer (dub) guards the same way.
29
+ */
30
+ export declare function signaturesMatch(a: string, b: string): boolean;
31
+ /**
32
+ * Verify a presented signature against the payload. Returns the reason it failed, or `null` when
33
+ * it is valid — so a caller can map a MISSING header and a WRONG one to the different statuses
34
+ * Veriff uses for them.
35
+ */
36
+ export declare function verifyVeriffSignature(payload: string, presented: string | undefined | null, sharedSecret: string): 'missing' | 'mismatch' | null;
@@ -0,0 +1,55 @@
1
+ import { nodeBuiltin } from '@volter/world-core';
2
+ function nodeCrypto() {
3
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
4
+ return nodeBuiltin('node:crypto');
5
+ }
6
+ /** The header Veriff carries the public API key in (case-insensitive on the wire). */
7
+ export const AUTH_CLIENT_HEADER = 'x-auth-client';
8
+ /** The header Veriff carries the HMAC in (case-insensitive on the wire). */
9
+ export const HMAC_SIGNATURE_HEADER = 'x-hmac-signature';
10
+ /**
11
+ * Compute Veriff's signature for a payload: lowercase-hex HMAC-SHA256(sharedSecret, payload).
12
+ *
13
+ * `payload` is the raw request/webhook body for anything with a body, and the bare path parameter
14
+ * (session id, attempt id, media id) for a GET. Callers pass the exact bytes — this function never
15
+ * re-serializes, because a re-serialized body signs different bytes than the ones sent.
16
+ */
17
+ export function veriffSignature(payload, sharedSecret) {
18
+ return nodeCrypto().createHmac('sha256', sharedSecret).update(payload, 'utf8').digest('hex');
19
+ }
20
+ /**
21
+ * The same signature over RAW BYTES rather than a string.
22
+ *
23
+ * Needed because one response is not JSON: `GET /v1/media/{id}` answers with the media itself under
24
+ * its own mimetype, and the documented `X-HMAC-SIGNATURE` header is "Response body signed with the
25
+ * shared secret key" — the body a receiver actually got. Signing the base64 TEXT the twin happens to
26
+ * store it as produces a digest that verifies against nothing the consumer holds; a §9 round-two
27
+ * review measured exactly that mismatch before this existed.
28
+ */
29
+ export function veriffSignatureBytes(payload, sharedSecret) {
30
+ return nodeCrypto().createHmac('sha256', sharedSecret).update(payload).digest('hex');
31
+ }
32
+ /**
33
+ * Constant-time compare of two signature strings.
34
+ *
35
+ * Length is compared first and NOT with `timingSafeEqual` — the node primitive THROWS on a length
36
+ * mismatch rather than returning false, so an unequal-length forgery would crash the handler
37
+ * instead of being rejected. The real consumer (dub) guards the same way.
38
+ */
39
+ export function signaturesMatch(a, b) {
40
+ const ab = Buffer.from(a, 'utf8');
41
+ const bb = Buffer.from(b, 'utf8');
42
+ if (ab.length !== bb.length)
43
+ return false;
44
+ return nodeCrypto().timingSafeEqual(Uint8Array.from(ab), Uint8Array.from(bb));
45
+ }
46
+ /**
47
+ * Verify a presented signature against the payload. Returns the reason it failed, or `null` when
48
+ * it is valid — so a caller can map a MISSING header and a WRONG one to the different statuses
49
+ * Veriff uses for them.
50
+ */
51
+ export function verifyVeriffSignature(payload, presented, sharedSecret) {
52
+ if (presented === undefined || presented === null || presented === '')
53
+ return 'missing';
54
+ return signaturesMatch(veriffSignature(payload, sharedSecret), presented) ? null : 'mismatch';
55
+ }
@@ -0,0 +1,104 @@
1
+ export type VeriffRequest = {
2
+ method: string;
3
+ path: string;
4
+ body?: string;
5
+ headers?: Record<string, string | undefined>;
6
+ occurredAt?: string;
7
+ root?: string;
8
+ readOnly?: boolean;
9
+ /** The shared secret this twin verifies `X-HMAC-SIGNATURE` against and signs webhooks with. */
10
+ sharedSecret?: string;
11
+ /** The API key this twin stamps into `x-auth-client` on outbound webhook deliveries. */
12
+ apiKey?: string;
13
+ };
14
+ export type VeriffResponse = {
15
+ status: number;
16
+ body: unknown;
17
+ };
18
+ /**
19
+ * The default local credentials. A twin fakes auth, so these are FIXTURES, not secrets: the point
20
+ * of pinning them is that a consumer's `.env` can point `VERIFF_API_KEY` / `VERIFF_SHARED_SECRET`
21
+ * at the twin and the HMAC round-trip works end to end with no per-run coordination. Shaped like
22
+ * the real thing — Veriff's API key and shared secret are both UUIDs.
23
+ */
24
+ export declare const TWIN_API_KEY = "11111111-2222-4333-8444-555555555555";
25
+ export declare const TWIN_SHARED_SECRET = "abcdef12-abcd-abcd-abcd-abcdef012345";
26
+ /**
27
+ * The troubleshooting/credential codes this twin actually emits, each with the verbatim message
28
+ * from the endpoint's own published example. `1101` is heavily overloaded by the vendor — it is the
29
+ * generic code across 400 (validation), 401 (missing key), 404 (not found) and 500.
30
+ */
31
+ /**
32
+ * The verbatim message the vendor's troubleshooting-codes table pairs with each code this twin
33
+ * emits. Kept as data next to the codes so the two can never drift apart, and so a consumer that
34
+ * surfaces `message` to a support engineer sees the string Veriff's own docs use.
35
+ */
36
+ export declare const VERIFF_ERROR_MESSAGES: {
37
+ readonly '1302': "Only HTTPS return URLs are allowed.";
38
+ readonly '1401': "Image is not in valid `base64`.";
39
+ readonly '1402': "Image context is not supported.";
40
+ readonly '1500': "`vendorData` field cannot be more than 1000 symbols. We require only non-semantic data to be submitted (UUID-s etc., that can not be resolved or used outside the customer's domain)";
41
+ readonly '1501': "`vendorData` must be a string. We require only non-semantic data to be submitted (UUID-s etc., that can not be resolved or used outside the customer's domain)";
42
+ };
43
+ export declare const VERIFF_ERROR_CODES: {
44
+ readonly generic: "1101";
45
+ readonly invalidHmac: "1812";
46
+ readonly notCompleted: "1305";
47
+ readonly inProgress: "1306";
48
+ readonly badTransition: "1304";
49
+ readonly invalidBase64: "1401";
50
+ readonly unsupportedContext: "1402";
51
+ readonly httpsOnlyCallback: "1302";
52
+ readonly vendorDataLength: "1500";
53
+ readonly vendorDataType: "1501";
54
+ };
55
+ /** Every state a session can be in, across both halves of the lifecycle. */
56
+ export declare const VERIFF_SESSION_STATUSES: readonly ["created", "started", "submitted", "approved", "declined", "resubmission_requested", "review", "expired", "abandoned"];
57
+ export type VeriffSessionStatus = (typeof VERIFF_SESSION_STATUSES)[number];
58
+ /** The terminal decision statuses — the exact set the decision webhook documents, and the exact set
59
+ * the reference consumer's zod enum accepts (dub's `veriffDecisionEventSchema`). */
60
+ export declare const VERIFF_DECISION_STATUSES: readonly ["approved", "declined", "resubmission_requested", "review", "expired", "abandoned"];
61
+ export type VeriffDecisionStatus = (typeof VERIFF_DECISION_STATUSES)[number];
62
+ /**
63
+ * The numeric `code` Veriff carries alongside a decision status.
64
+ *
65
+ * Grounded four ways in the decision endpoint's own published document, which is the endpoint this
66
+ * twin serves: its prose list ("`9104` - Expired", "`9121` - Abandoned"), its
67
+ * `session_abandoned_generic` example (`"code":"9121"` with `"status":"abandoned"`), its
68
+ * `session_expired_generic` example (`"code":"9104"` with `"status":"expired"`), and the schema's
69
+ * `code` enum, which is exactly [9001, 9102, 9103, 9104, 9121].
70
+ *
71
+ * `review` therefore gets **null**, not a borrowed code. It is a real status — the decision-webhook
72
+ * page lists it among `approved|declined|resubmission_requested|review|expired|abandoned`, and it is
73
+ * opt-in ("Only if previously agreed with Veriff") — but it appears in NEITHER the decision schema's
74
+ * `status` enum NOR its `code` enum, so no published number belongs to it. Handing it another
75
+ * status's code would be invention, and a consumer branching on `code` would silently treat a
76
+ * manual-review case as abandoned. `veriff.decisions.review_code` is the filed todo.
77
+ *
78
+ * (An earlier revision of this pack mapped `abandoned: 9104` / `review: 9121` on the strength of a
79
+ * status-codes table it had not actually read — a §9 review caught it. Recorded because the lesson
80
+ * generalises: prefer the document you can quote over the one you were told about.)
81
+ *
82
+ * Emitted as a NUMBER, matching the decision-webhook sample (`"code": 9001`) and the schema's
83
+ * declared `"type": "integer"`.
84
+ */
85
+ export declare const VERIFF_DECISION_CODES: Record<VeriffDecisionStatus, number | null>;
86
+ /** The event-webhook `code` per action. 7001/7002 are the two the event-webhook page documents as
87
+ * needing no extra configuration; the 7007-7011 web-flow actions are a filed todo. */
88
+ export declare const VERIFF_EVENT_CODES: Record<'started' | 'submitted', number>;
89
+ /**
90
+ * The subject types this twin projects.
91
+ *
92
+ * `delivery` is the twin's own outbound-webhook log: every decision/event webhook the twin would
93
+ * have PUSHED is recorded here, signed exactly as it would go on the wire, so a consumer can read
94
+ * one back and verify it offline instead of standing up a receiver.
95
+ */
96
+ export declare const VERIFF_RESOURCE_TYPES: readonly ["session", "attempt", "media", "delivery"];
97
+ /**
98
+ * Every endpoint this twin DISPATCHES, as a `METHOD /path` template. The conformance check drives
99
+ * one real request per entry and asserts the OUTCOME a live handler produces, so deleting a route —
100
+ * or a single method branch — from the switchboard below turns it red. Its census is a two-way
101
+ * bijection with this list, so adding a route here without implementing it is red too.
102
+ */
103
+ export declare const VERIFF_IMPLEMENTED_ENDPOINTS: readonly ["POST /v1/sessions", "PATCH /v1/sessions/{sessionId}", "DELETE /v1/sessions/{sessionId}", "GET /v1/sessions/{sessionId}/decision", "GET /v1/sessions/{sessionId}/person", "GET /v1/sessions/{sessionId}/attempts", "GET /v1/sessions/{sessionId}/media", "POST /v1/sessions/{sessionId}/media", "GET /v1/attempts/{attemptId}/media", "GET /v1/media/{mediaId}"];
104
+ export declare function handleVeriffTwinRequest(req: VeriffRequest): Promise<VeriffResponse>;