@interop/was-react 0.8.2 → 0.9.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.
Files changed (150) hide show
  1. package/README.md +55 -44
  2. package/dist/auth/chapi.d.ts.map +1 -1
  3. package/dist/auth/chapi.js +16 -5
  4. package/dist/auth/chapi.js.map +1 -1
  5. package/dist/auth/loginFlow.d.ts.map +1 -1
  6. package/dist/auth/loginFlow.js +55 -49
  7. package/dist/auth/loginFlow.js.map +1 -1
  8. package/dist/auth/verifyResponse.d.ts.map +1 -1
  9. package/dist/auth/verifyResponse.js +27 -10
  10. package/dist/auth/verifyResponse.js.map +1 -1
  11. package/dist/auth/walletRequestTypes.d.ts +9 -90
  12. package/dist/auth/walletRequestTypes.d.ts.map +1 -1
  13. package/dist/config.d.ts +9 -9
  14. package/dist/config.js +0 -0
  15. package/dist/dev/provisionDevGrants.d.ts.map +1 -1
  16. package/dist/dev/provisionDevGrants.js +18 -19
  17. package/dist/dev/provisionDevGrants.js.map +1 -1
  18. package/dist/grants.d.ts +19 -9
  19. package/dist/grants.d.ts.map +1 -1
  20. package/dist/grants.js +17 -2
  21. package/dist/grants.js.map +1 -1
  22. package/dist/identity/agents.d.ts +1 -1
  23. package/dist/identity/agents.d.ts.map +1 -1
  24. package/dist/identity/agents.js +10 -25
  25. package/dist/identity/agents.js.map +1 -1
  26. package/dist/identity/appSession.d.ts +27 -0
  27. package/dist/identity/appSession.d.ts.map +1 -1
  28. package/dist/identity/appSession.js +51 -13
  29. package/dist/identity/appSession.js.map +1 -1
  30. package/dist/identity/seedCredential.d.ts +7 -0
  31. package/dist/identity/seedCredential.d.ts.map +1 -1
  32. package/dist/identity/seedCredential.js +11 -14
  33. package/dist/identity/seedCredential.js.map +1 -1
  34. package/dist/identity/seedStore.d.ts +19 -0
  35. package/dist/identity/seedStore.d.ts.map +1 -1
  36. package/dist/identity/seedStore.js +40 -13
  37. package/dist/identity/seedStore.js.map +1 -1
  38. package/dist/index.d.ts +4 -5
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3 -4
  41. package/dist/index.js.map +1 -1
  42. package/dist/jsonLd.d.ts +19 -0
  43. package/dist/jsonLd.d.ts.map +1 -0
  44. package/dist/jsonLd.js +24 -0
  45. package/dist/jsonLd.js.map +1 -0
  46. package/dist/mui/AdoptDialog.d.ts.map +1 -1
  47. package/dist/mui/AdoptDialog.js +7 -3
  48. package/dist/mui/AdoptDialog.js.map +1 -1
  49. package/dist/mui/ClearDataDialog.d.ts.map +1 -1
  50. package/dist/mui/ClearDataDialog.js +12 -11
  51. package/dist/mui/ClearDataDialog.js.map +1 -1
  52. package/dist/mui/ConfirmDialog.d.ts +35 -0
  53. package/dist/mui/ConfirmDialog.d.ts.map +1 -0
  54. package/dist/mui/ConfirmDialog.js +18 -0
  55. package/dist/mui/ConfirmDialog.js.map +1 -0
  56. package/dist/mui/LogoutDialog.d.ts.map +1 -1
  57. package/dist/mui/LogoutDialog.js +7 -3
  58. package/dist/mui/LogoutDialog.js.map +1 -1
  59. package/dist/react/documentApp.d.ts +29 -0
  60. package/dist/react/documentApp.d.ts.map +1 -1
  61. package/dist/react/documentApp.js +9 -38
  62. package/dist/react/documentApp.js.map +1 -1
  63. package/dist/react/hooks.d.ts +13 -7
  64. package/dist/react/hooks.d.ts.map +1 -1
  65. package/dist/react/hooks.js +17 -11
  66. package/dist/react/hooks.js.map +1 -1
  67. package/dist/session/authStore.d.ts +9 -9
  68. package/dist/session/authStore.d.ts.map +1 -1
  69. package/dist/session/authStore.js +203 -112
  70. package/dist/session/authStore.js.map +1 -1
  71. package/dist/storage/adopt.d.ts +5 -1
  72. package/dist/storage/adopt.d.ts.map +1 -1
  73. package/dist/storage/adopt.js +8 -24
  74. package/dist/storage/adopt.js.map +1 -1
  75. package/dist/storage/entityStore.d.ts.map +1 -1
  76. package/dist/storage/entityStore.js +27 -20
  77. package/dist/storage/entityStore.js.map +1 -1
  78. package/dist/storage/localStore.d.ts +12 -6
  79. package/dist/storage/localStore.d.ts.map +1 -1
  80. package/dist/storage/localStore.js +146 -77
  81. package/dist/storage/localStore.js.map +1 -1
  82. package/dist/storage/rehydrate.d.ts.map +1 -1
  83. package/dist/storage/rehydrate.js +20 -4
  84. package/dist/storage/rehydrate.js.map +1 -1
  85. package/dist/storage/sharedCollectionReader.d.ts +1 -1
  86. package/dist/storage/sharedCollectionReader.d.ts.map +1 -1
  87. package/dist/storage/sharedCollectionReader.js +79 -64
  88. package/dist/storage/sharedCollectionReader.js.map +1 -1
  89. package/dist/storage/storageManager.d.ts +3 -1
  90. package/dist/storage/storageManager.d.ts.map +1 -1
  91. package/dist/storage/storageManager.js +20 -6
  92. package/dist/storage/storageManager.js.map +1 -1
  93. package/dist/storage/syncController.d.ts +1 -16
  94. package/dist/storage/syncController.d.ts.map +1 -1
  95. package/dist/storage/syncController.js +48 -37
  96. package/dist/storage/syncController.js.map +1 -1
  97. package/dist/storage/syncStatusStore.d.ts +8 -6
  98. package/dist/storage/syncStatusStore.d.ts.map +1 -1
  99. package/dist/storage/syncStatusStore.js +6 -6
  100. package/dist/storage/syncStatusStore.js.map +1 -1
  101. package/dist/storage/wasRemoteStore.d.ts +34 -2
  102. package/dist/storage/wasRemoteStore.d.ts.map +1 -1
  103. package/dist/storage/wasRemoteStore.js +45 -7
  104. package/dist/storage/wasRemoteStore.js.map +1 -1
  105. package/dist/storage/wasSync.d.ts.map +1 -1
  106. package/dist/storage/wasSync.js +47 -38
  107. package/dist/storage/wasSync.js.map +1 -1
  108. package/dist/sync/changesQuery.d.ts.map +1 -1
  109. package/dist/sync/changesQuery.js +2 -9
  110. package/dist/sync/changesQuery.js.map +1 -1
  111. package/dist/sync/docCipher.d.ts +23 -10
  112. package/dist/sync/docCipher.d.ts.map +1 -1
  113. package/dist/sync/docCipher.js +17 -11
  114. package/dist/sync/docCipher.js.map +1 -1
  115. package/dist/sync/feedMasterPort.d.ts +6 -0
  116. package/dist/sync/feedMasterPort.d.ts.map +1 -1
  117. package/dist/sync/feedMasterPort.js +82 -37
  118. package/dist/sync/feedMasterPort.js.map +1 -1
  119. package/dist/sync/index.d.ts +6 -5
  120. package/dist/sync/index.d.ts.map +1 -1
  121. package/dist/sync/index.js +6 -3
  122. package/dist/sync/index.js.map +1 -1
  123. package/dist/sync/lww.d.ts +27 -18
  124. package/dist/sync/lww.d.ts.map +1 -1
  125. package/dist/sync/lww.js +35 -8
  126. package/dist/sync/lww.js.map +1 -1
  127. package/dist/sync/lwwConflictHandler.d.ts +15 -14
  128. package/dist/sync/lwwConflictHandler.d.ts.map +1 -1
  129. package/dist/sync/lwwConflictHandler.js +37 -26
  130. package/dist/sync/lwwConflictHandler.js.map +1 -1
  131. package/dist/sync/pushWrites.d.ts +9 -9
  132. package/dist/sync/pushWrites.d.ts.map +1 -1
  133. package/dist/sync/pushWrites.js +102 -68
  134. package/dist/sync/pushWrites.js.map +1 -1
  135. package/dist/sync/syncedDocSchema.d.ts.map +1 -1
  136. package/dist/sync/syncedDocSchema.js +3 -0
  137. package/dist/sync/syncedDocSchema.js.map +1 -1
  138. package/dist/sync/types.d.ts +115 -15
  139. package/dist/sync/types.d.ts.map +1 -1
  140. package/dist/sync/types.js +36 -18
  141. package/dist/sync/types.js.map +1 -1
  142. package/dist/sync/wasSyncPort.d.ts +0 -11
  143. package/dist/sync/wasSyncPort.d.ts.map +1 -1
  144. package/dist/sync/wasSyncPort.js +20 -36
  145. package/dist/sync/wasSyncPort.js.map +1 -1
  146. package/package.json +6 -6
  147. package/dist/identity/initAppSession.d.ts +0 -27
  148. package/dist/identity/initAppSession.d.ts.map +0 -1
  149. package/dist/identity/initAppSession.js +0 -30
  150. package/dist/identity/initAppSession.js.map +0 -1
