@soliton-id/web-sdk 1.0.1 → 1.1.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ All notable changes to `@soliton-id/web-sdk`. The `soliton-v1` postMessage
4
4
  protocol is frozen: changes are additive only, so older SDK versions keep
5
5
  working against the hosted flow.
6
6
 
7
+ ## 1.1.1 — 2026-10-29
8
+
9
+ Package metadata only; no code or API changes. The repository and issue links
10
+ point to `georges-soliton/soliton-front-end`.
11
+
12
+ ## 1.1.0 — 2026-10-29
13
+
14
+ Additive only. `open()` and the `soliton-v1` protocol are unchanged, so
15
+ existing integrations need no code change.
16
+
17
+ - Add the isolated `/server` export and downloadable `soliton-server.mjs` for
18
+ Node.js 18+: combined v2 session creation and link issuance, with a pre-link
19
+ persistence callback, bounded requests, explicit link recovery and
20
+ structured partial-failure errors.
21
+ - Add the optional `verificationOrigin` option for customer-hosted deployments.
22
+ - Bound initial iframe readiness to 30 seconds; report `load_timeout` and clean
23
+ up stalled overlays without emitting a verification result. A frame that
24
+ stays blank until then usually means the embedding page's origin is not
25
+ registered for the link's environment.
26
+ - Document the v2 session flow: completion callbacks keep the UUID
27
+ `verificationId`, and results are read server-side from the saved session.
28
+ - Document server-side `liveness_tier` selection for Standard, Medium and High
29
+ in sandbox and production; the returned URL selects the capture.
30
+ - Document that registered embedding origins apply on the next page load, with
31
+ no Soliton deployment.
32
+
33
+ ## 1.0.2 — 2026-08-06
34
+
35
+ Documentation only. No runtime, API, or bundle changes; `soliton-v1` is
36
+ unchanged and `1.0.0`/`1.0.1` remain fully compatible.
37
+
38
+ - Reframed `sk_test_` as the **sandbox environment** rather than a
39
+ fixtures-only mode. A sandbox credential now runs real captures; the
40
+ deterministic `pass` / `fail` / `partial` fixtures remain available and
41
+ remain sandbox-only. `sk_live_` still cannot request a fixture.
42
+ - Documented that redirect domains, iframe origins, and webhook endpoints are
43
+ registered per environment, so a staging origin can embed sandbox links
44
+ without being able to embed production ones, and each environment's webhook
45
+ has its own signing secret.
46
+ - Documented the additive `environment` field on the create response, every
47
+ status read, and every webhook payload.
48
+
7
49
  ## 1.0.1 — 2026-08-02
8
50
 
9
51
  Documentation only. No runtime, API, or bundle changes; `soliton-v1` is
package/README.md CHANGED
@@ -1,3 +1,87 @@
1
+ ## Customer-hosted appliances
2
+
3
+ Set `verificationOrigin` to your trusted customer-hosted HTTPS origin when calling
4
+ `open({ url, verificationOrigin, onComplete })`. The issued URL must match that
5
+ origin exactly. Omit the option for the existing cloud allowlist. The iframe
6
+ protocol, completion callbacks and server-side result/webhook contracts are
7
+ unchanged. See [the appliance guide](../docs/on-premise.md) for runtime origins,
8
+ local assets, packaging, iframe permissions and top-level mobile WebViews.
9
+
10
+ ## Console API v2
11
+
12
+ The SDK opens `https://soliton.id/v/{token}` links from
13
+ `POST /api/v2/sessions/{id}/link`. Create the session first with your console
14
+ API key using `POST /api/v2/sessions`; resolve `browser_path` against the
15
+ verification origin and pass only the resulting URL to `open()`.
16
+ Both redirect and embedded methods use the same API key and v2 endpoints.
17
+ Register exact embedding origins per account/environment; they apply on the next
18
+ page load with no frontend deploy. There is no wildcard or implicit
19
+ same-company-domain grant.
20
+
21
+ `onComplete({ verificationId })` is unchanged: it carries the browser UUID alias,
22
+ not a verdict. Your backend must fetch `GET /api/v2/sessions/{savedSessionId}`
23
+ using the `ses_…` ID bound to the authenticated user at creation. Never trust the
24
+ callback ID as the authority for a lookup. The `/sdk/v1/` path and `soliton-v1`
25
+ message marker are the SDK protocol, independent of REST API version.
26
+
27
+ ## Optional v2 server helper (Node.js 18+)
28
+
29
+ The helper keeps v2 intact while combining session creation and link issuance
30
+ into one server-side function. Download `/sdk/v1/soliton-server.mjs` from your
31
+ Soliton deployment into your backend project, review it, and commit the file.
32
+ Do not use a browser script tag or dynamically download it on every request.
33
+ The source package also exports `@soliton-id/web-sdk/server` (ESM/CJS + types);
34
+ use the downloaded module until a package containing that export is published.
35
+
36
+ ```js
37
+ import { createVerificationClient, VerificationStartError } from './soliton-server.mjs';
38
+ const soliton = createVerificationClient({
39
+ apiOrigin: process.env.API_ORIGIN,
40
+ verificationOrigin: process.env.VERIFICATION_ORIGIN,
41
+ apiKey: process.env.API_KEY,
42
+ timeoutMs: 20000, // Per request, including its response body.
43
+ });
44
+ // user/attempt come from your authenticated server endpoint.
45
+ // saveSession/saveLink are your application database operations.
46
+ const { sessionId, url } = await soliton.startVerification({
47
+ userReference: user.id,
48
+ tier: 'high',
49
+ idempotencyKey: attempt.id,
50
+ onSessionCreated: async id => { await saveSession(user.id, attempt.id, id); },
51
+ });
52
+ await saveLink(attempt.id, url);
53
+ // Return only { url } to the browser. Keep sessionId on your server.
54
+ ```
55
+
56
+ This still makes two HTTP requests, with no automatic retries. Only session
57
+ creation is idempotent; the combined helper is **not** end-to-end idempotent.
58
+ Persist a stable key for each logical attempt, serialize duplicate requests,
59
+ and return a securely stored URL for an attempt that already succeeded.
60
+ Neither successful URLs nor service credentials belong in logs.
61
+
62
+ `VerificationStartError` includes `stage` (`create`, `persist`, `link`),
63
+ `sessionId` when known, `status` when a response was available, and `retryAfter`
64
+ (the raw HTTP Retry-After value, if supplied). It omits arbitrary response bodies
65
+ and transport errors so secrets cannot leak through its message.
66
+
67
+ - `create`: resolve the failure and retry with the same idempotency key and body;
68
+ a lost response may conceal a successful creation. Respect 429/Retry-After;
69
+ fix authentication or conflicting inputs rather than retrying unchanged.
70
+ - `persist`: your `onSessionCreated` callback failed. No link was requested.
71
+ Save the known session ID before proceeding.
72
+ - `link`: the session exists; retain the ID. A timeout or malformed response may
73
+ conceal successful issuance. Make an explicit recovery decision, then call
74
+ `soliton.issueVerificationLink(savedSessionId)` to get `{ sessionId, url }`.
75
+ This replaces the previous link; do not rotate a link already in use.
76
+
77
+ Origins must be HTTPS (HTTP only for loopback development), without credentials,
78
+ paths, queries or fragments. Never derive them from browser input. The helper
79
+ refuses API redirects, validates returned hosted paths and rejects browser use.
80
+ It does not store state, authenticate your user, or make access decisions.
81
+
82
+ See `/docs/integration/sdk` for the full v2 SDK guide and `/docs/integration/api`
83
+ for raw requests. Existing v1 instructions below remain compatible.
84
+
1
85
  # @soliton-id/web-sdk
