@ism/verify-sdk 0.0.0-stage → 1.0.116

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 +178 -2
  3. package/index.d.ts +109 -0
  4. package/index.js +270 -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,179 @@
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 |
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
+ Each `start()` call starts a new verification.
80
+
81
+ ## 2 · Collect the result
82
+
83
+ **When the person comes back** to your `redirectUri`:
84
+
85
+ ```js
86
+ app.get('/verify/return', async (request, response) => {
87
+ const back = await ism.handleReturn(new URL(request.url, 'https://your-site.example'), {
88
+ expectedState: storedState, // the state you passed to start()
89
+ });
90
+ if (!back.ok) {
91
+ // back.outcome: 'denied' (the person declined), 'failed' or 'abandoned'
92
+ return response.send('The verification was not completed.');
93
+ }
94
+ const { claims, consentReceiptId } = back.result;
95
+ // claims → { age_over_18: true }
96
+ });
97
+ ```
98
+
99
+ **When the person finishes in the app and does not come back**, your webhook receives
100
+ `verification.completed`; fetch the result by the `req` from `start()`:
101
+
102
+ ```js
103
+ import { verifyWebhook } from '@ism/verify-sdk';
104
+
105
+ app.post('/verify/webhook', express.raw({ type: 'application/json' }), async (request, response) => {
106
+ const event = verifyWebhook({
107
+ rawBody: request.body, // the raw bytes, never a re-serialised object
108
+ signature: request.get('ISM-Signature'),
109
+ secret: process.env.ISM_VERIFY_WEBHOOK_SECRET, // whsec_…, not the client secret
110
+ });
111
+ response.sendStatus(200); // answer within 10 seconds, then do the work
112
+ if (event.type === 'verification.completed') {
113
+ const result = await ism.fetchResult(event.data.req);
114
+ }
115
+ });
116
+ ```
117
+
118
+ Webhooks carry no personal data — only the outcome, your `state` and the `req`. De-duplicate on
119
+ `event.id`: retries and replays keep it.
120
+
121
+ ### The result
122
+
123
+ ```js
124
+ {
125
+ req: 'Xk2…', // the reference start() returned
126
+ claims: { age_over_18: true }, // only what you asked for
127
+ assurance: 'L1',
128
+ verifiedBy: 'device', // or 'server'
129
+ deviceAttestation: 'verified', // a genuine ID Protekt app on genuine hardware
130
+ verifiedAt: '2026-10-29T15:04:05.000Z',
131
+ consentReceiptId: 'rc_…', // the person's consent, on record
132
+ state: '…', // yours, echoed back
133
+ }
134
+ ```
135
+
136
+ 🔴 **A result is released once.** Whichever of `exchange(code)`, `handleReturn()` and
137
+ `fetchResult(req)` asks first receives it; every later request is refused. It is kept for 24 hours
138
+ after the person finishes. Store what you need from it when it arrives.
139
+
140
+ ## Errors
141
+
142
+ Every failed call throws a `VerifyError` with `status` (HTTP, or 0 when nothing came back) and
143
+ `code`:
144
+
145
+ | `code` | Meaning |
146
+ |---|---|
147
+ | `invalid_client` | the client id or secret was refused |
148
+ | `not_entitled` | the request asks for more than your client may — a claim, a level, a redirect URI. The message says which |
149
+ | `invalid_request` | a malformed request; on `/token`, also a code or reference that is unknown, expired or already released — deliberately indistinguishable |
150
+ | `client_suspended` | the client is not active |
151
+ | `rate_limited` | slow down |
152
+ | `timeout`, `network_error` | no answer arrived. **Do not retry `exchange` or `fetchResult` blindly**: the result may already have been released to the lost response |
153
+ | `invalid_signature` | `verifyWebhook` refused the body |
154
+ | `state_mismatch` | `handleReturn` was given a URL whose `state` is not yours |
155
+
156
+ No call is retried by the SDK.
157
+
158
+ ## Reference
159
+
160
+ | | |
161
+ |---|---|
162
+ | `new Verify({ clientId, clientSecret, baseUrl?, timeoutMs?, fetch? })` | `baseUrl` defaults to `https://api.ism.ec/verify/v1`; `timeoutMs` to 10 000 |
163
+ | `start({ bundle \| claims, level, purpose, retention?, claimLabels?, redirectUri, state?, network?, endUserIp? })` | → `{ req, verifyUrl, expiresAt }` |
164
+ | `exchange(code)` | → result |
165
+ | `fetchResult(req)` | → result |
166
+ | `handleReturn(url, { expectedState? })` | → `{ ok: true, result }` or `{ ok: false, error, outcome, state }` |
167
+ | `Verify.parseReturn(url)` | reads a return URL without calling anything |
168
+ | `verifyWebhook({ rawBody, signature, secret, toleranceSeconds?, now? })` | → the event, or throws |
169
+
170
+ `network` and `endUserIp` apply to clients with network signals: pass the IP your backend saw the
171
+ person's browser connect from, and the result reports where each step of the verification
172
+ connected from.
173
+
174
+ The API underneath is plain HTTPS and JSON — `POST /par` and `POST /token`, authenticated with
175
+ HTTP Basic — so any language can integrate without this package.
176
+
177
+ ## License
178
+
179
+ MIT
package/index.d.ts ADDED
@@ -0,0 +1,109 @@
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, 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
+ /** Where the document evidence comes from. Default: the document's chip. */
33
+ evidence?: { source: 'chip' };
34
+ }
35
+
36
+ export interface StartWithBundle extends StartCommon {
37
+ /** A bundle id (`bn_…`) configured in the console. */
38
+ bundle: string;
39
+ }
40
+
41
+ export interface StartComposed extends StartCommon {
42
+ /** e.g. ['age_over_18'], ['subject_is:EC-ID-0000000000'] */
43
+ claims: string[];
44
+ level: AssuranceLevel;
45
+ /** Shown to the person and recorded on the consent receipt. */
46
+ purpose: string;
47
+ retention?: string;
48
+ claimLabels?: Record<string, string>;
49
+ }
50
+
51
+ export interface Started {
52
+ /** The verification's reference. Keep it with your record. */
53
+ req: string;
54
+ /** Send the person here. */
55
+ verifyUrl: string;
56
+ expiresAt: string;
57
+ }
58
+
59
+ export interface VerificationResult {
60
+ req: string;
61
+ /** Only the claims you asked for: booleans for predicates, values otherwise. */
62
+ claims: Record<string, unknown>;
63
+ assurance: AssuranceLevel;
64
+ /** 'device' or 'server' — who computed the claims. */
65
+ verifiedBy: string;
66
+ /** 'verified' when a genuine ISM app on genuine hardware reported the result. */
67
+ deviceAttestation: string;
68
+ verifiedAt: string;
69
+ consentReceiptId: string;
70
+ state: string | null;
71
+ /** Present only with the network-signals add-on. */
72
+ network?: Record<string, unknown>;
73
+ }
74
+
75
+ export type ParsedReturn =
76
+ | { code: string; state: string | null; outcome: 'completed' }
77
+ | { code: null; error: string; outcome: Outcome; state: string | null };
78
+
79
+ export type HandledReturn =
80
+ | { ok: true; result: VerificationResult }
81
+ | { ok: false; error: string; outcome: Outcome; state: string | null };
82
+
83
+ export declare class Verify {
84
+ constructor(options: VerifyOptions);
85
+ readonly clientId: string;
86
+ start(options: StartWithBundle | StartComposed): Promise<Started>;
87
+ exchange(code: string): Promise<VerificationResult>;
88
+ fetchResult(req: string): Promise<VerificationResult>;
89
+ handleReturn(url: string | URL, options?: { expectedState?: string }): Promise<HandledReturn>;
90
+ static parseReturn(url: string | URL): ParsedReturn;
91
+ }
92
+
93
+ export interface WebhookEvent {
94
+ id: string;
95
+ type: 'verification.completed' | 'verification.denied' | 'verification.failed' | 'verification.abandoned';
96
+ created: string;
97
+ data: { client_id: string; environment: string; req: string; state: string | null; outcome: Outcome };
98
+ }
99
+
100
+ export declare function verifyWebhook(input: {
101
+ rawBody: string | Buffer;
102
+ /** The ISM-Signature header. */
103
+ signature: string;
104
+ /** The webhook's whsec_… secret. */
105
+ secret: string;
106
+ toleranceSeconds?: number;
107
+ /** Milliseconds. */
108
+ now?: number;
109
+ }): WebhookEvent;
package/index.js ADDED
@@ -0,0 +1,270 @@
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. `/par` mints a new verification on every call, and `/token` releases a
12
+ // result exactly once: a retry after a lost response would find it already released.
13
+ // - This file logs nothing. Claims are personal data, and logging is the caller's decision.
14
+
15
+ const crypto = require('node:crypto');
16
+
17
+ const DEFAULT_BASE_URL = 'https://api.ism.ec/verify/v1';
18
+ const DEFAULT_TIMEOUT_MS = 10_000;
19
+ const LEVELS = ['L1', 'L2', 'L3', 'L4'];
20
+ const EVIDENCE_SOURCES = ['chip'];
21
+
22
+ class VerifyError extends Error {
23
+ /**
24
+ * @param {string} message
25
+ * @param {{ status?: number, code?: string }} [details]
26
+ */
27
+ constructor(message, { status = 0, code = 'network_error' } = {}) {
28
+ super(message);
29
+ this.name = 'VerifyError';
30
+ /** HTTP status, or 0 when no response arrived. */
31
+ this.status = status;
32
+ /** The API's error code: invalid_client, invalid_request, not_entitled, … */
33
+ this.code = code;
34
+ }
35
+ }
36
+
37
+ const requireString = (value, name) => {
38
+ if (typeof value !== 'string' || value.trim() === '') {
39
+ throw new TypeError(`${name} is required and must be a non-empty string.`);
40
+ }
41
+ return value;
42
+ };
43
+
44
+ const basicAuth = (id, secret) =>
45
+ 'Basic ' + Buffer.from(`${encodeURIComponent(id)}:${encodeURIComponent(secret)}`, 'utf8').toString('base64');
46
+
47
+ /** The API's result, in the SDK's names. Fields the API did not send are left out. */
48
+ const toResult = (body) => {
49
+ const result = {
50
+ req: body.req,
51
+ claims: body.claims || {},
52
+ assurance: body.assurance,
53
+ verifiedBy: body.verified_by,
54
+ deviceAttestation: body.device_attestation,
55
+ verifiedAt: body.verified_at,
56
+ consentReceiptId: body.consent_receipt_id,
57
+ state: body.state ?? null
58
+ };
59
+ if (body.network) result.network = body.network;
60
+ return result;
61
+ };
62
+
63
+ class Verify {
64
+ #clientId;
65
+ #clientSecret;
66
+ #baseUrl;
67
+ #timeoutMs;
68
+ #fetch;
69
+
70
+ /**
71
+ * @param {object} options
72
+ * @param {string} options.clientId `cl_…`, from the console
73
+ * @param {string} options.clientSecret `sk_…`, shown once when the client was created
74
+ * @param {string} [options.baseUrl] defaults to https://api.ism.ec/verify/v1
75
+ * @param {number} [options.timeoutMs] per call, default 10 000
76
+ * @param {typeof fetch} [options.fetch] an alternative fetch implementation
77
+ */
78
+ constructor({ clientId, clientSecret, baseUrl = DEFAULT_BASE_URL, timeoutMs = DEFAULT_TIMEOUT_MS, fetch } = {}) {
79
+ this.#clientId = requireString(clientId, 'clientId');
80
+ this.#clientSecret = requireString(clientSecret, 'clientSecret');
81
+ const url = new URL(requireString(baseUrl, 'baseUrl'));
82
+ if (url.protocol !== 'https:') throw new TypeError('baseUrl must be https.');
83
+ this.#baseUrl = url.toString().replace(/\/+$/, '');
84
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new TypeError('timeoutMs must be a positive number.');
85
+ this.#timeoutMs = timeoutMs;
86
+ this.#fetch = fetch || globalThis.fetch;
87
+ if (typeof this.#fetch !== 'function') throw new TypeError('No fetch available: use Node 18 or later, or pass options.fetch.');
88
+ }
89
+
90
+ get clientId() { return this.#clientId; }
91
+
92
+ /** What `console.log(client)` and `JSON.stringify(client)` show: never the secret. */
93
+ toJSON() { return { clientId: this.#clientId, baseUrl: this.#baseUrl }; }
94
+ [Symbol.for('nodejs.util.inspect.custom')]() { return `Verify { clientId: '${this.#clientId}', baseUrl: '${this.#baseUrl}' }`; }
95
+
96
+ async #post(path, body) {
97
+ let response;
98
+ try {
99
+ response = await this.#fetch(`${this.#baseUrl}${path}`, {
100
+ method: 'POST',
101
+ headers: {
102
+ 'Content-Type': 'application/json',
103
+ Accept: 'application/json',
104
+ Authorization: basicAuth(this.#clientId, this.#clientSecret)
105
+ },
106
+ body: JSON.stringify(body),
107
+ redirect: 'error',
108
+ signal: AbortSignal.timeout(this.#timeoutMs)
109
+ });
110
+ } catch (err) {
111
+ const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');
112
+ throw new VerifyError(timedOut ? `${path} timed out after ${this.#timeoutMs} ms.` : `${path} could not be reached: ${err && err.message}`,
113
+ { status: 0, code: timedOut ? 'timeout' : 'network_error' });
114
+ }
115
+ const text = await response.text();
116
+ let parsed = null;
117
+ try { parsed = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
118
+ if (!response.ok) {
119
+ throw new VerifyError(
120
+ (parsed && parsed.message) || `${path} answered HTTP ${response.status}.`,
121
+ { status: response.status, code: (parsed && parsed.error) || 'http_error' }
122
+ );
123
+ }
124
+ if (!parsed || typeof parsed !== 'object') {
125
+ throw new VerifyError(`${path} answered with a body that is not JSON.`, { status: response.status, code: 'invalid_response' });
126
+ }
127
+ return parsed;
128
+ }
129
+
130
+ /**
131
+ * Starts a verification. Send the person to `verifyUrl`; keep `req` beside your own record.
132
+ *
133
+ * Name a bundle configured in the console (`bundle`), or compose the request here (`claims`,
134
+ * `level`, `purpose`). Each call starts a new verification.
135
+ *
136
+ * @returns {Promise<{ req: string, verifyUrl: string, expiresAt: string }>}
137
+ */
138
+ async start(options = {}) {
139
+ const {
140
+ bundle, claims, level, purpose, retention, claimLabels,
141
+ redirectUri, state, network, endUserIp, evidence
142
+ } = options;
143
+
144
+ const source = evidence === undefined ? 'chip' : evidence && evidence.source;
145
+ if (!EVIDENCE_SOURCES.includes(source)) {
146
+ throw new TypeError(`evidence.source must be one of: ${EVIDENCE_SOURCES.join(', ')}.`);
147
+ }
148
+ requireString(redirectUri, 'redirectUri');
149
+ if (state !== undefined && state !== null) requireString(state, 'state');
150
+
151
+ const body = { redirect_uri: redirectUri };
152
+ if (bundle !== undefined) {
153
+ requireString(bundle, 'bundle');
154
+ if (claims !== undefined || level !== undefined || purpose !== undefined) {
155
+ throw new TypeError('Pass either bundle, or claims + level + purpose — not both.');
156
+ }
157
+ body.plan_id = bundle;
158
+ } else {
159
+ if (!Array.isArray(claims) || claims.length === 0 || !claims.every((c) => typeof c === 'string' && c)) {
160
+ throw new TypeError('claims must be a non-empty array of claim names, or pass bundle.');
161
+ }
162
+ if (!LEVELS.includes(level)) throw new TypeError(`level must be one of: ${LEVELS.join(', ')}.`);
163
+ requireString(purpose, 'purpose');
164
+ body.claims = claims;
165
+ body.level = level;
166
+ body.purpose = purpose;
167
+ if (retention !== undefined) body.retention = requireString(retention, 'retention');
168
+ if (claimLabels !== undefined) body.claim_labels = claimLabels;
169
+ }
170
+ if (state !== undefined && state !== null) body.state = state;
171
+ if (network !== undefined) {
172
+ if (typeof network !== 'boolean') throw new TypeError('network must be true or false.');
173
+ body.network = network;
174
+ }
175
+ if (endUserIp !== undefined && endUserIp !== null) body.end_user_ip = requireString(endUserIp, 'endUserIp');
176
+
177
+ const answer = await this.#post('/par', body);
178
+ if (typeof answer.req !== 'string' || typeof answer.verify_url !== 'string') {
179
+ throw new VerifyError('/par answered without req and verify_url.', { status: 200, code: 'invalid_response' });
180
+ }
181
+ return { req: answer.req, verifyUrl: answer.verify_url, expiresAt: answer.expires_at };
182
+ }
183
+
184
+ /**
185
+ * Exchanges the `code` from your redirect for the result. A result is released once: a
186
+ * second exchange, or one after `fetchResult`, is refused.
187
+ */
188
+ async exchange(code) {
189
+ requireString(code, 'code');
190
+ return toResult(await this.#post('/token', { grant_type: 'authorization_code', code }));
191
+ }
192
+
193
+ /**
194
+ * Collects the result by the `req` that `start` returned — for a person who finished in the
195
+ * app and never came back, typically after a `verification.completed` webhook. Kept 24 hours
196
+ * after completion and released once, whichever of this and `exchange` asks first.
197
+ */
198
+ async fetchResult(req) {
199
+ requireString(req, 'req');
200
+ return toResult(await this.#post('/token', { grant_type: 'request', req }));
201
+ }
202
+
203
+ /**
204
+ * Handles the request that arrived at your `redirectUri`: checks `state` against the one you
205
+ * stored, then exchanges the code. A person who declined, or whose verification did not
206
+ * succeed, arrives with an error instead of a code; that is returned, not thrown.
207
+ *
208
+ * @param {string|URL} url the full URL the browser opened
209
+ * @param {{ expectedState?: string }} [options]
210
+ * @returns {Promise<{ ok: true, result: object } | { ok: false, error: string, outcome: string, state: string|null }>}
211
+ */
212
+ async handleReturn(url, { expectedState } = {}) {
213
+ const back = Verify.parseReturn(url);
214
+ if (expectedState !== undefined && back.state !== expectedState) {
215
+ throw new VerifyError('state does not match the one this verification was started with.', { code: 'state_mismatch' });
216
+ }
217
+ if (!back.code) return { ok: false, error: back.error, outcome: back.outcome, state: back.state };
218
+ return { ok: true, result: await this.exchange(back.code) };
219
+ }
220
+
221
+ /**
222
+ * Reads a return URL without calling anything.
223
+ * `outcome` is `completed`, `denied`, `failed` or `abandoned`.
224
+ */
225
+ static parseReturn(url) {
226
+ const params = (url instanceof URL ? url : new URL(String(url))).searchParams;
227
+ const state = params.get('state');
228
+ const code = params.get('code');
229
+ if (code) return { code, state, outcome: 'completed' };
230
+ const error = params.get('error');
231
+ if (!error) throw new VerifyError('The return URL carries neither code nor error.', { code: 'invalid_return' });
232
+ return { code: null, error, outcome: params.get('ism_outcome') || 'denied', state };
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Checks a webhook's `ISM-Signature` header and returns the parsed event. Throws a
238
+ * `VerifyError` (code `invalid_signature`) when the signature, or its timestamp, does not hold.
239
+ *
240
+ * Pass the raw body exactly as it arrived — a re-serialised parse will not verify.
241
+ *
242
+ * @param {{ rawBody: string|Buffer, signature: string, secret: string, toleranceSeconds?: number, now?: number }} input
243
+ * `secret` is the webhook's `whsec_…`, not the client secret. `now` is milliseconds.
244
+ */
245
+ const verifyWebhook = ({ rawBody, signature, secret, toleranceSeconds = 300, now = Date.now() } = {}) => {
246
+ requireString(secret, 'secret');
247
+ if (typeof rawBody !== 'string' && !Buffer.isBuffer(rawBody)) {
248
+ throw new TypeError('rawBody must be the raw request body, as a string or Buffer.');
249
+ }
250
+ const body = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody;
251
+ const parts = String(signature || '').split(',').map((p) => p.trim().split('='));
252
+ const t = Number(parts.find(([k]) => k === 't')?.[1]);
253
+ const v1 = parts.filter(([k]) => k === 'v1').map(([, v]) => v || '');
254
+ const refuse = (why) => new VerifyError(why, { code: 'invalid_signature' });
255
+ if (!Number.isFinite(t) || v1.length === 0) throw refuse('Malformed ISM-Signature header.');
256
+ if (Math.abs(Math.floor(now / 1000) - t) > toleranceSeconds) throw refuse('Webhook timestamp is outside the tolerance.');
257
+ const expected = crypto.createHmac('sha256', secret).update(`${t}.${body}`, 'utf8').digest();
258
+ const matches = v1.some((v) => {
259
+ const got = Buffer.from(v, 'hex');
260
+ return got.length === expected.length && crypto.timingSafeEqual(got, expected);
261
+ });
262
+ if (!matches) throw refuse('Webhook signature does not match.');
263
+ try {
264
+ return JSON.parse(body);
265
+ } catch {
266
+ throw refuse('Webhook body is not JSON.');
267
+ }
268
+ };
269
+
270
+ 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.0.116",
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": "20cf21925ff3f8d038c5063b6cc2547977b8f6fc"
34
+ }