@enbox/auth 0.6.28 → 0.6.30

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 (44) hide show
  1. package/dist/esm/auth-manager.js +82 -46
  2. package/dist/esm/auth-manager.js.map +1 -1
  3. package/dist/esm/connect/import.js +20 -13
  4. package/dist/esm/connect/import.js.map +1 -1
  5. package/dist/esm/connect/lifecycle.js +356 -68
  6. package/dist/esm/connect/lifecycle.js.map +1 -1
  7. package/dist/esm/connect/local.js +2 -1
  8. package/dist/esm/connect/local.js.map +1 -1
  9. package/dist/esm/connect/restore.js +87 -64
  10. package/dist/esm/connect/restore.js.map +1 -1
  11. package/dist/esm/connect/wallet.js +1 -0
  12. package/dist/esm/connect/wallet.js.map +1 -1
  13. package/dist/esm/discovery.js +2 -1
  14. package/dist/esm/discovery.js.map +1 -1
  15. package/dist/esm/events.js.map +1 -1
  16. package/dist/esm/registration.js +70 -12
  17. package/dist/esm/registration.js.map +1 -1
  18. package/dist/esm/types.js.map +1 -1
  19. package/dist/types/auth-manager.d.ts +26 -15
  20. package/dist/types/auth-manager.d.ts.map +1 -1
  21. package/dist/types/connect/import.d.ts.map +1 -1
  22. package/dist/types/connect/lifecycle.d.ts +60 -1
  23. package/dist/types/connect/lifecycle.d.ts.map +1 -1
  24. package/dist/types/connect/local.d.ts.map +1 -1
  25. package/dist/types/connect/restore.d.ts +8 -0
  26. package/dist/types/connect/restore.d.ts.map +1 -1
  27. package/dist/types/connect/wallet.d.ts.map +1 -1
  28. package/dist/types/events.d.ts +1 -1
  29. package/dist/types/events.d.ts.map +1 -1
  30. package/dist/types/registration.d.ts +28 -3
  31. package/dist/types/registration.d.ts.map +1 -1
  32. package/dist/types/types.d.ts +18 -9
  33. package/dist/types/types.d.ts.map +1 -1
  34. package/package.json +4 -4
  35. package/src/auth-manager.ts +100 -63
  36. package/src/connect/import.ts +24 -19
  37. package/src/connect/lifecycle.ts +360 -74
  38. package/src/connect/local.ts +5 -4
  39. package/src/connect/restore.ts +79 -66
  40. package/src/connect/wallet.ts +2 -1
  41. package/src/discovery.ts +1 -1
  42. package/src/events.ts +1 -1
  43. package/src/registration.ts +82 -15
  44. package/src/types.ts +18 -9
@@ -14,15 +14,13 @@ import type { StorageAdapter } from '../types.js';
14
14
 
15
15
  import type { EnboxUserAgent } from '@enbox/agent';
16
16
 
17
- import type { DwnDataEncodedRecordsWriteMessage, DwnMessagesPermissionScope, DwnRecordsPermissionScope } from '@enbox/agent';
18
-
19
17
  import { Convert } from '@enbox/common';
20
- import { DataStream, PermissionsProtocol } from '@enbox/dwn-sdk-js';
18
+ import { DataStream } from '@enbox/dwn-sdk-js';
21
19
  import { DwnInterface, DwnPermissionGrant } from '@enbox/agent';
22
20
 
23
21
  import { applyLocalDwnDiscovery } from '../discovery.js';
24
22
  import { STORAGE_KEYS } from '../types.js';
25
- import { ensureVaultReady, finalizeSession, resolveIdentityDids, resolvePassword, startSyncIfEnabled } from './lifecycle.js';
23
+ import { ensureVaultReady, finalizeSession, registerSyncScopeForIdentity, resolveIdentityDids, resolvePassword, startSyncIfEnabled } from './lifecycle.js';
26
24
 
27
25
  /**
28
26
  * Decrypt a stored key blob.
@@ -45,6 +43,45 @@ async function decryptStoredKeys(
45
43
  return stored;
46
44
  }
47
45
 
46
+ /**
47
+ * Load delegate keys from SecretStore, falling back to legacy StorageAdapter.
48
+ *
49
+ * On a successful legacy read the keys are migrated into the SecretStore and
50
+ * the plaintext/JWE copy is removed (best-effort). Returns the JSON string
51
+ * representation of the keys, or `undefined` if nothing is stored.
52
+ */
53
+ async function loadDelegateKeysWithFallback(
54
+ userAgent: EnboxUserAgent,
55
+ storage: StorageAdapter,
56
+ key: string,
57
+ ): Promise<string | undefined> {
58
+ // 1. Try SecretStore (preferred path).
59
+ try {
60
+ const bytes = await userAgent.secrets.get(key);
61
+ if (bytes) {
62
+ return Convert.uint8Array(bytes).toString();
63
+ }
64
+ } catch { /* vault may be locked — fall through to legacy path */ }
65
+
66
+ // 2. Fall back to legacy StorageAdapter (JWE or plaintext JSON).
67
+ const legacy = await storage.get(key);
68
+ if (!legacy) { return undefined; }
69
+
70
+ try {
71
+ const json = await decryptStoredKeys(userAgent, legacy);
72
+
73
+ // Migrate into SecretStore and remove legacy copy (best-effort).
74
+ try {
75
+ await userAgent.secrets.put(key, Convert.string(json).toUint8Array());
76
+ await storage.remove(key);
77
+ } catch { /* best-effort migration */ }
78
+
79
+ return json;
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
84
+
48
85
  /**
49
86
  * Attempt to restore a previous session.
50
87
  *
@@ -153,6 +190,7 @@ export async function restoreSession(
153
190
 
154
191
  if (!isAgentOnlySession) {
155
192
  // Truly stale session data — clean up and bail.
193
+ // Remove delegate keys from both SecretStore and legacy StorageAdapter.
156
194
  await storage.remove(STORAGE_KEYS.PREVIOUSLY_CONNECTED);
157
195
  await storage.remove(STORAGE_KEYS.ACTIVE_IDENTITY);
158
196
  await storage.remove(STORAGE_KEYS.DELEGATE_DID);
@@ -161,6 +199,8 @@ export async function restoreSession(
161
199
  await storage.remove(STORAGE_KEYS.DELEGATE_CONTEXT_KEYS);
162
200
  await storage.remove(STORAGE_KEYS.DELEGATE_MULTI_PARTY_PROTOCOLS);
163
201
  await storage.remove(STORAGE_KEYS.SESSION_REVOCATIONS);
202
+ try { await userAgent.secrets.delete(STORAGE_KEYS.DELEGATE_DECRYPTION_KEYS); } catch { /* best-effort */ }
203
+ try { await userAgent.secrets.delete(STORAGE_KEYS.DELEGATE_CONTEXT_KEYS); } catch { /* best-effort */ }
164
204
  // Do NOT remove REVOCATION_RETRY_CONTEXT here — it has its own
165
205
  // lifecycle managed by the retry maintenance path. Stale session
166
206
  // cleanup must not silently drop pending revocations.
@@ -185,37 +225,36 @@ export async function restoreSession(
185
225
  // which causes the sync engine to attempt syncing the permissions protocol
186
226
  // and fail with "No permissions found for MessagesSync".
187
227
  // Derive the correct protocol list from stored grants and update.
188
- if (delegateDid && ctx.defaultSync !== 'off') {
228
+ // Always repair regardless of sync state — a stale registration persists
229
+ // on disk and would take effect if sync is later enabled.
230
+ let syncRepairFailed = false;
231
+ if (delegateDid) {
189
232
  try {
190
- const protocols = await deriveProtocolsFromGrants(userAgent, delegateDid);
191
- const options = { delegateDid, protocols };
192
- try {
193
- await userAgent.sync.registerIdentity({ did: connectedDid, options });
194
- } catch (regError: unknown) {
195
- const msg = regError instanceof Error ? regError.message : '';
196
- if (msg.includes('already registered')) {
197
- await userAgent.sync.updateIdentityOptions({ did: connectedDid, options });
198
- }
199
- // Other errors are ignored — sync will still work with existing registration.
200
- }
233
+ await registerSyncScopeForIdentity({ userAgent, connectedDid, delegateDid });
201
234
  } catch {
202
- // Best-effort — don't block restore if grant query fails.
235
+ // Grant query or registration repair failed — don't block restore,
236
+ // but don't let a stale registration remain usable.
237
+ syncRepairFailed = true;
238
+ // Best-effort: remove any existing registration so a later manual
239
+ // startSync() cannot use stale scope for this connected DID.
240
+ try { await userAgent.sync.unregisterIdentity(connectedDid); } catch { /* already gone or store error */ }
203
241
  }
204
242
  }
205
243
 
206
- // Start sync after the registration is correct.
207
- await startSyncIfEnabled(userAgent, ctx.defaultSync);
208
-
209
- // Restore delegate decryption keys if persisted.
210
- // Keys are stored as compact JWE (encrypted with the vault CEK).
211
- // Falls back to plaintext JSON for backward compatibility with sessions
212
- // created before the encryption-at-rest fix.
244
+ // Restore delegate decryption/context keys BEFORE starting sync so that
245
+ // the first sync cycle can decrypt encrypted records.
246
+ //
247
+ // Keys are loaded from the vault-backed SecretStore (preferred). When
248
+ // the SecretStore is empty, we fall back to the legacy StorageAdapter
249
+ // which may hold either a compact JWE (encrypted with the vault CEK)
250
+ // or raw JSON (from sessions created before the encryption-at-rest fix).
251
+ // On successful fallback, the legacy copy is removed (best-effort) so
252
+ // all future reads come from the SecretStore.
213
253
  if (delegateDid && connectedDid) {
214
- const storedKeys = await storage.get(STORAGE_KEYS.DELEGATE_DECRYPTION_KEYS);
215
- if (storedKeys) {
254
+ const decryptionKeysJson = await loadDelegateKeysWithFallback(userAgent, storage, STORAGE_KEYS.DELEGATE_DECRYPTION_KEYS);
255
+ if (decryptionKeysJson) {
216
256
  try {
217
- const keysJson = await decryptStoredKeys(userAgent, storedKeys);
218
- const keys = JSON.parse(keysJson);
257
+ const keys = JSON.parse(decryptionKeysJson);
219
258
  if (Array.isArray(keys) && keys.length > 0) {
220
259
  userAgent.dwn.importDelegateDecryptionKeys(delegateDid, keys);
221
260
  }
@@ -223,7 +262,7 @@ export async function restoreSession(
223
262
  }
224
263
 
225
264
  // Restore context keys for multi-party encrypted protocols.
226
- const storedCtxKeys = await storage.get(STORAGE_KEYS.DELEGATE_CONTEXT_KEYS);
265
+ const contextKeysJson = await loadDelegateKeysWithFallback(userAgent, storage, STORAGE_KEYS.DELEGATE_CONTEXT_KEYS);
227
266
  // Restore multi-party protocol registrations (not encrypted — no key material).
228
267
  const mpProtocolsJson = await storage.get(STORAGE_KEYS.DELEGATE_MULTI_PARTY_PROTOCOLS);
229
268
  let multiPartyProtocols: string[] | undefined;
@@ -234,12 +273,9 @@ export async function restoreSession(
234
273
  } catch { /* best effort */ }
235
274
  }
236
275
 
237
- if (storedCtxKeys || multiPartyProtocols) {
276
+ if (contextKeysJson || multiPartyProtocols) {
238
277
  try {
239
- const ctxKeysJson = storedCtxKeys
240
- ? await decryptStoredKeys(userAgent, storedCtxKeys)
241
- : '[]';
242
- const ctxKeys = JSON.parse(ctxKeysJson);
278
+ const ctxKeys = contextKeysJson ? JSON.parse(contextKeysJson) : [];
243
279
  userAgent.dwn.importDelegateContextKeys(
244
280
  delegateDid,
245
281
  Array.isArray(ctxKeys) ? ctxKeys : [],
@@ -255,13 +291,18 @@ export async function restoreSession(
255
291
  if (changedDelegateDid !== restoreDelegateDid) { return; }
256
292
  try {
257
293
  const keys = userAgent.dwn.exportDelegateContextKeys(restoreDelegateDid);
258
- const pt = Convert.string(JSON.stringify(keys)).toUint8Array();
259
- const encrypted = await userAgent.vault.encryptData({ plaintext: pt });
260
- await storage.set(STORAGE_KEYS.DELEGATE_CONTEXT_KEYS, encrypted);
294
+ const bytes = Convert.string(JSON.stringify(keys)).toUint8Array();
295
+ await userAgent.secrets.put(STORAGE_KEYS.DELEGATE_CONTEXT_KEYS, bytes);
261
296
  } catch { /* best effort — keys will be re-derived on next connect */ }
262
297
  };
263
298
  }
264
299
 
300
+ // Start sync only if the registration repair succeeded (or was not needed).
301
+ // If repair failed, don't start sync with potentially stale scope.
302
+ if (!syncRepairFailed) {
303
+ await startSyncIfEnabled(userAgent, ctx.defaultSync);
304
+ }
305
+
265
306
  // Persist session info, build AuthSession, and emit lifecycle events.
266
307
  // Session restore does not emit `identity-added` (identity was already added in the original flow).
267
308
  return finalizeSession({
@@ -444,7 +485,7 @@ async function sendRevocationToEndpoints(
444
485
  const reply = await userAgent.rpc.sendDwnRequest({
445
486
  dwnUrl,
446
487
  targetDid : connectedDid,
447
- message : rawMessage as any,
488
+ message : rawMessage,
448
489
  data,
449
490
  });
450
491
  if (reply?.status?.code === 202 || reply?.status?.code === 409) {
@@ -530,32 +571,4 @@ export async function retryOrphanedRevocations(
530
571
  * permissions protocol itself (permission records are already included
531
572
  * in each protocol's sync stream via `constructAdditionalMessageFilter`).
532
573
  */
533
- async function deriveProtocolsFromGrants(
534
- userAgent: EnboxUserAgent,
535
- delegateDid: string,
536
- ): Promise<string[]> {
537
- const response = await userAgent.processDwnRequest({
538
- author : delegateDid,
539
- target : delegateDid,
540
- messageType : DwnInterface.RecordsQuery,
541
- messageParams : {
542
- filter: {
543
- protocol : PermissionsProtocol.uri,
544
- protocolPath : PermissionsProtocol.grantPath,
545
- },
546
- },
547
- });
548
-
549
- const protocols: string[] = [];
550
- if (response.reply.status.code === 200 && response.reply.entries) {
551
- for (const entry of response.reply.entries as DwnDataEncodedRecordsWriteMessage[]) {
552
- const grant = DwnPermissionGrant.parse(entry);
553
- const scopeProtocol = (grant.scope as DwnMessagesPermissionScope | DwnRecordsPermissionScope).protocol;
554
- if (scopeProtocol && scopeProtocol !== PermissionsProtocol.uri) {
555
- protocols.push(scopeProtocol);
556
- }
557
- }
558
- }
559
574
 
560
- return [...new Set(protocols)];
561
- }
@@ -71,8 +71,9 @@ export async function walletConnect(
71
71
  {
72
72
  userAgent,
73
73
  dwnEndpoints,
74
- agentDid: userAgent.agentDid.uri,
74
+ agentDid : userAgent.agentDid.uri,
75
75
  connectedDid,
76
+ secretStore : userAgent.secrets,
76
77
  storage,
77
78
  },
78
79
  ctx.registration,
package/src/discovery.ts CHANGED
@@ -58,7 +58,7 @@ export function checkUrlForDwnDiscoveryPayload(): string | undefined {
58
58
 
59
59
  // Clear the fragment to prevent re-reading on subsequent calls or
60
60
  // if the user refreshes the page after the redirect.
61
- if (typeof globalThis.history !== 'undefined' && globalThis.history.replaceState) {
61
+ if (globalThis.history?.replaceState) {
62
62
  const cleanUrl = globalThis.location.href.split('#')[0];
63
63
  globalThis.history.replaceState(null, '', cleanUrl);
64
64
  }
package/src/events.ts CHANGED
@@ -19,7 +19,7 @@ import type { AuthEvent, AuthEventHandler, AuthEventMap } from './types.js';
19
19
  * ```
20
20
  */
21
21
  export class AuthEventEmitter {
22
- private _listeners = new Map<AuthEvent, Set<AuthEventHandler<AuthEvent>>>();
22
+ private readonly _listeners = new Map<AuthEvent, Set<AuthEventHandler<AuthEvent>>>();
23
23
 
24
24
  /**
25
25
  * Subscribe to an event. Returns an unsubscribe function.
@@ -11,8 +11,9 @@
11
11
  * @module
12
12
  */
13
13
 
14
- import type { EnboxUserAgent } from '@enbox/agent';
14
+ import type { EnboxUserAgent, SecretStore } from '@enbox/agent';
15
15
 
16
+ import { Convert } from '@enbox/common';
16
17
  import { DwnRegistrar } from '@enbox/dwn-clients';
17
18
 
18
19
  import { STORAGE_KEYS } from './types.js';
@@ -38,8 +39,20 @@ export interface RegistrationContext {
38
39
  connectedDid: string;
39
40
 
40
41
  /**
41
- * Storage adapter for automatic token persistence.
42
- * Only used when `registration.persistTokens` is `true`.
42
+ * Vault-backed secret store for encrypted token persistence.
43
+ *
44
+ * When provided **and** the vault is unlocked, registration tokens are
45
+ * stored here instead of in the plaintext `StorageAdapter`, keeping
46
+ * bearer credentials out of `localStorage`.
47
+ */
48
+ secretStore?: SecretStore;
49
+
50
+ /**
51
+ * Plaintext storage adapter for automatic token persistence.
52
+ *
53
+ * @deprecated Prefer {@link secretStore} when the vault is available.
54
+ * This field is retained for backwards compatibility with
55
+ * callers that have not yet migrated.
43
56
  */
44
57
  storage?: StorageAdapter;
45
58
  }
@@ -60,14 +73,21 @@ export async function registerWithDwnEndpoints(
60
73
  ctx: RegistrationContext,
61
74
  registration: RegistrationOptions,
62
75
  ): Promise<void> {
63
- const { userAgent, dwnEndpoints, agentDid, connectedDid, storage } = ctx;
76
+ const { userAgent, dwnEndpoints, agentDid, connectedDid, secretStore, storage } = ctx;
64
77
 
65
- // Load initial tokens: when persistTokens is enabled, load from storage
66
- // (ignoring any explicit registrationTokens). Otherwise use the explicit map.
78
+ // Load initial tokens: when persistTokens is enabled, try the
79
+ // vault-backed SecretStore first, then fall back to the legacy plaintext
80
+ // StorageAdapter. This ensures upgraded clients that already have tokens
81
+ // in localStorage can still read them (and migrate them on the next save).
67
82
  let seedTokens: Record<string, RegistrationTokenData> = {};
68
83
 
69
- if (registration.persistTokens && storage) {
70
- seedTokens = await loadTokensFromStorage(storage);
84
+ if (registration.persistTokens) {
85
+ if (secretStore) {
86
+ seedTokens = await loadTokensFromSecretStore(secretStore);
87
+ }
88
+ if (Object.keys(seedTokens).length === 0 && storage) {
89
+ seedTokens = await loadTokensFromStorage(storage);
90
+ }
71
91
  } else {
72
92
  seedTokens = registration.registrationTokens ?? {};
73
93
  }
@@ -103,8 +123,8 @@ export async function registerWithDwnEndpoints(
103
123
  tokenData = {
104
124
  registrationToken : refreshed.registrationToken,
105
125
  refreshToken : refreshed.refreshToken,
106
- expiresAt : refreshed.expiresIn !== undefined
107
- ? Date.now() + (refreshed.expiresIn * 1000) : undefined,
126
+ expiresAt : refreshed.expiresIn === undefined
127
+ ? undefined : Date.now() + (refreshed.expiresIn * 1000),
108
128
  tokenUrl : tokenData.tokenUrl,
109
129
  refreshUrl : tokenData.refreshUrl,
110
130
  };
@@ -140,8 +160,8 @@ export async function registerWithDwnEndpoints(
140
160
  tokenData = {
141
161
  registrationToken : tokenResponse.registrationToken,
142
162
  refreshToken : tokenResponse.refreshToken,
143
- expiresAt : tokenResponse.expiresIn !== undefined
144
- ? Date.now() + (tokenResponse.expiresIn * 1000) : undefined,
163
+ expiresAt : tokenResponse.expiresIn === undefined
164
+ ? undefined : Date.now() + (tokenResponse.expiresIn * 1000),
145
165
  tokenUrl : providerAuth.tokenUrl,
146
166
  refreshUrl : providerAuth.refreshUrl,
147
167
  };
@@ -162,9 +182,21 @@ export async function registerWithDwnEndpoints(
162
182
  }
163
183
  }
164
184
 
165
- // Persist tokens to storage when auto-persistence is enabled.
166
- if (registration.persistTokens && storage) {
167
- await saveTokensToStorage(storage, updatedTokens);
185
+ // Persist tokens: prefer vault-backed SecretStore over plaintext StorageAdapter.
186
+ // When migrating to SecretStore, also clear the legacy plaintext copy so
187
+ // bearer tokens no longer sit in localStorage.
188
+ if (registration.persistTokens) {
189
+ if (secretStore) {
190
+ await saveTokensToSecretStore(secretStore, updatedTokens);
191
+ // Best-effort cleanup: remove the legacy plaintext copy so bearer
192
+ // tokens no longer sit in localStorage. A failure here must not
193
+ // turn a successful registration into an error.
194
+ if (storage) {
195
+ try { await storage.remove(STORAGE_KEYS.REGISTRATION_TOKENS); } catch { /* best-effort */ }
196
+ }
197
+ } else if (storage) {
198
+ await saveTokensToStorage(storage, updatedTokens);
199
+ }
168
200
  }
169
201
 
170
202
  // Notify app of updated tokens (always, even when auto-persisting).
@@ -210,3 +242,38 @@ export async function saveTokensToStorage(
210
242
  ): Promise<void> {
211
243
  await storage.set(STORAGE_KEYS.REGISTRATION_TOKENS, JSON.stringify(tokens));
212
244
  }
245
+
246
+ // ─── SecretStore helpers ─────────────────────────────────────────
247
+
248
+ /**
249
+ * Load registration tokens from the vault-backed {@link SecretStore}.
250
+ *
251
+ * Returns an empty record if no tokens are stored, the stored value is
252
+ * corrupt, or the vault is locked (best-effort — never throws).
253
+ *
254
+ * @internal
255
+ */
256
+ export async function loadTokensFromSecretStore(
257
+ secretStore: SecretStore,
258
+ ): Promise<Record<string, RegistrationTokenData>> {
259
+ try {
260
+ const bytes = await secretStore.get(STORAGE_KEYS.REGISTRATION_TOKENS);
261
+ if (!bytes) { return {}; }
262
+ const json = Convert.uint8Array(bytes).toString();
263
+ return JSON.parse(json) as Record<string, RegistrationTokenData>;
264
+ } catch {
265
+ return {};
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Save registration tokens to the vault-backed {@link SecretStore}.
271
+ * @internal
272
+ */
273
+ export async function saveTokensToSecretStore(
274
+ secretStore: SecretStore,
275
+ tokens: Record<string, RegistrationTokenData>,
276
+ ): Promise<void> {
277
+ const bytes = Convert.string(JSON.stringify(tokens)).toUint8Array();
278
+ await secretStore.put(STORAGE_KEYS.REGISTRATION_TOKENS, bytes);
279
+ }
package/src/types.ts CHANGED
@@ -171,7 +171,8 @@ export interface RegistrationOptions {
171
171
  * endpoint, it is used directly without re-running the auth flow.
172
172
  *
173
173
  * When {@link persistTokens} is `true`, this field is ignored —
174
- * tokens are loaded automatically from the `StorageAdapter`.
174
+ * tokens are loaded automatically from the agent's vault-backed
175
+ * `SecretStore` (preferred) or the legacy `StorageAdapter` (fallback).
175
176
  */
176
177
  registrationTokens?: Record<string, RegistrationTokenData>;
177
178
 
@@ -180,21 +181,29 @@ export interface RegistrationOptions {
180
181
  * The app should persist these for future sessions.
181
182
  *
182
183
  * When {@link persistTokens} is `true`, tokens are saved automatically
183
- * to the `StorageAdapter`. This callback is still invoked (if provided)
184
- * **after** the automatic save, so consumers can observe token changes
185
- * without handling persistence themselves.
184
+ * to the agent's vault-backed `SecretStore` (or the legacy
185
+ * `StorageAdapter` when no `SecretStore` is available). This callback
186
+ * is still invoked (if provided) **after** the automatic save, so
187
+ * consumers can observe token changes without handling persistence
188
+ * themselves.
186
189
  */
187
190
  onRegistrationTokens?: (tokens: Record<string, RegistrationTokenData>) => void;
188
191
 
189
192
  /**
190
- * Automatically persist and restore registration tokens using the
191
- * auth manager's `StorageAdapter`.
193
+ * Automatically persist and restore registration tokens.
192
194
  *
193
- * When `true`, tokens are loaded from storage before registration and
194
- * saved back after new or refreshed tokens are obtained. This removes
195
- * the need for consumers to implement their own token I/O via
195
+ * When `true`, tokens are loaded before registration and saved back
196
+ * after new or refreshed tokens are obtained, removing the need for
197
+ * consumers to implement their own token I/O via
196
198
  * {@link registrationTokens} and {@link onRegistrationTokens}.
197
199
  *
200
+ * **Storage preference:** tokens are stored in the agent's vault-backed
201
+ * `SecretStore` (encrypted at rest) when available. On the first run
202
+ * after an upgrade, any tokens left in the legacy plaintext
203
+ * `StorageAdapter` are migrated into the `SecretStore` and the
204
+ * plaintext copy is removed. If no `SecretStore` is provided, the
205
+ * `StorageAdapter` is used as a fallback.
206
+ *
198
207
  * Defaults to `false` for backward compatibility.
199
208
  *
200
209
  * @example