@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.
package/src/index.js ADDED
@@ -0,0 +1,616 @@
1
+ /**
2
+ * @matanetwork/sovereign-id — page-side SDK for MATA's permissionless self-
3
+ * issued identity protocol (mID).
4
+ *
5
+ * Phase 5 of the mID Mission. This package is what relying parties
6
+ * (RPs) `npm install` and call from their sign-in button. It:
7
+ *
8
+ * 1. Probes for the MATA browser extension via `window.__mata_mid__`.
9
+ * 2. Falls back to deep-linking the MATA native app via
10
+ * `mata-mid://request?payload=...` if no extension is found.
11
+ * 3. Surfaces an install prompt if neither is available.
12
+ *
13
+ * The wire protocol the SDK speaks (page ↔ content-script / native
14
+ * app) is locked by ADR 0005 Decision 2/3 — see the Rust
15
+ * `mid-consent-types` crate for the canonical types this package
16
+ * mirrors.
17
+ */
18
+
19
+ // ─── Wire protocol constants (ADR 0005 Decision 2/3) ────────────────────────
20
+
21
+ /**
22
+ * The JavaScript global the extension's content script injects on every
23
+ * page. The SDK probes for this presence to detect the extension.
24
+ * @internal
25
+ */
26
+ export const WINDOW_MID_GLOBAL = '__mata_mid__';
27
+
28
+ /**
29
+ * The discriminator key on every page ↔ content-script postMessage.
30
+ * Lets the SDK filter `window.postMessage` events cheaply.
31
+ * @internal
32
+ */
33
+ export const MESSAGE_DISCRIMINATOR = '__mata_mid_v1';
34
+
35
+ /** The `kind` value on the sign-in request message. @internal */
36
+ export const KIND_SIGN_IN_REQUEST = 'sign_in_request';
37
+
38
+ /** The `kind` value on the sign-in response message. @internal */
39
+ export const KIND_SIGN_IN_RESPONSE = 'sign_in_response';
40
+
41
+ /** The native app's URL scheme. @internal */
42
+ export const URL_SCHEME = 'mata-mid';
43
+
44
+ /** The path under the URL scheme. @internal */
45
+ export const SCHEME_PATH_REQUEST = 'request';
46
+
47
+ /** The query parameter that carries the base64url-encoded payload. @internal */
48
+ export const QUERY_PARAM_PAYLOAD = 'payload';
49
+
50
+ /**
51
+ * The URL-fragment key in the response callback that carries the
52
+ * base64url-encoded response.
53
+ * @internal
54
+ */
55
+ export const FRAGMENT_KEY_RESPONSE = 'mid_response';
56
+
57
+ /** Native protocol version. Pinned at 1 per ADR. @internal */
58
+ export const PROTOCOL_VERSION = 1;
59
+
60
+ // ─── Standard error codes ──────────────────────────────────────────────────
61
+
62
+ export const ERR_USER_DENIED = 'user_denied';
63
+ export const ERR_ORIGIN_MISMATCH = 'origin_mismatch';
64
+ export const ERR_INVALID_REQUEST = 'invalid_request';
65
+ export const ERR_WALLET_UNAVAILABLE = 'wallet_unavailable';
66
+ export const ERR_REQUIRED_CLAIM_UNAVAILABLE = 'required_claim_unavailable';
67
+ export const ERR_INTERNAL = 'internal_error';
68
+ export const ERR_NO_WALLET_INSTALLED = 'no_wallet_installed';
69
+ export const ERR_TIMEOUT = 'timeout';
70
+ /**
71
+ * The user dismissed the install upsell ("Cancel" or Escape).
72
+ * Distinct from `user_denied` (the user reached the consent screen
73
+ * and denied disclosure). RPs typically render the same UI for both,
74
+ * but the distinct code lets analytics separate "didn't install" from
75
+ * "installed but said no."
76
+ */
77
+ export const ERR_UPSELL_CANCELED = 'upsell_canceled';
78
+
79
+ // ─── SignInError class ─────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Error thrown by `signIn()` on any failure path. The `code` is one of
83
+ * the `ERR_*` constants exported above; the RP's catch block can
84
+ * branch on it.
85
+ */
86
+ export class SignInError extends Error {
87
+ /**
88
+ * @param {string} code - one of the `ERR_*` constants
89
+ * @param {string} message - human-readable detail
90
+ */
91
+ constructor(code, message) {
92
+ super(message);
93
+ this.name = 'SignInError';
94
+ /** @type {string} */
95
+ this.code = code;
96
+ }
97
+ }
98
+
99
+ // ─── signIn() — the main API ───────────────────────────────────────────────
100
+
101
+ import {
102
+ stashPendingSignIn,
103
+ readPendingSignIn,
104
+ clearPendingSignIn,
105
+ } from './resume.js';
106
+
107
+ /**
108
+ * Default token request timeout: 2 minutes. The user has this long to
109
+ * approve or deny the consent screen before the SDK rejects.
110
+ */
111
+ const DEFAULT_TIMEOUT_MS = 120_000;
112
+
113
+ /**
114
+ * Request a sign-in from the user's MATA wallet.
115
+ *
116
+ * @param {object} request
117
+ * @param {string} request.rpOrigin - RP's bare origin, e.g. "https://acme.com"
118
+ * @param {string} request.nonce - RP-issued single-use nonce
119
+ * @param {object} request.claims - claim catalog
120
+ * @param {string[]} request.claims.required - required claim keys
121
+ * @param {string[]} [request.claims.optional] - optional claim keys
122
+ * @param {Record<string, {optional: true, description?: string}>} [request.claims.custom] - custom claims
123
+ * @param {object} [options]
124
+ * @param {number} [options.timeoutMs] - request timeout (default: 2 min)
125
+ * @param {string} [options.nativeAppCallback] - URL the native app opens to return (default: window.location.href)
126
+ * @param {boolean} [options.installUpsell] - whether to show the install upsell when no wallet is detected (default: true). Set to false to get the raw `ERR_NO_WALLET_INSTALLED` error and handle the upsell UI yourself.
127
+ * @param {string | null} [options.ref] - referral code attributed to signups that flow through the install upsell. Defaults to the hostname extracted from `rpOrigin` (e.g., `"acme.com"`) so RPs get attribution by default. Pass `null` to opt out, or a custom string to override (e.g., a configured MATA referral code). Follows the existing `?ref=` convention captured by my.mata.network's signup flow.
128
+ * @returns {Promise<{jwt: string, surface: "extension" | "native_app"}>}
129
+ * @throws {SignInError} with `.code` matching one of the `ERR_*` constants
130
+ */
131
+ export async function signIn(request, options = {}) {
132
+ validateRequest(request);
133
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
134
+ const upsellEnabled = options.installUpsell !== false;
135
+
136
+ if (hasExtension()) {
137
+ // A direct extension path supersedes any stashed resume entry —
138
+ // the user is no longer waiting on the install upsell. Clear so
139
+ // a later `resumePendingSignIn()` call doesn't re-fire this
140
+ // same request.
141
+ clearPendingSignIn();
142
+ return await signInViaExtension(request, timeoutMs);
143
+ }
144
+
145
+ // Extension not present — try native app surface. If that also
146
+ // can't find a wallet AND the RP opted into the install upsell, run
147
+ // the upsell and retry signIn on success.
148
+ try {
149
+ const result = await signInViaNativeApp(request, options, timeoutMs);
150
+ clearPendingSignIn();
151
+ return result;
152
+ } catch (err) {
153
+ if (
154
+ upsellEnabled &&
155
+ err instanceof SignInError &&
156
+ err.code === ERR_NO_WALLET_INSTALLED
157
+ ) {
158
+ return await runInstallUpsellAndRetry(request, options, timeoutMs);
159
+ }
160
+ // Any other error means the request itself was bad — purge the
161
+ // stash so the next reload doesn't pointlessly re-attempt a
162
+ // request that's structurally broken (malformed nonce, denied,
163
+ // etc.). Network-flake retries are the RP's job.
164
+ clearPendingSignIn();
165
+ throw err;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Run the install upsell modal. On `"installed"`, retry signIn (the
171
+ * extension is now present, so the retry hits the extension surface).
172
+ * On `"canceled"`, throw `ERR_UPSELL_CANCELED`.
173
+ *
174
+ * Before opening the modal, stash a resume entry in sessionStorage so
175
+ * that a manual page reload (or Chrome's "extension installed" toast
176
+ * nudging the user) doesn't lose the in-flight request. See
177
+ * [`resume.js`](./resume.js) for the storage model.
178
+ *
179
+ * @internal
180
+ */
181
+ async function runInstallUpsellAndRetry(request, options, timeoutMs) {
182
+ // Lazy import so RPs who opt out of the upsell don't pay the bundle
183
+ // cost. Bundlers (Webpack/Vite/esbuild) tree-shake this when
184
+ // `installUpsell: false` is the only call site.
185
+ const { showInstallUpsell } = await import('./install-upsell.js');
186
+
187
+ // Stash for resume-after-reload. `options` may carry `installUpsell:
188
+ // false` from a self-retry path; strip it when stashing so the
189
+ // resumed signIn doesn't skip the upsell entirely.
190
+ stashPendingSignIn({
191
+ request,
192
+ options: { ...options, installUpsell: undefined },
193
+ expiresAt: Date.now() + timeoutMs,
194
+ });
195
+
196
+ let result;
197
+ try {
198
+ result = await showInstallUpsell({
199
+ rpOrigin: request.rpOrigin,
200
+ hasExtensionFn: hasExtension,
201
+ // Thread the RP's explicit ref through. `undefined` (i.e. RP
202
+ // didn't set the option) leaves the upsell to auto-derive from
203
+ // rpOrigin; `null` opts the RP out of attribution entirely.
204
+ ref: options.ref,
205
+ });
206
+ } catch (e) {
207
+ clearPendingSignIn();
208
+ throw e;
209
+ }
210
+
211
+ if (result === 'installed') {
212
+ clearPendingSignIn();
213
+ if (hasExtension()) {
214
+ return await signInViaExtension(request, timeoutMs);
215
+ }
216
+ // Race — extension was detected by the modal's poll but is now
217
+ // gone (uninstall? content-script crash?). Fall through to the
218
+ // native attempt with upsell disabled to avoid infinite-modal.
219
+ return await signInViaNativeApp(request, { ...options, installUpsell: false }, timeoutMs);
220
+ }
221
+ clearPendingSignIn();
222
+ throw new SignInError(
223
+ ERR_UPSELL_CANCELED,
224
+ 'user dismissed the install upsell'
225
+ );
226
+ }
227
+
228
+ /**
229
+ * Resume a sign-in that was interrupted by a page reload during the
230
+ * install upsell.
231
+ *
232
+ * Call this once on page boot — ideally as early as possible in your
233
+ * app's startup so the user sees the sign-in continue without a
234
+ * visible flicker through your logged-out state.
235
+ *
236
+ * Returns:
237
+ *
238
+ * - `{jwt, surface}` — a pending request was found, the extension is
239
+ * now installed, and the resumed sign-in succeeded.
240
+ * - `null` — no pending request was stashed, or the stash is stale,
241
+ * or the extension is still not installed (the user reloaded
242
+ * before completing the install).
243
+ *
244
+ * Rejects with a `SignInError` if a pending request exists AND the
245
+ * extension is present but the sign-in itself failed (user denied,
246
+ * timeout, etc.) — same error shape as `signIn()`.
247
+ *
248
+ * Typical usage:
249
+ *
250
+ * ```js
251
+ * import { resumePendingSignIn } from '@matanetwork/sovereign-id';
252
+ *
253
+ * resumePendingSignIn().then((result) => {
254
+ * if (result) {
255
+ * // user installed the extension and the sign-in completed.
256
+ * handleSignedIn(result.jwt);
257
+ * }
258
+ * // else: no pending request, the boot path proceeds normally.
259
+ * });
260
+ * ```
261
+ *
262
+ * @returns {Promise<SignInSuccess | null>}
263
+ */
264
+ export async function resumePendingSignIn() {
265
+ const pending = readPendingSignIn();
266
+ if (!pending) return null;
267
+
268
+ // Extension still not installed → keep the stash in place so a
269
+ // follow-up reload (after the user finally installs) still resumes.
270
+ // Returning null lets the RP's normal boot flow continue rendering
271
+ // the sign-in button.
272
+ if (!hasExtension()) return null;
273
+
274
+ // Compute the residual timeout — total budget minus the elapsed
275
+ // wall-clock since the original signIn(). Floor at 1s so a very
276
+ // late reload still gets a real try at the extension surface.
277
+ const remainingMs = Math.max(1_000, pending.expiresAt - Date.now());
278
+
279
+ clearPendingSignIn();
280
+ try {
281
+ return await signInViaExtension(pending.request, remainingMs);
282
+ } catch (e) {
283
+ // Surface the error to the RP — they decide whether to show the
284
+ // sign-in button again, surface "your previous attempt timed
285
+ // out," etc.
286
+ throw e;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Whether the MATA browser extension is installed on this page.
292
+ *
293
+ * @returns {boolean}
294
+ */
295
+ export function hasExtension() {
296
+ return (
297
+ typeof window !== 'undefined' &&
298
+ typeof window[WINDOW_MID_GLOBAL] === 'object' &&
299
+ window[WINDOW_MID_GLOBAL] !== null &&
300
+ window[WINDOW_MID_GLOBAL].version === PROTOCOL_VERSION
301
+ );
302
+ }
303
+
304
+ /**
305
+ * Validate the request shape. Throws SignInError with code
306
+ * `invalid_request` if anything is off.
307
+ *
308
+ * @internal
309
+ * @param {object} request
310
+ */
311
+ function validateRequest(request) {
312
+ if (typeof request !== 'object' || request === null) {
313
+ throw new SignInError(ERR_INVALID_REQUEST, 'request must be an object');
314
+ }
315
+ if (typeof request.rpOrigin !== 'string' || request.rpOrigin.length === 0) {
316
+ throw new SignInError(ERR_INVALID_REQUEST, 'rpOrigin must be a non-empty string');
317
+ }
318
+ if (typeof request.nonce !== 'string' || request.nonce.length === 0) {
319
+ throw new SignInError(ERR_INVALID_REQUEST, 'nonce must be a non-empty string');
320
+ }
321
+ if (typeof request.claims !== 'object' || request.claims === null) {
322
+ throw new SignInError(ERR_INVALID_REQUEST, 'claims must be an object');
323
+ }
324
+ if (!Array.isArray(request.claims.required)) {
325
+ throw new SignInError(
326
+ ERR_INVALID_REQUEST,
327
+ 'claims.required must be an array (use [] if no required claims)'
328
+ );
329
+ }
330
+ }
331
+
332
+ // ─── Extension surface ─────────────────────────────────────────────────────
333
+
334
+ /**
335
+ * Send the sign-in request via the extension's `window.postMessage`
336
+ * channel, wait for the response.
337
+ *
338
+ * @internal
339
+ * @param {object} request
340
+ * @param {number} timeoutMs
341
+ * @returns {Promise<{jwt: string, surface: "extension"}>}
342
+ */
343
+ function signInViaExtension(request, timeoutMs) {
344
+ return new Promise((resolve, reject) => {
345
+ const requestId = generateRequestId();
346
+ let timeoutHandle = null;
347
+
348
+ const messageHandler = (event) => {
349
+ const data = event.data;
350
+ if (typeof data !== 'object' || data === null) return;
351
+ if (data[MESSAGE_DISCRIMINATOR] !== true) return;
352
+ if (data.kind !== KIND_SIGN_IN_RESPONSE) return;
353
+ if (data.request_id !== requestId) return;
354
+
355
+ // It's our response.
356
+ window.removeEventListener('message', messageHandler);
357
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
358
+
359
+ const result = data.result;
360
+ if (typeof result !== 'object' || result === null) {
361
+ reject(new SignInError(ERR_INTERNAL, 'malformed response from wallet'));
362
+ return;
363
+ }
364
+ if (result.outcome === 'ok') {
365
+ resolve({ jwt: result.jwt, surface: 'extension' });
366
+ } else if (result.outcome === 'denied') {
367
+ reject(new SignInError(ERR_USER_DENIED, 'user denied disclosure'));
368
+ } else if (result.outcome === 'error') {
369
+ reject(new SignInError(result.error_code ?? ERR_INTERNAL, result.message ?? ''));
370
+ } else {
371
+ reject(new SignInError(ERR_INTERNAL, `unknown outcome: ${result.outcome}`));
372
+ }
373
+ };
374
+
375
+ window.addEventListener('message', messageHandler);
376
+
377
+ // Send the request.
378
+ const payload = {
379
+ [MESSAGE_DISCRIMINATOR]: true,
380
+ kind: KIND_SIGN_IN_REQUEST,
381
+ request_id: requestId,
382
+ rp_origin: request.rpOrigin,
383
+ nonce: request.nonce,
384
+ claims: {
385
+ required: request.claims.required,
386
+ optional: request.claims.optional ?? [],
387
+ custom: request.claims.custom ?? {},
388
+ },
389
+ };
390
+ window.postMessage(payload, '*');
391
+
392
+ // Timeout fallback.
393
+ timeoutHandle = setTimeout(() => {
394
+ window.removeEventListener('message', messageHandler);
395
+ reject(new SignInError(ERR_TIMEOUT, `no response within ${timeoutMs}ms`));
396
+ }, timeoutMs);
397
+ });
398
+ }
399
+
400
+ // ─── Native app surface ────────────────────────────────────────────────────
401
+
402
+ /**
403
+ * Deep-link the native MATA app via `mata-mid://`. Resolves when the
404
+ * user returns to the browser tab and the SDK reads `location.hash`
405
+ * for the `mid_response` payload.
406
+ *
407
+ * @internal
408
+ * @param {object} request
409
+ * @param {object} options
410
+ * @param {number} timeoutMs
411
+ * @returns {Promise<{jwt: string, surface: "native_app"}>}
412
+ */
413
+ function signInViaNativeApp(request, options, timeoutMs) {
414
+ return new Promise((resolve, reject) => {
415
+ if (typeof window === 'undefined') {
416
+ reject(new SignInError(ERR_NO_WALLET_INSTALLED, 'no window — server context?'));
417
+ return;
418
+ }
419
+
420
+ const requestId = generateRequestId();
421
+ const callback = options.nativeAppCallback ?? window.location.href;
422
+
423
+ const payload = {
424
+ version: PROTOCOL_VERSION,
425
+ request_id: requestId,
426
+ rp_origin: request.rpOrigin,
427
+ nonce: request.nonce,
428
+ claims: {
429
+ required: request.claims.required,
430
+ optional: request.claims.optional ?? [],
431
+ custom: request.claims.custom ?? {},
432
+ },
433
+ callback,
434
+ };
435
+
436
+ const payloadJson = JSON.stringify(payload);
437
+ const payloadB64 = base64UrlEncode(payloadJson);
438
+ const url = `${URL_SCHEME}://${SCHEME_PATH_REQUEST}?${QUERY_PARAM_PAYLOAD}=${payloadB64}`;
439
+
440
+ // Detect resume — when the page comes back (visibilitychange ->
441
+ // visible OR hashchange), inspect location.hash for our response.
442
+ let timeoutHandle = null;
443
+ const resumeHandler = () => {
444
+ const response = extractResponseFromHash(window.location.hash, requestId);
445
+ if (response === null) {
446
+ // Not our response (yet) — keep listening.
447
+ return;
448
+ }
449
+ cleanup();
450
+ // Wipe the fragment so a refresh doesn't replay the response.
451
+ try {
452
+ const cleanUrl = window.location.href.split('#')[0];
453
+ window.history.replaceState(null, '', cleanUrl);
454
+ } catch (_) {
455
+ /* best-effort */
456
+ }
457
+ const result = response.result;
458
+ if (result.outcome === 'ok') {
459
+ resolve({ jwt: result.jwt, surface: 'native_app' });
460
+ } else if (result.outcome === 'denied') {
461
+ reject(new SignInError(ERR_USER_DENIED, 'user denied disclosure'));
462
+ } else {
463
+ reject(new SignInError(result.error_code ?? ERR_INTERNAL, result.message ?? ''));
464
+ }
465
+ };
466
+
467
+ const cleanup = () => {
468
+ window.removeEventListener('hashchange', resumeHandler);
469
+ document.removeEventListener('visibilitychange', resumeHandler);
470
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
471
+ };
472
+
473
+ window.addEventListener('hashchange', resumeHandler);
474
+ document.addEventListener('visibilitychange', resumeHandler);
475
+
476
+ // Try to detect that the URL scheme actually launched a registered
477
+ // app. If the page is still visible 1.5s after dispatching the
478
+ // deep link AND no hash response has arrived, assume no app is
479
+ // installed and reject with `no_wallet_installed`.
480
+ const NO_APP_DETECT_MS = 1500;
481
+ const noAppCheckHandle = setTimeout(() => {
482
+ if (document.visibilityState === 'visible') {
483
+ cleanup();
484
+ clearTimeout(noAppCheckHandle);
485
+ reject(
486
+ new SignInError(
487
+ ERR_NO_WALLET_INSTALLED,
488
+ 'no MATA app responded to the deep link'
489
+ )
490
+ );
491
+ }
492
+ }, NO_APP_DETECT_MS);
493
+
494
+ // Hard timeout — user has the configured timeoutMs to complete.
495
+ timeoutHandle = setTimeout(() => {
496
+ cleanup();
497
+ clearTimeout(noAppCheckHandle);
498
+ reject(new SignInError(ERR_TIMEOUT, `no response within ${timeoutMs}ms`));
499
+ }, timeoutMs);
500
+
501
+ // Dispatch the deep link.
502
+ window.location.href = url;
503
+ });
504
+ }
505
+
506
+ /**
507
+ * Try to extract a `PageSignInResponse` from the URL hash. Returns
508
+ * `null` if the hash doesn't contain our fragment or the embedded
509
+ * response doesn't match `expectedRequestId`.
510
+ *
511
+ * @internal
512
+ * @param {string} hash
513
+ * @param {string} expectedRequestId
514
+ * @returns {object | null}
515
+ */
516
+ function extractResponseFromHash(hash, expectedRequestId) {
517
+ if (!hash || hash.length < 2) return null;
518
+ // Strip leading #
519
+ const fragment = hash.startsWith('#') ? hash.slice(1) : hash;
520
+ const pairs = fragment.split('&');
521
+ for (const pair of pairs) {
522
+ const idx = pair.indexOf('=');
523
+ if (idx === -1) continue;
524
+ const key = pair.slice(0, idx);
525
+ if (key !== FRAGMENT_KEY_RESPONSE) continue;
526
+ const value = pair.slice(idx + 1);
527
+ try {
528
+ const json = base64UrlDecode(value);
529
+ const obj = JSON.parse(json);
530
+ if (obj.request_id === expectedRequestId) {
531
+ return obj;
532
+ }
533
+ } catch (_) {
534
+ // Malformed; keep looking.
535
+ }
536
+ }
537
+ return null;
538
+ }
539
+
540
+ // ─── Helpers ───────────────────────────────────────────────────────────────
541
+
542
+ /**
543
+ * Generate a UUID v4 for the request_id. Uses `crypto.randomUUID()`
544
+ * where available (modern browsers + Node 19+); falls back to a
545
+ * Math.random()-based generator if not.
546
+ *
547
+ * @internal
548
+ * @returns {string}
549
+ */
550
+ export function generateRequestId() {
551
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
552
+ return crypto.randomUUID();
553
+ }
554
+ // Fallback — RFC 4122 v4 layout, not cryptographically random but
555
+ // sufficient for in-flight correlation.
556
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
557
+ const r = (Math.random() * 16) | 0;
558
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
559
+ return v.toString(16);
560
+ });
561
+ }
562
+
563
+ /**
564
+ * base64url-encode a string. Browser-compatible (no Buffer).
565
+ *
566
+ * @internal
567
+ * @param {string} str
568
+ * @returns {string}
569
+ */
570
+ export function base64UrlEncode(str) {
571
+ // UTF-8 → bytes → base64 → URL-safe charset, no padding.
572
+ const utf8Bytes = new TextEncoder().encode(str);
573
+ let binary = '';
574
+ for (const b of utf8Bytes) binary += String.fromCharCode(b);
575
+ const b64 = btoa(binary);
576
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
577
+ }
578
+
579
+ /**
580
+ * base64url-decode to a string.
581
+ *
582
+ * @internal
583
+ * @param {string} b64url
584
+ * @returns {string}
585
+ */
586
+ export function base64UrlDecode(b64url) {
587
+ // URL-safe charset → standard base64. Add padding back.
588
+ let b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
589
+ const pad = b64.length % 4;
590
+ if (pad === 2) b64 += '==';
591
+ else if (pad === 3) b64 += '=';
592
+ const binary = atob(b64);
593
+ const bytes = new Uint8Array(binary.length);
594
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
595
+ return new TextDecoder().decode(bytes);
596
+ }
597
+
598
+ // ─── Upsell surface re-exports ─────────────────────────────────────────────
599
+ //
600
+ // Re-exported so RPs that want full control (e.g. their own modal UI)
601
+ // can call `showInstallUpsell` directly without depending on the
602
+ // internal lazy-import path. They'd typically pass `installUpsell:
603
+ // false` to `signIn()`, catch `ERR_NO_WALLET_INSTALLED`, and call
604
+ // `showInstallUpsell()` from their own handler.
605
+
606
+ export {
607
+ showInstallUpsell,
608
+ pickInstallCta,
609
+ defaultRefFromOrigin,
610
+ } from './install-upsell.js';
611
+
612
+ // Resume-after-reload surface. `resumePendingSignIn` is the public
613
+ // entry; `clearPendingSignIn` is exposed for RPs who need to drop
614
+ // the stash imperatively (e.g. after navigating to a different
615
+ // sign-in flow that supersedes the pending mID request).
616
+ export { clearPendingSignIn } from './resume.js';