@thirdfactor/web 1.0.0

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 ADDED
@@ -0,0 +1,311 @@
1
+ # @thirdfactor/web
2
+
3
+ Web client SDK for the **ThirdFactor / Obsidian** native KYC engine. It opens the
4
+ hosted verification flow in a **popup**, a cross-origin **modal** iframe, or a
5
+ full-page **redirect**, listens to the flow over the shared bridge protocol, and
6
+ resolves a single typed `VerificationResult`.
7
+
8
+ The SDK is a thin shell: your backend creates the session and gets a hosted `url`;
9
+ the browser hands that `url` to this SDK. Session creation needs your **secret API
10
+ key** and is done on your server (an optional `@thirdfactor/web/server` helper is
11
+ included).
12
+
13
+ > **The client result is advisory.** It is a UX signal only. Grant access / move
14
+ > money **only after** your backend confirms the decision from the signed webhook
15
+ > (`identity.kyc.session.approved` / `.declined` / `.review` / `.expired`) or
16
+ > `GET /v3/session/<id>/decision/`. A user can close the tab after approval or
17
+ > tamper with the browser; the webhook is HMAC-signed and cannot be spoofed.
18
+
19
+ - **Bridge protocol:** [`../../docs/sdk/PROTOCOL.md`](../../docs/sdk/PROTOCOL.md)
20
+ - **REST reference:** [`../../OBSIDIAN_API.md`](../../OBSIDIAN_API.md)
21
+
22
+ ## Requirements
23
+
24
+ | | |
25
+ | --- | --- |
26
+ | Browser | Any modern evergreen browser (Chromium, Firefox, Safari) with `postMessage` + camera (`getUserMedia`). |
27
+ | Bundler / module system | ESM. Ships `.js` + `.d.ts`; works with Vite, webpack, Next.js, etc. |
28
+ | Backend (for the session helper) | Node 18+ (global `fetch`) or any runtime with a WHATWG `fetch`. |
29
+ | TypeScript | Optional; full types are bundled. |
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ npm install @thirdfactor/web
35
+ ```
36
+
37
+ Two entry points, deliberately separated so your **browser bundle never imports
38
+ anything that touches your API key**:
39
+
40
+ ```ts
41
+ import { ThirdFactor } from "@thirdfactor/web"; // browser
42
+ import { createSession } from "@thirdfactor/web/server"; // server only
43
+ ```
44
+
45
+ ## Permissions
46
+
47
+ The hosted flow captures the camera (document + selfie/liveness) and may use the
48
+ microphone. There is no manifest on the web — the browser prompts the user the
49
+ first time the flow calls `getUserMedia`. What matters is the **browsing context**
50
+ the camera runs in:
51
+
52
+ - **`popup`** (and `redirect`) run the flow in a top-level context, so the camera
53
+ prompt and stream work everywhere, including iOS Safari.
54
+ - **`modal`** runs the flow in a cross-origin iframe. The browser only grants it
55
+ camera/mic if the flow deployment allowlists your origin (see
56
+ [Configuration → `modal`](#enabling-modal-cross-origin-iframe)), and camera in a
57
+ cross-origin iframe is still unreliable on iOS Safari.
58
+
59
+ The SDK already sets `allow="camera; microphone"` on the modal iframe; your app's
60
+ own CSP `frame-src` must also permit the flow origin when you use `modal`.
61
+
62
+ ## Quick Start
63
+
64
+ ```ts
65
+ // 1) Backend — create the session (secret key, never in the browser).
66
+ import { createSession } from "@thirdfactor/web/server";
67
+ const session = await createSession(
68
+ { baseUrl: process.env.TF_BASE_URL!, apiKey: process.env.TF_API_KEY! },
69
+ { vendorData: user.id, callbackUrl: "https://app.acme.com/kyc/done" },
70
+ );
71
+ // send { url: session.url, id: session.id } to the browser
72
+
73
+ // 2) Browser — open the flow from a real click.
74
+ import { ThirdFactor } from "@thirdfactor/web";
75
+ button.addEventListener("click", async () => {
76
+ const result = await ThirdFactor.verify({ url: session.url });
77
+ if (result.status === "approved") showSuccess(); // then confirm server-side
78
+ });
79
+ ```
80
+
81
+ > Call `verify()` from a genuine click/tap — popup mode is blocked otherwise.
82
+
83
+ ## Integration Methods
84
+
85
+ ### Method 1 — Backend session helper (recommended)
86
+
87
+ `@thirdfactor/web/server` wraps `POST /v3/session/` so you don't hand-roll the
88
+ request. It runs **only** on your server.
89
+
90
+ ```ts
91
+ import { createSession } from "@thirdfactor/web/server";
92
+
93
+ const session = await createSession(
94
+ { baseUrl: process.env.TF_BASE_URL!, apiKey: process.env.TF_API_KEY! },
95
+ {
96
+ vendorData: user.id, // your reference for this user
97
+ callbackUrl: "https://app.acme.com/kyc/done",
98
+ contactEmail: user.email,
99
+ locale: "en", // hosted-flow language
100
+ prefill: { full_name: user.name }, // pre-populate the flow
101
+ idempotencyKey: `kyc-${user.id}`, // safe retries, never double-charged
102
+ },
103
+ );
104
+ // → { id, url, status, decision, expiresAt, ... }; send { url, id } to the browser
105
+ ```
106
+
107
+ On a non-2xx response it throws `ThirdFactorApiError` with `.status` and a stable
108
+ `.code` (e.g. `insufficient_credits`).
109
+
110
+ ### Method 2 — Any backend + `verify({ url })`
111
+
112
+ The helper is optional. `POST /v3/session/` is a single authenticated call; create
113
+ the session however you like (cURL / Python / Go — see
114
+ [`../../docs/sdk/README.md`](../../docs/sdk/README.md)) and pass the returned `url`
115
+ to `ThirdFactor.verify({ url })`. The browser side is identical either way.
116
+
117
+ ### Presentation modes
118
+
119
+ | Mode | Call | Best for | Notes |
120
+ | --- | --- | --- | --- |
121
+ | `auto` (default) | `verify({ url })` | Anything | Resolves to **popup** — a top-level context with reliable camera everywhere. |
122
+ | `popup` | `verify({ url, mode: "popup" })` | Mobile web, zero-config | New window; most reliable camera. |
123
+ | `modal` | `verify({ url, mode: "modal" })` | Desktop, in-page UX | Cross-origin iframe. **Opt-in** — the flow must allowlist your origin. |
124
+ | `redirect` | `ThirdFactor.redirect(url)` | Max compatibility | Full-page navigate; does not return a Promise. Result arrives via `callback_url` + webhook. |
125
+
126
+ ## Configuration
127
+
128
+ ### `ThirdFactor.verify(options): Promise<VerificationResult>`
129
+
130
+ Never rejects — always resolves a `VerificationResult`.
131
+
132
+ | Option | Type | Default | Notes |
133
+ | --- | --- | --- | --- |
134
+ | `url` | `string` | — (required) | Hosted session URL from `POST /v3/session/`. |
135
+ | `mode` | `"modal" \| "popup" \| "auto"` | `"auto"` | `auto` = popup. `modal` is opt-in (see below). |
136
+ | `onEvent` | `(e: VerificationEvent) => void` | — | Called for every lifecycle event: `ready` / `completed` / `error` / `close`. |
137
+ | `title` | `string` | `"Identity verification"` | Accessible aria-label for the modal dialog. |
138
+ | `autoClose` | `boolean` | `true` | `false` keeps the flow's own result screen up until the user taps Done. |
139
+ | `expectedOrigin` | `string` | origin of `url` | Only override behind a proxy that serves the flow on a different origin. |
140
+
141
+ `ThirdFactor.redirect(url: string): void` navigates the whole page to the flow and
142
+ does not return.
143
+
144
+ ### `createSession(config, input)` — server helper
145
+
146
+ `config`: `{ baseUrl, apiKey, fetchImpl? }`. `input` (all optional):
147
+
148
+ | Field | Type | Maps to `POST /v3/session/` | Notes |
149
+ | --- | --- | --- | --- |
150
+ | `workflowId` | `string` | `workflow_id` | KYC workflow UUID; tenant default when omitted. |
151
+ | `vendorData` | `string` | `vendor_data` | Your external user reference (also the linked identity's external id). |
152
+ | `callbackUrl` | `string` | `callback_url` | Where the applicant is redirected after finishing. |
153
+ | `contactEmail` / `contactPhone` | `string` | `contact_email` / `contact_phone` | OTP + notifications. |
154
+ | `expiresInHours` | `number` | `expires_in_hours` | Hosted-URL lifetime, `1..720` (default `48`). |
155
+ | `locale` | `string` | `locale` | Hosted-flow language (default `en`). |
156
+ | `prefill` | `Record<string,string>` | `prefill` | Applicant fields to pre-populate (only documented keys honored). |
157
+ | `idempotencyKey` | `string` | `Idempotency-Key` header | Repeat creates return the original session, uncharged. |
158
+
159
+ See [`OBSIDIAN_API.md`](../../OBSIDIAN_API.md#create-verification-session) for the
160
+ authoritative field list.
161
+
162
+ ### Enabling `modal` (cross-origin iframe)
163
+
164
+ `modal` embeds the flow in an iframe on **your** origin. For that to work the flow
165
+ deployment must allowlist your app origin — otherwise the browser refuses the frame
166
+ (`X-Frame-Options`) and denies the iframe camera (`Permissions-Policy`). On the
167
+ Obsidian flow deployment set:
168
+
169
+ ```bash
170
+ FLOW_EMBED_ORIGINS="https://app.acme.com https://staging.acme.com"
171
+ ```
172
+
173
+ This adds your origins to the flow route's `frame-ancestors` and grants them
174
+ `camera`/`microphone`. Every other route keeps `X-Frame-Options: DENY`. With it
175
+ unset (the default), cross-origin `modal` is refused by design — use `auto` (popup)
176
+ or `redirect`. Even correctly allowlisted, camera in a cross-origin iframe is
177
+ unreliable on iOS Safari; that's why `auto` uses popup.
178
+
179
+ ## Language / Localization
180
+
181
+ The hosted flow's language and copy are chosen at **session-create time**, not in
182
+ the browser SDK. Pass `locale` to `createSession` (or `"locale"` in the raw
183
+ `POST /v3/session/` body); the SDK renders whatever the flow serves — no
184
+ per-platform i18n code. See
185
+ [`OBSIDIAN_API.md`](../../OBSIDIAN_API.md#create-verification-session).
186
+
187
+ ## Advanced Options
188
+
189
+ Prefill, expected identity details, and metadata are also **session-create
190
+ concerns**, not browser options:
191
+
192
+ - **Prefill** — pass `prefill` to `createSession` to pre-populate the flow
193
+ (`full_name`, `first_name`, `last_name`, `display_name`, `date_of_birth`,
194
+ `email`, `phone`, `nationality`, `address_line1/2`, `city`, `state`, `country`,
195
+ `zip_code`). Unknown keys are ignored.
196
+ - **Metadata / your reference** — `vendorData` becomes the session's external
197
+ reference and the linked identity's `external_user_id`; it comes back on the
198
+ session and every webhook.
199
+ - **Workflow selection** — `workflowId` picks a specific KYC workflow; omit it for
200
+ the tenant default.
201
+
202
+ Browser-side advanced knobs are just `expectedOrigin` (proxy override) and
203
+ `autoClose` (keep the flow's result screen visible until Done).
204
+
205
+ ## Result Handling
206
+
207
+ Every SDK resolves the same small vocabulary:
208
+
209
+ ```ts
210
+ interface VerificationResult {
211
+ status: "approved" | "declined" | "in_review" | "cancelled" | "error";
212
+ sessionId?: string; // echoed from the flow when available
213
+ error?: { code: string; message: string }; // present only when status === "error"
214
+ rawStatus?: string; // raw server status (e.g. "abandoned")
215
+ }
216
+ ```
217
+
218
+ | `status` | When | Your move |
219
+ | --- | --- | --- |
220
+ | `approved` | Flow approved the applicant | Continue (after server confirm) |
221
+ | `declined` | Flow rejected the applicant | Block / offer appeal |
222
+ | `in_review` | Needs an operator / AML hit / any unknown terminal status (conservative) | Show "pending", wait for webhook |
223
+ | `cancelled` | User closed the surface, or server `abandoned` | Let them retry |
224
+ | `error` | Invalid/expired session or load failure | Surface `error.code`, retry / re-mint session |
225
+
226
+ ### Error cases
227
+
228
+ `status === "error"` carries a stable `error.code` — branch on it, never on
229
+ `error.message`:
230
+
231
+ | `error.code` | Cause |
232
+ | --- | --- |
233
+ | `invalid_session` | Token is not a valid session. |
234
+ | `expired` | Session/URL expired. |
235
+ | `popup_blocked` | `verify()` was not called from a user gesture. |
236
+ | `load_error` | The flow surface failed to load. |
237
+ | `missing_url` | `options.url` was empty. |
238
+ | `no_window` | `verify()` ran outside a browser. |
239
+ | `network` / `unknown` | Transport failure / unrecognized error. |
240
+
241
+ ### Full example
242
+
243
+ ```ts
244
+ import { ThirdFactor } from "@thirdfactor/web";
245
+
246
+ const result = await ThirdFactor.verify({
247
+ url: session.url,
248
+ mode: "auto",
249
+ autoClose: true,
250
+ onEvent: (e) => console.log(e.type), // ready | completed | error | close
251
+ });
252
+
253
+ switch (result.status) {
254
+ case "approved": showSuccess(); break; // confirm server-side before trusting
255
+ case "declined": showFailure(); break;
256
+ case "in_review": showPending(); break;
257
+ case "cancelled": /* user closed the flow — let them retry */ break;
258
+ case "error":
259
+ if (result.error?.code === "expired") reMintSession();
260
+ else showError(result.error);
261
+ break;
262
+ }
263
+ ```
264
+
265
+ ## NFC Verification
266
+
267
+ **The web SDK has no NFC module.** Reading an ePassport / eID chip requires the
268
+ device NFC radio, which a browser/WebView cannot reach. NFC is therefore a
269
+ **native-only** capability, implemented in the mobile SDKs:
270
+
271
+ - [iOS — `ThirdFactorKYCNFC`](../ios/README.md#nfc-verification-epassport--eid)
272
+ - [Android — `ThirdFactorNfc`](../android/README.md#nfc-verification-epassport--eid)
273
+ - [Flutter — `ThirdFactorNfc`](../flutter/README.md#nfc-verification-epassport--eid)
274
+
275
+ On the web, when a workflow includes an `NFC_VERIFICATION` step, the flow
276
+ advertises no `nfc` capability, so the server-side fallback applies (the NFC step
277
+ resolves to manual review). See [`PROTOCOL.md` §8](../../docs/sdk/PROTOCOL.md#8-native-capability-channel-bidirectional).
278
+
279
+ ## End-to-End Workflow
280
+
281
+ ```
282
+ 1. Backend POST /v3/session/ (x-api-key: SECRET) → { id, url }
283
+ 2. Backend send { url } to the browser
284
+ 3. Browser ThirdFactor.verify({ url }) → opens url?embed=1&sdk=web
285
+ 4. Flow bridge events over postMessage → SDK resolves ONE advisory result
286
+ 5. Backend AUTHORITATIVE decision — do this before granting access:
287
+ webhook identity.kyc.session.{approved|declined|review|expired}
288
+ or GET /v3/session/<id>/decision/
289
+ ```
290
+
291
+ Webhook signature verification (`X-Webhook-Signature: t=…,v1=…`) is documented in
292
+ [`OBSIDIAN_API.md`](../../OBSIDIAN_API.md#verifying-webhook-signatures).
293
+
294
+ ### How it works
295
+
296
+ `verify()` opens the hosted flow with `?embed=1&sdk=web` appended. In embed mode
297
+ the flow reports lifecycle over `postMessage` instead of redirecting. The SDK
298
+ validates every message against the flow's origin and the `thirdfactor` source tag
299
+ before acting. The same bridge protocol backs the Flutter, iOS, and Android SDKs.
300
+
301
+ ## Local development
302
+
303
+ ```bash
304
+ npm install
305
+ npm run build
306
+ # exercise the SDK against a mock flow — no backend required:
307
+ npx http-server . -p 8899 # then visit /example/index.html
308
+ ```
309
+
310
+ `example/` contains a self-contained test harness (`index.html`) and a mock flow
311
+ (`mock-flow.html`) that speaks the real bridge protocol.
@@ -0,0 +1,22 @@
1
+ import type { VerificationResult, VerifyOptions } from "./types.js";
2
+ /**
3
+ * Open the hosted verification flow and resolve once it reaches a terminal
4
+ * state, the user cancels, or an error occurs. Never rejects — inspect
5
+ * `result.status` (and `result.error` when it is `"error"`).
6
+ *
7
+ * Call this from a user gesture (click/tap) so popup mode is not blocked.
8
+ *
9
+ * ```ts
10
+ * const result = await ThirdFactor.verify({ url: session.url });
11
+ * if (result.status === "approved") showSuccess();
12
+ * // Then confirm server-side before trusting it.
13
+ * ```
14
+ */
15
+ export declare function verify(options: VerifyOptions): Promise<VerificationResult>;
16
+ /**
17
+ * Navigate the whole page to the hosted flow. The flow returns the user to the
18
+ * session's `callback_url` on completion; read the authoritative decision from
19
+ * your backend / webhook there. Does not return.
20
+ */
21
+ export declare function redirect(url: string): void;
22
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,kBAAkB,EAAc,aAAa,EAAE,MAAM,YAAY,CAAC;AAYhF;;;;;;;;;;;;GAYG;AACH,wBAAgB,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAqB1E;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAK1C"}
package/dist/client.js ADDED
@@ -0,0 +1,63 @@
1
+ // Public entry points: verify() (modal/popup, resolves a result) and
2
+ // redirect() (full-page navigation, result arrives via your callback_url +
3
+ // webhook).
4
+ import { createModalSurface } from "./modal.js";
5
+ import { createPopupSurface } from "./popup.js";
6
+ import { originOf, withEmbedParams } from "./protocol.js";
7
+ import { runSession } from "./runner.js";
8
+ function resolveMode(mode) {
9
+ if (mode === "modal" || mode === "popup")
10
+ return mode;
11
+ // `auto`: popup is a top-level browsing context, so it works with the flow's
12
+ // default (strict, anti-clickjacking) headers AND has the most reliable
13
+ // camera access on every platform. Cross-origin `modal` needs the operator to
14
+ // allowlist the app origin via FLOW_EMBED_ORIGINS on the flow deployment, and
15
+ // that can't be feature-detected from here — so it stays explicit opt-in.
16
+ return "popup";
17
+ }
18
+ /**
19
+ * Open the hosted verification flow and resolve once it reaches a terminal
20
+ * state, the user cancels, or an error occurs. Never rejects — inspect
21
+ * `result.status` (and `result.error` when it is `"error"`).
22
+ *
23
+ * Call this from a user gesture (click/tap) so popup mode is not blocked.
24
+ *
25
+ * ```ts
26
+ * const result = await ThirdFactor.verify({ url: session.url });
27
+ * if (result.status === "approved") showSuccess();
28
+ * // Then confirm server-side before trusting it.
29
+ * ```
30
+ */
31
+ export function verify(options) {
32
+ if (typeof window === "undefined") {
33
+ return Promise.resolve({
34
+ status: "error",
35
+ error: { code: "no_window", message: "verify() must run in a browser." },
36
+ });
37
+ }
38
+ if (!options?.url) {
39
+ return Promise.resolve({
40
+ status: "error",
41
+ error: { code: "missing_url", message: "options.url is required." },
42
+ });
43
+ }
44
+ const mode = resolveMode(options.mode);
45
+ const expectedOrigin = options.expectedOrigin || originOf(options.url);
46
+ const embeddedUrl = withEmbedParams(options.url, "web");
47
+ const title = options.title || "Identity verification";
48
+ const surface = mode === "popup" ? createPopupSurface() : createModalSurface(title);
49
+ return runSession(embeddedUrl, expectedOrigin, options, surface);
50
+ }
51
+ /**
52
+ * Navigate the whole page to the hosted flow. The flow returns the user to the
53
+ * session's `callback_url` on completion; read the authoritative decision from
54
+ * your backend / webhook there. Does not return.
55
+ */
56
+ export function redirect(url) {
57
+ if (typeof window === "undefined")
58
+ return;
59
+ // Redirect mode has no embedding host, so the flow uses its normal
60
+ // callback_url redirect — do NOT append the embed flag.
61
+ window.location.assign(url);
62
+ }
63
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,2EAA2E;AAC3E,YAAY;AAEZ,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAGzC,SAAS,WAAW,CAAC,IAA4B;IAC/C,IAAI,IAAI,KAAK,OAAO,IAAI,IAAI,KAAK,OAAO;QAAE,OAAO,IAAI,CAAC;IACtD,6EAA6E;IAC7E,wEAAwE;IACxE,8EAA8E;IAC9E,8EAA8E;IAC9E,0EAA0E;IAC1E,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,MAAM,CAAC,OAAsB;IAC3C,IAAI,OAAO,MAAM,KAAK,WAAW,EAAE,CAAC;QAClC,OAAO,OAAO,CAAC,OAAO,CAAC;YACrB,MAAM,EAAE,OAAO;YACf,KAAK,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,iCAAiC,EAAE;SACzE,CAAC,CAAC;IACL,CAAC;IACD,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC;QAClB,OAAO,OAAO,CAAC,OAAO,CAAC;YACrB,MAAM,EAAE,OAAO;YACf,KAAK,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,0BAA0B,EAAE;SACpE,CAAC,CAAC;IACL,CAAC;IAED,MAAM,IAAI,GAAG,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,IAAI,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACvE,MAAM,WAAW,GAAG,eAAe,CAAC,OAAO,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACxD,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,uBAAuB,CAAC;IAEvD,MAAM,OAAO,GAAG,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,kBAAkB,EAAE,CAAC,CAAC,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC;IACpF,OAAO,UAAU,CAAC,WAAW,EAAE,cAAc,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AACnE,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,QAAQ,CAAC,GAAW;IAClC,IAAI,OAAO,MAAM,KAAK,WAAW;QAAE,OAAO;IAC1C,mEAAmE;IACnE,wDAAwD;IACxD,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AAC9B,CAAC"}
@@ -0,0 +1,11 @@
1
+ import { redirect, verify } from "./client.js";
2
+ export { verify, redirect };
3
+ export type { VerificationEvent, VerificationError, VerificationResult, VerificationStatus, VerifyMode, VerifyOptions, } from "./types.js";
4
+ export { BRIDGE_SOURCE, normalizeStatus, parseBridgeMessage } from "./protocol.js";
5
+ /** Namespace object for `import { ThirdFactor } from "@thirdfactor/web"`. */
6
+ export declare const ThirdFactor: {
7
+ readonly verify: typeof verify;
8
+ readonly redirect: typeof redirect;
9
+ };
10
+ export default ThirdFactor;
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE/C,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;AAC5B,YAAY,EACV,iBAAiB,EACjB,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,aAAa,GACd,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAEnF,6EAA6E;AAC7E,eAAO,MAAM,WAAW;;;CAAgC,CAAC;AAEzD,eAAe,WAAW,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // @thirdfactor/web — browser entry point.
2
+ //
3
+ // Server-side session creation lives in the separate "@thirdfactor/web/server"
4
+ // entry so the browser bundle never imports anything that touches your API key.
5
+ import { redirect, verify } from "./client.js";
6
+ export { verify, redirect };
7
+ export { BRIDGE_SOURCE, normalizeStatus, parseBridgeMessage } from "./protocol.js";
8
+ /** Namespace object for `import { ThirdFactor } from "@thirdfactor/web"`. */
9
+ export const ThirdFactor = { verify, redirect };
10
+ export default ThirdFactor;
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,0CAA0C;AAC1C,EAAE;AACF,+EAA+E;AAC/E,gFAAgF;AAEhF,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE/C,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;AAS5B,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAEnF,6EAA6E;AAC7E,MAAM,CAAC,MAAM,WAAW,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAW,CAAC;AAEzD,eAAe,WAAW,CAAC"}
@@ -0,0 +1,3 @@
1
+ import type { Surface } from "./runner.js";
2
+ export declare function createModalSurface(title: string): Surface;
3
+ //# sourceMappingURL=modal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modal.d.ts","sourceRoot":"","sources":["../src/modal.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAK3C,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAyHzD"}
package/dist/modal.js ADDED
@@ -0,0 +1,121 @@
1
+ // Full-screen overlay modal surface: a branded backdrop + centered iframe with
2
+ // a close affordance. Camera/microphone are granted to the iframe via `allow`;
3
+ // note that some mobile browsers (notably iOS Safari) restrict getUserMedia in
4
+ // cross-origin iframes — that is why `mode: "auto"` prefers a popup on mobile.
5
+ const OVERLAY_ID = "tf-verify-overlay";
6
+ const Z = 2147483000; // just under max, above app chrome
7
+ export function createModalSurface(title) {
8
+ let overlay = null;
9
+ let cancelHandler = null;
10
+ let onKeyDown = null;
11
+ let priorOverflow = "";
12
+ let priorActive = null;
13
+ const triggerCancel = () => cancelHandler?.();
14
+ return {
15
+ onCancel(handler) {
16
+ cancelHandler = handler;
17
+ },
18
+ mount(url) {
19
+ priorActive = document.activeElement;
20
+ priorOverflow = document.body.style.overflow;
21
+ document.body.style.overflow = "hidden"; // lock background scroll
22
+ overlay = document.createElement("div");
23
+ overlay.id = OVERLAY_ID;
24
+ overlay.setAttribute("role", "dialog");
25
+ overlay.setAttribute("aria-modal", "true");
26
+ overlay.setAttribute("aria-label", title);
27
+ Object.assign(overlay.style, {
28
+ position: "fixed",
29
+ inset: "0",
30
+ zIndex: String(Z),
31
+ display: "flex",
32
+ alignItems: "center",
33
+ justifyContent: "center",
34
+ padding: "0",
35
+ background: "rgba(15, 23, 42, 0.55)",
36
+ backdropFilter: "blur(2px)",
37
+ // Fade in without a stylesheet dependency.
38
+ animation: "none",
39
+ opacity: "1",
40
+ });
41
+ const frameWrap = document.createElement("div");
42
+ Object.assign(frameWrap.style, {
43
+ position: "relative",
44
+ width: "100%",
45
+ height: "100%",
46
+ maxWidth: "480px",
47
+ maxHeight: "820px",
48
+ background: "#fff",
49
+ borderRadius: "16px",
50
+ overflow: "hidden",
51
+ boxShadow: "0 24px 60px rgba(0,0,0,0.35)",
52
+ });
53
+ // On small viewports go edge-to-edge.
54
+ if (window.innerWidth < 520 || window.innerHeight < 860) {
55
+ frameWrap.style.maxWidth = "100%";
56
+ frameWrap.style.maxHeight = "100%";
57
+ frameWrap.style.borderRadius = "0";
58
+ }
59
+ const iframe = document.createElement("iframe");
60
+ iframe.src = url;
61
+ iframe.title = title;
62
+ iframe.allow = "camera; microphone; fullscreen; geolocation; clipboard-write";
63
+ iframe.setAttribute("allowfullscreen", "true");
64
+ Object.assign(iframe.style, {
65
+ width: "100%",
66
+ height: "100%",
67
+ border: "0",
68
+ display: "block",
69
+ });
70
+ const closeBtn = document.createElement("button");
71
+ closeBtn.type = "button";
72
+ closeBtn.setAttribute("aria-label", "Close verification");
73
+ closeBtn.innerHTML =
74
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
75
+ '<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/></svg>';
76
+ Object.assign(closeBtn.style, {
77
+ position: "absolute",
78
+ top: "12px",
79
+ right: "12px",
80
+ width: "32px",
81
+ height: "32px",
82
+ display: "grid",
83
+ placeItems: "center",
84
+ borderRadius: "9999px",
85
+ border: "0",
86
+ cursor: "pointer",
87
+ color: "#0f172a",
88
+ background: "rgba(255,255,255,0.9)",
89
+ boxShadow: "0 1px 4px rgba(0,0,0,0.2)",
90
+ });
91
+ closeBtn.addEventListener("click", triggerCancel);
92
+ frameWrap.appendChild(iframe);
93
+ frameWrap.appendChild(closeBtn);
94
+ overlay.appendChild(frameWrap);
95
+ document.body.appendChild(overlay);
96
+ // Esc closes; backdrop click closes.
97
+ onKeyDown = (e) => {
98
+ if (e.key === "Escape")
99
+ triggerCancel();
100
+ };
101
+ document.addEventListener("keydown", onKeyDown);
102
+ overlay.addEventListener("click", (e) => {
103
+ if (e.target === overlay)
104
+ triggerCancel();
105
+ });
106
+ closeBtn.focus();
107
+ },
108
+ unmount() {
109
+ if (onKeyDown) {
110
+ document.removeEventListener("keydown", onKeyDown);
111
+ onKeyDown = null;
112
+ }
113
+ overlay?.remove();
114
+ overlay = null;
115
+ document.body.style.overflow = priorOverflow;
116
+ if (priorActive instanceof HTMLElement)
117
+ priorActive.focus();
118
+ },
119
+ };
120
+ }
121
+ //# sourceMappingURL=modal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modal.js","sourceRoot":"","sources":["../src/modal.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import { type Surface } from "./runner.js";
2
+ export declare function createPopupSurface(): Surface;
3
+ //# sourceMappingURL=popup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"popup.d.ts","sourceRoot":"","sources":["../src/popup.ts"],"names":[],"mappings":"AAIA,OAAO,EAAqB,KAAK,OAAO,EAAE,MAAM,aAAa,CAAC;AAE9D,wBAAgB,kBAAkB,IAAI,OAAO,CA6C5C"}
package/dist/popup.js ADDED
@@ -0,0 +1,49 @@
1
+ // Popup / new-tab surface. A top-level browsing context has the most reliable
2
+ // camera access, which is why `mode: "auto"` uses it on mobile. Must be invoked
3
+ // synchronously from a user gesture or the browser will block window.open.
4
+ import { PopupBlockedError } from "./runner.js";
5
+ export function createPopupSurface() {
6
+ let popup = null;
7
+ let poll = null;
8
+ let cancelHandler = null;
9
+ const stopPoll = () => {
10
+ if (poll !== null) {
11
+ window.clearInterval(poll);
12
+ poll = null;
13
+ }
14
+ };
15
+ return {
16
+ onCancel(handler) {
17
+ cancelHandler = handler;
18
+ },
19
+ mount(url) {
20
+ const w = 480;
21
+ const h = Math.min(820, window.screen.availHeight || 820);
22
+ const left = window.screenX + Math.max(0, ((window.outerWidth || w) - w) / 2);
23
+ const top = window.screenY + Math.max(0, ((window.outerHeight || h) - h) / 2);
24
+ const features = `popup=yes,width=${w},height=${h},left=${Math.round(left)},top=${Math.round(top)}`;
25
+ popup = window.open(url, "thirdfactor-verify", features);
26
+ if (!popup)
27
+ throw new PopupBlockedError();
28
+ // Detect a user closing the tab/window before the flow reports a result.
29
+ poll = window.setInterval(() => {
30
+ if (popup && popup.closed) {
31
+ stopPoll();
32
+ cancelHandler?.();
33
+ }
34
+ }, 500);
35
+ },
36
+ unmount() {
37
+ stopPoll();
38
+ try {
39
+ if (popup && !popup.closed)
40
+ popup.close();
41
+ }
42
+ catch {
43
+ /* cross-origin close guard */
44
+ }
45
+ popup = null;
46
+ },
47
+ };
48
+ }
49
+ //# sourceMappingURL=popup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"popup.js","sourceRoot":"","sources":["../src/popup.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,gFAAgF;AAChF,2EAA2E;AAE3E,OAAO,EAAE,iBAAiB,EAAgB,MAAM,aAAa,CAAC;AAE9D,MAAM,UAAU,kBAAkB;IAChC,IAAI,KAAK,GAAkB,IAAI,CAAC;IAChC,IAAI,IAAI,GAAkB,IAAI,CAAC;IAC/B,IAAI,aAAa,GAAwB,IAAI,CAAC;IAE9C,MAAM,QAAQ,GAAG,GAAG,EAAE;QACpB,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YAClB,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC;YAC3B,IAAI,GAAG,IAAI,CAAC;QACd,CAAC;IACH,CAAC,CAAC;IAEF,OAAO;QACL,QAAQ,CAAC,OAAO;YACd,aAAa,GAAG,OAAO,CAAC;QAC1B,CAAC;QAED,KAAK,CAAC,GAAG;YACP,MAAM,CAAC,GAAG,GAAG,CAAC;YACd,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,CAAC,WAAW,IAAI,GAAG,CAAC,CAAC;YAC1D,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,UAAU,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAC9E,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,WAAW,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAC9E,MAAM,QAAQ,GAAG,mBAAmB,CAAC,WAAW,CAAC,SAAS,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;YACpG,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,oBAAoB,EAAE,QAAQ,CAAC,CAAC;YACzD,IAAI,CAAC,KAAK;gBAAE,MAAM,IAAI,iBAAiB,EAAE,CAAC;YAE1C,yEAAyE;YACzE,IAAI,GAAG,MAAM,CAAC,WAAW,CAAC,GAAG,EAAE;gBAC7B,IAAI,KAAK,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;oBAC1B,QAAQ,EAAE,CAAC;oBACX,aAAa,EAAE,EAAE,CAAC;gBACpB,CAAC;YACH,CAAC,EAAE,GAAG,CAAC,CAAC;QACV,CAAC;QAED,OAAO;YACL,QAAQ,EAAE,CAAC;YACX,IAAI,CAAC;gBACH,IAAI,KAAK,IAAI,CAAC,KAAK,CAAC,MAAM;oBAAE,KAAK,CAAC,KAAK,EAAE,CAAC;YAC5C,CAAC;YAAC,MAAM,CAAC;gBACP,8BAA8B;YAChC,CAAC;YACD,KAAK,GAAG,IAAI,CAAC;QACf,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,27 @@
1
+ import type { VerificationEvent, VerificationStatus } from "./types.js";
2
+ /** Must match TF_BRIDGE_SOURCE in the flow bridge. */
3
+ export declare const BRIDGE_SOURCE = "thirdfactor";
4
+ /**
5
+ * Validate and narrow an untrusted postMessage payload into a typed event.
6
+ * Returns null for anything that isn't one of our bridge events, so callers can
7
+ * safely ignore unrelated messages (analytics beacons, other embeds, etc.).
8
+ *
9
+ * Origin checking is the CALLER's job and must happen before this — see
10
+ * `messageMatches`.
11
+ */
12
+ export declare function parseBridgeMessage(data: unknown): VerificationEvent | null;
13
+ /**
14
+ * Map a raw server session status onto the normalized SDK status.
15
+ * Unknown terminal statuses fall back to `in_review` — the conservative choice,
16
+ * since an unrecognized outcome should get a human look rather than silently
17
+ * pass or fail.
18
+ */
19
+ export declare function normalizeStatus(raw: string): VerificationStatus;
20
+ /** Origin of a URL string, or "" if it can't be parsed. */
21
+ export declare function originOf(url: string): string;
22
+ /**
23
+ * Append the embed flag + platform tag the flow's bridge looks for, preserving
24
+ * any existing query/hash on the URL.
25
+ */
26
+ export declare function withEmbedParams(url: string, platform?: string): string;
27
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAExE,sDAAsD;AACtD,eAAO,MAAM,aAAa,gBAAgB,CAAC;AAE3C;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,OAAO,GAAG,iBAAiB,GAAG,IAAI,CAwB1E;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,kBAAkB,CAkB/D;AAED,2DAA2D;AAC3D,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM5C;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,SAAQ,GAAG,MAAM,CAWrE"}
@@ -0,0 +1,92 @@
1
+ // Wire protocol shared with the hosted flow's bridge
2
+ // (frontend/components/verify/sdk-bridge.ts). Keep the source tag and event
3
+ // shapes in lock-step with that file.
4
+ /** Must match TF_BRIDGE_SOURCE in the flow bridge. */
5
+ export const BRIDGE_SOURCE = "thirdfactor";
6
+ /**
7
+ * Validate and narrow an untrusted postMessage payload into a typed event.
8
+ * Returns null for anything that isn't one of our bridge events, so callers can
9
+ * safely ignore unrelated messages (analytics beacons, other embeds, etc.).
10
+ *
11
+ * Origin checking is the CALLER's job and must happen before this — see
12
+ * `messageMatches`.
13
+ */
14
+ export function parseBridgeMessage(data) {
15
+ if (!data || typeof data !== "object")
16
+ return null;
17
+ const d = data;
18
+ if (d.source !== BRIDGE_SOURCE)
19
+ return null;
20
+ switch (d.type) {
21
+ case "ready":
22
+ return { type: "ready" };
23
+ case "completed":
24
+ return {
25
+ type: "completed",
26
+ status: String(d.status ?? ""),
27
+ sessionId: d.sessionId != null ? String(d.sessionId) : undefined,
28
+ };
29
+ case "error":
30
+ return {
31
+ type: "error",
32
+ code: String(d.code ?? "unknown"),
33
+ message: d.message != null ? String(d.message) : undefined,
34
+ };
35
+ case "close":
36
+ return { type: "close" };
37
+ default:
38
+ return null;
39
+ }
40
+ }
41
+ /**
42
+ * Map a raw server session status onto the normalized SDK status.
43
+ * Unknown terminal statuses fall back to `in_review` — the conservative choice,
44
+ * since an unrecognized outcome should get a human look rather than silently
45
+ * pass or fail.
46
+ */
47
+ export function normalizeStatus(raw) {
48
+ switch (raw) {
49
+ case "approved":
50
+ return "approved";
51
+ case "declined":
52
+ return "declined";
53
+ case "in_review":
54
+ case "review":
55
+ case "manual_review":
56
+ case "pending":
57
+ return "in_review";
58
+ case "abandoned":
59
+ case "cancelled":
60
+ case "canceled":
61
+ return "cancelled";
62
+ default:
63
+ return "in_review";
64
+ }
65
+ }
66
+ /** Origin of a URL string, or "" if it can't be parsed. */
67
+ export function originOf(url) {
68
+ try {
69
+ return new URL(url).origin;
70
+ }
71
+ catch {
72
+ return "";
73
+ }
74
+ }
75
+ /**
76
+ * Append the embed flag + platform tag the flow's bridge looks for, preserving
77
+ * any existing query/hash on the URL.
78
+ */
79
+ export function withEmbedParams(url, platform = "web") {
80
+ try {
81
+ const u = new URL(url);
82
+ u.searchParams.set("embed", "1");
83
+ u.searchParams.set("sdk", platform);
84
+ return u.toString();
85
+ }
86
+ catch {
87
+ // Fall back to a naive append if URL() is unavailable/relative.
88
+ const sep = url.includes("?") ? "&" : "?";
89
+ return `${url}${sep}embed=1&sdk=${platform}`;
90
+ }
91
+ }
92
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,qDAAqD;AACrD,4EAA4E;AAC5E,sCAAsC;AAItC,sDAAsD;AACtD,MAAM,CAAC,MAAM,aAAa,GAAG,aAAa,CAAC;AAE3C;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAa;IAC9C,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACnD,MAAM,CAAC,GAAG,IAA+B,CAAC;IAC1C,IAAI,CAAC,CAAC,MAAM,KAAK,aAAa;QAAE,OAAO,IAAI,CAAC;IAC5C,QAAQ,CAAC,CAAC,IAAI,EAAE,CAAC;QACf,KAAK,OAAO;YACV,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;QAC3B,KAAK,WAAW;YACd,OAAO;gBACL,IAAI,EAAE,WAAW;gBACjB,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,MAAM,IAAI,EAAE,CAAC;gBAC9B,SAAS,EAAE,CAAC,CAAC,SAAS,IAAI,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS;aACjE,CAAC;QACJ,KAAK,OAAO;YACV,OAAO;gBACL,IAAI,EAAE,OAAO;gBACb,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,IAAI,SAAS,CAAC;gBACjC,OAAO,EAAE,CAAC,CAAC,OAAO,IAAI,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,SAAS;aAC3D,CAAC;QACJ,KAAK,OAAO;YACV,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;QAC3B;YACE,OAAO,IAAI,CAAC;IAChB,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,GAAW;IACzC,QAAQ,GAAG,EAAE,CAAC;QACZ,KAAK,UAAU;YACb,OAAO,UAAU,CAAC;QACpB,KAAK,UAAU;YACb,OAAO,UAAU,CAAC;QACpB,KAAK,WAAW,CAAC;QACjB,KAAK,QAAQ,CAAC;QACd,KAAK,eAAe,CAAC;QACrB,KAAK,SAAS;YACZ,OAAO,WAAW,CAAC;QACrB,KAAK,WAAW,CAAC;QACjB,KAAK,WAAW,CAAC;QACjB,KAAK,UAAU;YACb,OAAO,WAAW,CAAC;QACrB;YACE,OAAO,WAAW,CAAC;IACvB,CAAC;AACH,CAAC;AAED,2DAA2D;AAC3D,MAAM,UAAU,QAAQ,CAAC,GAAW;IAClC,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,GAAW,EAAE,QAAQ,GAAG,KAAK;IAC3D,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QACvB,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;QACjC,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;QACpC,OAAO,CAAC,CAAC,QAAQ,EAAE,CAAC;IACtB,CAAC;IAAC,MAAM,CAAC;QACP,gEAAgE;QAChE,MAAM,GAAG,GAAG,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;QAC1C,OAAO,GAAG,GAAG,GAAG,GAAG,eAAe,QAAQ,EAAE,CAAC;IAC/C,CAAC;AACH,CAAC"}
@@ -0,0 +1,16 @@
1
+ import type { VerificationResult, VerifyOptions } from "./types.js";
2
+ /** A presentation surface (modal iframe or popup window). */
3
+ export interface Surface {
4
+ /** Show the surface pointed at `url`. Throws on hard failure (e.g. popup
5
+ * blocked) so the runner can resolve an error result. */
6
+ mount(url: string): void;
7
+ /** Tear the surface down. Idempotent. */
8
+ unmount(): void;
9
+ /** Register the user-initiated dismiss handler (× button, Esc, closed tab). */
10
+ onCancel(handler: () => void): void;
11
+ }
12
+ export declare function runSession(embeddedUrl: string, expectedOrigin: string, opts: VerifyOptions, surface: Surface): Promise<VerificationResult>;
13
+ export declare class PopupBlockedError extends Error {
14
+ constructor();
15
+ }
16
+ //# sourceMappingURL=runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEpE,6DAA6D;AAC7D,MAAM,WAAW,OAAO;IACtB;8DAC0D;IAC1D,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,yCAAyC;IACzC,OAAO,IAAI,IAAI,CAAC;IAChB,+EAA+E;IAC/E,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;CACrC;AAED,wBAAgB,UAAU,CACxB,WAAW,EAAE,MAAM,EACnB,cAAc,EAAE,MAAM,EACtB,IAAI,EAAE,aAAa,EACnB,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,kBAAkB,CAAC,CAsE7B;AAED,qBAAa,iBAAkB,SAAQ,KAAK;;CAK3C"}
package/dist/runner.js ADDED
@@ -0,0 +1,84 @@
1
+ // Shared session runner used by both the modal and popup surfaces.
2
+ //
3
+ // It owns the one piece of logic that must be identical across surfaces: the
4
+ // origin-validated message loop and how bridge events resolve into a single
5
+ // VerificationResult. Surfaces only handle presentation + user-cancel.
6
+ import { parseBridgeMessage, normalizeStatus } from "./protocol.js";
7
+ export function runSession(embeddedUrl, expectedOrigin, opts, surface) {
8
+ return new Promise((resolve) => {
9
+ let settled = false;
10
+ // The authoritative result from a `completed`/`error` bridge event, if one
11
+ // arrived. A subsequent `close` (user tapped Done) then dismisses with THIS
12
+ // result rather than treating the dismiss as a cancel.
13
+ let pending = null;
14
+ const finish = (result) => {
15
+ if (settled)
16
+ return;
17
+ settled = true;
18
+ window.removeEventListener("message", onMessage);
19
+ surface.unmount();
20
+ resolve(result);
21
+ };
22
+ const onMessage = (ev) => {
23
+ if (expectedOrigin && ev.origin !== expectedOrigin)
24
+ return;
25
+ const event = parseBridgeMessage(ev.data);
26
+ if (!event)
27
+ return;
28
+ opts.onEvent?.(event);
29
+ switch (event.type) {
30
+ case "ready":
31
+ return;
32
+ case "completed": {
33
+ const result = {
34
+ status: normalizeStatus(event.status),
35
+ sessionId: event.sessionId,
36
+ rawStatus: event.status,
37
+ };
38
+ if (opts.autoClose === false) {
39
+ // Keep the flow's result screen up; dismiss on the later `close`.
40
+ pending = result;
41
+ }
42
+ else {
43
+ finish(result);
44
+ }
45
+ return;
46
+ }
47
+ case "error": {
48
+ finish({
49
+ status: "error",
50
+ error: { code: event.code, message: event.message ?? event.code },
51
+ });
52
+ return;
53
+ }
54
+ case "close": {
55
+ // User acknowledged. If a terminal result is pending, deliver it;
56
+ // otherwise the user dismissed mid-flow → cancelled.
57
+ finish(pending ?? { status: "cancelled" });
58
+ return;
59
+ }
60
+ }
61
+ };
62
+ window.addEventListener("message", onMessage);
63
+ surface.onCancel(() => finish(pending ?? { status: "cancelled" }));
64
+ try {
65
+ surface.mount(embeddedUrl);
66
+ }
67
+ catch (err) {
68
+ finish({
69
+ status: "error",
70
+ error: {
71
+ code: err instanceof PopupBlockedError ? "popup_blocked" : "load_error",
72
+ message: err instanceof Error ? err.message : String(err),
73
+ },
74
+ });
75
+ }
76
+ });
77
+ }
78
+ export class PopupBlockedError extends Error {
79
+ constructor() {
80
+ super("The verification popup was blocked. Call verify() from a click handler.");
81
+ this.name = "PopupBlockedError";
82
+ }
83
+ }
84
+ //# sourceMappingURL=runner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runner.js","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,57 @@
1
+ export interface CreateSessionInput {
2
+ /** KYC workflow UUID. Omit to use the tenant's default workflow. */
3
+ workflowId?: string;
4
+ /** Your external user reference; becomes the linked identity's external id. */
5
+ vendorData?: string;
6
+ /** Where the applicant is redirected after finishing (redirect mode). */
7
+ callbackUrl?: string;
8
+ contactEmail?: string;
9
+ contactPhone?: string;
10
+ /** Hosted URL lifetime in hours, 1..720 (default 48). */
11
+ expiresInHours?: number;
12
+ /** UI language, e.g. "en" | "ne". */
13
+ locale?: string;
14
+ /** Applicant fields to pre-fill (only the documented keys are honored). */
15
+ prefill?: Record<string, string>;
16
+ /** Idempotency key — repeat creates return the original session, uncharged. */
17
+ idempotencyKey?: string;
18
+ }
19
+ export interface Session {
20
+ id: string;
21
+ status: string;
22
+ /** Hand this to `ThirdFactor.verify({ url })` (or redirect) in the browser. */
23
+ url: string;
24
+ decision: string;
25
+ vendorData?: string | null;
26
+ expiresAt?: string | null;
27
+ createdAt?: string | null;
28
+ /** The raw response body, in case you need a field not surfaced above. */
29
+ raw: Record<string, unknown>;
30
+ }
31
+ export interface CreateSessionConfig {
32
+ /** Your Obsidian base URL, e.g. "https://kyc.acme.com". No trailing slash. */
33
+ baseUrl: string;
34
+ /** Tenant API key (Console → Settings → API keys). Server-side only. */
35
+ apiKey: string;
36
+ /** Override fetch (tests, custom agents). Defaults to global fetch. */
37
+ fetchImpl?: typeof fetch;
38
+ }
39
+ export declare class ThirdFactorApiError extends Error {
40
+ status: number;
41
+ code: string;
42
+ constructor(status: number, code: string, message: string);
43
+ }
44
+ /**
45
+ * Create a verification session via `POST /v3/session/`.
46
+ *
47
+ * ```ts
48
+ * import { createSession } from "@thirdfactor/web/server";
49
+ * const session = await createSession(
50
+ * { baseUrl: process.env.TF_BASE_URL!, apiKey: process.env.TF_API_KEY! },
51
+ * { vendorData: user.id, callbackUrl: "https://app.acme.com/kyc/done" },
52
+ * );
53
+ * // return { url: session.url, id: session.id } to the browser.
54
+ * ```
55
+ */
56
+ export declare function createSession(config: CreateSessionConfig, input?: CreateSessionInput): Promise<Session>;
57
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,kBAAkB;IACjC,oEAAoE;IACpE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,yEAAyE;IACzE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,yDAAyD;IACzD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qCAAqC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,+EAA+E;IAC/E,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B;AAED,MAAM,WAAW,mBAAmB;IAClC,8EAA8E;IAC9E,OAAO,EAAE,MAAM,CAAC;IAChB,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;gBACD,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;CAM1D;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,mBAAmB,EAC3B,KAAK,GAAE,kBAAuB,GAC7B,OAAO,CAAC,OAAO,CAAC,CAmDlB"}
package/dist/server.js ADDED
@@ -0,0 +1,82 @@
1
+ // Optional server helper (`@thirdfactor/web/server`) for creating a KYC session
2
+ // from your Node backend. Runs ONLY on the server — it uses your secret API
3
+ // key, which must never reach the browser. Node 18+ (global fetch) or any
4
+ // runtime with a WHATWG `fetch`.
5
+ export class ThirdFactorApiError extends Error {
6
+ constructor(status, code, message) {
7
+ super(message);
8
+ this.name = "ThirdFactorApiError";
9
+ this.status = status;
10
+ this.code = code;
11
+ }
12
+ }
13
+ /**
14
+ * Create a verification session via `POST /v3/session/`.
15
+ *
16
+ * ```ts
17
+ * import { createSession } from "@thirdfactor/web/server";
18
+ * const session = await createSession(
19
+ * { baseUrl: process.env.TF_BASE_URL!, apiKey: process.env.TF_API_KEY! },
20
+ * { vendorData: user.id, callbackUrl: "https://app.acme.com/kyc/done" },
21
+ * );
22
+ * // return { url: session.url, id: session.id } to the browser.
23
+ * ```
24
+ */
25
+ export async function createSession(config, input = {}) {
26
+ const doFetch = config.fetchImpl ?? globalThis.fetch;
27
+ if (typeof doFetch !== "function") {
28
+ throw new ThirdFactorApiError(0, "no_fetch", "No fetch implementation available (Node 18+ or pass fetchImpl).");
29
+ }
30
+ const base = config.baseUrl.replace(/\/+$/, "");
31
+ const body = {};
32
+ if (input.workflowId)
33
+ body.workflow_id = input.workflowId;
34
+ if (input.vendorData)
35
+ body.vendor_data = input.vendorData;
36
+ if (input.callbackUrl)
37
+ body.callback_url = input.callbackUrl;
38
+ if (input.contactEmail)
39
+ body.contact_email = input.contactEmail;
40
+ if (input.contactPhone)
41
+ body.contact_phone = input.contactPhone;
42
+ if (input.expiresInHours != null)
43
+ body.expires_in_hours = input.expiresInHours;
44
+ if (input.locale)
45
+ body.locale = input.locale;
46
+ if (input.prefill)
47
+ body.prefill = input.prefill;
48
+ const headers = {
49
+ "content-type": "application/json",
50
+ "x-api-key": config.apiKey,
51
+ };
52
+ if (input.idempotencyKey)
53
+ headers["Idempotency-Key"] = input.idempotencyKey;
54
+ const res = await doFetch(`${base}/v3/session/`, {
55
+ method: "POST",
56
+ headers,
57
+ body: JSON.stringify(body),
58
+ });
59
+ const text = await res.text();
60
+ let json = {};
61
+ try {
62
+ json = text ? JSON.parse(text) : {};
63
+ }
64
+ catch {
65
+ /* non-JSON error body */
66
+ }
67
+ if (!res.ok) {
68
+ const code = typeof json.detail === "string" ? json.detail : `http_${res.status}`;
69
+ throw new ThirdFactorApiError(res.status, code, `Session create failed (${res.status}): ${code}`);
70
+ }
71
+ return {
72
+ id: String(json.id ?? ""),
73
+ status: String(json.status ?? ""),
74
+ url: String(json.url ?? ""),
75
+ decision: String(json.decision ?? ""),
76
+ vendorData: json.vendor_data ?? null,
77
+ expiresAt: json.expires_at ?? null,
78
+ createdAt: json.created_at ?? null,
79
+ raw: json,
80
+ };
81
+ }
82
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ /** Normalized terminal outcome the SDK resolves with. */
2
+ export type VerificationStatus = "approved" | "declined" | "in_review" | "cancelled" | "error";
3
+ export interface VerificationError {
4
+ /** Stable machine code: invalid_session | expired | popup_blocked |
5
+ * load_error | network | unknown. Branch on this, not `message`. */
6
+ code: string;
7
+ message: string;
8
+ }
9
+ export interface VerificationResult {
10
+ status: VerificationStatus;
11
+ /** The verification session id (echoed from the flow when available). */
12
+ sessionId?: string;
13
+ /** Populated only when `status === "error"`. */
14
+ error?: VerificationError;
15
+ /** The raw server status when it differs from the normalized one
16
+ * (e.g. "abandoned" normalizes to "cancelled"). */
17
+ rawStatus?: string;
18
+ }
19
+ /** Lifecycle events emitted while the flow is open. */
20
+ export type VerificationEvent = {
21
+ type: "ready";
22
+ } | {
23
+ type: "completed";
24
+ status: string;
25
+ sessionId?: string;
26
+ } | {
27
+ type: "error";
28
+ code: string;
29
+ message?: string;
30
+ } | {
31
+ type: "close";
32
+ };
33
+ /**
34
+ * How the hosted flow is presented.
35
+ * - `modal` — full-screen overlay iframe. Best desktop UX.
36
+ * - `popup` — a new tab/window. Most reliable camera access on mobile.
37
+ * - `auto` — modal on desktop, popup on mobile (default).
38
+ *
39
+ * Full-page `redirect` is a separate call, `ThirdFactor.redirect(url)`, because
40
+ * it navigates away and cannot return a Promise.
41
+ */
42
+ export type VerifyMode = "modal" | "popup" | "auto";
43
+ export interface VerifyOptions {
44
+ /**
45
+ * The hosted session URL returned by `POST /v3/session/` (the `url` field),
46
+ * e.g. `https://kyc.acme.com/verify/<token>`. Create the session from your
47
+ * backend with your secret API key — never in the browser.
48
+ */
49
+ url: string;
50
+ /** Presentation mode. Default `auto`. */
51
+ mode?: VerifyMode;
52
+ /** Called for every lifecycle event (ready/completed/error/close). */
53
+ onEvent?: (event: VerificationEvent) => void;
54
+ /** Accessible label for the modal dialog. Default "Identity verification". */
55
+ title?: string;
56
+ /**
57
+ * Origin the SDK will accept postMessage from. Defaults to the origin of
58
+ * `url`. Only override if the flow renders on a different origin than the URL
59
+ * you pass (rare — e.g. a proxy).
60
+ */
61
+ expectedOrigin?: string;
62
+ /**
63
+ * Auto-dismiss the surface once a terminal result arrives. Default `true`.
64
+ * Set `false` to keep the flow's own result screen visible until the user
65
+ * taps "Done" (which then dismisses).
66
+ */
67
+ autoClose?: boolean;
68
+ }
69
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAaA,yDAAyD;AACzD,MAAM,MAAM,kBAAkB,GAC1B,UAAU,GACV,UAAU,GACV,WAAW,GACX,WAAW,GACX,OAAO,CAAC;AAEZ,MAAM,WAAW,iBAAiB;IAChC;yEACqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,yEAAyE;IACzE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gDAAgD;IAChD,KAAK,CAAC,EAAE,iBAAiB,CAAC;IAC1B;wDACoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,uDAAuD;AACvD,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GACzD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GACjD;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAEtB;;;;;;;;GAQG;AACH,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,CAAC;AAEpD,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,yCAAyC;IACzC,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,sEAAsE;IACtE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC7C,8EAA8E;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB"}
package/dist/types.js ADDED
@@ -0,0 +1,14 @@
1
+ // Public type surface for @thirdfactor/web.
2
+ //
3
+ // The result vocabulary is deliberately small and identical across all four
4
+ // ThirdFactor SDKs (web / Flutter / iOS / Android):
5
+ //
6
+ // approved | declined | in_review | cancelled | error
7
+ //
8
+ // The client-reported result is a UX signal, NOT the source of truth. Always
9
+ // confirm the authoritative decision server-side — via the KYC webhook
10
+ // (identity.kyc.session.*) or GET /v3/session/<id>/decision/ — before granting
11
+ // access or moving money. A user can close the tab after approval, or tamper
12
+ // with the browser; the webhook cannot be spoofed (it is HMAC-signed).
13
+ export {};
14
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,4CAA4C;AAC5C,EAAE;AACF,4EAA4E;AAC5E,oDAAoD;AACpD,EAAE;AACF,wDAAwD;AACxD,EAAE;AACF,6EAA6E;AAC7E,uEAAuE;AACvE,+EAA+E;AAC/E,6EAA6E;AAC7E,uEAAuE"}
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@thirdfactor/web",
3
+ "version": "1.0.0",
4
+ "description": "ThirdFactor / Obsidian KYC — web client SDK. Opens the hosted verification flow in a modal, popup, or full-page redirect and returns a typed result.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ },
15
+ "./server": {
16
+ "types": "./dist/server.d.ts",
17
+ "import": "./dist/server.js"
18
+ }
19
+ },
20
+ "files": ["dist", "README.md"],
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit",
24
+ "clean": "rm -rf dist"
25
+ },
26
+ "keywords": ["kyc", "identity", "verification", "thirdfactor", "obsidian", "aml"],
27
+ "license": "UNLICENSED",
28
+ "devDependencies": {
29
+ "typescript": "^5.4.0"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }