@ism/verify-sdk 1.0.116 → 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.
package/README.md CHANGED
@@ -30,7 +30,7 @@ npm install @ism/verify-sdk
30
30
  | `clientId` | `cl_…` |
31
31
  | `clientSecret` | `sk_…` — shown once, when the client is created. Keep it in your secret store |
32
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 |
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
34
  | A webhook (recommended) | so you learn the outcome even when the person does not come back |
35
35
 
36
36
  Sandbox clients behave exactly like production ones, with the same API and the same app.
@@ -76,7 +76,25 @@ await ism.start({ claims: ['subject_is:EC-ID-0000000000'], level: 'L4', purpose:
76
76
  ```
77
77
 
78
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.
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.
80
98
 
81
99
  ## 2 · Collect the result
82
100
 
@@ -147,20 +165,21 @@ Every failed call throws a `VerifyError` with `status` (HTTP, or 0 when nothing
147
165
  | `invalid_client` | the client id or secret was refused |
148
166
  | `not_entitled` | the request asks for more than your client may — a claim, a level, a redirect URI. The message says which |
149
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 |
150
169
  | `client_suspended` | the client is not active |
151
170
  | `rate_limited` | slow down |
152
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 |
153
172
  | `invalid_signature` | `verifyWebhook` refused the body |
154
173
  | `state_mismatch` | `handleReturn` was given a URL whose `state` is not yours |
155
174
 
156
- No call is retried by the SDK.
175
+ No call is retried by the SDK; `idempotencyKey` is what makes your own retry of `start()` safe.
157
176
 
158
177
  ## Reference
159
178
 
160
179
  | | |
161
180
  |---|---|
162
181
  | `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 }` |
182
+ | `start({ bundle \| claims, level, purpose, retention?, claimLabels?, redirectUri, state?, network?, endUserIp?, idempotencyKey? })` | → `{ req, verifyUrl, expiresAt, replayed }` |
164
183
  | `exchange(code)` | → result |
165
184
  | `fetchResult(req)` | → result |
166
185
  | `handleReturn(url, { expectedState? })` | → `{ ok: true, result }` or `{ ok: false, error, outcome, state }` |
package/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export type Outcome = 'completed' | 'denied' | 'failed' | 'abandoned';
6
6
  export declare class VerifyError extends Error {
7
7
  /** HTTP status, or 0 when no response arrived. */
8
8
  readonly status: number;
9
- /** invalid_client, invalid_request, not_entitled, client_suspended, rate_limited, timeout, network_error, invalid_signature, state_mismatch, … */
9
+ /** invalid_client, invalid_request, not_entitled, idempotency_conflict, client_suspended, rate_limited, timeout, network_error, invalid_signature, state_mismatch, … */
10
10
  readonly code: string;
11
11
  }
12
12
 
@@ -29,6 +29,12 @@ interface StartCommon {
29
29
  network?: boolean;
30
30
  /** The person's IP as your backend saw it, for network signals. */
31
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;
32
38
  /** Where the document evidence comes from. Default: the document's chip. */
33
39
  evidence?: { source: 'chip' };
34
40
  }
@@ -54,6 +60,8 @@ export interface Started {
54
60
  /** Send the person here. */
55
61
  verifyUrl: string;
56
62
  expiresAt: string;
63
+ /** true when an idempotencyKey matched an earlier start and this is that verification. */
64
+ replayed: boolean;
57
65
  }
58
66
 
59
67
  export interface VerificationResult {
package/index.js CHANGED
@@ -8,15 +8,17 @@
8
8
  // - The client secret travels only in the Authorization header of `/par` and `/token`, never in
9
9
  // a body, a URL, an error or a log line. It lives in a private field, so printing the client
10
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.
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.
13
14
  // - This file logs nothing. Claims are personal data, and logging is the caller's decision.
14
15
 
15
16
  const crypto = require('node:crypto');
16
17
 
17
18
  const DEFAULT_BASE_URL = 'https://api.ism.ec/verify/v1';
18
19
  const DEFAULT_TIMEOUT_MS = 10_000;
19
- const LEVELS = ['L1', 'L2', 'L3', 'L4'];
20
+ const LEVEL = /^L[0-9]$/;
21
+ const IDEMPOTENCY_KEY = /^[\x21-\x7e]{8,255}$/;
20
22
  const EVIDENCE_SOURCES = ['chip'];
21
23
 
22
24
  class VerifyError extends Error {
@@ -93,7 +95,7 @@ class Verify {
93
95
  toJSON() { return { clientId: this.#clientId, baseUrl: this.#baseUrl }; }
94
96
  [Symbol.for('nodejs.util.inspect.custom')]() { return `Verify { clientId: '${this.#clientId}', baseUrl: '${this.#baseUrl}' }`; }
95
97
 
96
- async #post(path, body) {
98
+ async #post(path, body, { withHeaders = false } = {}) {
97
99
  let response;
98
100
  try {
99
101
  response = await this.#fetch(`${this.#baseUrl}${path}`, {
@@ -124,21 +126,23 @@ class Verify {
124
126
  if (!parsed || typeof parsed !== 'object') {
125
127
  throw new VerifyError(`${path} answered with a body that is not JSON.`, { status: response.status, code: 'invalid_response' });
126
128
  }
127
- return parsed;
129
+ return withHeaders ? { body: parsed, headers: response.headers } : parsed;
128
130
  }
129
131
 
130
132
  /**
131
133
  * Starts a verification. Send the person to `verifyUrl`; keep `req` beside your own record.
132
134
  *
133
135
  * Name a bundle configured in the console (`bundle`), or compose the request here (`claims`,
134
- * `level`, `purpose`). Each call starts a new verification.
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`).
135
139
  *
136
- * @returns {Promise<{ req: string, verifyUrl: string, expiresAt: string }>}
140
+ * @returns {Promise<{ req: string, verifyUrl: string, expiresAt: string, replayed: boolean }>}
137
141
  */
138
142
  async start(options = {}) {
139
143
  const {
140
144
  bundle, claims, level, purpose, retention, claimLabels,
141
- redirectUri, state, network, endUserIp, evidence
145
+ redirectUri, state, network, endUserIp, evidence, idempotencyKey
142
146
  } = options;
143
147
 
144
148
  const source = evidence === undefined ? 'chip' : evidence && evidence.source;
@@ -159,7 +163,7 @@ class Verify {
159
163
  if (!Array.isArray(claims) || claims.length === 0 || !claims.every((c) => typeof c === 'string' && c)) {
160
164
  throw new TypeError('claims must be a non-empty array of claim names, or pass bundle.');
161
165
  }
162
- if (!LEVELS.includes(level)) throw new TypeError(`level must be one of: ${LEVELS.join(', ')}.`);
166
+ if (typeof level !== 'string' || !LEVEL.test(level)) throw new TypeError('level must be an assurance level such as L1 or L4.');
163
167
  requireString(purpose, 'purpose');
164
168
  body.claims = claims;
165
169
  body.level = level;
@@ -173,12 +177,23 @@ class Verify {
173
177
  body.network = network;
174
178
  }
175
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
+ }
176
186
 
177
- const answer = await this.#post('/par', body);
187
+ const { body: answer, headers } = await this.#post('/par', body, { withHeaders: true });
178
188
  if (typeof answer.req !== 'string' || typeof answer.verify_url !== 'string') {
179
189
  throw new VerifyError('/par answered without req and verify_url.', { status: 200, code: 'invalid_response' });
180
190
  }
181
- return { req: answer.req, verifyUrl: answer.verify_url, expiresAt: answer.expires_at };
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
+ };
182
197
  }
183
198
 
184
199
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ism/verify-sdk",
3
- "version": "1.0.116",
3
+ "version": "1.1.118",
4
4
  "description": "Verify with ISM for relying-party backends: start a verification, collect the result, check webhook signatures.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -30,5 +30,5 @@
30
30
  "publishConfig": {
31
31
  "access": "public"
32
32
  },
33
- "gitHead": "20cf21925ff3f8d038c5063b6cc2547977b8f6fc"
33
+ "gitHead": "44d2fc4511c4e4fd3a685616fc74ed2bafa0b69f"
34
34
  }