@matanetwork/sovereign-id 0.1.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.
@@ -0,0 +1,592 @@
1
+ /**
2
+ * Install upsell — the SDK's "no wallet installed" fallback UI.
3
+ *
4
+ * Phase 4 of the mID Mission. When `signIn()` can't reach the extension
5
+ * (no `window.__mata_mid__` global) AND the native-app deep link
6
+ * doesn't get answered within ~1.5s, the SDK shows this overlay
7
+ * instead of throwing `no_wallet_installed` to the RP. The overlay:
8
+ *
9
+ * 1. Tells the user MATA needs to be installed.
10
+ * 2. Surfaces the right install link for the user's browser / OS.
11
+ * 3. Polls `hasExtension()` once a second — when the user installs
12
+ * the extension in a new tab and comes back, the overlay detects
13
+ * the new `window.__mata_mid__` and auto-resumes the sign-in.
14
+ * 4. Lets the user click "Continue" to manually retry, or "Cancel"
15
+ * to reject the sign-in.
16
+ *
17
+ * ## Why Shadow DOM
18
+ *
19
+ * The RP's page has unknown CSS. A naked `<div>` would inherit the
20
+ * page's font, get hidden by aggressive `body *` rules, or worse —
21
+ * be skinnable by a malicious RP. Shadow DOM gives us style isolation:
22
+ * the overlay always looks the same regardless of where it mounts.
23
+ *
24
+ * ## What we do NOT do
25
+ *
26
+ * - **No fingerprinting.** Browser detection uses `navigator.userAgent`
27
+ * only for choosing the right install CTA — we never report it.
28
+ * - **No tracking pixels.** The overlay is purely client-side.
29
+ * - **No redirect.** v1 stays inline. A future "hosted upsell page"
30
+ * is possible (would let us A/B install conversion) but the inline
31
+ * modal keeps the RP page in control and avoids a third-party
32
+ * redirect on the critical sign-in path.
33
+ */
34
+
35
+ const CHROME_WEB_STORE_URL =
36
+ 'https://chromewebstore.google.com/detail/mata-digital-freedom-tool/fdpehkhlkfdmodfdjfjfaikaalabgimg';
37
+
38
+ /** Where the upsell sends the user when they click "create your account". */
39
+ const MATA_SIGNUP_URL = 'https://my.mata.network/signup';
40
+
41
+ /**
42
+ * Extract a sensible default referral code from an RP origin. Returns
43
+ * the bare hostname (no scheme, no port, no path) — e.g.
44
+ * `"https://acme.com"` → `"acme.com"`, `"http://localhost:3000"` →
45
+ * `"localhost"`. The MATA signup flow reads `?ref=` verbatim and
46
+ * stores it under the `mata_ref` cookie + `referral_code` Supabase
47
+ * field, so downstream attribution sees the RP's domain directly.
48
+ *
49
+ * Returns `null` when the origin can't be parsed — caller may want
50
+ * to omit the ref entirely in that case.
51
+ *
52
+ * @param {string} rpOrigin
53
+ * @returns {string | null}
54
+ */
55
+ export function defaultRefFromOrigin(rpOrigin) {
56
+ if (typeof rpOrigin !== 'string' || rpOrigin.length === 0) return null;
57
+ try {
58
+ const url = new URL(rpOrigin);
59
+ return url.hostname || null;
60
+ } catch (_) {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Append `?ref=<code>` (or `&ref=<code>`) to a URL, preserving any
67
+ * existing query string. Used to attach the referral on both the
68
+ * install CTA and the signup link in the upsell modal.
69
+ *
70
+ * @internal
71
+ */
72
+ function appendRef(url, ref) {
73
+ if (!ref) return url;
74
+ const sep = url.includes('?') ? '&' : '?';
75
+ return `${url}${sep}ref=${encodeURIComponent(ref)}`;
76
+ }
77
+
78
+ /**
79
+ * One of the CTAs the upsell can present. Pick by browser/OS.
80
+ *
81
+ * @typedef {object} InstallCta
82
+ * @property {string} label - Button text (e.g. "Install MATA for Chrome")
83
+ * @property {string} url - Where the button navigates to
84
+ * @property {string} hint - Sub-text under the CTA (e.g. "Opens the Chrome Web Store in a new tab")
85
+ */
86
+
87
+ /**
88
+ * Detect the browser/OS and pick the right install CTA.
89
+ *
90
+ * @returns {InstallCta}
91
+ */
92
+ export function pickInstallCta() {
93
+ if (typeof navigator === 'undefined') {
94
+ return chromeExtensionCta();
95
+ }
96
+ const ua = navigator.userAgent || '';
97
+
98
+ // Mobile: Chrome on Android, Safari on iOS. The Chrome extension
99
+ // doesn't run on mobile Chrome; deep-link to the native app once
100
+ // those listings exist. Until then, mobile users see a clear
101
+ // "MATA isn't on mobile yet" message rather than a broken link.
102
+ if (/Android/i.test(ua)) {
103
+ return {
104
+ label: 'MATA is coming to Android',
105
+ url: 'https://my.mata.network/',
106
+ hint: 'Open mata.network for updates.',
107
+ };
108
+ }
109
+ if (/iPhone|iPad|iPod/i.test(ua)) {
110
+ return {
111
+ label: 'MATA is coming to iOS',
112
+ url: 'https://my.mata.network/',
113
+ hint: 'Open mata.network for updates.',
114
+ };
115
+ }
116
+
117
+ // Desktop: pick a CTA per browser. Chrome / Edge / Brave / Arc /
118
+ // Opera all support Chrome Web Store extensions.
119
+ if (/Chrome|CriOS|Edg|OPR|Brave/i.test(ua) && !/Firefox/i.test(ua)) {
120
+ return chromeExtensionCta();
121
+ }
122
+ if (/Firefox/i.test(ua)) {
123
+ return {
124
+ label: 'MATA is coming to Firefox',
125
+ url: 'https://my.mata.network/',
126
+ hint: 'For now, use Chrome / Edge / Brave / Arc / Opera, or install the desktop app.',
127
+ };
128
+ }
129
+ if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) {
130
+ return {
131
+ label: 'MATA is coming to Safari',
132
+ url: 'https://my.mata.network/',
133
+ hint: 'For now, use Chrome / Edge / Brave / Arc / Opera, or install the desktop app.',
134
+ };
135
+ }
136
+ // Fallback — assume Chromium-flavored. The Web Store fails gracefully
137
+ // for genuinely-unsupported browsers (they show their own "this won't
138
+ // work here" page).
139
+ return chromeExtensionCta();
140
+ }
141
+
142
+ function chromeExtensionCta() {
143
+ return {
144
+ label: 'Install MATA from the Chrome Web Store',
145
+ url: CHROME_WEB_STORE_URL,
146
+ hint: 'Opens in a new tab. After installing, this page will detect MATA automatically.',
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Render the install upsell overlay. Returns a Promise that resolves
152
+ * with one of:
153
+ *
154
+ * - `"installed"` — the upsell detected (via polling) that the
155
+ * extension is now installed. The caller should retry `signIn()`.
156
+ * - `"canceled"` — the user clicked Cancel or pressed Escape.
157
+ *
158
+ * The overlay polls `hasExtensionFn()` (defaults to a check of
159
+ * `window.__mata_mid__`) once a second. The Promise lives until one
160
+ * of those terminal states fires.
161
+ *
162
+ * @param {object} options
163
+ * @param {string} options.rpOrigin - shown prominently as the
164
+ * requesting RP.
165
+ * @param {() => boolean} [options.hasExtensionFn] - inversion-of-control
166
+ * hook so the SDK's `hasExtension()` is used in production +
167
+ * tests can stub it.
168
+ * @param {InstallCta} [options.cta] - override the auto-picked CTA.
169
+ * @param {number} [options.pollIntervalMs] - default 1000.
170
+ * @param {string | null} [options.ref] - referral code stamped onto
171
+ * the install CTA and the signup link. Defaults to the hostname
172
+ * extracted from `rpOrigin` (e.g., `"acme.com"`), so RPs get
173
+ * attribution by default. Pass `null` to disable, or a custom
174
+ * string to override (e.g., a configured MATA referral code).
175
+ * Follows the existing `?ref=` convention captured by
176
+ * [my.mata.network](https://my.mata.network)'s welcome view.
177
+ * @returns {Promise<"installed" | "canceled">}
178
+ */
179
+ export function showInstallUpsell(options) {
180
+ const rpOrigin = options.rpOrigin || 'this site';
181
+ const hasExtensionFn = options.hasExtensionFn || defaultHasExtension;
182
+ const cta = options.cta || pickInstallCta();
183
+ const pollIntervalMs = options.pollIntervalMs ?? 1000;
184
+ // Default: derive the ref code from the RP's domain so RPs get
185
+ // attribution without any extra wiring. Pass `null` to disable.
186
+ const ref =
187
+ options.ref === undefined
188
+ ? defaultRefFromOrigin(options.rpOrigin)
189
+ : options.ref;
190
+ // Apply ref to both outbound URLs. Chrome Web Store strips unknown
191
+ // params on its own redirect, but stamping it costs nothing and
192
+ // keeps the future option open if the store starts honoring it.
193
+ const installUrl = appendRef(cta.url, ref);
194
+ const signupUrl = appendRef(MATA_SIGNUP_URL, ref);
195
+ const resolvedCta = { ...cta, url: installUrl };
196
+
197
+ // Already detected? Skip the modal entirely. Saves a flash.
198
+ // Check this BEFORE the document-existence guard so server-side
199
+ // tests with a stubbed `hasExtensionFn` still see the right
200
+ // short-circuit.
201
+ if (hasExtensionFn()) {
202
+ return Promise.resolve('installed');
203
+ }
204
+
205
+ if (typeof document === 'undefined') {
206
+ // Server-context safety — there's nothing to render.
207
+ return Promise.resolve('canceled');
208
+ }
209
+
210
+ return new Promise((resolve) => {
211
+ const host = document.createElement('div');
212
+ host.setAttribute('data-mata-mid-upsell', '');
213
+ // Maximum z — RPs can layer their own modals, this should win.
214
+ host.style.cssText = 'position: fixed; inset: 0; z-index: 2147483647;';
215
+ document.body.appendChild(host);
216
+
217
+ const shadow = host.attachShadow({ mode: 'closed' });
218
+ shadow.innerHTML = upsellMarkup({ rpOrigin, cta: resolvedCta, signupUrl });
219
+
220
+ let pollHandle = null;
221
+ let escHandler = null;
222
+ let trapHandler = null;
223
+ let resolved = false;
224
+
225
+ // Save the element that had focus before the modal opened so
226
+ // we can restore it on close — standard accessible-modal
227
+ // behavior. Without this, dismissing the modal leaves focus on
228
+ // <body>, which is disorienting for keyboard + screen-reader
229
+ // users.
230
+ const previouslyFocused =
231
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
232
+
233
+ const finish = (result) => {
234
+ if (resolved) return;
235
+ resolved = true;
236
+ if (pollHandle !== null) clearInterval(pollHandle);
237
+ if (escHandler !== null) document.removeEventListener('keydown', escHandler);
238
+ if (trapHandler !== null) host.removeEventListener('keydown', trapHandler, true);
239
+ try {
240
+ host.remove();
241
+ } catch (_) {
242
+ /* best-effort */
243
+ }
244
+ // Restore focus to wherever it was before we opened. Skip if
245
+ // the element is no longer in the document (e.g. RP nav-ed
246
+ // away and back).
247
+ if (previouslyFocused && document.contains(previouslyFocused)) {
248
+ try {
249
+ previouslyFocused.focus();
250
+ } catch (_) {
251
+ /* best-effort */
252
+ }
253
+ }
254
+ resolve(result);
255
+ };
256
+
257
+ // Wire button handlers.
258
+ const installBtn = shadow.querySelector('[data-action="install"]');
259
+ const continueBtn = shadow.querySelector('[data-action="continue"]');
260
+ const cancelBtn = shadow.querySelector('[data-action="cancel"]');
261
+ const backdrop = shadow.querySelector('[data-role="backdrop"]');
262
+
263
+ if (installBtn) {
264
+ installBtn.addEventListener('click', () => {
265
+ // Open the install URL in a new tab so this page stays open
266
+ // (the polling needs the current tab alive to detect the
267
+ // extension once the user comes back).
268
+ window.open(resolvedCta.url, '_blank', 'noopener,noreferrer');
269
+ });
270
+ }
271
+ if (continueBtn) {
272
+ continueBtn.addEventListener('click', () => {
273
+ if (hasExtensionFn()) {
274
+ finish('installed');
275
+ } else {
276
+ flashContinueHint(shadow);
277
+ }
278
+ });
279
+ }
280
+ if (cancelBtn) {
281
+ cancelBtn.addEventListener('click', () => finish('canceled'));
282
+ }
283
+ if (backdrop) {
284
+ backdrop.addEventListener('click', (e) => {
285
+ // Only fire on direct backdrop clicks, not bubbled clicks
286
+ // from the dialog content.
287
+ if (e.target === backdrop) finish('canceled');
288
+ });
289
+ }
290
+
291
+ // Escape key cancels.
292
+ escHandler = (e) => {
293
+ if (e.key === 'Escape') finish('canceled');
294
+ };
295
+ document.addEventListener('keydown', escHandler);
296
+
297
+ // Focus trap. Modals MUST keep keyboard focus inside themselves
298
+ // for accessibility compliance — without this, Tab eventually
299
+ // escapes into the RP's underlying page (which is now
300
+ // inert-but-not-actually-inert behind the backdrop). We listen
301
+ // on `host` in capture phase so Tab is intercepted before the
302
+ // browser's native focus move runs.
303
+ trapHandler = (e) => {
304
+ if (e.key !== 'Tab') return;
305
+ const focusables = collectFocusables(shadow);
306
+ if (focusables.length === 0) {
307
+ e.preventDefault();
308
+ return;
309
+ }
310
+ const first = focusables[0];
311
+ const last = focusables[focusables.length - 1];
312
+ // `shadow.activeElement` is the focused element inside the
313
+ // shadow tree (the document's activeElement is the host).
314
+ const active = shadow.activeElement;
315
+ if (e.shiftKey) {
316
+ // Shift+Tab on first → wrap to last.
317
+ if (active === first || !focusables.includes(active)) {
318
+ e.preventDefault();
319
+ last.focus();
320
+ }
321
+ } else {
322
+ // Tab on last → wrap to first.
323
+ if (active === last) {
324
+ e.preventDefault();
325
+ first.focus();
326
+ }
327
+ }
328
+ };
329
+ host.addEventListener('keydown', trapHandler, true);
330
+
331
+ // Move initial focus into the dialog. Default to the install
332
+ // button (primary action) — that matches the visual hierarchy.
333
+ // Wrapped in `requestAnimationFrame` so the dialog's open
334
+ // animation has a frame to settle; otherwise some browsers
335
+ // refuse to focus elements that just became visible.
336
+ requestAnimationFrame(() => {
337
+ const focusables = collectFocusables(shadow);
338
+ if (focusables.length > 0) {
339
+ try {
340
+ focusables[0].focus();
341
+ } catch (_) {
342
+ /* best-effort */
343
+ }
344
+ }
345
+ });
346
+
347
+ // Poll for the extension. Once the user installs in a new tab and
348
+ // returns to this tab, the next poll detects `window.__mata_mid__`
349
+ // and auto-resumes the sign-in.
350
+ pollHandle = setInterval(() => {
351
+ if (hasExtensionFn()) {
352
+ finish('installed');
353
+ }
354
+ }, pollIntervalMs);
355
+ });
356
+ }
357
+
358
+ /**
359
+ * Find the focusable elements inside the dialog's shadow tree, in
360
+ * tab order. Buttons + anchors with `href` cover everything the
361
+ * upsell ships today; the broader selector list is here so future
362
+ * additions (text inputs, custom-tabindex elements) work without a
363
+ * change to this helper.
364
+ *
365
+ * @internal
366
+ */
367
+ function collectFocusables(shadow) {
368
+ const dialog = shadow.querySelector('.dialog');
369
+ if (!dialog) return [];
370
+ const selector = [
371
+ 'button:not([disabled])',
372
+ '[href]',
373
+ 'input:not([disabled])',
374
+ 'select:not([disabled])',
375
+ 'textarea:not([disabled])',
376
+ '[tabindex]:not([tabindex="-1"])',
377
+ ].join(',');
378
+ return Array.from(dialog.querySelectorAll(selector)).filter((el) => {
379
+ // Skip elements that are visually hidden (display:none) or
380
+ // explicitly inert. We use offsetParent === null as a fast
381
+ // proxy for "not laid out / not visible." Doesn't catch
382
+ // visibility:hidden, but the upsell never renders such
383
+ // elements.
384
+ return el.offsetParent !== null;
385
+ });
386
+ }
387
+
388
+ /**
389
+ * The runtime's default extension probe. Mirrors `hasExtension()` from
390
+ * the SDK index — kept inline so install-upsell.js can be imported
391
+ * standalone (e.g. for visual review).
392
+ *
393
+ * @internal
394
+ */
395
+ function defaultHasExtension() {
396
+ return (
397
+ typeof window !== 'undefined' &&
398
+ typeof window['__mata_mid__'] === 'object' &&
399
+ window['__mata_mid__'] !== null &&
400
+ window['__mata_mid__'].version === 1
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Briefly highlight the polling hint when the user clicks Continue
406
+ * but the extension isn't detected yet. Helps them understand why
407
+ * nothing happened.
408
+ *
409
+ * @internal
410
+ */
411
+ function flashContinueHint(shadow) {
412
+ const hint = shadow.querySelector('[data-role="continue-hint"]');
413
+ if (!hint) return;
414
+ hint.classList.remove('flash');
415
+ // Restart the CSS animation by forcing a reflow.
416
+ void hint.offsetWidth;
417
+ hint.classList.add('flash');
418
+ }
419
+
420
+ /**
421
+ * The actual HTML the Shadow DOM renders. Inlined CSS keeps the
422
+ * package single-file and avoids loader configuration on the RP side.
423
+ *
424
+ * @internal
425
+ */
426
+ function upsellMarkup({ rpOrigin, cta, signupUrl }) {
427
+ // Strip any HTML that might appear in rpOrigin / cta. They're
428
+ // already validated upstream but defense-in-depth is cheap.
429
+ const safe = (s) =>
430
+ String(s)
431
+ .replace(/&/g, '&amp;')
432
+ .replace(/</g, '&lt;')
433
+ .replace(/>/g, '&gt;')
434
+ .replace(/"/g, '&quot;');
435
+
436
+ return `
437
+ <style>
438
+ :host { all: initial; }
439
+ * { box-sizing: border-box; }
440
+ [data-role="backdrop"] {
441
+ position: fixed; inset: 0;
442
+ background: rgba(0, 0, 0, 0.55);
443
+ display: flex; align-items: center; justify-content: center;
444
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
445
+ color: #1a1a1a;
446
+ animation: fade-in 0.18s ease-out;
447
+ }
448
+ @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
449
+ .dialog {
450
+ background: #fff;
451
+ border-radius: 12px;
452
+ padding: 28px;
453
+ max-width: 460px;
454
+ width: calc(100% - 32px);
455
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25);
456
+ animation: slide-up 0.22s ease-out;
457
+ }
458
+ @keyframes slide-up {
459
+ from { transform: translateY(12px); opacity: 0; }
460
+ to { transform: translateY(0); opacity: 1; }
461
+ }
462
+ h2 {
463
+ margin: 0 0 4px;
464
+ font-size: 1.25rem;
465
+ font-weight: 600;
466
+ letter-spacing: -0.01em;
467
+ }
468
+ .rp {
469
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
470
+ font-size: 0.92rem;
471
+ color: #555;
472
+ word-break: break-all;
473
+ }
474
+ p {
475
+ margin: 14px 0 0;
476
+ line-height: 1.5;
477
+ font-size: 0.95rem;
478
+ color: #333;
479
+ }
480
+ .actions {
481
+ margin-top: 22px;
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 10px;
485
+ }
486
+ .btn {
487
+ font: inherit;
488
+ font-weight: 500;
489
+ padding: 11px 18px;
490
+ border-radius: 6px;
491
+ border: none;
492
+ cursor: pointer;
493
+ transition: background 0.12s, opacity 0.12s;
494
+ text-align: center;
495
+ text-decoration: none;
496
+ display: inline-block;
497
+ }
498
+ .btn-primary { background: #2563eb; color: #fff; }
499
+ .btn-primary:hover { background: #1d4ed8; }
500
+ .btn-secondary {
501
+ background: #fff;
502
+ color: #1a1a1a;
503
+ border: 1px solid #d0d0d0;
504
+ }
505
+ .btn-secondary:hover { background: #f6f6f6; }
506
+ .btn-tertiary {
507
+ background: transparent;
508
+ color: #555;
509
+ padding: 8px;
510
+ font-size: 0.88rem;
511
+ }
512
+ .btn-tertiary:hover { color: #1a1a1a; }
513
+ /* Keyboard focus ring. :focus-visible so mouse users don't
514
+ see a ring on click but keyboard / screen-reader users do —
515
+ standard accessible-modal practice. */
516
+ .btn:focus-visible,
517
+ .signup-line a:focus-visible {
518
+ outline: 2px solid #2563eb;
519
+ outline-offset: 2px;
520
+ }
521
+ .cta-hint {
522
+ margin: 6px 0 0;
523
+ font-size: 0.82rem;
524
+ color: #777;
525
+ text-align: center;
526
+ }
527
+ .row {
528
+ display: flex;
529
+ gap: 8px;
530
+ }
531
+ .row .btn { flex: 1; }
532
+ .continue-hint {
533
+ margin: 12px 0 0;
534
+ text-align: center;
535
+ font-size: 0.84rem;
536
+ color: #888;
537
+ }
538
+ .continue-hint.flash {
539
+ animation: flash-warn 0.6s ease-out;
540
+ }
541
+ @keyframes flash-warn {
542
+ 0% { color: #888; }
543
+ 40% { color: #b45309; }
544
+ 100% { color: #888; }
545
+ }
546
+ .signup-line {
547
+ margin: 14px 0 0;
548
+ padding-top: 14px;
549
+ border-top: 1px solid #eee;
550
+ font-size: 0.88rem;
551
+ color: #555;
552
+ text-align: center;
553
+ }
554
+ .signup-line a {
555
+ color: #2563eb;
556
+ text-decoration: none;
557
+ }
558
+ .signup-line a:hover { text-decoration: underline; }
559
+ </style>
560
+ <div data-role="backdrop" role="dialog" aria-modal="true" aria-labelledby="mata-mid-title">
561
+ <div class="dialog">
562
+ <h2 id="mata-mid-title">Sign in with Sovereign ID</h2>
563
+ <div class="rp">${safe(rpOrigin)}</div>
564
+
565
+ <p>
566
+ To sign in here, install <strong>MATA</strong>. Your wallet stays
567
+ on your device — MATA never sees who you are or where you sign in.
568
+ </p>
569
+
570
+ <div class="actions">
571
+ <button class="btn btn-primary" data-action="install">${safe(cta.label)}</button>
572
+ <p class="cta-hint">${safe(cta.hint)}</p>
573
+
574
+ <div class="row">
575
+ <button class="btn btn-secondary" data-action="continue">I've installed it</button>
576
+ <button class="btn btn-secondary" data-action="cancel">Cancel</button>
577
+ </div>
578
+ <p class="continue-hint" data-role="continue-hint">
579
+ Watching for MATA — this will continue automatically once installed.
580
+ </p>
581
+ </div>
582
+
583
+ <p class="signup-line">
584
+ New to MATA? After installing, the extension will walk you through
585
+ <a href="${safe(signupUrl)}" target="_blank" rel="noopener noreferrer">
586
+ creating your account
587
+ </a>.
588
+ </p>
589
+ </div>
590
+ </div>
591
+ `;
592
+ }
package/src/resume.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Resume-after-reload — sessionStorage-backed pending sign-in handoff.
3
+ *
4
+ * The install upsell modal handles the common case where the user
5
+ * installs MATA in a new tab and returns to the RP tab — the modal's
6
+ * polling detects `window.__mata_mid__` and the in-flight `signIn()`
7
+ * promise resolves. But there's a second path: the user reloads the
8
+ * RP tab manually (or Chrome's "Extension installed" toast nudges them
9
+ * to). In that path the modal is gone and the awaiting promise dies.
10
+ *
11
+ * This module persists enough state across the reload that
12
+ * `resumePendingSignIn()` can pick the flow back up the moment the
13
+ * page boots.
14
+ *
15
+ * ## Storage model
16
+ *
17
+ * - **sessionStorage**, not localStorage. Per-tab, cleared on tab
18
+ * close. Matches the natural lifespan of a sign-in request — we
19
+ * don't want a stale resume to fire 3 days later in a different
20
+ * browsing session.
21
+ * - **One pending entry per tab.** If `signIn()` is called twice
22
+ * before the first resolves, the second overwrites — same UX as the
23
+ * modal-replacement behavior in the consent handoff.
24
+ * - **TTL = the original request's `timeoutMs`**. If the user reloads
25
+ * after the request would have timed out anyway, the resume entry
26
+ * is treated as stale and dropped.
27
+ *
28
+ * ## When the entry is cleared
29
+ *
30
+ * - On successful resolution (JWT received).
31
+ * - On user cancel from the upsell modal.
32
+ * - On TTL expiry at the next `resumePendingSignIn()` call.
33
+ * - On any non-`ERR_NO_WALLET_INSTALLED` error from `signIn()` (so
34
+ * resume doesn't keep retrying a malformed request).
35
+ */
36
+
37
+ /** Fixed sessionStorage key. One pending sign-in per tab. */
38
+ const STORAGE_KEY = 'mata_mid_pending_signin';
39
+
40
+ /**
41
+ * Stash a pending sign-in for possible resume after page reload.
42
+ *
43
+ * @param {object} entry
44
+ * @param {object} entry.request - the original `SignInRequest` shape.
45
+ * @param {object} entry.options - the original `SignInOptions` shape.
46
+ * @param {number} entry.expiresAt - Unix ms; entries past this time
47
+ * are treated as stale.
48
+ */
49
+ export function stashPendingSignIn(entry) {
50
+ if (typeof sessionStorage === 'undefined') return;
51
+ try {
52
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entry));
53
+ } catch (_) {
54
+ // Storage quota / private-mode rejection — silent skip is fine;
55
+ // the only consequence is that reload-resume won't fire.
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Read the pending sign-in entry. Returns `null` when there isn't one,
61
+ * when it's malformed, or when the TTL has passed (in which case the
62
+ * entry is also cleared).
63
+ *
64
+ * @returns {{request: object, options: object, expiresAt: number} | null}
65
+ */
66
+ export function readPendingSignIn() {
67
+ if (typeof sessionStorage === 'undefined') return null;
68
+ let raw;
69
+ try {
70
+ raw = sessionStorage.getItem(STORAGE_KEY);
71
+ } catch (_) {
72
+ return null;
73
+ }
74
+ if (!raw) return null;
75
+
76
+ let parsed;
77
+ try {
78
+ parsed = JSON.parse(raw);
79
+ } catch (_) {
80
+ clearPendingSignIn();
81
+ return null;
82
+ }
83
+ if (
84
+ typeof parsed !== 'object' ||
85
+ parsed === null ||
86
+ typeof parsed.request !== 'object' ||
87
+ typeof parsed.expiresAt !== 'number'
88
+ ) {
89
+ clearPendingSignIn();
90
+ return null;
91
+ }
92
+ if (Date.now() >= parsed.expiresAt) {
93
+ clearPendingSignIn();
94
+ return null;
95
+ }
96
+ // `options` is optional — older entries from a partial-deploy may
97
+ // omit it. Treat missing as `{}` so the resume call still composes.
98
+ if (typeof parsed.options !== 'object' || parsed.options === null) {
99
+ parsed.options = {};
100
+ }
101
+ return parsed;
102
+ }
103
+
104
+ /**
105
+ * Clear any pending sign-in entry. Safe to call when nothing is
106
+ * pending.
107
+ */
108
+ export function clearPendingSignIn() {
109
+ if (typeof sessionStorage === 'undefined') return;
110
+ try {
111
+ sessionStorage.removeItem(STORAGE_KEY);
112
+ } catch (_) {
113
+ /* best-effort */
114
+ }
115
+ }