@@ -33,26 +33,22 @@
33
33
  * vanilla zustand store the hooks consume through context.
34
34
  */
35
35
  import { createStore } from 'zustand/vanilla';
36
+ import { acquireDescriptors } from '@interop/wallet-core/descriptors';
36
37
  import { DEFAULT_DB_NAME, DEFAULT_EXPIRY_WARNING_MS, DEFAULT_EXPIRY_WATCH_MS, DEFAULT_ONBOARDING } from '../config.js';
37
38
  import { createDocumentLoader } from '../identity/documentLoader.js';
38
- import { initAppSession } from '../identity/initAppSession.js';
39
- import { createSeedStore } from '../identity/seedStore.js';
40
- import { clearAppSession, persistAppSession, restoreAppSession, isNearExpiry, earliestExpiry } from '../identity/appSession.js';
39
+ import { deriveIdentity } from '../identity/agents.js';
40
+ import { createDescriptorCache, createSeedStore } from '../identity/seedStore.js';
41
+ import { clearAppSession, peekAppSession, persistAppSession, restoreAppSession, isNearExpiry, earliestExpiry, NO_EXPIRY_MS } from '../identity/appSession.js';
41
42
  import { loginWithWallet, requestGrants, LoginCancelledError } from '../auth/loginFlow.js';
