@oxyhq/core 9.2.0 → 9.2.2

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 (40) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +13 -0
  3. package/dist/cjs/index.js +6 -4
  4. package/dist/cjs/mixins/OxyServices.accounts.js +3 -0
  5. package/dist/cjs/mixins/OxyServices.utility.js +9 -5
  6. package/dist/cjs/session/SessionClient.js +45 -0
  7. package/dist/cjs/session/accountDialogController.js +31 -0
  8. package/dist/cjs/session/authStateStore.js +196 -16
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/boot/sessionColdBoot.js +13 -0
  11. package/dist/esm/index.js +1 -0
  12. package/dist/esm/mixins/OxyServices.accounts.js +1 -0
  13. package/dist/esm/mixins/OxyServices.utility.js +9 -5
  14. package/dist/esm/session/SessionClient.js +45 -0
  15. package/dist/esm/session/accountDialogController.js +31 -0
  16. package/dist/esm/session/authStateStore.js +195 -15
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/index.d.ts +2 -1
  19. package/dist/types/mixins/OxyServices.accounts.d.ts +8 -0
  20. package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
  21. package/dist/types/models/interfaces.d.ts +3 -1
  22. package/dist/types/models/session.d.ts +6 -0
  23. package/dist/types/session/SessionClient.d.ts +11 -0
  24. package/dist/types/session/accountDialogController.d.ts +17 -0
  25. package/dist/types/session/authStateStore.d.ts +33 -8
  26. package/package.json +2 -2
  27. package/src/boot/__tests__/sessionColdBoot.test.ts +20 -0
  28. package/src/boot/sessionColdBoot.ts +13 -0
  29. package/src/index.ts +3 -0
  30. package/src/mixins/OxyServices.accounts.ts +9 -0
  31. package/src/mixins/OxyServices.auth.ts +2 -0
  32. package/src/mixins/OxyServices.utility.ts +10 -9
  33. package/src/models/interfaces.ts +3 -1
  34. package/src/models/session.ts +6 -0
  35. package/src/session/SessionClient.ts +44 -0
  36. package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
  37. package/src/session/__tests__/accountDialogController.test.ts +93 -1
  38. package/src/session/__tests__/authStateStore.test.ts +170 -0
  39. package/src/session/accountDialogController.ts +54 -1
  40. package/src/session/authStateStore.ts +219 -15
