@ism/verify-sdk 1.0.116 → 1.1.136

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,10 +30,12 @@ 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
- Sandbox clients behave exactly like production ones, with the same API and the same app.
36
+ Sandbox clients use the same API and the same app. One difference: for production clients the
37
+ server re-checks the chip itself and its verdict decides (`verifiedBy: 'server'`); in sandbox that
38
+ check is only recorded.
37
39
 
38
40
  ## 1 · Start a verification
39
41
 
@@ -76,7 +78,25 @@ await ism.start({ claims: ['subject_is:EC-ID-0000000000'], level: 'L4', purpose:
76
78
  ```
77
79
 
78
80
  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.
81
+
82
+ ### Retrying safely
83
+
84
+ Each `start()` call starts a new verification. To make a retry safe — after a timeout, or from a
85
+ job runner that may run twice — pass an `idempotencyKey`, such as your own record's id:
86
+
87
+ ```js
88
+ const { req, verifyUrl, replayed } = await ism.start({
89
+ bundle: 'bn_…',
90
+ redirectUri,
91
+ state,
92
+ idempotencyKey: `onboarding-${application.id}`,
93
+ });
94
+ ```
95
+
96
+ The same key with the same request, while its link is still valid, returns the verification the
97
+ first call started (`replayed: true`) instead of a second one. Once the link has expired the key
98
+ starts a new one. Reusing a key for a *different* request fails with `idempotency_conflict`. Keys
99
+ are scoped to your client and stored only as a hash.
80
100
 
81
101
  ## 2 · Collect the result
82
102
 
@@ -147,20 +167,21 @@ Every failed call throws a `VerifyError` with `status` (HTTP, or 0 when nothing
147
167
  | `invalid_client` | the client id or secret was refused |
148
168
  | `not_entitled` | the request asks for more than your client may — a claim, a level, a redirect URI. The message says which |
149
169
  | `invalid_request` | a malformed request; on `/token`, also a code or reference that is unknown, expired or already released — deliberately indistinguishable |
170
+ | `idempotency_conflict` | the `idempotencyKey` was already used for a different request |
150
171
  | `client_suspended` | the client is not active |
151
172
  | `rate_limited` | slow down |
152
173
  | `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
174
  | `invalid_signature` | `verifyWebhook` refused the body |
154
175
  | `state_mismatch` | `handleReturn` was given a URL whose `state` is not yours |
155
176
 
156
- No call is retried by the SDK.
177
+ No call is retried by the SDK; `idempotencyKey` is what makes your own retry of `start()` safe.
157
178
 
158
179
  ## Reference
159
180
 
160
181
  | | |
161
182
  |---|---|
162
183
  | `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 }` |
184
+ | `start({ bundle \| claims, level, purpose, retention?, claimLabels?, redirectUri, state?, network?, endUserIp?, idempotencyKey? })` | → `{ req, verifyUrl, expiresAt, replayed }` |
164
185
  | `exchange(code)` | → result |
165
186
  | `fetchResult(req)` | → result |
166
187
  | `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.136",
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": "6331d1db35381fbcd40ec873c9720d5d269ad95f"
34
34
  }