2
86
 
3
87
  Soliton identity verification for the browser. `open()` overlays the Soliton
@@ -35,9 +119,19 @@ bundles the npm package does not need that remote script source.
35
119
 
36
120
  ## Quickstart
37
121
 
38
- Your backend creates a verification with your secret API key (never do this
39
- from the browser), hands the returned `url` to your page, and your page opens
40
- it:
122
+ Your backend chooses the liveness tier and creates a verification with your
123
+ secret API key (never do this from the browser):
124
+
125
+ ```bash
126
+ # Use sk_live_... for production; sk_test_... for sandbox.
127
+ curl -X POST https://api.soliton.id/api/v1/verifications \
128
+ -H "Authorization: Bearer sk_test_..." \
129
+ -H "Content-Type: application/json" \
130
+ -d '{"liveness_tier":"standard","client_reference_id":"user-8412"}'
131
+ ```
132
+
133
+ Store the returned `verification_id`, requested tier and `environment` against
134
+ your authenticated user on your backend. Hand the returned `url` to your page:
41
135
 
42
136
  ```ts
43
137
  import { open } from "@soliton-id/web-sdk";
@@ -56,30 +150,82 @@ const handle = open({
56
150
  },
57
151
  onError: ({ code, message }) => {
58
152
  // link_expired | link_invalid | origin_not_allowed |
59
- // storage_unavailable | internal
153
+ // storage_unavailable | internal | load_timeout
60
154
  },
61
155
  onClose: () => {
62
- // The user dismissed the flow before completion.
156
+ // The flow closed before completion (dismissal or initial load timeout).
63
157
  },
64
158
  });
65
159
 
66
160
  // handle.close() dismisses the overlay programmatically.
67
161
  ```
68
162
 
69
- The overlay is always full-viewport: the flow runs a screen-light challenge
70
- that needs the whole screen, so it cannot be clipped into a widget.
163
+ The overlay is always full-viewport for every tier. High uses the whole screen
164
+ for its light challenge; Standard and Medium keep that layout without flashes.
165
+ Do not clip the overlay into a widget.
166
+
167
+ ## Choose liveness (sandbox and production)
168
+
169
+ | Server-side `liveness_tier` | Capture | Browser permissions |
170
+ | --- | --- | --- |
171
+ | `standard` | Camera only, no sound or flashes | Camera |
172
+ | `medium` | Camera and sound, no flashes | Camera and microphone |
173
+ | `high` (default) | Camera, sound and light | Camera and microphone |
174
+
175
+ All three use the same `POST /api/v1/verifications` endpoint and work with both
176
+ `sk_test_` and `sk_live_` keys. Omitting the field preserves High for existing
177
+ integrations. Choose the tier using your server's policy; do not let untrusted
178
+ browser input override that policy.
179
+
180
+ The URL binds the chosen tier for capture and every retake. Pass it unchanged
181
+ to `open()` or use it as a redirect: **there is no tier option in `open()` and
182
+ no tier query parameter to add**. Existing SDK versions already support every
183
+ tier; no package upgrade or separate bundle is needed. To change tiers, create
184
+ a new verification.
185
+
186
+ On completion, require the authenticated status response's `liveness_tier`
187
+ and `environment` to match the values stored for your user before accepting
188
+ the verdict. The callback still carries only `verificationId`.
71
189
 
72
190
  ## Callback semantics
73
191
 
192
+ An integration that provides its own completion screen can opt out of the
193
+ hosted result summary by adding `completion=parent` to the hosted URL:
194
+
195
+ ```ts
196
+ const embeddedUrl = new URL(url);
197
+ embeddedUrl.searchParams.set("completion", "parent");
198
+ const handle = open({
199
+ url: embeddedUrl.toString(),
200
+ onComplete: () => {
201
+ handle.close();
202
+ // Show your own final screen and have your backend fetch the saved result.
203
+ },
204
+ });
205
+ ```
206
+
207
+ The preference is honored only inside an iframe after its parent origin passes
208
+ the usual registration check. It hands control back immediately at a terminal
209
+ state, without the default three-second result summary. Retryable failures and
210
+ errors remain in the hosted flow. Top-level redirects and embeds without this
211
+ opt-in retain their existing behavior. Existing SDK bundles preserve this URL
212
+ parameter; the hosted frontend must support it. The callback still means the
213
+ attempt ended, not that verification passed; fetch the verdict server-to-server.
214
+
74
215
  - `onComplete({ verificationId })`: fired once, when the user reaches a
75
216
  terminal state. It carries ONLY the verification id, never a verdict.
76
217
  - `onError({ code, message })`: flow-level errors (`link_expired`,
77
218
  `link_invalid`, `origin_not_allowed`, `storage_unavailable` when the user's
78
219
  browser blocks the session storage the flow needs, `internal`). The overlay
79
220
  stays open so the hosted page can show its own error card; call
80
- `handle.close()` if you want it gone.
81
- - `onClose()`: the user dismissed the flow (the X button, the hosted Cancel,
82
- or `handle.close()`) before completion. Not fired after `onComplete`.
221
+ `handle.close()` if you want it gone. If the frame has not become ready or
222
+ reported an error within 30 seconds, `load_timeout` is emitted instead and
223
+ the overlay closes automatically, followed by `onClose`. This is a loading
224
+ failure, never a biometric verdict. A frame that stayed blank before it
225
+ almost always means your page's exact origin is not registered for the
226
+ link's environment: the browser console shows a `frame-ancestors` error. Once ready, there is no SDK capture deadline.
227
+ - `onClose()`: the flow closed before completion (the X button, hosted Cancel,
228
+ `handle.close()`, or initial load timeout). Not fired after `onComplete`.
83
229
 