@@ -106,6 +106,19 @@ export async function runSessionColdBoot(opts) {
106
106
  if (!session?.accessToken) {
107
107
  return { kind: 'skip' };
108
108
  }
109
+ // `verifyChallenge` mints a rotating deviceSecret; persist it so the next
110
+ // boot can use the faster device-secret-mint lane (sockets + tab-focus
111
+ // re-mint depend on the credential being in the store).
112
+ if (session.deviceId && session.deviceSecret) {
113
+ await store.save({
114
+ sessionId: session.sessionId,
115
+ userId: session.user.id,
116
+ deviceId: session.deviceId,
117
+ deviceSecret: session.deviceSecret,
118
+ accessToken: session.accessToken,
119
+ expiresAt: session.expiresAt,
120
+ });
121
+ }
109
122
  return {
110
123
  kind: 'session',
111
124
  session: {
package/dist/esm/index.js CHANGED
@@ -34,6 +34,7 @@ export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData.js';
34
34
  export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity.js';
35
35
  export { getCanonicalUserHandle, getNormalizedUserHandle, } from './utils/userHandle.js';
36
36
  export { normalizeProfileLinks } from './utils/profileLinks.js';
37
+ export { ORGANIZATION_CATEGORIES } from './mixins/OxyServices.accounts.js';
37
38
  // ---------------------------------------------------------------------------
38
39
  // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping,
39
40
  // verified domains). Wire shapes (DidDocument, SignedRecordEnvelope,
@@ -1,5 +1,6 @@
1
1
  import { normalizeUserIdentity } from '../utils/userIdentity.js';
2
2
  import { CACHE_TIMES } from './mixinHelpers.js';
3
+ export { ORGANIZATION_CATEGORIES } from '@oxyhq/contracts';
3
4
  export function OxyServicesAccountsMixin(Base) {
4
5
  return class extends Base {
5
6
  constructor(...args) {
@@ -6,8 +6,8 @@
6
6
  */
7
7
  import { jwtDecode } from 'jwt-decode';
8
8
  import { loadNodeCrypto } from '@oxyhq/protocol';
9
+ import { buildUrl } from '../utils/apiUtils.js';
9
10
  import { logger } from '../utils/loggerUtils.js';
10
- import { CACHE_TIMES } from './mixinHelpers.js';
11
11
  /**
12
12
  * Expected JWT audience for tokens issued by the Oxy auth service.
13
13
  */
@@ -108,10 +108,14 @@ export function OxyServicesUtilityMixin(Base) {
108
108
  */
109
109
  async fetchLinkMetadata(url) {
110
110
  try {
111
- return await this.makeRequest('GET', '/link-metadata', { url }, {
112
- cache: true,
113
- cacheTTL: CACHE_TIMES.EXTRA_LONG,
114
- });
111
+ const path = buildUrl('/links/preview', { url, wait: 1 });
112
+ const preview = await this.makeRequest('GET', path, undefined, { cache: false });
113
+ return {
114
+ url: preview.url,
115
+ title: preview.title?.trim() || preview.url.replace(/^https?:\/\//, '').replace(/\/$/, ''),
116
+ description: preview.description?.trim() || 'Link',
117
+ image: preview.image,
118
+ };
115
119
  }
116
120
  catch (error) {
117
121
  throw this.handleError(error);
@@ -21,6 +21,10 @@ export class SessionClient {
21
21
  this.started = false;
22
22
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
23
23
  this.channel = null;
24
+ /** App-facing subscriptions to named server-pushed socket events. */
25
+ this.serverEvents = new Map();
26
+ /** Event names already bound on the CURRENT socket instance. */
27
+ this.boundServerEvents = new Set();
24
28
  }
25
29
  getState() {
26
30
  return this.state;
@@ -31,6 +35,41 @@ export class SessionClient {
31
35
  this.listeners.delete(listener);
32
36
  };
33
37
  }
38
+ /**
39
+ * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
40
+ * Listeners survive reconnects and socket re-creation; the returned function
41
+ * unsubscribes. Payloads are delivered as-is — callers validate shape.
42
+ */
43
+ onServerEvent(event, listener) {
44
+ let listeners = this.serverEvents.get(event);
45
+ if (!listeners) {
46
+ listeners = new Set();
47
+ this.serverEvents.set(event, listeners);
48
+ }
49
+ listeners.add(listener);
50
+ this.bindServerEvent(event);
51
+ return () => {
52
+ listeners.delete(listener);
53
+ };
54
+ }
55
+ bindServerEvent(event) {
56
+ if (!this.socket || this.boundServerEvents.has(event))
57
+ return;
58
+ this.boundServerEvents.add(event);
59
+ this.socket.on(event, (payload) => {
60
+ const listeners = this.serverEvents.get(event);
61
+ if (!listeners)
62
+ return;
63
+ for (const listener of [...listeners]) {
64
+ try {
65
+ listener(payload);
66
+ }
67
+ catch (error) {
68
+ logger.warn('[SessionClient] server-event listener threw', { component: 'SessionClient' }, error);
69
+ }
70
+ }
71
+ });
72
+ }
34
73
  notify() {
35
74
  for (const listener of this.listeners) {
36
75
  try {
@@ -210,6 +249,7 @@ export class SessionClient {
210
249
  if (this.socket) {
211
250
  this.socket.disconnect();
212
251
  this.socket = null;
252
+ this.boundServerEvents.clear();
213
253
  }
214
254
  }
215
255
  async connectSocket() {
@@ -264,6 +304,11 @@ export class SessionClient {
264
304
  }
265
305
  });
266
306
  this.socket = socket;
307
+ // (Re)bind app-facing server-event subscriptions on the fresh socket.
308
+ this.boundServerEvents.clear();
309
+ for (const event of this.serverEvents.keys()) {
310
+ this.bindServerEvent(event);
311
+ }
267
312
  }
268
313
  /**
269
314
  * Open the same-origin `BroadcastChannel` (web only). A sibling tab that
@@ -30,6 +30,12 @@ import { CENTRAL_IDP_APEX } from '../utils/authWebUrl.js';
30
30
  import { generateOAuthState, generatePkcePair, normalizeOAuthRedirectUri, persistOAuthHandshake, } from '../utils/oauthPkce.js';
31
31
  import { projectSwitchableAccounts, switchableAccountIds, } from './accountProjection.js';
32
32
  const DEFAULT_POLL_INTERVAL_MS = 3000;
33
+ /**
34
+ * Commons's custom URL scheme. Probed via the injected `canOpenApp` to detect an
35
+ * installed Commons on the same device; the `oxycommons://approve?...` deep link
36
+ * itself is the flow's `qrPayload`.
37
+ */
38
+ const COMMONS_APP_SCHEME = 'oxycommons://';
33
39
  const IDLE_SIGN_IN = {
34
40
  phase: 'idle',
35
41
  authorizeCode: null,
@@ -72,6 +78,7 @@ export class AccountDialogController {
72
78
  this.authRedirectUri = options.authRedirectUri ?? null;
73
79
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
74
80
  this.openUrl = options.openUrl;
81
+ this.canOpenApp = options.canOpenApp;
75
82
  this.snapshot = this.computeSnapshot();
76
83
  }
77
84
  // =========================================================================
@@ -396,11 +403,34 @@ export class AccountDialogController {
396
403
  error: null,
397
404
  });
398
405
  this.scheduleNextPoll(handle.sessionToken);
406
+ // Same-device convenience: if Commons is installed (native only — `canOpenApp`
407
+ // is undefined/false on web), deep-link straight into its approve screen with
408
+ // the same `oxycommons://approve?...` payload the QR encodes. The QR + polling
409
+ // stay live as the fallback, so a user who dismisses the app-open still
410
+ // completes the sign-in by scanning.
411
+ void this.maybeOpenCommons(handle.qrPayload);
399
412
  }
400
413
  catch (error) {
401
414
  this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
402
415
  }
403
416
  }
417
+ /**
418
+ * When a `canOpenApp` probe is injected and reports Commons installed, open the
419
+ * approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
420
+ * probe/open failure is logged and swallowed — the QR/polling fallback remains.
421
+ */
422
+ async maybeOpenCommons(qrPayload) {
423
+ if (!this.canOpenApp || !this.openUrl)
424
+ return;
425
+ try {
426
+ if (await this.canOpenApp(COMMONS_APP_SCHEME)) {
427
+ this.openUrl(qrPayload);
428
+ }
429
+ }
430
+ catch (error) {
431
+ logger.debug('[AccountDialogController] Commons deep-link probe failed (QR fallback active)', { component: 'AccountDialogController' }, error);
432
+ }
433
+ }
404
434
  /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
405
435
  cancelSignIn() {
406
436
  this.clearPollTimer();
@@ -523,6 +553,7 @@ export class AccountDialogController {
523
553
  expiresAt: claimed.expiresAt ?? '',
524
554
  user: minimalUser,
525
555
  accessToken: claimed.accessToken,
556
+ ...(claimed.deviceSecret ? { deviceSecret: claimed.deviceSecret } : {}),
526
557
  }, minimalUser);
527
558
  }
528
559
  catch (error) {
@@ -16,12 +16,34 @@
16
16
  *
17
17
  * ESM-safe (no `require()`).
18
18
  */
19
+ import { logger } from '../utils/loggerUtils.js';
19
20
  /**
20
- * Versioned storage key. The `.v1` suffix lets a future shape change ship a
21
- * `.v2` key without reading a stale/incompatible `.v1` blob. Distinct from the
22
- * `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
21
+ * Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
22
+ * (`sessionId`, `userId`, `deviceId`, `deviceSecret`) never the large JWT
23
+ * `accessToken`. Keeping this blob small (<2KB) matters on Android
24
+ * `expo-secure-store`, whose backing store can silently fail to persist an
25
+ * oversize value; bundling the token here previously took the mint credential
26
+ * down with it on every write, losing the session on cold restart.
27
+ *
28
+ * The `.v1` suffix lets a future shape change ship a `.v2` key without reading a
29
+ * stale/incompatible `.v1` blob. Distinct from the `oxy_shared_*` keychain keys
30
+ * in `KeyManager`, so it never collides.
31
+ *
32
+ * BACK-COMPAT: pre-split builds wrote the WHOLE state (including `accessToken` /
33
+ * `expiresAt`) into this single key. `load()` still reads those token fields
34
+ * from here when the warm key ({@link AUTH_STATE_TOKEN_STORAGE_KEY}) is absent,
35
+ * so upgrading users are not signed out; the next `save()` splits them apart.
23
36
  */
24
37
  export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
38
+ /**
39
+ * Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
40
+ * `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
41
+ * failure (quota / oversize keychain value) is swallowed because the session is
42
+ * fully re-mintable from the durable `deviceSecret`. Kept separate from
43
+ * {@link AUTH_STATE_STORAGE_KEY} so a failed token write can NEVER abort or
44
+ * corrupt the durable credential write.
45
+ */
46
+ export const AUTH_STATE_TOKEN_STORAGE_KEY = 'oxy.auth.token.v1';
25
47
  /**
26
48
  * Parse + shape-validate a stored blob. Returns `null` for anything that is
27
49
  * not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
@@ -72,6 +94,95 @@ function deserialize(raw) {
72
94
  }
73
95
  return state;
74
96
  }
97
+ /**
98
+ * Parse the best-effort warm-token blob. Returns `null` for anything not a
99
+ * well-formed object so a corrupt warm entry simply forgoes the warm-boot
100
+ * optimization (the durable credential re-mints a fresh token).
101
+ */
102
+ function parseWarmToken(raw) {
103
+ if (!raw) {
104
+ return null;
105
+ }
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(raw);
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ if (!parsed || typeof parsed !== 'object') {
114
+ return null;
115
+ }
116
+ const candidate = parsed;
117
+ const warm = {};
118
+ if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
119
+ warm.accessToken = candidate.accessToken;
120
+ }
121
+ if (typeof candidate.expiresAt === 'string' && candidate.expiresAt.length > 0) {
122
+ warm.expiresAt = candidate.expiresAt;
123
+ }
124
+ return warm;
125
+ }
126
+ /** Serialize ONLY the small, re-mint-critical fields for the durable key. */
127
+ function serializeDurable(state) {
128
+ const durable = {
129
+ sessionId: state.sessionId,
130
+ userId: state.userId,
131
+ };
132
+ if (state.deviceId) {
133
+ durable.deviceId = state.deviceId;
134
+ }
135
+ if (state.deviceSecret) {
136
+ durable.deviceSecret = state.deviceSecret;
137
+ }
138
+ return JSON.stringify(durable);
139
+ }
140
+ /**
141
+ * Serialize the warm-token blob, or `null` when there is no token to persist
142
+ * (so the caller clears the warm key rather than writing an empty object).
143
+ */
144
+ function serializeWarmToken(state) {
145
+ const warm = {};
146
+ if (state.accessToken) {
147
+ warm.accessToken = state.accessToken;
148
+ }
149
+ if (state.expiresAt) {
150
+ warm.expiresAt = state.expiresAt;
151
+ }
152
+ if (!warm.accessToken && !warm.expiresAt) {
153
+ return null;
154
+ }
155
+ return JSON.stringify(warm);
156
+ }
157
+ /**
158
+ * Compose the unchanged {@link PersistedAuthState} return shape from the two
159
+ * on-disk keys. `accessToken` / `expiresAt` come from the warm key when it is
160
+ * present; when the warm key is ABSENT the token fields fall back to whatever
161
+ * the durable blob carried — the pre-split combined `oxy.auth.v1` blob (BACK-COMPAT).
162
+ */
163
+ function composeState(durableRaw, warmRaw) {
164
+ const state = deserialize(durableRaw);
165
+ if (!state) {
166
+ return null;
167
+ }
168
+ if (warmRaw !== null) {
169
+ // New split layout: the warm key is authoritative for the token fields.
170
+ // Drop anything the durable blob may have carried, then overlay the warm
171
+ // values (a warm key with no token → the session simply has no warm token).
172
+ delete state.accessToken;
173
+ delete state.expiresAt;
174
+ const warm = parseWarmToken(warmRaw);
175
+ if (warm?.accessToken) {
176
+ state.accessToken = warm.accessToken;
177
+ }
178
+ if (warm?.expiresAt) {
179
+ state.expiresAt = warm.expiresAt;
180
+ }
181
+ }
182
+ // else: BACK-COMPAT — the warm key is absent, so `deserialize` already applied
183
+ // any `accessToken` / `expiresAt` from the old combined blob.
184
+ return state;
185
+ }
75
186
  /**
76
187
  * A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
77
188
  * tests/SSR and as the degraded fallback of the web store when `localStorage`
@@ -109,8 +220,9 @@ function safeGetLocalStorage() {
109
220
  }
110
221
  }
111
222
  /**
112
- * A `localStorage`-backed {@link AuthStateStore} under the versioned
113
- * {@link AUTH_STATE_STORAGE_KEY}.
223
+ * A `localStorage`-backed {@link AuthStateStore} split across the durable
224
+ * {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the best-effort
225
+ * {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token).
114
226
  *
115
227
  * Resilience:
116
228
  * - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
@@ -137,7 +249,7 @@ export function createWebAuthStateStore() {
137
249
  return sessionMirror;
138
250
  }
139
251
  try {
140
- return deserialize(storage.getItem(AUTH_STATE_STORAGE_KEY));
252
+ return composeState(storage.getItem(AUTH_STATE_STORAGE_KEY), storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY));
141
253
  }
142
254
  catch {
143
255
  return null;
@@ -145,12 +257,35 @@ export function createWebAuthStateStore() {
145
257
  },
146
258
  save: async (state) => {
147
259
  sessionMirror = state; // mirror FIRST — authoritative even if persist fails
260
+ // Durable credential FIRST, then VERIFY it landed. The in-memory mirror
261
+ // keeps the session live for this page, but a failed DURABLE write means
262
+ // the mint credential will NOT survive a reload — surface it, never swallow.
263
+ let durablePersisted = false;
264
+ try {
265
+ const durableJson = serializeDurable(state);
266
+ storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
267
+ durablePersisted = storage.getItem(AUTH_STATE_STORAGE_KEY) === durableJson;
268
+ if (!durablePersisted) {
269
+ logger.error('[authStateStore] durable credential read-back mismatch after save — the device credential did not persist; the session survives this process via the in-memory mirror but will be lost on reload', undefined, { component: 'authStateStore' });
270
+ }
271
+ }
272
+ catch (error) {
273
+ logger.error('[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this process via the in-memory mirror but will be lost on reload', error, { component: 'authStateStore' });
274
+ }
275
+ // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
276
+ // durable credential re-mints a fresh token) and must never abort or
277
+ // corrupt the durable write above.
148
278
  try {
149
- storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
279
+ const warmJson = serializeWarmToken(state);
280
+ if (warmJson) {
281
+ storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
282
+ }
283
+ else {
284
+ storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
285
+ }
150
286
  }
151
287
  catch {
152
- // Quota / private-mode / disabled storage — non-fatal. The session
153
- // stays live via the in-memory mirror; only reload durability is lost.
288
+ // Quota / private-mode / disabled storage — non-fatal warm-boot loss only.
154
289
  }
155
290
  },
156
291
  clear: async () => {
@@ -161,6 +296,12 @@ export function createWebAuthStateStore() {
161
296
  catch {
162
297
  // Non-fatal — see save().
163
298
  }
299
+ try {
300
+ storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
301
+ }
302
+ catch {
303
+ // Non-fatal — see save().
304
+ }
164
305
  },
165
306
  };
166
307
  }
@@ -168,9 +309,12 @@ export function createWebAuthStateStore() {
168
309
  * A native {@link AuthStateStore} over an injected async key/value store.
169
310
  *
170
311
  * `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
171
- * the SecureStore-backed adapter and passes it here. Every operation is wrapped
172
- * so a storage exception degrades gracefully (read `null`, write → swallowed)
173
- * exactly like the web store.
312
+ * the SecureStore-backed adapter and passes it here. Persistence is split across
313
+ * the durable {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the
314
+ * best-effort {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token) — the
315
+ * durable write is read-back-verified and its failure surfaced (not swallowed),
316
+ * while the warm-token write and all reads degrade gracefully exactly like the
317
+ * web store.
174
318
  */
175
319
  export function createNativeAuthStateStore(storage) {
176
320
  // Same in-memory mirror as the web store — a locked/failed SecureStore write
@@ -182,7 +326,11 @@ export function createNativeAuthStateStore(storage) {
182
326
  return sessionMirror;
183
327
  }
184
328
  try {
185
- return deserialize(await storage.getItem(AUTH_STATE_STORAGE_KEY));
329
+ const [durableRaw, warmRaw] = await Promise.all([
330
+ storage.getItem(AUTH_STATE_STORAGE_KEY),
331
+ storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
332
+ ]);
333
+ return composeState(durableRaw, warmRaw);
186
334
  }
187
335
  catch {
188
336
  return null;
@@ -190,11 +338,37 @@ export function createNativeAuthStateStore(storage) {
190
338
  },
191
339
  save: async (state) => {
192
340
  sessionMirror = state;
341
+ // Durable credential FIRST, then VERIFY. On Android SecureStore an
342
+ // oversize/failed write can resolve WITHOUT throwing, so a read-back is the
343
+ // only reliable proof. The mirror keeps the session live for this app run,
344
+ // but a failed DURABLE write means the mint credential will NOT survive a
345
+ // cold restart — surface it, never swallow.
346
+ let durablePersisted = false;
193
347
  try {
194
- await storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
348
+ const durableJson = serializeDurable(state);
349
+ await storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
350
+ durablePersisted = (await storage.getItem(AUTH_STATE_STORAGE_KEY)) === durableJson;
351
+ if (!durablePersisted) {
352
+ logger.error('[authStateStore] durable credential read-back mismatch after save — the device credential did not persist (likely oversize SecureStore value); the session survives this app run via the in-memory mirror but will be lost on cold restart', undefined, { component: 'authStateStore' });
353
+ }
354
+ }
355
+ catch (error) {
356
+ logger.error('[authStateStore] durable credential persist threw — the device credential did not persist; the session survives this app run via the in-memory mirror but will be lost on cold restart', error, { component: 'authStateStore' });
357
+ }
358
+ // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
359
+ // durable credential re-mints a fresh token) and must never abort or
360
+ // corrupt the durable write above.
361
+ try {
362
+ const warmJson = serializeWarmToken(state);
363
+ if (warmJson) {
364
+ await storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
365
+ }
366
+ else {
367
+ await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
368
+ }
195
369
  }
196
370
  catch {
197
- // Non-fatal session stays live via the in-memory mirror.
371
+ // Locked / oversize keychain non-fatal warm-boot loss only.
198
372
  }
199
373
  },
200
374
  clear: async () => {
@@ -205,6 +379,12 @@ export function createNativeAuthStateStore(storage) {
205
379
  catch {
206
380
  // Non-fatal.
207
381
  }
382
+ try {
383
+ await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
384
+ }
385
+ catch {
386
+ // Non-fatal.
387
+ }
208
388
  },
209
389
  };
210
390
  }