@meshconnect/web-link-sdk 3.13.0 → 3.14.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/backupFlow.js ADDED
@@ -0,0 +1,536 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ var __generator = (this && this.__generator) || function (thisArg, body) {
11
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
12
+ return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
13
+ function verb(n) { return function (v) { return step([n, v]); }; }
14
+ function step(op) {
15
+ if (f) throw new TypeError("Generator is already executing.");
16
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
17
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
18
+ if (y = 0, t) op = [op[0] & 2, t.value];
19
+ switch (op[0]) {
20
+ case 0: case 1: t = op; break;
21
+ case 4: _.label++; return { value: op[1], done: false };
22
+ case 5: _.label++; y = op[1]; op = [0]; continue;
23
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
24
+ default:
25
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
26
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
27
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
28
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
29
+ if (t[2]) _.ops.pop();
30
+ _.trys.pop(); continue;
31
+ }
32
+ op = body.call(thisArg, _);
33
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
34
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
35
+ }
36
+ };
37
+ import { addPopup, buildIframeAllowPolicy, iframeId, removePopup } from './utils/popup';
38
+ import { sdkSpecs } from './utils/sdk-specs';
39
+ import { BACKUP_CONFIG_MESSAGE_TYPE, DEFAULT_BACKUP_WIDGET_ORIGIN, JIT_RESPONSE_MESSAGE_TYPE, SESSION_NONCE_PARAM, buildBackupWidgetUrl, createSessionNonce } from './utils/backup';
40
+ import { createBackupTierController } from './utils/backupTier';
41
+ import { BridgeParent } from '@meshconnect/uwc-bridge-parent';
42
+ import { removePrewarmIframe } from './utils/prewarm';
43
+ /**
44
+ * Validate a host `onStatusPoll` result against the three-variant contract and
45
+ * return ONLY the contract fields. Throws on anything malformed — a missing,
46
+ * misspelled, or non-string `status`, or a `'ready'` without a non-empty string
47
+ * `address` — so the handler's catch fails the poll closed (`ok: false`). The
48
+ * previous builder silently coerced any unknown status to `'pending'` (which
49
+ * polls forever) and forwarded `'ready'` without an address (an invalid
50
+ * success); both are money-path failure modes. Extra fields (e.g. a raw backend
51
+ * response carrying identifiers/credentials) are dropped, never forwarded across
52
+ * the bridge to the widget's independent origin.
53
+ */
54
+ export function sanitizeStatusResult(result) {
55
+ var status = typeof result === 'object' && result !== null
56
+ ? result.status
57
+ : undefined;
58
+ if (status === 'pending')
59
+ return { status: 'pending' };
60
+ if (status === 'failed')
61
+ return { status: 'failed' };
62
+ if (status === 'ready') {
63
+ var address = result.address;
64
+ if (typeof address !== 'string' || address.length === 0) {
65
+ throw new Error("onStatusPoll returned status 'ready' without a valid address");
66
+ }
67
+ var addressTag = result.addressTag;
68
+ // A memo/tag chain deposit without its tag can strand funds, so a tag that is
69
+ // present but not a string is malformed — fail closed rather than drop it and
70
+ // report a tag-less 'ready'. `null`/`undefined`/`''` mean "no tag".
71
+ if (addressTag !== undefined &&
72
+ addressTag !== null &&
73
+ typeof addressTag !== 'string') {
74
+ throw new Error("onStatusPoll returned status 'ready' with a non-string addressTag");
75
+ }
76
+ return typeof addressTag === 'string' && addressTag.length > 0
77
+ ? { status: 'ready', address: address, addressTag: addressTag }
78
+ : { status: 'ready', address: address };
79
+ }
80
+ throw new Error("onStatusPoll returned an invalid status: ".concat(JSON.stringify(status), " (expected 'pending' | 'ready' | 'failed')"));
81
+ }
82
+ export function createBackupFlow(host) {
83
+ // Set by `open`, delivered to the widget on its `loaded` handshake.
84
+ var backupSession;
85
+ // See `ActiveFlow`: `!backupSession` is NOT a safe token-forwarding gate, because
86
+ // an aborted re-open (`openLink` with a bad token, or `open` with a bad origin)
87
+ // clears `backupSession` while the previous backup iframe + listener are still
88
+ // live; a late `loaded` from that stale iframe would then pass a `!backupSession`
89
+ // check and ship `frontAccessTokens` to the independent backup origin. In that
90
+ // aborted/dead window `activeFlow` is `null`, so tokens are withheld.
91
+ var activeFlow = null;
92
+ // Tier-1 → Tier-2 cascade state machine, live only during a backup session.
93
+ var backupTierController = null;
94
+ // Monotonic id for the current backup session. Every async operation (JIT reply,
95
+ // cascade import, iframe `error`) captures the id it started under and re-checks
96
+ // it before acting, so work from a session that has ended or been replaced can
97
+ // never post into / clobber a later one (a reused `callId` must never associate a
98
+ // deposit address with the wrong session — money path).
99
+ var backupSessionId = 0;
100
+ // The widget iframe's window for the active backup session. Backup messages
101
+ // (`loaded`, JIT) are only honoured when they come from this exact window
102
+ // (`event.source`), so another frame cannot drive the client's callbacks.
103
+ var backupIframeWindow = null;
104
+ // Removes the Tier-1 `error` listener bound to the current backup iframe.
105
+ var removeBackupIframeErrorListener = null;
106
+ // True once cascaded to Tier 2, which loads the bundled widget in a SANDBOXED,
107
+ // opaque-origin iframe (blob: URL). Its `event.origin` is `'null'`, so messages
108
+ // are authenticated by `event.source` instead, and we post to it with a `'*'`
109
+ // target. (Tier 1 stays origin-pinned.)
110
+ var backupTier2 = false;
111
+ // Object URL backing the Tier-2 iframe; revoked on teardown to avoid a leak.
112
+ var backupTier2BlobUrl = null;
113
+ // This open's session nonce, put on the widget URL (`?sid=` / Tier-2 `#sid=`) and
114
+ // echoed by the widget on every message. Navigating a reused embedded iframe
115
+ // keeps its WindowProxy and Tier-1 sessions share an origin, so this is the only
116
+ // thing that tells a stale message from the previous document apart from the
117
+ // current open's — e.g. a queued `close` (spurious onExit) or `loaded` (would
118
+ // cancel the Tier-1 → Tier-2 fallback).
119
+ var sessionNonce = null;
120
+ // This open's explicit theme (`dark`/`light`; undefined for `system`/unset). Tier 1
121
+ // sends it as `?theme=`; Tier 2's blob: URL can't, so it is stamped onto the
122
+ // bundled HTML instead (`withWidgetTheme`).
123
+ var backupTheme;
124
+ /**
125
+ * Tear down any in-flight backup session state. Called when a session ends or
126
+ * when the primary (token) flow starts, so a pending Tier-1 timeout can never
127
+ * fire against a later iframe and a stale config can never be re-delivered.
128
+ * Bumping `backupSessionId` invalidates any async work still bound to the old id.
129
+ */
130
+ function reset() {
131
+ backupSession = undefined;
132
+ // Entering a fresh/dead state: no flow is live until one re-commits. This is
133
+ // what makes a late `loaded` from a stale backup iframe fail the token-
134
+ // forwarding gate (`activeFlow === 'primary'`) during an aborted re-open.
135
+ activeFlow = null;
136
+ backupTierController === null || backupTierController === void 0 ? void 0 : backupTierController.destroy();
137
+ backupTierController = null;
138
+ backupIframeWindow = null;
139
+ removeBackupIframeErrorListener === null || removeBackupIframeErrorListener === void 0 ? void 0 : removeBackupIframeErrorListener();
140
+ removeBackupIframeErrorListener = null;
141
+ backupTier2 = false;
142
+ sessionNonce = null;
143
+ backupTheme = undefined;
144
+ if (backupTier2BlobUrl) {
145
+ URL.revokeObjectURL(backupTier2BlobUrl);
146
+ backupTier2BlobUrl = null;
147
+ }
148
+ backupSessionId += 1;
149
+ }
150
+ /**
151
+ * Run the host's JIT callback for a widget request and post the result back over
152
+ * the bridge, correlated by `callId` (OR-452). `addressInit` runs `onAddressInit`
153
+ * (its return is ignored — a throw/reject is a failure); `statusPoll` runs
154
+ * `onStatusPoll` and returns its `{ status, address?, addressTag? }`. A missing
155
+ * callback fails closed so an address-less destination surfaces an error rather
156
+ * than hanging. The callbacks run in the host app with the client's own session —
157
+ * their credentials never enter the widget; only `(symbol, networkId)` and the
158
+ * resolved address cross the bridge.
159
+ */
160
+ function runJitRequest(payload) {
161
+ return __awaiter(this, void 0, void 0, function () {
162
+ var callId, method, symbol, networkId, sessionId, targetWindow, replyTarget, respond, isJitDestination, onAddressInit, onStatusPoll, result, e_1;
163
+ var _a, _b;
164
+ return __generator(this, function (_c) {
165
+ switch (_c.label) {
166
+ case 0:
167
+ if (!payload || typeof payload.callId !== 'string')
168
+ return [2 /*return*/];
169
+ callId = payload.callId, method = payload.method, symbol = payload.symbol, networkId = payload.networkId;
170
+ sessionId = backupSessionId;
171
+ targetWindow = backupIframeWindow;
172
+ replyTarget = backupTier2 ? '*' : host.getLinkTokenOrigin();
173
+ respond = function (response) {
174
+ if (backupSessionId !== sessionId || !targetWindow || !replyTarget)
175
+ return;
176
+ try {
177
+ targetWindow.postMessage({ type: JIT_RESPONSE_MESSAGE_TYPE, payload: response }, replyTarget);
178
+ }
179
+ catch (e) {
180
+ console.error('Mesh SDK: Failed to deliver JIT response to the widget');
181
+ console.error(e);
182
+ }
183
+ };
184
+ _c.label = 1;
185
+ case 1:
186
+ _c.trys.push([1, 7, , 8]);
187
+ // The request drives real address generation against the client's backend —
188
+ // reject a malformed one rather than passing garbage to the callbacks.
189
+ if (typeof symbol !== 'string' || typeof networkId !== 'string') {
190
+ throw new TypeError('backup JIT request is missing a string symbol/networkId');
191
+ }
192
+ isJitDestination = backupSession === null || backupSession === void 0 ? void 0 : backupSession.destinations.some(function (d) { return d.symbol === symbol && d.networkId === networkId && !d.address; });
193
+ if (!isJitDestination) {
194
+ throw new Error('backup JIT request is not for an address-less destination in the session');
195
+ }
196
+ if (!(method === 'addressInit')) return [3 /*break*/, 3];
197
+ onAddressInit = (_a = host.getOptions()) === null || _a === void 0 ? void 0 : _a.onAddressInit;
198
+ if (!onAddressInit) {
199
+ throw new Error('onAddressInit callback is required to resolve an address-less backup destination');
200
+ }
201
+ return [4 /*yield*/, onAddressInit(symbol, networkId)];
202
+ case 2:
203
+ _c.sent();
204
+ respond({ callId: callId, ok: true });
205
+ return [3 /*break*/, 6];
206
+ case 3:
207
+ if (!(method === 'statusPoll')) return [3 /*break*/, 5];
208
+ onStatusPoll = (_b = host.getOptions()) === null || _b === void 0 ? void 0 : _b.onStatusPoll;
209
+ if (!onStatusPoll) {
210
+ throw new Error('onStatusPoll callback is required to resolve an address-less backup destination');
211
+ }
212
+ return [4 /*yield*/, onStatusPoll(symbol, networkId)
213
+ // Validate against the three-variant contract and post ONLY the contract
214
+ // fields to the widget's (independent) origin. A malformed result throws
215
+ // here, so the catch fails the poll closed (`ok: false`) rather than
216
+ // silently polling forever on a coerced 'pending' or forwarding a 'ready'
217
+ // with no address. Extra fields (e.g. a raw backend response with
218
+ // identifiers/credentials) are dropped, never forwarded.
219
+ ];
220
+ case 4:
221
+ result = _c.sent();
222
+ // Validate against the three-variant contract and post ONLY the contract
223
+ // fields to the widget's (independent) origin. A malformed result throws
224
+ // here, so the catch fails the poll closed (`ok: false`) rather than
225
+ // silently polling forever on a coerced 'pending' or forwarding a 'ready'
226
+ // with no address. Extra fields (e.g. a raw backend response with
227
+ // identifiers/credentials) are dropped, never forwarded.
228
+ respond({ callId: callId, ok: true, result: sanitizeStatusResult(result) });
229
+ return [3 /*break*/, 6];
230
+ case 5: throw new Error("Unknown backup JIT method: ".concat(String(method)));
231
+ case 6: return [3 /*break*/, 8];
232
+ case 7:
233
+ e_1 = _c.sent();
234
+ // Log the real error host-side, but send the widget a fixed, non-revealing
235
+ // message — callback exceptions can contain URLs / identifiers / credentials,
236
+ // which must not cross to the independent widget origin.
237
+ console.error('Mesh SDK: backup JIT callback failed', e_1);
238
+ respond({ callId: callId, ok: false, error: 'JIT callback failed' });
239
+ return [3 /*break*/, 8];
240
+ case 8: return [2 /*return*/];
241
+ }
242
+ });
243
+ });
244
+ }
245
+ /**
246
+ * Fail the backup flow closed: tear down the popup/bridge and listener and exit
247
+ * with an error (never leave a blank or hung screen — design §5A/§5H). Teardown
248
+ * runs before `onExit`, and `reset` clears the session, so the removed iframe
249
+ * cannot emit a later `close`/`done` that would call `onExit` twice.
250
+ */
251
+ function failBackupClosed(errorMessage) {
252
+ var _a, _b;
253
+ (_a = host.getBridgeParent()) === null || _a === void 0 ? void 0 : _a.destroy();
254
+ host.setBridgeParent(null);
255
+ removePopup();
256
+ host.removeMessageListener();
257
+ var onExit = (_b = host.getOptions()) === null || _b === void 0 ? void 0 : _b.onExit;
258
+ reset();
259
+ onExit === null || onExit === void 0 ? void 0 : onExit(errorMessage);
260
+ }
261
+ /**
262
+ * Cascade the backup flow from Tier 1 (widget from the backup origin) to Tier 2
263
+ * (the SDK-bundled offline widget) when the backup origin is unreachable (design
264
+ * §5H). Emits `backupTierChanged`, then swaps the current iframe to the bundled
265
+ * widget loaded as a SANDBOXED, opaque-origin `blob:` URL (not `srcdoc`) — see the
266
+ * detailed rationale at the swap below. The bundle is imported dynamically so
267
+ * Tier-1-only consumers can code-split the ~135 KB asset out of their main chunk.
268
+ *
269
+ * ⚠️ Host CSP: navigating the iframe to a `blob:` URL requires the embedding page's
270
+ * CSP to permit `blob:` in `frame-src`/`child-src` (or `default-src`). A host that
271
+ * only allows the Tier-1 origin there will block the Tier-2 frame and the fallback
272
+ * will time out — this is a documented integration requirement (see README).
273
+ */
274
+ function cascadeBackupToTier2(reason, sessionId) {
275
+ return __awaiter(this, void 0, void 0, function () {
276
+ var tier1Iframe, html, bundle, _a, iframe, blobUrl;
277
+ var _b, _c, _d;
278
+ return __generator(this, function (_e) {
279
+ switch (_e.label) {
280
+ case 0:
281
+ // Consumer code (often telemetry): a throw here must not abort the cascade —
282
+ // that would leave Tier 1 dead with no Tier 2, and the fail-closed timer would
283
+ // then report the whole backup flow unavailable.
284
+ try {
285
+ (_c = (_b = host.getOptions()) === null || _b === void 0 ? void 0 : _b.onEvent) === null || _c === void 0 ? void 0 : _c.call(_b, {
286
+ type: 'backupTierChanged',
287
+ payload: { from: 'tier1', to: 'tier2', reason: reason }
288
+ });
289
+ }
290
+ catch (e) {
291
+ console.error('Mesh SDK: onEvent threw while handling backupTierChanged', e);
292
+ }
293
+ // `onEvent` ran consumer code synchronously; if it opened a new flow, `reset`
294
+ // advanced the session id — bail before touching any iframe.
295
+ if (backupSessionId !== sessionId)
296
+ return [2 /*return*/];
297
+ // Tear down the Tier-1 surface synchronously (before the async import) so
298
+ // nothing from it races the swap.
299
+ backupIframeWindow = null;
300
+ tier1Iframe = host.iframeElement();
301
+ if (tier1Iframe) {
302
+ (_d = host.getBridgeParent()) === null || _d === void 0 ? void 0 : _d.destroy();
303
+ host.setBridgeParent(null);
304
+ tier1Iframe.removeAttribute('src');
305
+ }
306
+ _e.label = 1;
307
+ case 1:
308
+ _e.trys.push([1, 3, , 4]);
309
+ return [4 /*yield*/, import('./backup-bundle')];
310
+ case 2:
311
+ bundle = _e.sent();
312
+ html = bundle.withWidgetTheme(bundle.getBundledOfflineWidget().html, backupTheme);
313
+ return [3 /*break*/, 4];
314
+ case 3:
315
+ _a = _e.sent();
316
+ // The bundled chunk could not be loaded (e.g. the host's own asset host is
317
+ // unreachable). Fail closed — never a blank QR. Only if still this session.
318
+ if (backupSessionId === sessionId) {
319
+ failBackupClosed('Backup deposit flow is unavailable');
320
+ }
321
+ return [2 /*return*/];
322
+ case 4:
323
+ // The session may have ended or been replaced while the bundle was importing —
324
+ // do not touch the current (possibly different) session's iframe.
325
+ if (backupSessionId !== sessionId)
326
+ return [2 /*return*/];
327
+ iframe = host.iframeElement();
328
+ if (!iframe) {
329
+ failBackupClosed('Backup deposit flow is unavailable');
330
+ return [2 /*return*/];
331
+ }
332
+ blobUrl = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
333
+ backupTier2BlobUrl = blobUrl;
334
+ backupTier2 = true;
335
+ iframe.removeAttribute('srcdoc');
336
+ // allow-scripts WITHOUT allow-same-origin ⇒ opaque origin. The iframe's `allow`
337
+ // (Permissions-Policy, incl. clipboard) set at mount is preserved for copy-address.
338
+ iframe.setAttribute('sandbox', 'allow-scripts');
339
+ // A blob: URL can't take a query, so the session nonce rides in the fragment.
340
+ iframe.src = "".concat(blobUrl, "#").concat(SESSION_NONCE_PARAM, "=").concat(sessionNonce !== null && sessionNonce !== void 0 ? sessionNonce : '');
341
+ host.setBridgeParent(new BridgeParent(iframe));
342
+ backupIframeWindow = iframe.contentWindow;
343
+ return [2 /*return*/];
344
+ }
345
+ });
346
+ });
347
+ }
348
+ function open(options, session, backupOptions) {
349
+ var _a, _b, _c, _d;
350
+ removePrewarmIframe();
351
+ // Every abort below tears down any prior backup flow first (reset) so a stale
352
+ // iframe from it can't keep reporting after this failed re-open calls onExit.
353
+ if (!session) {
354
+ reset();
355
+ (_a = options === null || options === void 0 ? void 0 : options.onExit) === null || _a === void 0 ? void 0 : _a.call(options, 'Invalid backup session!');
356
+ return;
357
+ }
358
+ var customIframeId = backupOptions === null || backupOptions === void 0 ? void 0 : backupOptions.customIframeId;
359
+ if ((options === null || options === void 0 ? void 0 : options.renderType) === 'embedded' && !customIframeId) {
360
+ var msg = 'Mesh SDK: Failed to open backup link - renderType "embedded" requires a customIframeId';
361
+ console.error(msg);
362
+ reset();
363
+ (_b = options === null || options === void 0 ? void 0 : options.onExit) === null || _b === void 0 ? void 0 : _b.call(options, msg);
364
+ return;
365
+ }
366
+ // Start a fresh backup session (clears any prior controller/timer), then make
367
+ // THIS createLink's options the live ones so the async JIT/cascade/exit
368
+ // callbacks below (which read `host.getOptions()`) resolve against this session.
369
+ reset();
370
+ if (options)
371
+ host.setOptions(options);
372
+ backupSession = session;
373
+ sessionNonce = createSessionNonce();
374
+ var widgetOrigin = (backupOptions === null || backupOptions === void 0 ? void 0 : backupOptions.widgetOrigin) || DEFAULT_BACKUP_WIDGET_ORIGIN;
375
+ var widgetUrl;
376
+ var widgetOriginParsed;
377
+ try {
378
+ // Only http(s) origins may be loaded into the iframe. `new URL().origin`
379
+ // alone would accept `javascript:`/`data:` schemes, which could execute in
380
+ // the iframe's initial same-origin context — reject anything else, matching
381
+ // the primary flow's protocol check.
382
+ var parsedOrigin = new URL(widgetOrigin);
383
+ if (parsedOrigin.protocol !== 'http:' &&
384
+ parsedOrigin.protocol !== 'https:') {
385
+ throw new Error('widgetOrigin must be an http(s) URL');
386
+ }
387
+ // The widget reads its params (incl. the required session nonce) from the
388
+ // query; with a fragment in the base they'd be appended after `#`, the widget
389
+ // couldn't echo `sid`, and every open would fall to Tier 2.
390
+ if (parsedOrigin.hash || widgetOrigin.includes('#')) {
391
+ throw new Error('widgetOrigin must not contain a fragment');
392
+ }
393
+ // The widget reads only `theme=dark|light`; `system`/unset is left to its
394
+ // own `prefers-color-scheme` fallback.
395
+ var theme = (options === null || options === void 0 ? void 0 : options.theme) === 'dark' || (options === null || options === void 0 ? void 0 : options.theme) === 'light'
396
+ ? options.theme
397
+ : undefined;
398
+ backupTheme = theme;
399
+ widgetUrl = buildBackupWidgetUrl(widgetOrigin, {
400
+ platform: sdkSpecs.platform,
401
+ sdkVersion: sdkSpecs.version,
402
+ theme: theme,
403
+ sessionNonce: sessionNonce
404
+ });
405
+ widgetOriginParsed = new URL(widgetUrl).origin;
406
+ host.setLinkTokenOrigin(widgetOriginParsed);
407
+ }
408
+ catch (_e) {
409
+ reset();
410
+ (_c = options === null || options === void 0 ? void 0 : options.onExit) === null || _c === void 0 ? void 0 : _c.call(options, 'Invalid backup widget origin!');
411
+ return;
412
+ }
413
+ // Origin validated — this backup flow is now the live one. (A later no-iframe
414
+ // abort below calls `reset()`, which clears this again.) Access tokens are never
415
+ // forwarded while `activeFlow === 'backup'`.
416
+ activeFlow = 'backup';
417
+ host.removeMessageListener();
418
+ if (customIframeId) {
419
+ var iframe_1 = document.getElementById(customIframeId);
420
+ if (!iframe_1) {
421
+ // No surface to render into — fail closed rather than leave the session
422
+ // active with no iframe/controller (which would hang with no onExit).
423
+ var msg = "Mesh SDK: No iframe found with id ".concat(customIframeId);
424
+ console.warn(msg);
425
+ reset();
426
+ (_d = options === null || options === void 0 ? void 0 : options.onExit) === null || _d === void 0 ? void 0 : _d.call(options, msg);
427
+ return;
428
+ }
429
+ iframe_1.allow = buildIframeAllowPolicy(widgetOriginParsed);
430
+ // A reused embedded iframe may still carry Tier-2 state from a prior backup
431
+ // session — clear `srcdoc` (precedence over `src`) and `sandbox` (opaque).
432
+ iframe_1.removeAttribute('srcdoc');
433
+ iframe_1.removeAttribute('sandbox');
434
+ iframe_1.src = widgetUrl;
435
+ host.setCurrentIframeId(customIframeId);
436
+ }
437
+ else {
438
+ host.setCurrentIframeId(iframeId);
439
+ addPopup(widgetUrl);
440
+ }
441
+ host.addMessageListener();
442
+ host.setTargetOrigin(window.location.origin);
443
+ var iframe = host.iframeElement();
444
+ if (iframe) {
445
+ host.setBridgeParent(new BridgeParent(iframe));
446
+ // Track this session's widget window so JIT requests are only honoured when
447
+ // they come from it (event.source check in `handleJitRequest`).
448
+ backupIframeWindow = iframe.contentWindow;
449
+ // Capture the session id so the cascade/fail-closed/error callbacks below
450
+ // only act while this session is still the active one.
451
+ var sessionId_1 = backupSessionId;
452
+ // Arm the Tier-1 → Tier-2 cascade. If the backup origin never completes its
453
+ // ready handshake (unreachable / served-but-broken), fall back to the
454
+ // bundled offline widget; if even that never becomes ready, fail closed.
455
+ backupTierController = createBackupTierController({
456
+ onFallback: function (reason) {
457
+ if (backupSessionId === sessionId_1) {
458
+ void cascadeBackupToTier2(reason, sessionId_1);
459
+ }
460
+ },
461
+ onTier2Unavailable: function () {
462
+ if (backupSessionId === sessionId_1) {
463
+ failBackupClosed('Backup deposit flow is unavailable');
464
+ }
465
+ }
466
+ });
467
+ // Best-effort hard-failure signal: a cross-origin iframe rarely fires `error`
468
+ // for a failed navigation (the ready-handshake timeout is the authoritative
469
+ // Tier-1 signal), but when it does fire we cascade at once instead of waiting
470
+ // the timeout out. Session-bound + removed on teardown so a late error from
471
+ // an old (embedded) iframe can't cascade a later session.
472
+ var onIframeError_1 = function () {
473
+ if (backupSessionId === sessionId_1) {
474
+ backupTierController === null || backupTierController === void 0 ? void 0 : backupTierController.reportLoadError();
475
+ }
476
+ };
477
+ iframe.addEventListener('error', onIframeError_1);
478
+ removeBackupIframeErrorListener = function () {
479
+ return iframe.removeEventListener('error', onIframeError_1);
480
+ };
481
+ backupTierController.start();
482
+ }
483
+ }
484
+ function isFromActiveWidget(event) {
485
+ var _a;
486
+ return (!!backupSession &&
487
+ !!sessionNonce &&
488
+ !!event.source &&
489
+ event.source === backupIframeWindow &&
490
+ ((_a = event.data) === null || _a === void 0 ? void 0 : _a[SESSION_NONCE_PARAM]) === sessionNonce);
491
+ }
492
+ function handleJitRequest(event) {
493
+ return __awaiter(this, void 0, void 0, function () {
494
+ return __generator(this, function (_a) {
495
+ switch (_a.label) {
496
+ case 0:
497
+ if (!isFromActiveWidget(event)) return [3 /*break*/, 2];
498
+ return [4 /*yield*/, runJitRequest(event.data.payload)];
499
+ case 1:
500
+ _a.sent();
501
+ _a.label = 2;
502
+ case 2: return [2 /*return*/];
503
+ }
504
+ });
505
+ });
506
+ }
507
+ function completeReadyHandshake(event) {
508
+ // Only the backup widget's OWN window may complete the ready handshake. The
509
+ // host's message handler also admits host-origin messages (Tier 1), so without
510
+ // this an unrelated same-origin frame posting `{ type: 'loaded' }` would call
511
+ // `markReady()` and permanently cancel the Tier-1 → Tier-2 fallback. The nonce
512
+ // check stops a stale `loaded` from a previous open in a reused embedded iframe
513
+ // (same window, same origin) doing the same.
514
+ if (isFromActiveWidget(event)) {
515
+ // This `loaded` IS the ready handshake — cancel the pending tier timeout
516
+ // (Tier-1 healthy, or Tier-2 mounted) before delivering the config.
517
+ backupTierController === null || backupTierController === void 0 ? void 0 : backupTierController.markReady();
518
+ host.sendMessageToIframe({
519
+ type: BACKUP_CONFIG_MESSAGE_TYPE,
520
+ payload: backupSession
521
+ });
522
+ }
523
+ }
524
+ return {
525
+ reset: reset,
526
+ setPrimaryFlow: function () {
527
+ activeFlow = 'primary';
528
+ },
529
+ getActiveFlow: function () { return activeFlow; },
530
+ isTier2: function () { return backupTier2; },
531
+ isFromActiveWidget: isFromActiveWidget,
532
+ open: open,
533
+ handleJitRequest: handleJitRequest,
534
+ completeReadyHandshake: completeReadyHandshake
535
+ };
536
+ }
package/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './utils/types';
2
2
  export * from './utils/event-types';
