@enbox/browser 0.3.70 → 0.3.72

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.
Files changed (39) hide show
  1. package/README.md +30 -7
  2. package/dist/browser.mjs +14 -14
  3. package/dist/browser.mjs.map +4 -4
  4. package/dist/esm/blob-url-pool.js +36 -0
  5. package/dist/esm/blob-url-pool.js.map +1 -0
  6. package/dist/esm/browser-connect-handler.js +3 -1
  7. package/dist/esm/browser-connect-handler.js.map +1 -1
  8. package/dist/esm/file-envelope-url-store.js +203 -0
  9. package/dist/esm/file-envelope-url-store.js.map +1 -0
  10. package/dist/esm/index.js +12 -12
  11. package/dist/esm/index.js.map +1 -1
  12. package/dist/esm/object-url-reference.js +68 -0
  13. package/dist/esm/object-url-reference.js.map +1 -0
  14. package/dist/esm/relay-connect-runner.js +9 -1
  15. package/dist/esm/relay-connect-runner.js.map +1 -1
  16. package/dist/esm/ui/connect-modal.js +134 -16
  17. package/dist/esm/ui/connect-modal.js.map +1 -1
  18. package/dist/types/blob-url-pool.d.ts +16 -0
  19. package/dist/types/blob-url-pool.d.ts.map +1 -0
  20. package/dist/types/browser-connect-handler.d.ts +8 -2
  21. package/dist/types/browser-connect-handler.d.ts.map +1 -1
  22. package/dist/types/file-envelope-url-store.d.ts +30 -0
  23. package/dist/types/file-envelope-url-store.d.ts.map +1 -0
  24. package/dist/types/index.d.ts +16 -14
  25. package/dist/types/index.d.ts.map +1 -1
  26. package/dist/types/object-url-reference.d.ts +20 -0
  27. package/dist/types/object-url-reference.d.ts.map +1 -0
  28. package/dist/types/relay-connect-runner.d.ts +4 -0
  29. package/dist/types/relay-connect-runner.d.ts.map +1 -1
  30. package/dist/types/ui/connect-modal.d.ts +24 -11
  31. package/dist/types/ui/connect-modal.d.ts.map +1 -1
  32. package/package.json +7 -7
  33. package/src/blob-url-pool.ts +55 -0
  34. package/src/browser-connect-handler.ts +13 -2
  35. package/src/file-envelope-url-store.ts +260 -0
  36. package/src/index.ts +23 -159
  37. package/src/object-url-reference.ts +82 -0
  38. package/src/relay-connect-runner.ts +17 -2
  39. package/src/ui/connect-modal.ts +187 -28
@@ -59,6 +59,12 @@ export type ConnectMethod = 'phone' | 'browser';
59
59
  /** localStorage key remembering the last successful method + wallet. */
60
60
  const LAST_CHOICE_STORAGE_KEY = 'enbox:connect:lastChoice';
61
61
 
62
+ /** localStorage key remembering the exact wallet route for each delegate/profile pair. */
63
+ const SESSION_CHOICES_STORAGE_KEY = 'enbox:connect:sessionChoices';
64
+
65
+ /** Bounds local reconnect provenance retained by one dapp origin. */
66
+ const MAX_SESSION_CHOICES = 20;
67
+
62
68
  /**
63
69
  * Safety margin subtracted from the relay pointer TTL before re-minting.
64
70
  *
@@ -168,8 +174,9 @@ export interface ConnectModalOptions {
168
174
  preferredMethod?: ConnectMethod;
169
175
 
170
176
  /**
171
- * Remember the successful method + wallet in localStorage and pre-shape
172
- * the next session accordingly.
177
+ * Remember the successful method + wallet in localStorage. Refreshes reuse
178
+ * the route saved for their delegate DID; new connections use the latest
179
+ * successful route as a convenience.
173
180
  * @default true
174
181
  */
175
182
  rememberChoice?: boolean;