42
43
  import { parseGrants } from '../grants.js';
43
44
  import { LocalStore, dbNameForController } from '../storage/localStore.js';
44
- import { clearLocalStore, clearRemoteStore, hasStore, requireStore, setLocalStore, setRemoteStore } from '../storage/storageManager.js';
45
+ import { clearLocalStore, clearRemoteStore, getClientId, hasStore, requireStore, setLocalStore, setRemoteStore } from '../storage/storageManager.js';
45
46
  import { cancelScheduledRehydrates, clearAllEntityStores, hydrateAll, patchFromChange } from '../storage/rehydrate.js';
46
47
  import { mergeAdopted } from '../storage/adopt.js';
47
48
  import { startWasSync } from '../storage/wasSync.js';
48
49
  import { errorMessage } from '../sync/index.js';
49
- import { createSyncController } from '../storage/syncController.js';
50
+ import { SyncController } from '../storage/syncController.js';
50
51
  import { useSyncStatusStore } from '../storage/syncStatusStore.js';
51
- /**
52
- * A nominal far-future expiry (ms) for grants minted without one (dev/test).
53
- * Well past the near-expiry warning, so the watch never fires against them.
54
- */
55
- const FAR_FUTURE_EXPIRY_MS = 100 * 365 * 24 * 60 * 60 * 1000;
56
52
  /**
57
53
  * Builds the session auth store bound to an app's config and store registry.
58
54
  * Call once (the React provider does) and share the returned store through
@@ -71,7 +67,20 @@ const FAR_FUTURE_EXPIRY_MS = 100 * 365 * 24 * 60 * 60 * 1000;
71
67
  export function createAuthStore({ config, registry, seedStore, storage }) {
72
68
  const dbName = config.dbName ?? DEFAULT_DB_NAME;
73
69
  const onboarding = config.onboarding ?? DEFAULT_ONBOARDING;
70
+ // Resolve the per-install LWW client id ONCE, honoring the configured
71
+ // localStorage prefix, so every stamp -- the app's own writes and the
72
+ // adoption repair -- carries the same attribution identity.
73
+ const clientId = getClientId({
74
+ ...(config.storageKeyPrefix !== undefined && {
75
+ storageKeyPrefix: config.storageKeyPrefix
76
+ })
77
+ });
74
78
  const sessionStore = seedStore ?? createSeedStore({ dbName: `${dbName}-session` });
79
+ // The offline encryption-descriptor cache, presented as the seam
80
+ // `@interop/wallet-core/descriptors` acquires through. Reads serve the
81
+ // connected replica's epoch-aware open (cache-only, no network); writes land
82
+ // the descriptors the sync bootstrap fetched.
83
+ const descriptorCache = createDescriptorCache({ store: sessionStore });
75
84
  // The anonymous-seed persistence for `local` mode: only a raw 32-byte seed,
76
85
  // no session record, in its own IndexedDB so it never collides with the
77
86
  // wallet session or a connected replica.
@@ -108,6 +117,7 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
108
117
  */
109
118
  const watchMs = config.expiry?.watchMs ?? DEFAULT_EXPIRY_WATCH_MS;
110
119
  let expiryTimer;
120
+ let expiryInitialCheck;
111
121
  // The per-session controller: single-use, stopped on logout and replaced on a
112
122
  // reconnect (started once per grant set).
113
123
  let controller = null;
@@ -145,11 +155,22 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
145
155
  clearInterval(expiryTimer);
146
156
  expiryTimer = undefined;
147
157
  }
158
+ if (expiryInitialCheck) {
159
+ clearTimeout(expiryInitialCheck);
160
+ expiryInitialCheck = undefined;
161
+ }
148
162
  }
149
163
  /**
150
164
  * Watches the session's earliest grant expiry and raises the reconnect banner
151
- * once it is within `warningMs` (or already past). Checks immediately, then on
152
- * a coarse interval; re-armed with the fresh expiry after a reconnect.
165
+ * once it is within `warningMs` (or already past). The first check is
166
+ * DEFERRED a macrotask rather than run synchronously: every caller awaits
167
+ * `persistAndStartSync` (which arms this watch) BEFORE writing
168
+ * `status: 'connected'`, so a synchronous check
169
+ * would always be swallowed by `notifyAccessExpired`'s connected gate and
170
+ * then actively cleared -- leaving a restored near-expiry session without
171
+ * its proactive banner until the first `watchMs` tick. Deferring runs the
172
+ * check after the caller's continuation has set the status. Then checks on a
173
+ * coarse interval; re-armed with the fresh expiry after a reconnect.
153
174
  */
154
175
  function armExpiryWatch(expires) {
155
176
  disarmExpiryWatch();
@@ -158,7 +179,7 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
158
179
  store.getState().notifyAccessExpired();
159
180
  }
160
181
  };
161
- check();
182
+ expiryInitialCheck = setTimeout(check, 0);
162
183
  expiryTimer = setInterval(check, watchMs);
163
184
  }
164
185
  /**
@@ -169,7 +190,7 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
169
190
  * `stopController` clears it.
170
191
  */
171
192
  async function beginSync({ parsed, zcapClient, keyAgreementKey, keyResolver }) {
172
- controller = createSyncController({
193
+ controller = new SyncController({
173
194
  collections: config.collections,
174
195
  ...(config.sync && { sync: config.sync })
175
196
  });
@@ -185,9 +206,7 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
185
206
  }),
186
207
  identityKeys: { keyAgreementKey, keyResolver },
187
208
  onAuthError: () => store.getState().notifyAccessExpired(),
188
- onDescriptorsFetched: descriptors => void sessionStore
189
- .saveDescriptors(descriptors)
190
- .catch(err => console.warn('Failed to cache encryption descriptors:', err))
209
+ onDescriptorsFetched: descriptors => void cacheDescriptors(descriptors).catch(err => console.warn('Failed to cache encryption descriptors:', err))
191
210
  });
192
211
  setRemoteStore(remoteStore);
193
212
  store.setState({ sharedCollections });
@@ -225,7 +244,20 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
225
244
  keyAgreementKey: identity.keyAgreementKey,
226
245
  keyResolver: identity.keyResolver
227
246
  });
228
- void pendingSync.catch(err => console.warn('WAS sync failed to start:', err));
247
+ // A failed bootstrap must not resolve silently: surface it on the session
248
+ // `error` (the sync rollup shows the per-collection error statuses the
249
+ // controller leaves behind). The session stays usable -- local-first --
250
+ // but the UI no longer reports "Local only" over a wallet-connected
251
+ // session that simply failed to start replicating.
252
+ void pendingSync.catch(err => {
253
+ console.warn('WAS sync failed to start:', err);
254
+ // Deferred a macrotask: an immediate bootstrap failure would otherwise
255
+ // race the caller's own `set({ ..., error: null })` (which lands right
256
+ // after `persistAndStartSync` resolves) and be wiped by it.
257
+ setTimeout(() => store.setState({
258
+ error: `Sync failed to start: ${errorMessage(err)}`
259
+ }), 0);
260
+ });
229
261
  armExpiryWatch(expires);
230
262
  }
231
263
  /**
@@ -286,7 +318,7 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
286
318
  });
287
319
  setLocalStore(local);
288
320
  if (adopt) {
289
- await mergeAdopted({ store: local, entities: adopt.entities });
321
+ await mergeAdopted({ store: local, entities: adopt.entities, clientId });
290
322
  }
291
323
  await hydrateAll(registry);
292
324
  }
@@ -315,7 +347,7 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
315
347
  */
316
348
  async function openLocal() {
317
349
  const { seed, created } = await loadOrCreateAnonSeed();
318
- const identity = await initAppSession({ seed });
350
+ const identity = await deriveIdentity({ seed });
319
351
  await openAndHydrate({ identity });
320
352
  if (created && config.seedLocal) {
321
353
  await config.seedLocal();
@@ -324,7 +356,6 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
324
356
  status: 'local',
325
357
  controllerDid: identity.controllerDid,
326
358
  expires: null,
327
- accessExpired: false,
328
359
  error: null
329
360
  });
330
361
  }
@@ -334,25 +365,42 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
334
365
  * anonymous local replica re-opened (so the app never dead-ends), then the
335
366
  * error is surfaced and rethrown for the caller to finalize.
336
367
  *
337
- * @param session {ActiveSession}
368
+ * @param session {object} the session to open: its seed, identity, parsed
369
+ * grants and expiry, plus the payloads to adopt (merge logins only)
338
370
  * @returns {Promise<void>}
339
371
  */
