@ism/verify-sdk 0.0.0-stage → 1.1.118

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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +197 -2
  3. package/index.d.ts +117 -0
  4. package/index.js +285 -0
  5. package/package.json +32 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ISM
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,198 @@
1
- # Temporary Holding Version
1
+ # @ism/verify-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Verify with ISM** for your backend. Ask a person to prove something about themselves — that
4
+ they are over 18, that they hold a given cédula or passport, which country issued their document —
5
+ and receive only the answer, read from the document's own chip by the ID Protekt app and recorded
6
+ with a consent receipt.
7
+
8
+ Your backend makes two calls. The person does the rest on their phone.
9
+
10
+ ```
11
+ your backend ── start() ──▶ api.ism.ec ──▶ verifyUrl ──▶ the person, in ID Protekt
12
+ ▲ │
13
+ └──── exchange(code) / fetchResult(req) ◀── redirect or webhook ◀┘
14
+ ```
15
+
16
+ - Node.js 18 or later. No dependencies. CommonJS and ES modules. Types included.
17
+ - Your client secret stays on your server. It is sent only in the `Authorization` header of the
18
+ two calls, and never logged.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ npm install @ism/verify-sdk
24
+ ```
25
+
26
+ ## What you need from the console
27
+
28
+ | | |
29
+ |---|---|
30
+ | `clientId` | `cl_…` |
31
+ | `clientSecret` | `sk_…` — shown once, when the client is created. Keep it in your secret store |
32
+ | A bundle | `bn_…` — what you ask for, at which assurance level, and the purpose the person reads |
33
+ | A redirect URI | where the person returns after the app. It must match one registered on your client exactly. Every new client starts with `https://id.ism.ec/listo`, a page that tells the person they are done, so you can start before your own page exists |
34
+ | A webhook (recommended) | so you learn the outcome even when the person does not come back |
35
+
36
+ Sandbox clients behave exactly like production ones, with the same API and the same app.
37
+
38
+ ## 1 · Start a verification
39
+
40
+ ```js
41
+ import { randomUUID } from 'node:crypto';
42
+ import { Verify } from '@ism/verify-sdk';
43
+
44
+ const ism = new Verify({
45
+ clientId: process.env.ISM_VERIFY_CLIENT_ID,
46
+ clientSecret: process.env.ISM_VERIFY_CLIENT_SECRET,
47
+ });
48
+
49
+ const state = randomUUID(); // store it with your own record
50
+ const { req, verifyUrl, expiresAt } = await ism.start({
51
+ bundle: 'bn_…',
52
+ redirectUri: 'https://your-site.example/verify/return',
53
+ state,
54
+ });
55
+ // Send the person to verifyUrl: a redirect, a link in a message, or a QR code on a desktop page.
56
+ ```
57
+
58
+ Instead of a bundle you can compose the request in code, within what your client is entitled to:
59
+
60
+ ```js
61
+ await ism.start({
62
+ claims: ['age_over_18'],
63
+ level: 'L1',
64
+ purpose: 'Confirm you are of legal age',
65
+ redirectUri: 'https://your-site.example/verify/return',
66
+ state,
67
+ });
68
+ ```
69
+
70
+ To confirm that the person holds a specific document, ask for `subject_is` with the document's
71
+ qualified reference — `EC-ID-<cédula>` for an Ecuadorian cédula, `<country>-P-<number>` for a
72
+ passport. It releases one boolean and nothing else:
73
+
74
+ ```js
75
+ await ism.start({ claims: ['subject_is:EC-ID-0000000000'], level: 'L4', purpose: '…', redirectUri, state });
76
+ ```
77
+
78
+ A link is valid for a few minutes before the person first opens it; `expiresAt` says until when.
79
+
80
+ ### Retrying safely
81
+
82
+ Each `start()` call starts a new verification. To make a retry safe — after a timeout, or from a
83
+ job runner that may run twice — pass an `idempotencyKey`, such as your own record's id:
84
+
85
+ ```js
86
+ const { req, verifyUrl, replayed } = await ism.start({
87
+ bundle: 'bn_…',
88
+ redirectUri,
89
+ state,
90
+ idempotencyKey: `onboarding-${application.id}`,
91
+ });
92
+ ```
93
+
94
+ The same key with the same request, while its link is still valid, returns the verification the
95
+ first call started (`replayed: true`) instead of a second one. Once the link has expired the key
96
+ starts a new one. Reusing a key for a *different* request fails with `idempotency_conflict`. Keys
97
+ are scoped to your client and stored only as a hash.
98
+
99
+ ## 2 · Collect the result
100
+
101
+ **When the person comes back** to your `redirectUri`:
102
+
103
+ ```js
104
+ app.get('/verify/return', async (request, response) => {
105
+ const back = await ism.handleReturn(new URL(request.url, 'https://your-site.example'), {
106
+ expectedState: storedState, // the state you passed to start()
107
+ });
108
+ if (!back.ok) {
109
+ // back.outcome: 'denied' (the person declined), 'failed' or 'abandoned'
110
+ return response.send('The verification was not completed.');
111
+ }
112
+ const { claims, consentReceiptId } = back.result;
113
+ // claims → { age_over_18: true }
114
+ });
115
+ ```
116
+
117
+ **When the person finishes in the app and does not come back**, your webhook receives
118
+ `verification.completed`; fetch the result by the `req` from `start()`:
119
+
120
+ ```js
121
+ import { verifyWebhook } from '@ism/verify-sdk';
122
+
123
+ app.post('/verify/webhook', express.raw({ type: 'application/json' }), async (request, response) => {
124
+ const event = verifyWebhook({
125
+ rawBody: request.body, // the raw bytes, never a re-serialised object
126
+ signature: request.get('ISM-Signature'),
127
+ secret: process.env.ISM_VERIFY_WEBHOOK_SECRET, // whsec_…, not the client secret
128
+ });
129
+ response.sendStatus(200); // answer within 10 seconds, then do the work
130
+ if (event.type === 'verification.completed') {
131
+ const result = await ism.fetchResult(event.data.req);
132
+ }
133
+ });
134
+ ```
135
+
136
+ Webhooks carry no personal data — only the outcome, your `state` and the `req`. De-duplicate on
137
+ `event.id`: retries and replays keep it.
138
+
139
+ ### The result
140
+
141
+ ```js
142
+ {
143
+ req: 'Xk2…', // the reference start() returned
144
+ claims: { age_over_18: true }, // only what you asked for
145
+ assurance: 'L1',
146
+ verifiedBy: 'device', // or 'server'
147
+ deviceAttestation: 'verified', // a genuine ID Protekt app on genuine hardware
148
+ verifiedAt: '2026-10-29T15:04:05.000Z',
149
+ consentReceiptId: 'rc_…', // the person's consent, on record
150
+ state: '…', // yours, echoed back
151
+ }
152
+ ```
153
+
154
+ 🔴 **A result is released once.** Whichever of `exchange(code)`, `handleReturn()` and
155
+ `fetchResult(req)` asks first receives it; every later request is refused. It is kept for 24 hours
156
+ after the person finishes. Store what you need from it when it arrives.
157
+
158
+ ## Errors
159
+
160
+ Every failed call throws a `VerifyError` with `status` (HTTP, or 0 when nothing came back) and
161
+ `code`:
162
+
163
+ | `code` | Meaning |
164
+ |---|---|
165
+ | `invalid_client` | the client id or secret was refused |
166
+ | `not_entitled` | the request asks for more than your client may — a claim, a level, a redirect URI. The message says which |
167
+ | `invalid_request` | a malformed request; on `/token`, also a code or reference that is unknown, expired or already released — deliberately indistinguishable |
168
+ | `idempotency_conflict` | the `idempotencyKey` was already used for a different request |
169
+ | `client_suspended` | the client is not active |
170
+ | `rate_limited` | slow down |
171
+ | `timeout`, `network_error` | no answer arrived. **Do not retry `exchange` or `fetchResult` blindly**: the result may already have been released to the lost response |
172
+ | `invalid_signature` | `verifyWebhook` refused the body |
173
+ | `state_mismatch` | `handleReturn` was given a URL whose `state` is not yours |
174
+
175
+ No call is retried by the SDK; `idempotencyKey` is what makes your own retry of `start()` safe.
176
+
177
+ ## Reference
178
+
179
+ | | |
180
+ |---|---|
181
+ | `new Verify({ clientId, clientSecret, baseUrl?, timeoutMs?, fetch? })` | `baseUrl` defaults to `https://api.ism.ec/verify/v1`; `timeoutMs` to 10 000 |
182
+ | `start({ bundle \| claims, level, purpose, retention?, claimLabels?, redirectUri, state?, network?, endUserIp?, idempotencyKey? })` | → `{ req, verifyUrl, expiresAt, replayed }` |
183
+ | `exchange(code)` | → result |
184
+ | `fetchResult(req)` | → result |
185
+ | `handleReturn(url, { expectedState? })` | → `{ ok: true, result }` or `{ ok: false, error, outcome, state }` |
186
+ | `Verify.parseReturn(url)` | reads a return URL without calling anything |
187
+ | `verifyWebhook({ rawBody, signature, secret, toleranceSeconds?, now? })` | → the event, or throws |
188
+
189
+ `network` and `endUserIp` apply to clients with network signals: pass the IP your backend saw the
190
+ person's browser connect from, and the result reports where each step of the verification
191
+ connected from.
192
+
193
+ The API underneath is plain HTTPS and JSON — `POST /par` and `POST /token`, authenticated with
194
+ HTTP Basic — so any language can integrate without this package.
195
+
196
+ ## License
197
+
198
+ MIT
package/index.d.ts ADDED
@@ -0,0 +1,117 @@
1
+ export declare const DEFAULT_BASE_URL: string;
2
+
3
+ export type AssuranceLevel = 'L1' | 'L2' | 'L3' | 'L4';
4
+ export type Outcome = 'completed' | 'denied' | 'failed' | 'abandoned';
5
+
6
+ export declare class VerifyError extends Error {
7
+ /** HTTP status, or 0 when no response arrived. */
8
+ readonly status: number;
9
+ /** invalid_client, invalid_request, not_entitled, idempotency_conflict, client_suspended, rate_limited, timeout, network_error, invalid_signature, state_mismatch, … */
10
+ readonly code: string;
11
+ }
12
+
13
+ export interface VerifyOptions {
14
+ clientId: string;
15
+ clientSecret: string;
16
+ /** Defaults to https://api.ism.ec/verify/v1 */
17
+ baseUrl?: string;
18
+ /** Per call. Default 10 000. */
19
+ timeoutMs?: number;
20
+ fetch?: typeof fetch;
21
+ }
22
+
23
+ interface StartCommon {
24
+ /** Exactly one of the URLs registered on your client. */
25
+ redirectUri: string;
26
+ /** Your own value, returned on the redirect, on the result and on webhooks. */
27
+ state?: string;
28
+ /** Network signals, for a client with that add-on. */
29
+ network?: boolean;
30
+ /** The person's IP as your backend saw it, for network signals. */
31
+ endUserIp?: string;
32
+ /**
33
+ * Makes a retried start safe: the same key with the same request, while its link is still
34
+ * valid, returns the first verification instead of starting a second. Use your own record's
35
+ * id. 8–255 printable ASCII characters, no spaces.
36
+ */
37
+ idempotencyKey?: string;
38
+ /** Where the document evidence comes from. Default: the document's chip. */
39
+ evidence?: { source: 'chip' };
40
+ }
41
+
42
+ export interface StartWithBundle extends StartCommon {
43
+ /** A bundle id (`bn_…`) configured in the console. */
44
+ bundle: string;
45
+ }
46
+
47
+ export interface StartComposed extends StartCommon {
48
+ /** e.g. ['age_over_18'], ['subject_is:EC-ID-0000000000'] */
49
+ claims: string[];
50
+ level: AssuranceLevel;
51
+ /** Shown to the person and recorded on the consent receipt. */
52
+ purpose: string;
53
+ retention?: string;
54
+ claimLabels?: Record<string, string>;
55
+ }
56
+
57
+ export interface Started {
58
+ /** The verification's reference. Keep it with your record. */
59
+ req: string;
60
+ /** Send the person here. */
61
+ verifyUrl: string;
62
+ expiresAt: string;
63
+ /** true when an idempotencyKey matched an earlier start and this is that verification. */
64
+ replayed: boolean;
65
+ }
66
+
67
+ export interface VerificationResult {
68
+ req: string;
69
+ /** Only the claims you asked for: booleans for predicates, values otherwise. */
70
+ claims: Record<string, unknown>;
71
+ assurance: AssuranceLevel;
72
+ /** 'device' or 'server' — who computed the claims. */
73
+ verifiedBy: string;
74
+ /** 'verified' when a genuine ISM app on genuine hardware reported the result. */
75
+ deviceAttestation: string;
76
+ verifiedAt: string;
77
+ consentReceiptId: string;
78
+ state: string | null;
79
+ /** Present only with the network-signals add-on. */
80
+ network?: Record<string, unknown>;
81
+ }
82
+
83
+ export type ParsedReturn =
84
+ | { code: string; state: string | null; outcome: 'completed' }
85
+ | { code: null; error: string; outcome: Outcome; state: string | null };
86
+
87
+ export type HandledReturn =
88
+ | { ok: true; result: VerificationResult }
89
+ | { ok: false; error: string; outcome: Outcome; state: string | null };
90
+
91
+ export declare class Verify {
92
+ constructor(options: VerifyOptions);
93
+ readonly clientId: string;
94
+ start(options: StartWithBundle | StartComposed): Promise<Started>;
95
+ exchange(code: string): Promise<VerificationResult>;
96
+ fetchResult(req: string): Promise<VerificationResult>;
97
+ handleReturn(url: string | URL, options?: { expectedState?: string }): Promise<HandledReturn>;
98
+ static parseReturn(url: string | URL): ParsedReturn;
99
+ }
100
+
101
+ export interface WebhookEvent {
102
+ id: string;
103
+ type: 'verification.completed' | 'verification.denied' | 'verification.failed' | 'verification.abandoned';
104
+ created: string;
105
+ data: { client_id: string; environment: string; req: string; state: string | null; outcome: Outcome };
106
+ }
107
+
108
+ export declare function verifyWebhook(input: {
109
+ rawBody: string | Buffer;
110
+ /** The ISM-Signature header. */
111
+ signature: string;
112
+ /** The webhook's whsec_… secret. */
113
+ secret: string;
114
+ toleranceSeconds?: number;
115
+ /** Milliseconds. */
116
+ now?: number;
117
+ }): WebhookEvent;
package/index.js ADDED
@@ -0,0 +1,285 @@
1
+ 'use strict';
2
+ // @ism/verify-sdk — the relying-party side of Verify with ISM, for a backend.
3
+ //
4
+ // Two HTTP calls and a signature check: `/par` to start a verification, `/token` to collect the
5
+ // result, and the HMAC on a webhook. Zero dependencies, Node 18+ (global fetch).
6
+ //
7
+ // Rules this file holds to:
8
+ // - The client secret travels only in the Authorization header of `/par` and `/token`, never in
9
+ // a body, a URL, an error or a log line. It lives in a private field, so printing the client
10
+ // does not print it.
11
+ // - No call is retried by the SDK. `/par` mints a new verification on every call unless the
12
+ // caller passes `idempotencyKey`, which makes the caller's own retry safe; `/token` releases a
13
+ // result exactly once, so a retry after a lost response would find it already released.
14
+ // - This file logs nothing. Claims are personal data, and logging is the caller's decision.
15
+
16
+ const crypto = require('node:crypto');
17
+
18
+ const DEFAULT_BASE_URL = 'https://api.ism.ec/verify/v1';
19
+ const DEFAULT_TIMEOUT_MS = 10_000;
20
+ const LEVEL = /^L[0-9]$/;
21
+ const IDEMPOTENCY_KEY = /^[\x21-\x7e]{8,255}$/;
22
+ const EVIDENCE_SOURCES = ['chip'];
23
+
24
+ class VerifyError extends Error {
25
+ /**
26
+ * @param {string} message
27
+ * @param {{ status?: number, code?: string }} [details]
28
+ */
29
+ constructor(message, { status = 0, code = 'network_error' } = {}) {
30
+ super(message);
31
+ this.name = 'VerifyError';
32
+ /** HTTP status, or 0 when no response arrived. */
33
+ this.status = status;
34
+ /** The API's error code: invalid_client, invalid_request, not_entitled, … */
35
+ this.code = code;
36
+ }
37
+ }
38
+
39
+ const requireString = (value, name) => {
40
+ if (typeof value !== 'string' || value.trim() === '') {
41
+ throw new TypeError(`${name} is required and must be a non-empty string.`);
42
+ }
43
+ return value;
44
+ };
45
+
46
+ const basicAuth = (id, secret) =>
47
+ 'Basic ' + Buffer.from(`${encodeURIComponent(id)}:${encodeURIComponent(secret)}`, 'utf8').toString('base64');
48
+
49
+ /** The API's result, in the SDK's names. Fields the API did not send are left out. */
50
+ const toResult = (body) => {
51
+ const result = {
52
+ req: body.req,
53
+ claims: body.claims || {},
54
+ assurance: body.assurance,
55
+ verifiedBy: body.verified_by,
56
+ deviceAttestation: body.device_attestation,
57
+ verifiedAt: body.verified_at,
58
+ consentReceiptId: body.consent_receipt_id,
59
+ state: body.state ?? null
60
+ };
61
+ if (body.network) result.network = body.network;
62
+ return result;
63
+ };
64
+
65
+ class Verify {
66
+ #clientId;
67
+ #clientSecret;
68
+ #baseUrl;
69
+ #timeoutMs;
70
+ #fetch;
71
+
72
+ /**
73
+ * @param {object} options
74
+ * @param {string} options.clientId `cl_…`, from the console
75
+ * @param {string} options.clientSecret `sk_…`, shown once when the client was created
76
+ * @param {string} [options.baseUrl] defaults to https://api.ism.ec/verify/v1
77
+ * @param {number} [options.timeoutMs] per call, default 10 000
78
+ * @param {typeof fetch} [options.fetch] an alternative fetch implementation
79
+ */
80
+ constructor({ clientId, clientSecret, baseUrl = DEFAULT_BASE_URL, timeoutMs = DEFAULT_TIMEOUT_MS, fetch } = {}) {
81
+ this.#clientId = requireString(clientId, 'clientId');
82
+ this.#clientSecret = requireString(clientSecret, 'clientSecret');
83
+ const url = new URL(requireString(baseUrl, 'baseUrl'));
84
+ if (url.protocol !== 'https:') throw new TypeError('baseUrl must be https.');
85
+ this.#baseUrl = url.toString().replace(/\/+$/, '');
86
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new TypeError('timeoutMs must be a positive number.');
87
+ this.#timeoutMs = timeoutMs;
88
+ this.#fetch = fetch || globalThis.fetch;
89
+ if (typeof this.#fetch !== 'function') throw new TypeError('No fetch available: use Node 18 or later, or pass options.fetch.');
90
+ }
91
+
92
+ get clientId() { return this.#clientId; }
93
+
94
+ /** What `console.log(client)` and `JSON.stringify(client)` show: never the secret. */
95
+ toJSON() { return { clientId: this.#clientId, baseUrl: this.#baseUrl }; }
96
+ [Symbol.for('nodejs.util.inspect.custom')]() { return `Verify { clientId: '${this.#clientId}', baseUrl: '${this.#baseUrl}' }`; }
97
+
98
+ async #post(path, body, { withHeaders = false } = {}) {
99
+ let response;
100
+ try {
101
+ response = await this.#fetch(`${this.#baseUrl}${path}`, {
102
+ method: 'POST',
103
+ headers: {
104
+ 'Content-Type': 'application/json',
105
+ Accept: 'application/json',
106
+ Authorization: basicAuth(this.#clientId, this.#clientSecret)
107
+ },
108
+ body: JSON.stringify(body),
109
+ redirect: 'error',
110
+ signal: AbortSignal.timeout(this.#timeoutMs)
111
+ });
112
+ } catch (err) {
113
+ const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');
114
+ throw new VerifyError(timedOut ? `${path} timed out after ${this.#timeoutMs} ms.` : `${path} could not be reached: ${err && err.message}`,
115
+ { status: 0, code: timedOut ? 'timeout' : 'network_error' });
116
+ }
117
+ const text = await response.text();
118
+ let parsed = null;
119
+ try { parsed = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
120
+ if (!response.ok) {
121
+ throw new VerifyError(
122
+ (parsed && parsed.message) || `${path} answered HTTP ${response.status}.`,
123
+ { status: response.status, code: (parsed && parsed.error) || 'http_error' }
124
+ );
125
+ }
126
+ if (!parsed || typeof parsed !== 'object') {
127
+ throw new VerifyError(`${path} answered with a body that is not JSON.`, { status: response.status, code: 'invalid_response' });
128
+ }
129
+ return withHeaders ? { body: parsed, headers: response.headers } : parsed;
130
+ }
131
+
132
+ /**
133
+ * Starts a verification. Send the person to `verifyUrl`; keep `req` beside your own record.
134
+ *
135
+ * Name a bundle configured in the console (`bundle`), or compose the request here (`claims`,
136
+ * `level`, `purpose`). Each call starts a new verification — unless it carries an
137
+ * `idempotencyKey` already used for the same request while its link is still valid, which
138
+ * answers with that first verification instead (`replayed: true`).
139
+ *
140
+ * @returns {Promise<{ req: string, verifyUrl: string, expiresAt: string, replayed: boolean }>}
141
+ */
142
+ async start(options = {}) {
143
+ const {
144
+ bundle, claims, level, purpose, retention, claimLabels,
145
+ redirectUri, state, network, endUserIp, evidence, idempotencyKey
146
+ } = options;
147
+
148
+ const source = evidence === undefined ? 'chip' : evidence && evidence.source;
149
+ if (!EVIDENCE_SOURCES.includes(source)) {
150
+ throw new TypeError(`evidence.source must be one of: ${EVIDENCE_SOURCES.join(', ')}.`);
151
+ }
152
+ requireString(redirectUri, 'redirectUri');
153
+ if (state !== undefined && state !== null) requireString(state, 'state');
154
+
155
+ const body = { redirect_uri: redirectUri };
156
+ if (bundle !== undefined) {
157
+ requireString(bundle, 'bundle');
158
+ if (claims !== undefined || level !== undefined || purpose !== undefined) {
159
+ throw new TypeError('Pass either bundle, or claims + level + purpose — not both.');
160
+ }
161
+ body.plan_id = bundle;
162
+ } else {
163
+ if (!Array.isArray(claims) || claims.length === 0 || !claims.every((c) => typeof c === 'string' && c)) {
164
+ throw new TypeError('claims must be a non-empty array of claim names, or pass bundle.');
165
+ }
166
+ if (typeof level !== 'string' || !LEVEL.test(level)) throw new TypeError('level must be an assurance level such as L1 or L4.');
167
+ requireString(purpose, 'purpose');
168
+ body.claims = claims;
169
+ body.level = level;
170
+ body.purpose = purpose;
171
+ if (retention !== undefined) body.retention = requireString(retention, 'retention');
172
+ if (claimLabels !== undefined) body.claim_labels = claimLabels;
173
+ }
174
+ if (state !== undefined && state !== null) body.state = state;
175
+ if (network !== undefined) {
176
+ if (typeof network !== 'boolean') throw new TypeError('network must be true or false.');
177
+ body.network = network;
178
+ }
179
+ if (endUserIp !== undefined && endUserIp !== null) body.end_user_ip = requireString(endUserIp, 'endUserIp');
180
+ if (idempotencyKey !== undefined && idempotencyKey !== null) {
181
+ if (typeof idempotencyKey !== 'string' || !IDEMPOTENCY_KEY.test(idempotencyKey)) {
182
+ throw new TypeError('idempotencyKey must be 8–255 printable ASCII characters, with no spaces.');
183
+ }
184
+ body.idempotency_key = idempotencyKey;
185
+ }
186
+
187
+ const { body: answer, headers } = await this.#post('/par', body, { withHeaders: true });
188
+ if (typeof answer.req !== 'string' || typeof answer.verify_url !== 'string') {
189
+ throw new VerifyError('/par answered without req and verify_url.', { status: 200, code: 'invalid_response' });
190
+ }
191
+ return {
192
+ req: answer.req,
193
+ verifyUrl: answer.verify_url,
194
+ expiresAt: answer.expires_at,
195
+ replayed: Boolean(headers && typeof headers.get === 'function' && headers.get('idempotent-replayed') === 'true')
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Exchanges the `code` from your redirect for the result. A result is released once: a
201
+ * second exchange, or one after `fetchResult`, is refused.
202
+ */
203
+ async exchange(code) {
204
+ requireString(code, 'code');
205
+ return toResult(await this.#post('/token', { grant_type: 'authorization_code', code }));
206
+ }
207
+
208
+ /**
209
+ * Collects the result by the `req` that `start` returned — for a person who finished in the
210
+ * app and never came back, typically after a `verification.completed` webhook. Kept 24 hours
211
+ * after completion and released once, whichever of this and `exchange` asks first.
212
+ */
213
+ async fetchResult(req) {
214
+ requireString(req, 'req');
215
+ return toResult(await this.#post('/token', { grant_type: 'request', req }));
216
+ }
217
+
218
+ /**
219
+ * Handles the request that arrived at your `redirectUri`: checks `state` against the one you
220
+ * stored, then exchanges the code. A person who declined, or whose verification did not
221
+ * succeed, arrives with an error instead of a code; that is returned, not thrown.
222
+ *
223
+ * @param {string|URL} url the full URL the browser opened
224
+ * @param {{ expectedState?: string }} [options]
225
+ * @returns {Promise<{ ok: true, result: object } | { ok: false, error: string, outcome: string, state: string|null }>}
226
+ */
227
+ async handleReturn(url, { expectedState } = {}) {
228
+ const back = Verify.parseReturn(url);
229
+ if (expectedState !== undefined && back.state !== expectedState) {
230
+ throw new VerifyError('state does not match the one this verification was started with.', { code: 'state_mismatch' });
231
+ }
232
+ if (!back.code) return { ok: false, error: back.error, outcome: back.outcome, state: back.state };
233
+ return { ok: true, result: await this.exchange(back.code) };
234
+ }
235
+
236
+ /**
237
+ * Reads a return URL without calling anything.
238
+ * `outcome` is `completed`, `denied`, `failed` or `abandoned`.
239
+ */
240
+ static parseReturn(url) {
241
+ const params = (url instanceof URL ? url : new URL(String(url))).searchParams;
242
+ const state = params.get('state');
243
+ const code = params.get('code');
244
+ if (code) return { code, state, outcome: 'completed' };
245
+ const error = params.get('error');
246
+ if (!error) throw new VerifyError('The return URL carries neither code nor error.', { code: 'invalid_return' });
247
+ return { code: null, error, outcome: params.get('ism_outcome') || 'denied', state };
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Checks a webhook's `ISM-Signature` header and returns the parsed event. Throws a
253
+ * `VerifyError` (code `invalid_signature`) when the signature, or its timestamp, does not hold.
254
+ *
255
+ * Pass the raw body exactly as it arrived — a re-serialised parse will not verify.
256
+ *
257
+ * @param {{ rawBody: string|Buffer, signature: string, secret: string, toleranceSeconds?: number, now?: number }} input
258
+ * `secret` is the webhook's `whsec_…`, not the client secret. `now` is milliseconds.
259
+ */
260
+ const verifyWebhook = ({ rawBody, signature, secret, toleranceSeconds = 300, now = Date.now() } = {}) => {
261
+ requireString(secret, 'secret');
262
+ if (typeof rawBody !== 'string' && !Buffer.isBuffer(rawBody)) {
263
+ throw new TypeError('rawBody must be the raw request body, as a string or Buffer.');
264
+ }
265
+ const body = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody;
266
+ const parts = String(signature || '').split(',').map((p) => p.trim().split('='));
267
+ const t = Number(parts.find(([k]) => k === 't')?.[1]);
268
+ const v1 = parts.filter(([k]) => k === 'v1').map(([, v]) => v || '');
269
+ const refuse = (why) => new VerifyError(why, { code: 'invalid_signature' });
270
+ if (!Number.isFinite(t) || v1.length === 0) throw refuse('Malformed ISM-Signature header.');
271
+ if (Math.abs(Math.floor(now / 1000) - t) > toleranceSeconds) throw refuse('Webhook timestamp is outside the tolerance.');
272
+ const expected = crypto.createHmac('sha256', secret).update(`${t}.${body}`, 'utf8').digest();
273
+ const matches = v1.some((v) => {
274
+ const got = Buffer.from(v, 'hex');
275
+ return got.length === expected.length && crypto.timingSafeEqual(got, expected);
276
+ });
277
+ if (!matches) throw refuse('Webhook signature does not match.');
278
+ try {
279
+ return JSON.parse(body);
280
+ } catch {
281
+ throw refuse('Webhook body is not JSON.');
282
+ }
283
+ };
284
+
285
+ module.exports = { Verify, VerifyError, verifyWebhook, DEFAULT_BASE_URL };
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@ism/verify-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.118",
4
+ "description": "Verify with ISM for relying-party backends: start a verification, collect the result, check webhook signatures.",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "node --test index.test.js"
8
+ },
9
+ "keywords": [
10
+ "ism",
11
+ "identity",
12
+ "verification",
13
+ "kyc",
14
+ "ecuador"
15
+ ],
16
+ "author": "ISM",
17
+ "license": "MIT",
18
+ "type": "commonjs",
19
+ "types": "index.d.ts",
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "files": [
24
+ "index.js",
25
+ "index.d.ts",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "homepage": "https://id.ism.ec",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "gitHead": "44d2fc4511c4e4fd3a685616fc74ed2bafa0b69f"
34
+ }