84
230
  ## Getting the result (server-to-server only)
85
231
 
@@ -87,12 +233,12 @@ that needs the whole screen, so it cannot be clipped into a widget.
87
233
  BACKEND with your API key:
88
234
 
89
235
  1. `GET /api/v1/verifications/{id}`: the verdict (`verified`,
90
- `partial_verification`, plus `portrait_available`).
236
+ `partial_verification`, `liveness_tier`, plus `portrait_available`).
91
237
  2. `GET /api/v1/verifications/{id}/portrait`: a JPEG portrait of the verified
92
238
  user, selected server-side from the capture under neutral lighting.
93
239
  Returns 404 until the verification is complete or when no acceptable
94
240
  portrait frame existed. Right after completion, `portrait_available` can
95
- briefly read `false` while extraction finishes; once status is `complete`
241
+ briefly read `false` while extraction finishes; once status is `completed`
96
242
  just fetch the portrait and treat a 404 after one short retry as final.
97
243
 
98
244
  Your page and the SDK never see the verdict or the portrait: callbacks and
@@ -118,26 +264,32 @@ creation and check it on every status read to make this auditable.
118
264
 
119
265
  ## Frame-origin registration
120
266
 
121
- Embedding is off by default and gated on BOTH layers:
267
+ Embedding is off by default. One origin list per environment controls two checks:
122
268
 
123
269
  1. Your Soliton account must have the exact origin of each page that runs the
124
270
  SDK and directly contains its Soliton iframe registered (scheme + host +
125
- port, e.g. `https://app.example.com`). This direct-parent page does not
126
- have to be the browser's top-level page.
127
- 2. The direct-parent origin must also be present in the soliton.id deploy-time
128
- `frame-ancestors` allowlist. If the SDK page is itself framed, every origin
129
- above it in the frame chain must be present there too (we manage this
130
- allowlist).
131
-
132
- A verification link opened inside an iframe with any other direct-parent
133
- origin refuses to start and reports `origin_not_allowed`.
134
-
135
- During the invite-only pilot, Soliton manually manages both layers. Send your
271
+ port, e.g. `https://app.example.com`), **for the environment that will open
272
+ the link**. This direct-parent page does not have to be the browser's
273
+ top-level page. A link is checked against the origins registered for the
274
+ environment of the key that created it, so `https://dev.example.com` can be
275
+ a sandbox embedder without becoming a production one.
276
+ 2. The same list feeds the hosted page's `frame-ancestors` policy, read on
277
+ every page load, so a registered origin applies with no deployment. If the
278
+ SDK page is itself framed, every origin above it in the frame chain must be
279
+ registered for the same environment too, because the browser checks
280
+ `frame-ancestors` against the whole chain.
281
+
282
+ Opened inside an iframe on any other origin, the browser blocks the frame: it
283
+ stays blank and the SDK reports `load_timeout` after 30 seconds.
284
+ `origin_not_allowed` means the hosted page loaded but this particular link may
285
+ not be embedded there, for example a console test link.
286
+
287
+ During the invite-only pilot, Soliton registers your origins. Send your
136
288
  Soliton contact every exact production and local direct-parent origin before
137
289
  testing. Include a staging origin only when Soliton has supplied a working
138
290
  staging flow URL. For a nested integration, also send every other ancestor
139
- origin. Wildcards are not accepted, and adding or changing an origin can
140
- require a Soliton deployment.
291
+ origin. Wildcards are not accepted. A registered origin applies on the next
292
+ page load; no Soliton deployment is involved.
141
293
 
142
294
  Register the direct-parent page's origin, not a URL path. For example,
143
295
  `https://app.example.com` and `https://app.example.com:8443` are different