340
372
  /**
341
- * Loads the persisted encryption-descriptor cache (keyed by WAS collection id),
342
- * or `undefined` when none is stored or it is malformed. Best-effort: a read
343
- * failure falls back to opening single-key ciphers (sync refreshes them).
373
+ * Loads the cached encryption descriptors for the registered collections
374
+ * (keyed by WAS collection id), or `undefined` when none are cached.
375
+ *
376
+ * Acquired CACHE-ONLY -- `acquireDescriptors` with no `source` -- because
377
+ * this runs before any remote store exists and must not touch the network:
378
+ * the whole point is that an offline hot restore opens epoch-aware. The
379
+ * connected session refreshes them from the server once sync bootstraps.
380
+ * Best-effort: a read failure falls back to opening single-key ciphers.
344
381
  */
345
382
  async function loadCachedDescriptors() {
346
383
  try {
347
- const stored = await sessionStore.loadDescriptors();
348
- if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
349
- return stored;
350
- }
384
+ const descriptors = await acquireDescriptors({
385
+ cache: descriptorCache,
386
+ collectionIds: config.collections.map(collection => collection.id)
387
+ });
388
+ return Object.keys(descriptors).length > 0 ? descriptors : undefined;
351
389
  }
352
390
  catch (err) {
353
391
  console.warn('Failed to load cached encryption descriptors:', err);
392
+ return undefined;
354
393
  }
355
- return undefined;
394
+ }
395
+ /**
396
+ * Writes the descriptors the sync bootstrap fetched into the offline cache,
397
+ * through the same seam {@link loadCachedDescriptors} reads.
398
+ *
399
+ * @param descriptors {Record<string, CollectionEncryption>}
400
+ * @returns {Promise<void>}
401
+ */
402
+ async function cacheDescriptors(descriptors) {
403
+ await Promise.all(Object.entries(descriptors).map(([collectionId, descriptor]) => descriptorCache.writeDescriptor({ collectionId, descriptor })));
356
404
  }