@@ -180,6 +187,15 @@ export interface ConnectModalOptions {
180
187
  /** Icon URL of the requesting application. */
181
188
  appIcon?: string;
182
189
 
190
+ /**
191
+ * Stable application identifier hint available to the wallet during approval.
192
+ * Defaults to the browser's canonical origin.
193
+ */
194
+ applicationId?: string;
195
+
196
+ /** Wallet profile DID that a refresh must renew. */
197
+ expectedProviderDid?: string;
198
+
183
199
  /**
184
200
  * Relay base URL override for the phone path. When omitted, each wallet's
185
201
  * `connectServerUrl` is discovered from its `/.well-known/enbox-connect`
@@ -216,15 +232,7 @@ export interface ConnectModalDeps {
216
232
  runRelay: typeof runRelayConnect;
217
233
 
218
234
  /** Runs one popup handshake (browser path); must open the popup synchronously. */
219
- runPopup: (options: {
220
- walletUrl: string;
221
- permissionRequests: ConnectPermissionRequest[];
222
- appName: string;
223
- appIcon?: string;
224
- timeoutMs?: number;
225
- delegatePortableDid?: PortableDid;
226
- requestType?: ConnectRequestType;
227
- }) => Promise<ConnectResult | undefined>;
235
+ runPopup: typeof runPopupConnect;
228
236
 
229
237
  /** Resolves a wallet origin to its relay `connectServerUrl` (or undefined). */
230
238
  discoverConnectServerUrl: (walletOrigin: string) => Promise<string | undefined>;
@@ -254,9 +262,11 @@ async function runPopupConnect(options: {
254
262
  permissionRequests: ConnectPermissionRequest[];
255
263
  appName: string;
256
264
  appIcon?: string;
265
+ applicationId?: string;
257
266
  timeoutMs?: number;
258
267
  delegatePortableDid?: PortableDid;
259
268
  requestType?: ConnectRequestType;
269
+ expectedProviderDid?: string;
260
270
  }): Promise<ConnectResult | undefined> {
261
271
  // The transport constructor calls `window.open` synchronously — callers
262
272
  // must invoke this inside the user-gesture call stack.
@@ -269,10 +279,12 @@ async function runPopupConnect(options: {
269
279
  return await client.connect({
270
280
  appName : options.appName,
271
281
  appIcon : options.appIcon,
282
+ applicationId : options.applicationId,
272
283
  clientMetadata : collectBrowserClientMetadata(),
273
284
  permissionRequests : options.permissionRequests,
274
285
  delegatePortableDid : options.delegatePortableDid,
275
286
  requestType : options.requestType,
287
+ expectedProviderDid : options.expectedProviderDid,
276
288
  });
277
289
  }
278
290
 
@@ -301,6 +313,12 @@ interface LastChoice {
301
313
  walletUrl: string;
302
314
  }
303
315
 
316
+ /** Stable reconnect provenance for one delegated app session. */
317
+ interface SessionChoice extends LastChoice {
318
+ delegateDid: string;
319
+ providerDid: string;
320
+ }
321
+
304
322
  function readLastChoice(storage: ConnectModalDeps['storage']): LastChoice | undefined {
305
323
  try {
306
324
  const raw = storage?.getItem(LAST_CHOICE_STORAGE_KEY);
@@ -321,6 +339,78 @@ function writeLastChoice(storage: ConnectModalDeps['storage'], choice: LastChoic
321
339
  } catch { /* best effort */ }
322
340
  }
323
341
 
342
+ function isSessionChoice(value: unknown): value is SessionChoice {
343
+ if (typeof value !== 'object' || value === null) {
344
+ return false;
345
+ }
346
+ const choice = value as Partial<SessionChoice>;
347
+ return (choice.method === 'phone' || choice.method === 'browser')
348
+ && isValidRememberedWalletUrl(choice.walletUrl)
349
+ && typeof choice.delegateDid === 'string'
350
+ && choice.delegateDid.length > 0
351
+ && typeof choice.providerDid === 'string'
352
+ && choice.providerDid.length > 0;
353
+ }
354
+
355
+ function readSessionChoices(storage: ConnectModalDeps['storage']): SessionChoice[] {
356
+ try {
357
+ const raw = storage?.getItem(SESSION_CHOICES_STORAGE_KEY);
358
+ if (raw === null || raw === undefined) {
359
+ return [];
360
+ }
361
+ const parsed = JSON.parse(raw) as unknown;
362
+ return Array.isArray(parsed) ? parsed.filter(isSessionChoice).slice(-MAX_SESSION_CHOICES) : [];
363
+ } catch {
364
+ return [];
365
+ }
366
+ }
367
+
368
+ function readSessionChoice(
369
+ storage: ConnectModalDeps['storage'],
370
+ delegateDid: string | undefined,
371
+ expectedProviderDid: string | undefined,
372
+ ): SessionChoice | undefined {
373
+ if (delegateDid === undefined) {
374
+ return undefined;
375
+ }
376
+ const choices = readSessionChoices(storage);
377
+ for (let index = choices.length - 1; index >= 0; index--) {
378
+ const choice = choices[index];
379
+ if (choice.delegateDid === delegateDid
380
+ && (expectedProviderDid === undefined || choice.providerDid === expectedProviderDid)) {
381
+ return choice;
382
+ }
383
+ }
384
+ return undefined;
385
+ }
386
+
387
+ function writeSessionChoice(storage: ConnectModalDeps['storage'], choice: SessionChoice): void {
388
+ try {
389
+ const choices = readSessionChoices(storage).filter(entry =>
390
+ entry.delegateDid !== choice.delegateDid || entry.providerDid !== choice.providerDid
391
+ );
392
+ choices.push(choice);
393
+ storage?.setItem(SESSION_CHOICES_STORAGE_KEY, JSON.stringify(choices.slice(-MAX_SESSION_CHOICES)));
394
+ } catch { /* best effort */ }
395
+ }
396
+
397
+ function removeSessionChoice(
398
+ storage: ConnectModalDeps['storage'],
399
+ delegateDid: string,
400
+ providerDid: string,
401
+ ): void {
402
+ try {
403
+ const choices = readSessionChoices(storage).filter(choice =>
404
+ choice.delegateDid !== delegateDid || choice.providerDid !== providerDid
405
+ );
406
+ if (choices.length === 0) {
407
+ storage?.removeItem(SESSION_CHOICES_STORAGE_KEY);
408
+ } else {
409
+ storage?.setItem(SESSION_CHOICES_STORAGE_KEY, JSON.stringify(choices));
410
+ }
411
+ } catch { /* best effort */ }
412
+ }
413
+
324
414
  /** One cancellable relay session. */
325
415
  interface RelaySession {
326
416
  cancelled: Promise<never>;
@@ -379,24 +469,37 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
379
469
  const wallets = dedupeWalletsByUrl(options.wallets ?? []);
380
470
  const rememberChoice = options.rememberChoice ?? true;
381
471
  const appName = options.appName ?? window.location.host;
382
- const relayWalletPath = options.relayWalletPath ?? '/connect/app';
472
+ const applicationId = options.applicationId ?? window.location.origin;
383
473
  const isMobile = deps.isMobile();
384
474
  const refreshing = options.mode === 'refresh';
385
475
 
386
476
  const lastChoice = rememberChoice ? readLastChoice(deps.storage) : undefined;
477
+ let savedRoute = rememberChoice && refreshing
478
+ ? readSessionChoice(
479
+ deps.storage,
480
+ options.delegatePortableDid?.uri,
481
+ options.expectedProviderDid,
482
+ )
483
+ : undefined;
484
+ const expectedProviderDid = options.expectedProviderDid ?? savedRoute?.providerDid;
485
+ const relayWalletPath = options.relayWalletPath ?? '/connect/app';
486
+ const savedRouteEscapeLabel = options.walletUrl === undefined
487
+ ? 'Choose another wallet or method'
488
+ : 'Choose another method';
387
489
 
388
- // Wallet resolution order: explicit option → remembered → catalog head.
490
+ // Refresh routes resolve by delegate first; general preferences remain a
491
+ // best-effort fallback for sessions created before route-aware storage.
389
492
  const rememberedWalletUrl = lastChoice !== undefined
390
493
  && (refreshing || walletInCatalog(wallets, lastChoice.walletUrl))
391
494
  ? lastChoice.walletUrl
392
495
  : undefined;
393
- const lockedWallet = options.walletUrl !== undefined || (refreshing && rememberedWalletUrl !== undefined);
394
- let walletUrl = options.walletUrl
496
+ let walletUrl = savedRoute?.walletUrl
497
+ ?? options.walletUrl
395
498
  ?? rememberedWalletUrl
396
499
  ?? wallets[0]?.url;
397
500
 
398
- // Method resolution order: explicit option → remembered → phone.
399
- let method: ConnectMethod = options.preferredMethod ?? lastChoice?.method ?? 'phone';
501
+ // An exact refresh route wins over generic handler and global preferences.
502
+ let method: ConnectMethod = savedRoute?.method ?? options.preferredMethod ?? lastChoice?.method ?? 'phone';
400
503
 
401
504
  return new Promise<ConnectResult | undefined>((resolve, reject) => {
402
505
  // ── Host + shadow root ─────────────────────────────────────
@@ -517,8 +620,14 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
517
620
  };
518
621
 
519
622
  const succeed = (result: ConnectResult): void => {
520
- if (rememberChoice) {
521
- writeLastChoice(deps.storage, { method, walletUrl: walletUrl ?? '' });
623
+ if (rememberChoice && walletUrl !== undefined) {
624
+ writeLastChoice(deps.storage, { method, walletUrl });
625
+ writeSessionChoice(deps.storage, {
626
+ delegateDid : result.delegatePortableDid.uri,
627
+ providerDid : result.connectedDid,
628
+ method,
629
+ walletUrl,
630
+ });
522
631
  }
523
632
  renderConnected();
524
633
  setTimeout(settleWith(settle, () => resolve(result)), 1_200);
@@ -703,15 +812,47 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
703
812
  );
704
813
  };
705
814
 
815
+ const escapeSavedRoute = (): void => {
816
+ if (savedRoute === undefined) {
817
+ return;
818
+ }
819
+
820
+ relaySession?.cancel();
821
+ relaySession = undefined;
822
+ clearRemint();
823
+ removeSessionChoice(deps.storage, savedRoute.delegateDid, savedRoute.providerDid);
824
+ savedRoute = undefined;
825
+ if (options.walletUrl !== undefined) {
826
+ walletUrl = options.walletUrl;
827
+ }
828
+ renderFooter();
829
+ setStage(
830
+ el('p', 'stage-caption', 'Choose another way to reconnect.'),
831
+ el('p', 'stage-subline', 'We’ll still require the same wallet profile used for this session.'),
832
+ );
833
+ };
834
+
706
835
  const renderError = (message: string, error?: Error): void => {
707
836
  setStage(
708
837
  el('p', 'stage-caption', message),
709
838
  stageButton('Start over', () => { void startMethod(); }),
839
+ ...(savedRoute !== undefined
840
+ ? [stageLinkButton(savedRouteEscapeLabel, escapeSavedRoute)]
841
+ : []),
710
842
  stageLinkButton('Close', settleWith(settle, () => reject(error ?? new Error(`[@enbox/browser] ${message}`)))),
711
843
  );
712
844
  };
713
845
 
714
846
  const renderPhoneUnavailable = (): void => {
847
+ if (savedRoute !== undefined) {
848
+ setStage(
849
+ el('p', 'stage-caption', 'Your saved wallet connection is unavailable right now.'),
850
+ el('p', 'stage-subline', 'Try again in a moment to reconnect the same session.'),
851
+ stageButton('Try again', () => { void startPhone(); }),
852
+ stageLinkButton(savedRouteEscapeLabel, escapeSavedRoute),
853
+ );
854
+ return;
855
+ }
715
856
  setStage(
716
857
  el('p', 'stage-caption', 'This wallet can’t connect by phone here.'),
717
858
  el('p', 'stage-subline', 'You can continue in this browser instead.'),
@@ -724,12 +865,24 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
724
865
  const popupSurface = isMobile ? 'tab' : 'window';
725
866
 
726
867
  const renderPopupPrompt = (): void => {
868
+ const walletName = walletUrl !== undefined ? walletByUrl(walletUrl).name : 'your wallet';
869
+ const exactReconnect = savedRoute !== undefined;
870
+ const connectDescription = isMobile
871
+ ? 'Your wallet opens in a new tab to approve this connection — no code needed.'
872
+ : 'Your wallet opens in a small window to approve this connection.';
873
+ const description = exactReconnect
874
+ ? `We’ll open the same ${walletName} profile used for this session.`
875
+ : connectDescription;
876
+ const connectButtonLabel = isMobile ? 'Open wallet' : 'Open wallet window';
877
+ const buttonLabel = exactReconnect
878
+ ? `Open ${walletName} ${popupSurface}`
879
+ : connectButtonLabel;
727
880
  setStage(
728
- el('p', 'stage-caption', 'Connect with a wallet in this browser'),
729
- el('p', 'stage-subline', isMobile
730
- ? 'Your wallet opens in a new tab to approve this connection — no code needed.'
731
- : 'Your wallet opens in a small window to approve this connection.'),
732
- stageButton(isMobile ? 'Open wallet' : 'Open wallet window', () => { startPopup(); }),
881
+ el('p', 'stage-caption', exactReconnect
882
+ ? `Reconnect with ${walletName}`
883
+ : 'Connect with a wallet in this browser'),
884
+ el('p', 'stage-subline', description),
885
+ stageButton(buttonLabel, () => { startPopup(); }),
733
886
  );
734
887
  };
735
888
 
@@ -747,7 +900,9 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
747
900
  ? `Your browser blocked the wallet ${popupSurface}.`
748
901
  : `The wallet ${popupSurface} was closed.`),
749
902
  stageButton(blocked ? 'Open it now' : `Reopen ${popupSurface}`, () => { startPopup(); }),
750
- stageLinkButton(isMobile ? 'Use a code instead' : 'Use your phone instead', () => { void switchMethod('phone'); }),
903
+ ...(savedRoute !== undefined
904
+ ? [stageLinkButton(savedRouteEscapeLabel, escapeSavedRoute)]
905
+ : [stageLinkButton(isMobile ? 'Use a code instead' : 'Use your phone instead', () => { void switchMethod('phone'); })]),
751
906
  );
752
907
  };
753
908
 
@@ -833,10 +988,12 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
833
988
  walletUri : new URL(relayWalletPath, origin).toString(),
834
989
  appName,
835
990
  appIcon : options.appIcon,
991
+ applicationId,
836
992
  clientMetadata : collectBrowserClientMetadata(),
837
993
  permissionRequests : options.permissionRequests,
838
994
  delegatePortableDid : options.delegatePortableDid,
839
995
  requestType : options.mode,
996
+ expectedProviderDid,
840
997
  timeoutMs : options.timeout,
841
998
  cancelled : session.cancelled,
842
999
  onWalletUriReady : (handoff): void => {
@@ -907,9 +1064,11 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
907
1064
  permissionRequests : options.permissionRequests,
908
1065
  appName,
909
1066
  appIcon : options.appIcon,
1067
+ applicationId,
910
1068
  timeoutMs : options.timeout,
911
1069
  delegatePortableDid : options.delegatePortableDid,
912
1070
  requestType : options.mode,
1071
+ expectedProviderDid,
913
1072
  });
914
1073
  if (settled) { return; }
915
1074
  if (result === undefined) {
@@ -945,7 +1104,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
945
1104
  const renderFooter = (): void => {
946
1105
  footer.replaceChildren();
947
1106
 
948
- if (!lockedWallet) {
1107
+ if (savedRoute === undefined && options.walletUrl === undefined) {
949
1108
  composeWalletRow();
950
1109
  const switcher = buildWalletSwitcher();
951
1110
  if (switcher.label !== undefined) {
@@ -958,7 +1117,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
958
1117
  const row = document.createElement('div');
959
1118
  row.className = 'footer-row';
960
1119
 
961
- if (method === 'phone') {
1120
+ if (method === 'phone' && savedRoute === undefined) {
962
1121
  const alt = document.createElement('button');
963
1122
  alt.className = 'footer-link method-link';
964
1123
  // On a phone both methods open the wallet on this device — what the
@@ -967,7 +1126,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
967
1126
  // startPopup runs synchronously in this click handler.
968
1127
  alt.addEventListener('click', () => { startPopup(); });
969
1128
  row.appendChild(alt);
970
- } else {
1129
+ } else if (method === 'browser' && savedRoute === undefined) {
971
1130
  const alt = document.createElement('button');
972
1131
  alt.className = 'footer-link method-link';
973
1132
  alt.textContent = isMobile ? 'Use a code instead →' : 'Use your phone instead →';