@@ -174,8 +326,9 @@ Permissions-Policy: camera=(self "https://soliton.id"), microphone=(self "https:
174
326
  bundles the npm package.
175
327
  - If any document above the Soliton iframe explicitly restricts camera or
176
328
  microphone with `Permissions-Policy`, every level in a nested frame chain
177
- must allow and delegate both features through each intervening iframe; an
178
- upstream denial cannot be overridden lower in the chain. The SDK already
329
+ must allow and delegate the features used by the selected tier: camera for
330
+ Standard; camera and microphone for Medium and High. The examples allow all
331
+ tiers. An upstream denial cannot be overridden lower in the chain. The SDK already
179
332
  sets the matching `allow="camera; microphone"` attribute on the iframe it
180
333
  creates.
181
334
 
@@ -190,38 +343,57 @@ soliton.id. Do not add Soliton to your page's `frame-ancestors`: that directive
190
343
  controls who may frame your page, while `frame-src` controls what your page may
191
344
  frame.
192
345
 
193
- ## Test mode
346
+ ## Sandbox
347
+
348
+ Your account has two environments and the API key prefix selects one:
349
+ `sk_live_` is **production**, `sk_test_` is **sandbox**. Each has its own
350
+ credentials, verifications, redirect domains, iframe origins, and webhook
351
+ endpoint with its own signing secret.
194
352
 
195
- `sk_test_` credentials produce deterministic synthetic fixtures, so you can
196
- build and regression-test the embed without capturing a person. Your server
197
- names the outcome when it creates the verification:
353
+ A sandbox verification opens in the overlay exactly like a production one and
354
+ runs the same real capture — but it is checked against the iframe origins
355
+ registered for **sandbox**, so your staging page can embed sandbox links
356
+ without being able to embed production ones. Register each origin for the
357
+ environment that will use it.
358
+
359
+ Sandbox is additionally the only place that can request a fixed outcome, which
360
+ is the practical way to exercise your `partial` branch:
198
361
 
199
362
  ```jsonc
200
363
  // POST /api/v1/verifications with an sk_test_ key
201
- { "client_reference_id": "user-8412", "test_fixture_scenario": "partial" }
364
+ { "liveness_tier": "medium", "client_reference_id": "user-8412", "test_fixture_scenario": "partial" }
202
365
  ```
203
366
 
204
- | `test_fixture_scenario` | `verified` | `partial_verification` |
205
- | ----------------------- | ---------- | ---------------------- |
206
- | `pass` | `true` | `false` |
207
- | `partial` | `true` | `true` |
208
- | `fail` | `false` | `false` |
209
-
210
- The resulting URL opens in the overlay exactly like a real one and fires
211
- `onComplete` the same way, but never requests camera permission and never
212
- records anything. The hosted page marks itself as synthetic on screen. This is
213
- the practical way to exercise your `partial` branch, which is awkward to
214
- trigger with a real capture.
215
-
216
- Test mode does not exercise liveness, anti-spoof, or anti-deepfake logic, and
217
- simulates no errors or timeouts — only the three terminal verdicts. Verify the
218
- production path with an `sk_live_` key.
219
-
220
- The two modes are strictly isolated: a live key cannot create or read a
221
- fixture, and a test key cannot read a real verification, even within one
222
- account. Every status read and webhook carries `result_source`
223
- (`biometric` or `synthetic_test_fixture`) — assert it is `biometric` anywhere
224
- a real verdict matters.
367
+ | `test_fixture_scenario` | `verified` | Standard `partial_verification` | Medium / High `partial_verification` |
368
+ | --- | --- | --- | --- |
369
+ | `pass` | `true` | `false` | `false` |
370
+ | `partial` | `true` | `false` | `true` |
371
+ | `fail` | `false` | `false` | `false` |
372
+
373
+ A fixture's URL opens in the overlay and fires `onComplete` the same way, but
374
+ never requests camera permission and never records anything. The hosted page
375
+ marks itself as synthetic on screen.
376
+
377
+ Fixtures do not exercise liveness, anti-spoof, or anti-deepfake logic, and
378
+ simulate no errors or timeouts — only the three terminal verdicts. Real
379
+ sandbox captures do run the full pipeline; a production `sk_live_` key cannot
380
+ request fixtures at all.
381
+
382
+ The two environments are strictly isolated: neither credential can read the
383
+ other's verifications, even within one account. Every create response, status
384
+ read, and webhook carries `environment` (`sandbox` or `production`) and
385
+ `result_source` (`biometric` or `synthetic_test_fixture`) — assert both
386
+ anywhere a real production verdict matters.
387
+
388
+ Keep the same explicit `liveness_tier` when moving from sandbox to production;
389
+ switch keys and use origins registered for production. The completed status
390
+ response and signed webhook include `liveness_tier`: require it to match your
391
+ stored choice, and reject missing or mismatched tiers. Fetch the verdict using
392
+ the verification id bound to your user's session, as in the quickstart above.
393
+
394
+ Standard has only verified and not-verified outcomes: completed results always
395
+ set `partial_verification: false`. Medium and High can still return partial
396
+ verification. A Standard `partial` test fixture resolves to a verified result.
225
397
 
226
398
  ## Completion webhooks
227
399
 
@@ -233,6 +405,12 @@ deduplicate on `X-Soliton-Event-Id`, and respond `2xx` within 5 seconds. Full
233
405
  setup, the signature recipe, and retry semantics are in the
234
406
  [integration guide](https://soliton.id/docs/integration).
235
407
 
408
+ Webhooks are configured per environment: one endpoint for sandbox and one for
409
+ production, each with its own signing secret. A sandbox completion is only ever
410
+ delivered to the sandbox endpoint, so a staging receiver cannot validate — or
411
+ forge — a production event. The payload's `environment` field tells you which
412
+ is which if you point both at one URL.
413
+
236
414
  ## How it works
237
415
 
238
416
  `open()` accepts hosted URLs from `https://soliton.id`. When Soliton has
@@ -2,7 +2,7 @@
2
2
  * Validation of the hosted verification URL handed to SolitonVerify.open().
3
3
  *
4
4
  * The SDK only ever frames URLs that look exactly like the ones minted by
5
- * POST /api/v1/verifications: an approved Soliton origin (http strictly for
5
+ * the v1 verification API or v2 session link API: an approved Soliton origin (http strictly for
6
6
  * localhost dev) and a bare /v/{32 hex} path. Everything else throws
7
7
  * synchronously — a bad URL is an integration bug, not a runtime flow event,
8
8
  * so it must fail loudly at the call site instead of rendering an
@@ -14,4 +14,4 @@ export interface ParsedHostedUrl {
14
14
  /** Origin the SDK will trust for postMessage events. */
15
15
  hostedOrigin: string;
16
16
  }
17
- export declare function parseHostedUrl(raw: string): ParsedHostedUrl;
17
+ export declare function parseHostedUrl(raw: string, verificationOrigin?: string): ParsedHostedUrl;
package/dist/index.cjs CHANGED
@@ -27,13 +27,24 @@ __export(index_exports, {
27
27
  });
28
28
  module.exports = __toCommonJS(index_exports);
29
29
 
30
+ // sdk/src/trustedOrigin.ts
31
+ function trustedOrigin(value) {
32
+ const url = new URL(value);
33
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
34
+ if (url.origin !== value || url.username || url.password || value.includes("*") || url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
35
+ throw new Error("Use an exact HTTPS origin (HTTP loopback is allowed for development)");
36
+ }
37
+ return url.origin;
38
+ }
39
+
30
40
  // sdk/src/hostedUrl.ts
31
41
  var HOSTED_PATH_RE = /^\/v\/[0-9a-f]{32}$/;
32
42
  var ALLOWED_HTTPS_ORIGINS = /* @__PURE__ */ new Set([
33
43
  "https://soliton.id",
34
44
  "https://staging.soliton.id"
35
45
  ]);
36
- function parseHostedUrl(raw) {
46
+ function parseHostedUrl(raw, verificationOrigin) {
47
+ const configuredOrigin = verificationOrigin === void 0 ? void 0 : trustedOrigin(verificationOrigin);
37
48
  let url;
38
49
  try {
39
50
  url = new URL(raw);
@@ -41,7 +52,10 @@ function parseHostedUrl(raw) {
41
52
  throw new Error(`SolitonVerify: "url" is not a valid absolute URL`);
42
53
  }
43
54
  const isLocalHttp = url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
44
- if (!ALLOWED_HTTPS_ORIGINS.has(url.origin) && !isLocalHttp) {
55
+ if (configuredOrigin !== void 0 && url.origin !== configuredOrigin) {
56
+ throw new Error('SolitonVerify: "url" does not match the configured verificationOrigin');
57
+ }
58
+ if (configuredOrigin === void 0 && !ALLOWED_HTTPS_ORIGINS.has(url.origin) && !isLocalHttp) {
45
59
  throw new Error(
46
60
  'SolitonVerify: "url" must be hosted on https://soliton.id or https://staging.soliton.id (http is allowed for localhost/127.0.0.1 development only)'
47
61
  );
@@ -166,6 +180,7 @@ var EMBED_PROTOCOL = "soliton-v1";
166
180
  var AUTO_CLOSE_DELAY_MS = 250;
167
181
  var HELLO_INTERVAL_MS = 250;
168
182
  var HELLO_MAX_PINGS = 40;
183
+ var READY_TIMEOUT_MS = 3e4;
169
184
  function open(options) {
170
185
  if (!options || typeof options.url !== "string") {
171
186
  throw new Error('SolitonVerify: open() requires an options object with a "url"');
@@ -173,14 +188,16 @@ function open(options) {
173
188
  if (typeof options.onComplete !== "function") {
174
189
  throw new Error("SolitonVerify: open() requires an onComplete callback");
175
190
  }
176
- const { url, hostedOrigin } = parseHostedUrl(options.url);
191
+ const { url, hostedOrigin } = parseHostedUrl(options.url, options.verificationOrigin);
177
192
  const src = new URL(url.toString());
178
193
  src.searchParams.set("mode", "embed");
179
194
  src.searchParams.set("sdk", "1");
180
195
  if (options.locale) src.searchParams.set("locale", options.locale);
181
196
  let closed = false;
182
197
  let terminalDelivered = false;
198
+ let loadTimedOut = false;
183
199
  let autoCloseTimer = null;
200
+ let readyTimer = null;
184
201
  const overlay = createOverlay(src.toString(), () => close());
185
202
  let helloPings = 0;
186
203
  const postHello = () => {
@@ -205,8 +222,13 @@ function open(options) {
205
222
  clearInterval(helloTimer);
206
223
  overlay.iframe.removeEventListener("load", onIframeLoad);
207
224
  }
225
+ function stopReadyWait() {
226
+ if (readyTimer !== null) clearTimeout(readyTimer);
227
+ readyTimer = null;
228
+ }
208
229
  const onMessage = (event) => {
209
230
  var _a;
231
+ if (closed || loadTimedOut || terminalDelivered) return;
210
232
  if (event.origin !== hostedOrigin) return;
211
233
  if (!overlay.iframe.contentWindow || event.source !== overlay.iframe.contentWindow) {
212
234
  return;
@@ -218,10 +240,12 @@ function open(options) {
218
240
  stopHello();
219
241
  switch (data.type) {
220
242
  case "soliton:ready":
243
+ stopReadyWait();
221
244
  overlay.markReady();
222
245
  break;
223
246
  case "soliton:complete": {
224
247
  if (terminalDelivered) break;
248
+ stopReadyWait();
225
249
  terminalDelivered = true;
226
250
  autoCloseTimer = setTimeout(() => close(), AUTO_CLOSE_DELAY_MS);
227
251
  options.onComplete({
@@ -230,6 +254,7 @@ function open(options) {
230
254
  break;
231
255
  }
232
256
  case "soliton:error":
257
+ stopReadyWait();
233
258
  (_a = options.onError) == null ? void 0 : _a.call(options, {
234
259
  code: typeof data.code === "string" ? data.code : "internal",
235
260
  message: typeof data.message === "string" ? data.message : ""
@@ -243,11 +268,25 @@ function open(options) {
243
268
  }
244
269
  };
245
270
  window.addEventListener("message", onMessage);
271
+ readyTimer = setTimeout(() => {
272
+ var _a;
273
+ if (readyTimer === null || closed || terminalDelivered) return;
274
+ loadTimedOut = true;
275
+ try {
276
+ (_a = options.onError) == null ? void 0 : _a.call(options, {
277
+ code: "load_timeout",
278
+ message: "Verification did not load. Please try again."
279
+ });
280
+ } finally {
281
+ close();
282
+ }
283
+ }, READY_TIMEOUT_MS);
246
284
  function close() {
247
285
  var _a;
248
286
  if (closed) return;
249
287
  closed = true;
250
288
  if (autoCloseTimer !== null) clearTimeout(autoCloseTimer);
289
+ stopReadyWait();
251
290
  stopHello();
252
291
  window.removeEventListener("message", onMessage);
253
292
  overlay.destroy();
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * Soliton Web SDK v1: embedded verification widget (Stripe/Sumsub wrapper
3
3
  * pattern). open() frames the EXISTING hosted verification page in a
4
4
  * full-viewport overlay on the customer's site and relays its lifecycle via
5
- * callbacks; the capture engine itself always runs on soliton.id inside the
6
- * iframe, so anti-injection defenses update with every deploy and the
5
+ * callbacks; the capture engine runs on the trusted verification origin inside
6
+ * the iframe, so anti-injection defenses update with each frontend release and the
7
7
  * verdict never touches the customer page (only the verification id does).
8
8
  *
9
9
  * Built artifacts (npm run build:sdk):
package/dist/index.mjs CHANGED
@@ -1,12 +1,23 @@
1
1
  /* Soliton Web SDK v1. Built from sdk/src via `npm run build:sdk`; do not edit by hand. */
2
2
 
3
+ // sdk/src/trustedOrigin.ts
4
+ function trustedOrigin(value) {
5
+ const url = new URL(value);
6
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
7
+ if (url.origin !== value || url.username || url.password || value.includes("*") || url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
8
+ throw new Error("Use an exact HTTPS origin (HTTP loopback is allowed for development)");
9
+ }
10
+ return url.origin;
11
+ }
12
+
3
13
  // sdk/src/hostedUrl.ts
4
14
  var HOSTED_PATH_RE = /^\/v\/[0-9a-f]{32}$/;
5
15
  var ALLOWED_HTTPS_ORIGINS = /* @__PURE__ */ new Set([
6
16
  "https://soliton.id",
7
17
  "https://staging.soliton.id"
8
18
  ]);
9
- function parseHostedUrl(raw) {
19
+ function parseHostedUrl(raw, verificationOrigin) {
20
+ const configuredOrigin = verificationOrigin === void 0 ? void 0 : trustedOrigin(verificationOrigin);
10
21
  let url;
11
22
  try {
12
23
  url = new URL(raw);
@@ -14,7 +25,10 @@ function parseHostedUrl(raw) {
14
25
  throw new Error(`SolitonVerify: "url" is not a valid absolute URL`);
15
26
  }
16
27
  const isLocalHttp = url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
17
- if (!ALLOWED_HTTPS_ORIGINS.has(url.origin) && !isLocalHttp) {
28
+ if (configuredOrigin !== void 0 && url.origin !== configuredOrigin) {
29
+ throw new Error('SolitonVerify: "url" does not match the configured verificationOrigin');
30
+ }
31
+ if (configuredOrigin === void 0 && !ALLOWED_HTTPS_ORIGINS.has(url.origin) && !isLocalHttp) {
18
32
  throw new Error(
19
33
  'SolitonVerify: "url" must be hosted on https://soliton.id or https://staging.soliton.id (http is allowed for localhost/127.0.0.1 development only)'
20
34
  );
@@ -139,6 +153,7 @@ var EMBED_PROTOCOL = "soliton-v1";
139
153
  var AUTO_CLOSE_DELAY_MS = 250;
140
154
  var HELLO_INTERVAL_MS = 250;
141
155
  var HELLO_MAX_PINGS = 40;
156
+ var READY_TIMEOUT_MS = 3e4;
142
157
  function open(options) {
143
158
  if (!options || typeof options.url !== "string") {
144
159
  throw new Error('SolitonVerify: open() requires an options object with a "url"');
@@ -146,14 +161,16 @@ function open(options) {
146
161
  if (typeof options.onComplete !== "function") {
147
162
  throw new Error("SolitonVerify: open() requires an onComplete callback");
148
163
  }
149
- const { url, hostedOrigin } = parseHostedUrl(options.url);
164
+ const { url, hostedOrigin } = parseHostedUrl(options.url, options.verificationOrigin);
150
165
  const src = new URL(url.toString());
151
166
  src.searchParams.set("mode", "embed");
152
167
  src.searchParams.set("sdk", "1");
153
168
  if (options.locale) src.searchParams.set("locale", options.locale);
154
169
  let closed = false;
155
170
  let terminalDelivered = false;
171
+ let loadTimedOut = false;
156
172
  let autoCloseTimer = null;
173
+ let readyTimer = null;
157
174
  const overlay = createOverlay(src.toString(), () => close());
158
175
  let helloPings = 0;
159
176
  const postHello = () => {
@@ -178,8 +195,13 @@ function open(options) {
178
195
  clearInterval(helloTimer);
179
196
  overlay.iframe.removeEventListener("load", onIframeLoad);
180
197
  }
198
+ function stopReadyWait() {
199
+ if (readyTimer !== null) clearTimeout(readyTimer);
200
+ readyTimer = null;
201
+ }
181
202
  const onMessage = (event) => {
182
203
  var _a;
204
+ if (closed || loadTimedOut || terminalDelivered) return;
183
205
  if (event.origin !== hostedOrigin) return;
184
206
  if (!overlay.iframe.contentWindow || event.source !== overlay.iframe.contentWindow) {
185
207
  return;
@@ -191,10 +213,12 @@ function open(options) {
191
213
  stopHello();
192
214
  switch (data.type) {
193
215
  case "soliton:ready":
216
+ stopReadyWait();
194
217
  overlay.markReady();
195
218
  break;
196
219
  case "soliton:complete": {
197
220
  if (terminalDelivered) break;
221
+ stopReadyWait();
198
222
  terminalDelivered = true;
199
223
  autoCloseTimer = setTimeout(() => close(), AUTO_CLOSE_DELAY_MS);
200
224
  options.onComplete({
@@ -203,6 +227,7 @@ function open(options) {
203
227
  break;
204
228
  }
205
229
  case "soliton:error":
230
+ stopReadyWait();
206
231
  (_a = options.onError) == null ? void 0 : _a.call(options, {
207
232
  code: typeof data.code === "string" ? data.code : "internal",
208
233
  message: typeof data.message === "string" ? data.message : ""
@@ -216,11 +241,25 @@ function open(options) {
216
241
  }
217
242
  };
218
243
  window.addEventListener("message", onMessage);
244
+ readyTimer = setTimeout(() => {
245
+ var _a;
246
+ if (readyTimer === null || closed || terminalDelivered) return;
247
+ loadTimedOut = true;
248
+ try {
249
+ (_a = options.onError) == null ? void 0 : _a.call(options, {
250
+ code: "load_timeout",
251
+ message: "Verification did not load. Please try again."
252
+ });
253
+ } finally {
254
+ close();
255
+ }
256
+ }, READY_TIMEOUT_MS);
219
257
  function close() {
220
258
  var _a;
221
259
  if (closed) return;
222
260
  closed = true;
223
261
  if (autoCloseTimer !== null) clearTimeout(autoCloseTimer);
262
+ stopReadyWait();
224
263
  stopHello();
225
264
  window.removeEventListener("message", onMessage);
226
265
  overlay.destroy();
@@ -0,0 +1,119 @@
1
+ /* Soliton Web SDK v1. Built from sdk/src via `npm run build:sdk`; do not edit by hand. */
2
+ "use strict";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // sdk/src/server.ts
22
+ var server_exports = {};
23
+ __export(server_exports, {
24
+ VerificationStartError: () => VerificationStartError,
25
+ createVerificationClient: () => createVerificationClient
26
+ });
27
+ module.exports = __toCommonJS(server_exports);
28
+ var VerificationStartError = class extends Error {
29
+ constructor(stage, sessionId, status, retryAfter) {
30
+ super(`Verification ${stage} failed${status ? ` (HTTP ${status})` : ""}. No automatic retry was attempted.`);
31
+ this.stage = stage;
32
+ this.sessionId = sessionId;
33
+ this.status = status;
34
+ this.retryAfter = retryAfter;
35
+ this.name = "VerificationStartError";
36
+ }
37
+ };
38
+ function origin(value) {
39
+ const url = new URL(value);
40
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
41
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:") || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
42
+ throw new TypeError("Use an HTTPS origin without credentials, path, query or fragment; HTTP is allowed only on localhost.");
43
+ }
44
+ return url.origin;
45
+ }
46
+ function validSessionId(value) {
47
+ return typeof value === "string" && /^ses_[a-zA-Z0-9]+$/.test(value);
48
+ }
49
+ function record(value) {
50
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
51
+ }
52
+ function createVerificationClient(options) {
53
+ if (typeof window !== "undefined") throw new Error("The verification client is server-only. Keep API keys on your server.");
54
+ const apiOrigin = origin(options.apiOrigin);
55
+ const verificationOrigin = origin(options.verificationOrigin);
56
+ if (!options.apiKey || /[\s\x00-\x1f\x7f]/.test(options.apiKey)) throw new TypeError("A server API key is required.");
57
+ const apiKey = options.apiKey;
58
+ const timeoutMs = options.timeoutMs ?? 2e4;
59
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 12e4) throw new TypeError("timeoutMs must be between 1 and 120000.");
60
+ async function post(path, stage, sessionId, body, idempotencyKey) {
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
63
+ let status;
64
+ try {
65
+ const response = await fetch(apiOrigin + path, {
66
+ method: "POST",
67
+ redirect: "error",
68
+ // Never forward a secret to an API redirect target.
69
+ signal: controller.signal,
70
+ headers: {
71
+ Authorization: `Bearer ${apiKey}`,
72
+ ...body ? { "Content-Type": "application/json" } : {},
73
+ ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}
74
+ },
75
+ ...body ? { body: JSON.stringify(body) } : {}
76
+ });
77
+ status = response.status;
78
+ if (!response.ok) throw new VerificationStartError(stage, sessionId, status, response.headers.get("Retry-After") ?? void 0);
79
+ return record(await response.json());
80
+ } catch (error) {
81
+ if (error instanceof VerificationStartError) throw error;
82
+ throw new VerificationStartError(stage, sessionId, status);
83
+ } finally {
84
+ clearTimeout(timer);
85
+ }
86
+ }
87
+ async function issueVerificationLink(sessionId) {
88
+ if (!validSessionId(sessionId)) throw new TypeError("A saved ses_ session ID is required.");
89
+ const issued = await post(`/api/v2/sessions/${sessionId}/link`, "link", sessionId);
90
+ if (typeof issued.browser_path !== "string" || !/^\/v\/[a-f0-9]{32}$/.test(issued.browser_path)) {
91
+ throw new VerificationStartError("link", sessionId);
92
+ }
93
+ return { sessionId, url: new URL(issued.browser_path, verificationOrigin).href };
94
+ }
95
+ async function startVerification(input) {
96
+ if (!["standard", "medium", "high"].includes(input.tier)) throw new TypeError("Choose standard, medium or high.");
97
+ if (typeof input.userReference !== "string" || !input.userReference.trim()) throw new TypeError("An authenticated user reference is required.");
98
+ if (typeof input.idempotencyKey !== "string" || !/^[\x21-\x7e]{1,196}$/.test(input.idempotencyKey)) {
99
+ throw new TypeError("Use a stable 1\u2013196 character idempotency key for this attempt.");
100
+ }
101
+ const session = await post("/api/v2/sessions", "create", void 0, {
102
+ client_reference_id: input.userReference,
103
+ checks: [{ type: "presence", configuration: { liveness_tier: input.tier } }]
104
+ }, input.idempotencyKey);
105
+ if (!validSessionId(session.id)) throw new VerificationStartError("create");
106
+ try {
107
+ await input.onSessionCreated?.(session.id);
108
+ } catch {
109
+ throw new VerificationStartError("persist", session.id);
110
+ }
111
+ return issueVerificationLink(session.id);
112
+ }
113
+ return { startVerification, issueVerificationLink };
114
+ }
115
+ // Annotate the CommonJS export names for ESM import in node:
116
+ 0 && (module.exports = {
117
+ VerificationStartError,
118
+ createVerificationClient
119
+ });
@@ -0,0 +1,34 @@
1
+ /** Server-only v2 convenience API. Never import this module into a browser bundle. */
2
+ export type VerificationTier = "standard" | "medium" | "high";
3
+ export type VerificationStage = "create" | "persist" | "link";
4
+ export declare class VerificationStartError extends Error {
5
+ readonly stage: VerificationStage;
6
+ readonly sessionId?: string | undefined;
7
+ readonly status?: number | undefined;
8
+ readonly retryAfter?: string | undefined;
9
+ constructor(stage: VerificationStage, sessionId?: string | undefined, status?: number | undefined, retryAfter?: string | undefined);
10
+ }
11
+ export interface VerificationClientOptions {
12
+ apiOrigin: string;
13
+ verificationOrigin: string;
14
+ apiKey: string;
15
+ /** Per-request timeout, including response body consumption. Default 20 seconds. */
16
+ timeoutMs?: number;
17
+ }
18
+ export interface StartVerificationOptions {
19
+ userReference: string;
20
+ tier: VerificationTier;
21
+ /** Persist this per logical attempt; reuse only with the identical request. */
22
+ idempotencyKey: string;
23
+ /** Awaited before link issuance. Persist the ID against the authenticated user here. */
24
+ onSessionCreated?: (sessionId: string) => void | Promise<void>;
25
+ }
26
+ export interface StartedVerification {
27
+ sessionId: string;
28
+ url: string;
29
+ }
30
+ /** Node 18+ (native fetch). No retries or in-memory caching; persistence belongs to your backend. */
31
+ export declare function createVerificationClient(options: VerificationClientOptions): {
32
+ startVerification: (input: StartVerificationOptions) => Promise<StartedVerification>;
33
+ issueVerificationLink: (sessionId: string) => Promise<StartedVerification>;
34
+ };
@@ -0,0 +1,94 @@
1
+ /* Soliton Web SDK v1. Built from sdk/src via `npm run build:sdk`; do not edit by hand. */
2
+
3
+ // sdk/src/server.ts
4
+ var VerificationStartError = class extends Error {
5
+ constructor(stage, sessionId, status, retryAfter) {
6
+ super(`Verification ${stage} failed${status ? ` (HTTP ${status})` : ""}. No automatic retry was attempted.`);
7
+ this.stage = stage;
8
+ this.sessionId = sessionId;
9
+ this.status = status;
10
+ this.retryAfter = retryAfter;
11
+ this.name = "VerificationStartError";
12
+ }
13
+ };
14
+ function origin(value) {
15
+ const url = new URL(value);
16
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
17
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:") || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
18
+ throw new TypeError("Use an HTTPS origin without credentials, path, query or fragment; HTTP is allowed only on localhost.");
19
+ }
20
+ return url.origin;
21
+ }
22
+ function validSessionId(value) {
23
+ return typeof value === "string" && /^ses_[a-zA-Z0-9]+$/.test(value);
24
+ }
25
+ function record(value) {
26
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
27
+ }
28
+ function createVerificationClient(options) {
29
+ if (typeof window !== "undefined") throw new Error("The verification client is server-only. Keep API keys on your server.");
30
+ const apiOrigin = origin(options.apiOrigin);
31
+ const verificationOrigin = origin(options.verificationOrigin);
32
+ if (!options.apiKey || /[\s\x00-\x1f\x7f]/.test(options.apiKey)) throw new TypeError("A server API key is required.");
33
+ const apiKey = options.apiKey;
34
+ const timeoutMs = options.timeoutMs ?? 2e4;
35
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 12e4) throw new TypeError("timeoutMs must be between 1 and 120000.");
36
+ async function post(path, stage, sessionId, body, idempotencyKey) {
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
39
+ let status;
40
+ try {
41
+ const response = await fetch(apiOrigin + path, {
42
+ method: "POST",
43
+ redirect: "error",
44
+ // Never forward a secret to an API redirect target.
45
+ signal: controller.signal,
46
+ headers: {
47
+ Authorization: `Bearer ${apiKey}`,
48
+ ...body ? { "Content-Type": "application/json" } : {},
49
+ ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}
50
+ },
51
+ ...body ? { body: JSON.stringify(body) } : {}
52
+ });
53
+ status = response.status;
54
+ if (!response.ok) throw new VerificationStartError(stage, sessionId, status, response.headers.get("Retry-After") ?? void 0);
55
+ return record(await response.json());
56
+ } catch (error) {
57
+ if (error instanceof VerificationStartError) throw error;
58
+ throw new VerificationStartError(stage, sessionId, status);
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+ async function issueVerificationLink(sessionId) {
64
+ if (!validSessionId(sessionId)) throw new TypeError("A saved ses_ session ID is required.");
65
+ const issued = await post(`/api/v2/sessions/${sessionId}/link`, "link", sessionId);
66
+ if (typeof issued.browser_path !== "string" || !/^\/v\/[a-f0-9]{32}$/.test(issued.browser_path)) {
67
+ throw new VerificationStartError("link", sessionId);
68
+ }
69
+ return { sessionId, url: new URL(issued.browser_path, verificationOrigin).href };
70
+ }
71
+ async function startVerification(input) {
72
+ if (!["standard", "medium", "high"].includes(input.tier)) throw new TypeError("Choose standard, medium or high.");
73
+ if (typeof input.userReference !== "string" || !input.userReference.trim()) throw new TypeError("An authenticated user reference is required.");
74
+ if (typeof input.idempotencyKey !== "string" || !/^[\x21-\x7e]{1,196}$/.test(input.idempotencyKey)) {
75
+ throw new TypeError("Use a stable 1\u2013196 character idempotency key for this attempt.");
76
+ }
77
+ const session = await post("/api/v2/sessions", "create", void 0, {
78
+ client_reference_id: input.userReference,
79
+ checks: [{ type: "presence", configuration: { liveness_tier: input.tier } }]
80
+ }, input.idempotencyKey);
81
+ if (!validSessionId(session.id)) throw new VerificationStartError("create");
82
+ try {
83
+ await input.onSessionCreated?.(session.id);
84
+ } catch {
85
+ throw new VerificationStartError("persist", session.id);
86
+ }
87
+ return issueVerificationLink(session.id);
88
+ }
89
+ return { startVerification, issueVerificationLink };
90
+ }
91
+ export {
92
+ VerificationStartError,
93
+ createVerificationClient
94
+ };
@@ -0,0 +1,2 @@
1
+ /** Exact origin configuration, never derived from the URL being opened. */
2
+ export declare function trustedOrigin(value: string): string;
package/dist/types.d.ts CHANGED
@@ -12,22 +12,38 @@
12
12
  * add payload fields the wrapper is required to understand.
13
13
  */
14
14
  export interface SolitonVerifyOptions {
15
- /** Hosted verification URL from POST /api/v1/verifications. */
15
+ /**
16
+ * Hosted URL from the v1 verification API or v2 session link API. Choose
17
+ * liveness_tier (standard, medium, high) in that server-side request;
18
+ * the URL binds the tier in both sandbox and production. open() does not
19
+ * select or override it, and existing SDK versions support every tier.
20
+ */
16
21
  url: string;
22
+ /**
23
+ * Explicit trusted origin for a customer-hosted deployment. Exact HTTPS origin
24
+ * (HTTP loopback for development); must come from your integration configuration,
25
+ * never from an untrusted link. When set, only this origin is accepted.
26
+ * Omit to retain the existing Soliton cloud origin allowlist.
27
+ */
28
+ verificationOrigin?: string;
17
29
  /**
18
30
  * Terminal state reached (pass OR final fail; deliberately not
19
31
  * distinguished here). Carries only the verification id: confirm the
20
- * verdict server-side via GET /api/v1/verifications/{id}.
32
+ * verdict server-side using the ID saved at creation. For v2, fetch
33
+ * GET /api/v2/sessions/{savedSessionId}; verificationId remains the
34
+ * browser UUID alias for compatibility, not the prefixed ses_ ID.
21
35
  */
22
36
  onComplete: (e: {
23
37
  verificationId: string;
24
38
  }) => void;
25
- /** Flow-level errors: link_expired, link_invalid, origin_not_allowed, storage_unavailable, internal. */
39
+ /** Flow-level errors: link_expired, link_invalid, origin_not_allowed,
40
+ * storage_unavailable, internal, load_timeout. A load_timeout closes the
41
+ * overlay after this callback and invokes onClose, never onComplete. */
26
42
  onError?: (e: {
27
43
  code: string;
28
44
  message: string;
29
45
  }) => void;
30
- /** User dismissed the flow before completion. */
46
+ /** Flow closed before completion, including an initial load timeout. */
31
47
  onClose?: () => void;
32
48
  /** Reserved; passed through to the hosted page as a query param. */
33
49
  locale?: string;
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
- "//": "Publishable manifest for the npm Web SDK. No devDependencies and no own node_modules by design: every build runs from the next-app root with its existing toolchain (npm run build:sdk), so the lockfile never grows for packaging. dist/ is rebuilt automatically by the prepack hook and is not committed.",
2
+ "//": "Publishable manifest for the npm Web SDK. No devDependencies and no own node_modules by design: every build runs from the front-end repository root with its existing toolchain (npm run build:sdk), so the lockfile never grows for packaging. dist/ is rebuilt automatically by the prepack hook and is not committed.",
3
3
  "name": "@soliton-id/web-sdk",
4
- "version": "1.0.1",
4
+ "version": "1.1.1",
5
5
  "description": "Soliton identity verification for the browser: opens the hosted verification flow in a full-viewport overlay iframe on your page and reports lifecycle events. Verdicts and portraits are fetched by your backend, never exposed in the browser.",
6
6
  "license": "MIT",
7
7
  "author": "Soliton",
8
8
  "homepage": "https://soliton.id/docs/integration/sdk",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/georgesabr/next-app.git",
11
+ "url": "git+https://github.com/georges-soliton/soliton-front-end.git",
12
12
  "directory": "sdk"
13
13
  },
14
14
  "bugs": {
15
- "url": "https://github.com/georgesabr/next-app/issues"
15
+ "url": "https://github.com/georges-soliton/soliton-front-end/issues"
16
16
  },
17
17
  "publishConfig": {
18
18
  "access": "public",
@@ -27,6 +27,12 @@
27
27
  "types": "./dist/index.d.ts",
28
28
  "import": "./dist/index.mjs",
29
29
  "require": "./dist/index.cjs"
30
+ },
31
+ "./server": {
32
+ "types": "./dist/server.d.ts",
33
+ "browser": null,
34
+ "import": "./dist/server.mjs",
35
+ "require": "./dist/server.cjs"
30
36
  }
31
37
  },
32
38
  "files": [