3
+ export { BACKUP_CONFIG_MESSAGE_TYPE, DEFAULT_BACKUP_WIDGET_ORIGIN } from './utils/backup';
3
4
  export { createLink } from './Link';
4
5
  export type { BrokerType } from '@meshconnect/node-api';
package/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './utils/types';
2
2
  export * from './utils/event-types';
3
+ export { BACKUP_CONFIG_MESSAGE_TYPE, DEFAULT_BACKUP_WIDGET_ORIGIN } from './utils/backup';
3
4
  export { createLink } from './Link';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meshconnect/web-link-sdk",
3
- "version": "3.13.0",
3
+ "version": "3.14.1",
4
4
  "description": "A client-side JS library for integrating with Mesh Connect",
5
5
  "exports": "./index.js",
6
6
  "license": "MIT",
@@ -10,7 +10,7 @@
10
10
  "node": ">=22"
11
11
  },
12
12
  "dependencies": {
13
- "@meshconnect/node-api": "^2.0.28",
13
+ "@meshconnect/node-api": "^2.0.29",
14
14
  "@meshconnect/uwc-bridge-parent": "1.7.0"
15
15
  },
16
16
  "module": "./index.js",
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Default origin for the standalone backup deposit widget (OR-449): the production
3
+ * Cloudflare Worker. It is under the `meshconnect.com` zone but served by
4
+ * Cloudflare, not Azure, so it shares no failure domain with the primary Mesh API
5
+ * (design doc §5C) — if the core API is down the widget still loads. Matches the
6
+ * React Native SDK's default. Override per-call via `openLinkBackup`'s
7
+ * `widgetOrigin` option for staging, demo, or self-host. Money path: this origin
8
+ * serves the deposit-address UI during an outage.
9
+ */
10
+ export declare const DEFAULT_BACKUP_WIDGET_ORIGIN = "https://backup.meshconnect.com";
11
+ /**
12
+ * The `type` the backup widget's message bridge requires on the config it
13
+ * receives. The widget matches `event.data.type === 'meshBackupConfig'` and
14
+ * drops any message without it, so the config must be delivered as
15
+ * `{ type: BACKUP_CONFIG_MESSAGE_TYPE, payload: MeshBackupConfig }`.
16
+ */
17
+ export declare const BACKUP_CONFIG_MESSAGE_TYPE = "meshBackupConfig";
18
+ /**
19
+ * JIT-over-bridge RPC message types (OR-452). An address-less destination is
20
+ * resolved by the widget invoking the host's `onAddressInit`/`onStatusPoll`
21
+ * callbacks over this same postMessage bridge, correlated by `callId` — the
22
+ * widget never calls a client HTTP endpoint and holds no token.
23
+ *
24
+ * - Widget → host: `{ type: JIT_REQUEST_MESSAGE_TYPE, payload: MeshBackupJitRequestPayload }`
25
+ * - Host → widget: `{ type: JIT_RESPONSE_MESSAGE_TYPE, payload: MeshBackupJitResponsePayload }`
26
+ *
27
+ * ⚠️ KEEP IN SYNC with `mesh-backup-widget`'s `src/bridge/contract.ts` — the
28
+ * string values and payload shapes are the frozen cross-repo contract.
29
+ */
30
+ export declare const JIT_REQUEST_MESSAGE_TYPE = "meshBackupJitRequest";
31
+ export declare const JIT_RESPONSE_MESSAGE_TYPE = "meshBackupJitResponse";
32
+ /**
33
+ * How long to wait for the Tier-1 widget's ready handshake (its `loaded`
34
+ * message) after the iframe starts loading the backup origin, before treating
35
+ * the origin as unreachable and cascading to the bundled Tier-2 assets.
36
+ *
37
+ * On the web this is the **authoritative** Tier-1 failure signal: a cross-origin
38
+ * iframe does not reliably fire a load `error` event for a network/DNS failure
39
+ * (and `onload` fires even for an error page), so a served-but-broken, hung, or
40
+ * captive-portal-intercepted origin is only caught by the absence of the ready
41
+ * handshake. 5000 ms absorbs a slow mobile network with margin (the widget shell
42
+ * is ~24 KB on a CDN) while keeping the degraded-path UX acceptable. Per-SDK
43
+ * tunable constant (design §5H).
44
+ */
45
+ export declare const TIER1_READY_TIMEOUT_MS = 5000;
46
+ /**
47
+ * Fail-closed safety net for Tier 2: the bundled assets load from a local `blob:`
48
+ * URL, so they should complete the ready handshake near-instantly.
49
+ * If they somehow do not within this window, the flow exits with an error rather
50
+ * than sitting on a blank screen (design §5A/§5H — never a blank QR). Deliberately
51
+ * generous because a Tier-2 timeout should be effectively unreachable.
52
+ */
53
+ export declare const TIER2_READY_TIMEOUT_MS = 8000;
54
+ export interface BackupWidgetUrlParams {
55
+ /** SDK platform identifier (`'web'`). */
56
+ platform: string;
57
+ /** SDK version, for widget-side diagnostics. */
58
+ sdkVersion: string;
59
+ /**
60
+ * Resolved colour theme. Appended as `?theme=dark|light` — the only theme
61
+ * value the backup widget reads. Omit for `system`/unset so the widget falls
62
+ * back to `prefers-color-scheme`.
63
+ */
64
+ theme?: 'dark' | 'light';
65
+ /**
66
+ * Per-open session nonce, appended as `?sid=`. The widget echoes it on every
67
+ * message it posts to the host, so a reused embedded iframe's stale messages
68
+ * from a previous open can be told apart (see `createSessionNonce`).
69
+ */
70
+ sessionNonce?: string;
71
+ }
72
+ /** URL parameter carrying the per-open session nonce (query for Tier 1, fragment
73
+ * for the Tier-2 `blob:` URL). KEEP IN SYNC with `mesh-backup-widget`'s
74
+ * `src/bridge/sessionNonce.ts`. */
75
+ export declare const SESSION_NONCE_PARAM = "sid";
76
+ /**
77
+ * A fresh, unguessable nonce for one backup open (128 bits, hex). Navigating an
78
+ * embedded iframe keeps its `WindowProxy` and Tier-1 sessions share an origin, so
79
+ * `event.source` + origin alone can't tell the current open's messages from ones
80
+ * the previous document queued before a reopen; the widget echoes this value as
81
+ * `sid` on every message and the SDK drops any that don't match.
82
+ */
83
+ export declare function createSessionNonce(): string;
84
+ /**
85
+ * Builds the URL the backup-widget iframe loads. Unlike the primary path there
86
+ * is no link token to decode — the widget is a static SPA at `origin` and is
87
+ * hydrated with the `MeshBackupConfig` over the message bridge after load. Only
88
+ * non-sensitive display hints are carried on the URL; the deposit config never
89
+ * appears in it.
90
+ *
91
+ * A trailing slash on `origin` is normalised away so params attach cleanly.
92
+ */
93
+ export declare function buildBackupWidgetUrl(origin: string, params: BackupWidgetUrlParams): string;