357
405
  async function activateConnected(session) {
358
406
  try {
@@ -384,20 +432,29 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
384
432
  /**
385
433
  * Tears down the live replica + sync + entity stores WITHOUT touching either
386
434
  * persisted seed (the anonymous seed and the session record survive). Closes
387
- * the database; use {@link resetToFreshLocal} to delete it.
435
+ * the database by default; `deleteDb` deletes it instead (the wipe paths, via
436
+ * {@link resetToFreshLocal}).
388
437
  *
438
+ * @param [options] {object}
439
+ * @param [options.deleteDb] {boolean} delete the database rather than
440
+ * closing it
389
441
  * @returns {Promise<void>}
390
442
  */
391
- async function deactivateStore() {
443
+ async function deactivateStore({ deleteDb = false } = {}) {
392
444
  disarmExpiryWatch();
393
445
  cancelScheduledRehydrates();
394
446
  await stopController();
395
447
  if (hasStore()) {
396
448
  try {
397
- await requireStore().close();
449
+ if (deleteDb) {
450
+ await requireStore().remove();
451
+ }
452
+ else {
453
+ await requireStore().close();
454
+ }
398
455
  }
399
456
  catch (err) {
400
- console.warn('Error closing the local store:', err);
457
+ console.warn('Error tearing down the local store:', err);
401
458
  }
402
459
  clearLocalStore();
403
460
  }
@@ -417,25 +474,7 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
417
474
  * @returns {Promise<void>}
418
475
  */
419
476
  async function resetToFreshLocal({ deleteDb, discardAnonSeed = false }) {
420
- disarmExpiryWatch();
421
- cancelScheduledRehydrates();
422
- await stopController();
423
- if (hasStore()) {
424
- try {
425
- if (deleteDb) {
426
- await requireStore().remove();
427
- }
428
- else {
429
- await requireStore().close();
430
- }
431
- }
432
- catch (err) {
433
- console.warn('Error tearing down the local store:', err);
434
- }
435
- clearLocalStore();
436
- }
437
- clearAllEntityStores(registry);
438
- useSyncStatusStore.getState().reset();
477
+ await deactivateStore({ deleteDb });
439
478
  if (discardAnonSeed) {
440
479
  await anonStore.clearSeedStore();
441
480
  }
@@ -457,10 +496,19 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
457
496
  * are OFF the serialized boot/destroy lifecycle chain). Re-deriving from the
458
497
  * seed keeps the collect independent of the holder's lifecycle.
459
498
  *
499
+ * Decides off the caller's PRE-FLOW snapshot of `{ status, controllerDid }`,
500
+ * never the live state: a provider unmount (a route change or StrictMode
501
+ * remount) firing `destroy` while the CHAPI popup is open resets the live
502
+ * status to `boot`, and reading it here would silently collect nothing --
503
+ * an `adopt: 'merge'` login would then resolve successfully while the
504
+ * user's pre-login data never reaches their Web Space.
505
+ *
506
+ * @param snapshot {object} the caller's state snapshot from before its
507
+ * first await
460
508
  * @returns {Promise<AdoptSource | null>}
461
509
  */
462
- async function collectAdoptable() {
463
- const { status, controllerDid } = store.getState();
510
+ async function collectAdoptable(snapshot) {
511
+ const { status, controllerDid } = snapshot;
464
512
  if (status !== 'local' || controllerDid === null) {
465
513
  return null;
466
514
  }
@@ -468,7 +516,7 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
468
516
  if (!seed) {
469
517
  return null;
470
518
  }
471
- const { keyAgreementKey, keyResolver } = await initAppSession({ seed });
519
+ const { keyAgreementKey, keyResolver } = await deriveIdentity({ seed });
472
520
  const anonLocal = await LocalStore.init({
473
521
  keyAgreementKey,
474
522
  keyResolver,
@@ -477,10 +525,16 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
477
525
  ...(storage && { storage })
478
526
  });
479
527
  try {
528
+ // Read every collection at once: the replica is detached for the whole
529
+ // read, so the transition is as short as the slowest collection rather
530
+ // than the sum of them all.
531
+ const listed = await Promise.all(config.collections.map(async ({ key }) => ({
532
+ key,
533
+ payloads: await anonLocal.listEntities(key)
534
+ })));
480
535
  const entities = {};
481
536
  let total = 0;
482
- for (const { key } of config.collections) {
483
- const payloads = await anonLocal.listEntities(key);
537
+ for (const { key, payloads } of listed) {
484
538
  if (payloads.length > 0) {
485
539
  entities[key] = payloads;
486
540
  total += payloads.length;
@@ -507,15 +561,17 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
507
561
  * has been adopted yet; `local` survives intact) and rethrows.
508
562
  *
509
563
  * @param adopt {'merge' | 'leave'}
564
+ * @param snapshot {object} the caller's pre-flow `{ status, controllerDid }`
565
+ * snapshot (see {@link collectAdoptable})
510
566
  * @returns {Promise<AdoptSource | null>}
511
567
  */
512
- async function detachAndCollect(adopt) {
568
+ async function detachAndCollect(adopt, snapshot) {
513
569
  await deactivateStore();
514
570
  if (adopt !== 'merge') {
515
571
  return null;
516
572
  }
517
573
  try {
518
- return await collectAdoptable();
574
+ return await collectAdoptable(snapshot);
519
575
  }
520
576
  catch (err) {
521
577
  await openLocal();
@@ -526,18 +582,30 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
526
582
  * Deletes the adopted anonymous replica -- its persisted seed and its
527
583
  * per-controller database -- so the data lives on only in the connected
528
584
  * replica and a later logout lands in a genuinely fresh `local`. Called only
529
- * after the connected activation has succeeded; any earlier failure leaves
530
- * the anonymous replica intact for the fallback to re-open.
585
+ * after the connected activation has landed (status written); any earlier
586
+ * failure leaves the anonymous replica intact for the fallback to re-open.
587
+ *
588
+ * Best-effort: a deletion failure (an anonymous Dexie database still open in
589
+ * another tab, an IndexedDB quota error) is logged and swallowed. The
590
+ * connected session is already live and syncing at this point, so failing
591
+ * the login over its cleanup would report `local` over a genuinely connected
592
+ * session; at worst a stale anonymous replica lingers and its (already
593
+ * merged) payloads are re-collected by a later login's idempotent LWW merge.
531
594
  *
532
595
  * @param controllerDid {string} the anonymous controller DID
533
596
  * @returns {Promise<void>}
534
597
  */
535
598
  async function discardAnonReplica(controllerDid) {
536
- await anonStore.clearSeedStore();
537
- await LocalStore.removeDatabase({
538
- dbName: dbNameForController({ dbName, controllerDid }),
539
- ...(storage && { storage })
540
- });
599
+ try {
600
+ await anonStore.clearSeedStore();
601
+ await LocalStore.removeDatabase({
602
+ dbName: dbNameForController({ dbName, controllerDid }),
603
+ ...(storage && { storage })
604
+ });
605
+ }
606
+ catch (err) {
607
+ console.warn('Failed to delete the adopted anonymous replica:', err);
608
+ }
541
609
  }
542
610
  // Declared last so `prefer-const` is satisfied; the lifecycle closures above
543
611
  // only dereference `store` at call time, by which point it is assigned.
@@ -552,7 +620,7 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
552
620
  await openLocal();
553
621
  return;
554
622
  }
555
- const identity = await initAppSession({ seed: restored.seed });
623
+ const identity = await deriveIdentity({ seed: restored.seed });
556
624
  if (identity.controllerDid !== restored.controllerDid) {
557
625
  // A corrupt record; treat as logged out and fall to local.
558
626
  await clearAppSession({ store: sessionStore });
@@ -576,8 +644,7 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
576
644
  status: uncovered.length > 0 ? 'reconnect' : 'connected',
577
645
  controllerDid: identity.controllerDid,
578
646
  expires: restored.expires,
579
- error: null,
580
- accessExpired: uncovered.length > 0
647
+ error: null
581
648
  });
582
649
  }
583
650
  catch (err) {
@@ -608,12 +675,10 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
608
675
  // fully-open session, never an in-flight one.
609
676
  set({
610
677
  status: 'boot',
611
- authenticating: false,
612
678
  phase: null,
613
679
  error: null,
614
680
  controllerDid: null,
615
681
  expires: null,
616
- accessExpired: false,
617
682
  reconnecting: false,
618
683
  sharedCollections: {}
619
684
  });
@@ -621,20 +686,27 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
621
686
  return {
622
687
  status: 'boot',
623
688
  onboarding,
624
- authenticating: false,
689
+ clientId,
625
690
  phase: null,
626
691
  error: null,
627
692
  controllerDid: null,
628
693
  expires: null,
629
- accessExpired: false,
630
694
  reconnecting: false,
631
695
  sharedCollections: {},
632
696
  boot: () => serializeLifecycle(bootImpl),
633
697
  login: async ({ adopt = 'merge' } = {}) => {
634
- if (get().authenticating || get().status === 'connected') {
698
+ if (get().phase !== null || get().status === 'connected') {
635
699
  return null;
636
700
  }
637
- set({ authenticating: true, error: null, phase: 'connecting' });
701
+ // Snapshot the pre-login state BEFORE the popup await: a provider
702
+ // unmount (`destroy`) landing while CHAPI is open resets the live
703
+ // status to `boot`, and the adoption collect must still see the
704
+ // `local` session this login left.
705
+ const preLogin = {
706
+ status: get().status,
707
+ controllerDid: get().controllerDid
708
+ };
709
+ set({ error: null, phase: 'connecting' });
638
710
  try {
639
711
  const outcome = await loginWithWallet({
640
712
  config: loginConfig,
@@ -645,7 +717,7 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
645
717
  // The anonymous seed and database are deleted only after the
646
718
  // activation lands, so a failure below still falls back to an intact
647
719
  // `local`.
648
- const source = await detachAndCollect(adopt);
720
+ const source = await detachAndCollect(adopt, preLogin);
649
721
  await activateConnected({
650
722
  seed: outcome.seed,
651
723
  identity: outcome.identity,
@@ -654,18 +726,19 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
654
726
  expires: outcome.expires,
655
727
  ...(source && { adopt: source })
656
728
  });
657
- if (source) {
658
- await discardAnonReplica(source.controllerDid);
659
- }
660
729
  set({
661
730
  status: 'connected',
662
- authenticating: false,
663
731
  controllerDid: outcome.identity.controllerDid,
664
732
  expires: outcome.expires,
665
733
  phase: null,
666
- error: null,
667
- accessExpired: false
734
+ error: null
668
735
  });
736
+ // Adopted-replica cleanup runs AFTER the connected status lands (and
737
+ // is best-effort): a cleanup failure must not leave the session
738
+ // reporting `local` over a live, syncing connected session.
739
+ if (source) {
740
+ await discardAnonReplica(source.controllerDid);
741
+ }
669
742
  return { firstRun: outcome.firstRun };
670
743
  }
671
744
  catch (err) {
@@ -673,15 +746,13 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
673
746
  // scary error, and resolve with `null` so the caller can distinguish it
674
747
  // from a connected outcome. `local` stays intact.
675
748
  if (err instanceof LoginCancelledError) {
676
- set({ authenticating: false, phase: null, error: null });
749
+ set({ phase: null, error: null });
677
750
  return null;
678
751
  }
679
752
  // A genuine failure: record the message so the UI state still reflects
680
753
  // it, then rethrow so the caller's promise rejects.
681
- const message = err instanceof Error
682
- ? `Login failed: ${err.message}`
683
- : 'Login failed.';
684
- set({ authenticating: false, phase: null, error: message });
754
+ const message = `Login failed: ${errorMessage(err)}`;
755
+ set({ phase: null, error: message });
685
756
  throw err;
686
757
  }
687
758
  },
@@ -703,11 +774,15 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
703
774
  if (get().status === 'connected') {
704
775
  return;
705
776
  }
706
- const identity = await initAppSession({ seed });
777
+ const preConnect = {
778
+ status: get().status,
779
+ controllerDid: get().controllerDid
780
+ };
781
+ const identity = await deriveIdentity({ seed });
707
782
  const parsed = parseGrants(grants);
708
783
  const expires = earliestExpiry(grants) ??
709
- new Date(Date.now() + FAR_FUTURE_EXPIRY_MS).toISOString();
710
- const source = await detachAndCollect(adopt);
784
+ new Date(Date.now() + NO_EXPIRY_MS).toISOString();
785
+ const source = await detachAndCollect(adopt, preConnect);
711
786
  await activateConnected({
712
787
  seed,
713
788
  identity,
@@ -716,16 +791,16 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
716
791
  expires,
717
792
  ...(source && { adopt: source })
718
793
  });
719
- if (source) {
720
- await discardAnonReplica(source.controllerDid);
721
- }
722
794
  set({
723
795
  status: 'connected',
724
796
  controllerDid: identity.controllerDid,
725
797
  expires,
726
- error: null,
727
- accessExpired: false
798
+ error: null
728
799
  });
800
+ // After the status lands, and best-effort (see `discardAnonReplica`).
801
+ if (source) {
802
+ await discardAnonReplica(source.controllerDid);
803
+ }
729
804
  });
730
805
  },
731
806
  reconnect: async () => {
@@ -735,17 +810,37 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
735
810
  }
736
811
  set({ reconnecting: true, error: null });
737
812
  try {
738
- // The seed survives grant expiry; only the grants need renewing. Read it
739
- // directly (not via `restoreAppSession`, which WIPES an expired record
740
- // -- seed included -- exactly in the case reconnect exists for). A
741
- // missing seed means the session is unrecoverable in place.
742
- const seed = await sessionStore.loadSeed();
813
+ // The seed survives grant expiry; only the grants need renewing, and
814
+ // the record carries the storage location the continuity check below
815
+ // compares against. Peeked (not `restoreAppSession`, which WIPES an
816
+ // expired record -- seed included -- exactly in the case reconnect
817
+ // exists for), and read before the popup so the comparison baseline
818
+ // cannot be overwritten mid-flow. A missing seed means the session is
819
+ // unrecoverable in place.
820
+ const { seed, record } = await peekAppSession({ store: sessionStore });
743
821
  if (!seed) {
744
822
  await get().logout();
745
823
  return;
746
824
  }
747
- const identity = await initAppSession({ seed });
825
+ const identity = await deriveIdentity({ seed });
748
826
  const checked = await requestGrants({ identity, config: loginConfig });
827
+ // Continuity check: `parseGrants` only asserts the returned set is
828
+ // INTERNALLY consistent. Without this comparison, a reconnect
829
+ // returning internally-consistent grants for a DIFFERENT server or
830
+ // space would silently re-point the whole encrypted replica (whose
831
+ // database name derives from the controller DID alone) at a new
832
+ // storage location the user never chose. Refuse instead; switching
833
+ // storage is a logout + fresh login, an explicit user action.
834
+ if (record !== null &&
835
+ (checked.parsed.serverUrl !== record.serverUrl ||
836
+ checked.parsed.spaceId !== record.spaceId)) {
837
+ throw new Error(`The renewed grants point at a different storage location ` +
838
+ `("${checked.parsed.serverUrl}", space ` +
839
+ `"${checked.parsed.spaceId}") than this session's ` +
840
+ `("${record.serverUrl}", space "${record.spaceId}"). ` +
841
+ `Refusing to re-point the session; log out and log in again ` +
842
+ `to switch storage.`);
843
+ }
749
844
  await stopController();
750
845
  await persistAndStartSync({
751
846
  seed,
@@ -756,14 +851,12 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
756
851
  });
757
852
  set({
758
853
  status: 'connected',
759
- accessExpired: false,
760
854
  expires: checked.expires,
761
855
  reconnecting: false
762
856
  });
763
857
  }
764
858
  catch (err) {
765
- const message = err instanceof Error ? err.message : 'Reconnect failed.';
766
- set({ reconnecting: false, error: message });
859
+ set({ reconnecting: false, error: errorMessage(err) });
767
860
  }
768
861
  },
769
862
  logout: async ({ wipe = false } = {}) => {
@@ -783,11 +876,10 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
783
876
  return false;
784
877
  }
785
878
  try {
786
- for (const { key } of config.collections) {
787
- if ((await requireStore().countEntities(key)) > 0) {
788
- return true;
789
- }
790
- }
879
+ // Counted in parallel: this runs on a login-button click, so the
880
+ // probe costs one round of counts rather than one per collection.
881
+ const counts = await Promise.all(config.collections.map(({ key }) => requireStore().countEntities(key)));
882
+ return counts.some(count => count > 0);
791
883
  }
792
884
  catch {
793
885
  // A defensive guard: this probe runs off the serialized boot/destroy
@@ -797,11 +889,10 @@ export function createAuthStore({ config, registry, seedStore, storage }) {
797
889
  // collects (through a fresh handle) whatever exists.
798
890
  return false;
799
891
  }
800
- return false;
801
892
  },
802
893
  notifyAccessExpired: () => {
803
894
  if (get().status === 'connected') {
804
- set({ status: 'reconnect', accessExpired: true });
895
+ set({ status: 'reconnect' });
805
896
  }
806
897
  },
807
898
  destroy: () => serializeLifecycle(destroyImpl)