@oxyhq/core 9.2.1 → 9.2.3

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 (45) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +27 -0
  3. package/dist/cjs/boot/sessionColdBoot.js +83 -53
  4. package/dist/cjs/crypto/keyManager.js +45 -12
  5. package/dist/cjs/index.js +2 -1
  6. package/dist/cjs/mixins/OxyServices.utility.js +9 -5
  7. package/dist/cjs/session/SessionClient.js +54 -4
  8. package/dist/cjs/session/accountDialogController.js +30 -0
  9. package/dist/cjs/session/authStateStore.js +204 -16
  10. package/dist/cjs/session/refresh.js +110 -37
  11. package/dist/esm/.tsbuildinfo +1 -1
  12. package/dist/esm/HttpService.js +27 -0
  13. package/dist/esm/boot/sessionColdBoot.js +83 -53
  14. package/dist/esm/crypto/keyManager.js +46 -13
  15. package/dist/esm/index.js +1 -1
  16. package/dist/esm/mixins/OxyServices.utility.js +9 -5
  17. package/dist/esm/session/SessionClient.js +54 -4
  18. package/dist/esm/session/accountDialogController.js +30 -0
  19. package/dist/esm/session/authStateStore.js +203 -15
  20. package/dist/esm/session/refresh.js +109 -37
  21. package/dist/types/.tsbuildinfo +1 -1
  22. package/dist/types/HttpService.d.ts +21 -0
  23. package/dist/types/boot/sessionColdBoot.d.ts +7 -3
  24. package/dist/types/index.d.ts +3 -3
  25. package/dist/types/session/SessionClient.d.ts +37 -4
  26. package/dist/types/session/accountDialogController.d.ts +17 -0
  27. package/dist/types/session/authStateStore.d.ts +48 -9
  28. package/dist/types/session/refresh.d.ts +67 -31
  29. package/package.json +2 -2
  30. package/src/HttpService.ts +31 -0
  31. package/src/boot/__tests__/sessionColdBoot.test.ts +119 -0
  32. package/src/boot/sessionColdBoot.ts +93 -74
  33. package/src/crypto/keyManager.ts +42 -15
  34. package/src/index.ts +3 -2
  35. package/src/mixins/OxyServices.utility.ts +10 -9
  36. package/src/session/SessionClient.ts +79 -7
  37. package/src/session/__tests__/SessionClient.additive.test.ts +58 -1
  38. package/src/session/__tests__/SessionClient.serverEvents.test.ts +71 -0
  39. package/src/session/__tests__/SessionClient.socket.test.ts +32 -0
  40. package/src/session/__tests__/accountDialogController.test.ts +85 -0
  41. package/src/session/__tests__/authStateStore.test.ts +232 -4
  42. package/src/session/__tests__/refresh.test.ts +141 -2
  43. package/src/session/accountDialogController.ts +45 -0
  44. package/src/session/authStateStore.ts +242 -16
  45. package/src/session/refresh.ts +146 -40
@@ -131,10 +131,27 @@ export interface AccountDialogControllerOptions {
131
131
  * `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
132
132
  */
133
133
  openUrl?: (url: string) => void;
134
+ /**
135
+ * Optional "can this app open this URL scheme?" probe, symmetric to
136
+ * {@link openUrl}. When provided, `showQr` uses it to detect an installed
137
+ * Commons (`oxycommons://`) and, if present, deep-links straight into its
138
+ * approve screen via {@link openUrl} — while KEEPING the QR/polling active as
139
+ * the fallback. Injected by the provider (native: `Linking.canOpenURL`; web:
140
+ * absent/false). Headless core never touches `Linking` itself; when absent
141
+ * `showQr` behaves exactly as before (render QR only).
142
+ */
143
+ canOpenApp?: (url: string) => Promise<boolean>;
134
144
  }
135
145
 
136
146
  const DEFAULT_POLL_INTERVAL_MS = 3000;
137
147
 
148
+ /**
149
+ * Commons's custom URL scheme. Probed via the injected `canOpenApp` to detect an
150
+ * installed Commons on the same device; the `oxycommons://approve?...` deep link
151
+ * itself is the flow's `qrPayload`.
152
+ */
153
+ const COMMONS_APP_SCHEME = 'oxycommons://';
154
+
138
155
  const IDLE_SIGN_IN: SignInFlowState = {
139
156
  phase: 'idle',
140
157
  authorizeCode: null,
@@ -160,6 +177,7 @@ export class AccountDialogController {
160
177
  private readonly authRedirectUri: string | null;
161
178
  private readonly pollIntervalMs: number;
162
179
  private readonly openUrl?: (url: string) => void;
180
+ private readonly canOpenApp?: (url: string) => Promise<boolean>;
163
181
 
164
182
  private readonly listeners = new Set<SnapshotListener>();
165
183
 
@@ -197,6 +215,7 @@ export class AccountDialogController {
197
215
  this.authRedirectUri = options.authRedirectUri ?? null;
198
216
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
199
217
  this.openUrl = options.openUrl;
218
+ this.canOpenApp = options.canOpenApp;
200
219
  this.snapshot = this.computeSnapshot();
201
220
  }
202
221
 
@@ -534,11 +553,37 @@ export class AccountDialogController {
534
553
  error: null,
535
554
  });
536
555
  this.scheduleNextPoll(handle.sessionToken);
556
+ // Same-device convenience: if Commons is installed (native only — `canOpenApp`
557
+ // is undefined/false on web), deep-link straight into its approve screen with
558
+ // the same `oxycommons://approve?...` payload the QR encodes. The QR + polling
559
+ // stay live as the fallback, so a user who dismisses the app-open still
560
+ // completes the sign-in by scanning.
561
+ void this.maybeOpenCommons(handle.qrPayload);
537
562
  } catch (error) {
538
563
  this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
539
564
  }
540
565
  }
541
566
 
567
+ /**
568
+ * When a `canOpenApp` probe is injected and reports Commons installed, open the
569
+ * approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
570
+ * probe/open failure is logged and swallowed — the QR/polling fallback remains.
571
+ */
572
+ private async maybeOpenCommons(qrPayload: string): Promise<void> {
573
+ if (!this.canOpenApp || !this.openUrl) return;
574
+ try {
575
+ if (await this.canOpenApp(COMMONS_APP_SCHEME)) {
576
+ this.openUrl(qrPayload);
577
+ }
578
+ } catch (error) {
579
+ logger.debug(
580
+ '[AccountDialogController] Commons deep-link probe failed (QR fallback active)',
581
+ { component: 'AccountDialogController' },
582
+ error,
583
+ );
584
+ }
585
+ }
586
+
542
587
  /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
543
588
  cancelSignIn(): void {
544
589
  this.clearPollTimer();
@@ -17,6 +17,8 @@
17
17
  * ESM-safe (no `require()`).
18
18
  */
19
19
 
20
+ import { logger } from '../utils/loggerUtils';
21
+
20
22
  /**
21
23
  * The persisted session credential set for a single origin.
22
24
  *
@@ -66,7 +68,21 @@ export interface PersistedAuthState {
66
68
  */
67
69
  export interface AuthStateStore {
68
70
  load(): Promise<PersistedAuthState | null>;
69
- save(state: PersistedAuthState): Promise<void>;
71
+ /**
72
+ * Persist the credential blob and report whether it durably landed.
73
+ *
74
+ * Resolves `true` when the state is retained consistent with this store's
75
+ * durability guarantee — a durable backing whose read-back matched, or a
76
+ * degraded/in-memory store that held it in memory. Resolves `false` when a
77
+ * DURABLE backing was expected but the write did NOT land (read-back mismatch
78
+ * or a thrown write); the in-memory mirror still keeps the session live for
79
+ * this process, but it will be lost on reload.
80
+ *
81
+ * A lane persisting a ROTATED device secret (the mint's `nextDeviceSecret`)
82
+ * MUST treat `false` as fatal for that mint: it must NOT plant/advertise a
83
+ * session built on a secret that will not survive a reload.
84
+ */
85
+ save(state: PersistedAuthState): Promise<boolean>;
70
86
  clear(): Promise<void>;
71
87
  }
72
88
 
@@ -81,12 +97,34 @@ export interface NativeKeyValueStorage {
81
97
  }
82
98
 
83
99
  /**
84
- * Versioned storage key. The `.v1` suffix lets a future shape change ship a
85
- * `.v2` key without reading a stale/incompatible `.v1` blob. Distinct from the
86
- * `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
100
+ * Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
101
+ * (`sessionId`, `userId`, `deviceId`, `deviceSecret`) never the large JWT
102
+ * `accessToken`. Keeping this blob small (<2KB) matters on Android
103
+ * `expo-secure-store`, whose backing store can silently fail to persist an
104
+ * oversize value; bundling the token here previously took the mint credential
105
+ * down with it on every write, losing the session on cold restart.
106
+ *
107
+ * The `.v1` suffix lets a future shape change ship a `.v2` key without reading a
108
+ * stale/incompatible `.v1` blob. Distinct from the `oxy_shared_*` keychain keys
109
+ * in `KeyManager`, so it never collides.
110
+ *
111
+ * BACK-COMPAT: pre-split builds wrote the WHOLE state (including `accessToken` /
112
+ * `expiresAt`) into this single key. `load()` still reads those token fields
113
+ * from here when the warm key ({@link AUTH_STATE_TOKEN_STORAGE_KEY}) is absent,
114
+ * so upgrading users are not signed out; the next `save()` splits them apart.
87
115
  */
88
116
  export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
89
117
 
118
+ /**
119
+ * Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
120
+ * `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
121
+ * failure (quota / oversize keychain value) is swallowed because the session is
122
+ * fully re-mintable from the durable `deviceSecret`. Kept separate from
123
+ * {@link AUTH_STATE_STORAGE_KEY} so a failed token write can NEVER abort or
124
+ * corrupt the durable credential write.
125
+ */
126
+ export const AUTH_STATE_TOKEN_STORAGE_KEY = 'oxy.auth.token.v1';
127
+
90
128
  /**
91
129
  * Parse + shape-validate a stored blob. Returns `null` for anything that is
92
130
  * not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
@@ -142,6 +180,104 @@ function deserialize(raw: string | null): PersistedAuthState | null {
142
180
  return state;
143
181
  }
144
182
 
183
+ /** The parsed warm-token blob: the short-lived optimization fields only. */
184
+ interface WarmToken {
185
+ accessToken?: string;
186
+ expiresAt?: string;
187
+ }
188
+
189
+ /**
190
+ * Parse the best-effort warm-token blob. Returns `null` for anything not a
191
+ * well-formed object so a corrupt warm entry simply forgoes the warm-boot
192
+ * optimization (the durable credential re-mints a fresh token).
193
+ */
194
+ function parseWarmToken(raw: string | null): WarmToken | null {
195
+ if (!raw) {
196
+ return null;
197
+ }
198
+ let parsed: unknown;
199
+ try {
200
+ parsed = JSON.parse(raw);
201
+ } catch {
202
+ return null;
203
+ }
204
+ if (!parsed || typeof parsed !== 'object') {
205
+ return null;
206
+ }
207
+ const candidate = parsed as Record<string, unknown>;
208
+ const warm: WarmToken = {};
209
+ if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
210
+ warm.accessToken = candidate.accessToken;
211
+ }
212
+ if (typeof candidate.expiresAt === 'string' && candidate.expiresAt.length > 0) {
213
+ warm.expiresAt = candidate.expiresAt;
214
+ }
215
+ return warm;
216
+ }
217
+
218
+ /** Serialize ONLY the small, re-mint-critical fields for the durable key. */
219
+ function serializeDurable(state: PersistedAuthState): string {
220
+ const durable: Record<string, string> = {
221
+ sessionId: state.sessionId,
222
+ userId: state.userId,
223
+ };
224
+ if (state.deviceId) {
225
+ durable.deviceId = state.deviceId;
226
+ }
227
+ if (state.deviceSecret) {
228
+ durable.deviceSecret = state.deviceSecret;
229
+ }
230
+ return JSON.stringify(durable);
231
+ }
232
+
233
+ /**
234
+ * Serialize the warm-token blob, or `null` when there is no token to persist
235
+ * (so the caller clears the warm key rather than writing an empty object).
236
+ */
237
+ function serializeWarmToken(state: PersistedAuthState): string | null {
238
+ const warm: WarmToken = {};
239
+ if (state.accessToken) {
240
+ warm.accessToken = state.accessToken;
241
+ }
242
+ if (state.expiresAt) {
243
+ warm.expiresAt = state.expiresAt;
244
+ }
245
+ if (!warm.accessToken && !warm.expiresAt) {
246
+ return null;
247
+ }
248
+ return JSON.stringify(warm);
249
+ }
250
+
251
+ /**
252
+ * Compose the unchanged {@link PersistedAuthState} return shape from the two
253
+ * on-disk keys. `accessToken` / `expiresAt` come from the warm key when it is
254
+ * present; when the warm key is ABSENT the token fields fall back to whatever
255
+ * the durable blob carried — the pre-split combined `oxy.auth.v1` blob (BACK-COMPAT).
256
+ */
257
+ function composeState(durableRaw: string | null, warmRaw: string | null): PersistedAuthState | null {
258
+ const state = deserialize(durableRaw);
259
+ if (!state) {
260
+ return null;
261
+ }
262
+ if (warmRaw !== null) {
263
+ // New split layout: the warm key is authoritative for the token fields.
264
+ // Drop anything the durable blob may have carried, then overlay the warm
265
+ // values (a warm key with no token → the session simply has no warm token).
266
+ delete state.accessToken;
267
+ delete state.expiresAt;
268
+ const warm = parseWarmToken(warmRaw);
269
+ if (warm?.accessToken) {
270
+ state.accessToken = warm.accessToken;
271
+ }
272
+ if (warm?.expiresAt) {
273
+ state.expiresAt = warm.expiresAt;
274
+ }
275
+ }
276
+ // else: BACK-COMPAT — the warm key is absent, so `deserialize` already applied
277
+ // any `accessToken` / `expiresAt` from the old combined blob.
278
+ return state;
279
+ }
280
+
145
281
  /**
146
282
  * A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
147
283
  * tests/SSR and as the degraded fallback of the web store when `localStorage`
@@ -153,7 +289,9 @@ export function createMemoryAuthStateStore(): AuthStateStore {
153
289
  return {
154
290
  load: async () => current,
155
291
  save: async (state) => {
292
+ // Memory IS this store's durability backing — the write always lands.
156
293
  current = state;
294
+ return true;
157
295
  },
158
296
  clear: async () => {
159
297
  current = null;
@@ -180,8 +318,9 @@ function safeGetLocalStorage(): Storage | null {
180
318
  }
181
319
 
182
320
  /**
183
- * A `localStorage`-backed {@link AuthStateStore} under the versioned
184
- * {@link AUTH_STATE_STORAGE_KEY}.
321
+ * A `localStorage`-backed {@link AuthStateStore} split across the durable
322
+ * {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the best-effort
323
+ * {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token).
185
324
  *
186
325
  * Resilience:
187
326
  * - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
@@ -208,19 +347,54 @@ export function createWebAuthStateStore(): AuthStateStore {
208
347
  return sessionMirror;
209
348
  }
210
349
  try {
211
- return deserialize(storage.getItem(AUTH_STATE_STORAGE_KEY));
350
+ return composeState(
351
+ storage.getItem(AUTH_STATE_STORAGE_KEY),
352
+ storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
353
+ );
212
354
  } catch {
213
355
  return null;
214
356
  }
215
357
  },
216
358
  save: async (state) => {
217
359
  sessionMirror = state; // mirror FIRST — authoritative even if persist fails
360
+ // Durable credential FIRST, then VERIFY it landed. The in-memory mirror
361
+ // keeps the session live for this page, but a failed DURABLE write means
362
+ // the mint credential will NOT survive a reload — surface it, never swallow.
363
+ let durablePersisted = false;
218
364
  try {
219
- storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
365
+ const durableJson = serializeDurable(state);
366
+ storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
367
+ durablePersisted = storage.getItem(AUTH_STATE_STORAGE_KEY) === durableJson;
368
+ if (!durablePersisted) {
369
+ logger.error(
370
+ '[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',
371
+ undefined,
372
+ { component: 'authStateStore' },
373
+ );
374
+ }
375
+ } catch (error) {
376
+ logger.error(
377
+ '[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',
378
+ error,
379
+ { component: 'authStateStore' },
380
+ );
381
+ }
382
+ // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
383
+ // durable credential re-mints a fresh token) and must never abort or
384
+ // corrupt the durable write above.
385
+ try {
386
+ const warmJson = serializeWarmToken(state);
387
+ if (warmJson) {
388
+ storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
389
+ } else {
390
+ storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
391
+ }
220
392
  } catch {
221
- // Quota / private-mode / disabled storage — non-fatal. The session
222
- // stays live via the in-memory mirror; only reload durability is lost.
393
+ // Quota / private-mode / disabled storage — non-fatal warm-boot loss only.
223
394
  }
395
+ // Report ONLY the durable-credential landing; the warm-token outcome above
396
+ // is intentionally excluded (it is a best-effort optimization).
397
+ return durablePersisted;
224
398
  },
225
399
  clear: async () => {
226
400
  sessionMirror = null;
@@ -229,6 +403,11 @@ export function createWebAuthStateStore(): AuthStateStore {
229
403
  } catch {
230
404
  // Non-fatal — see save().
231
405
  }
406
+ try {
407
+ storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
408
+ } catch {
409
+ // Non-fatal — see save().
410
+ }
232
411
  },
233
412
  };
234
413
  }
@@ -237,9 +416,12 @@ export function createWebAuthStateStore(): AuthStateStore {
237
416
  * A native {@link AuthStateStore} over an injected async key/value store.
238
417
  *
239
418
  * `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
240
- * the SecureStore-backed adapter and passes it here. Every operation is wrapped
241
- * so a storage exception degrades gracefully (read `null`, write → swallowed)
242
- * exactly like the web store.
419
+ * the SecureStore-backed adapter and passes it here. Persistence is split across
420
+ * the durable {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the
421
+ * best-effort {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token) — the
422
+ * durable write is read-back-verified and its failure surfaced (not swallowed),
423
+ * while the warm-token write and all reads degrade gracefully exactly like the
424
+ * web store.
243
425
  */
244
426
  export function createNativeAuthStateStore(storage: NativeKeyValueStorage): AuthStateStore {
245
427
  // Same in-memory mirror as the web store — a locked/failed SecureStore write
@@ -251,18 +433,57 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
251
433
  return sessionMirror;
252
434
  }
253
435
  try {
254
- return deserialize(await storage.getItem(AUTH_STATE_STORAGE_KEY));
436
+ const [durableRaw, warmRaw] = await Promise.all([
437
+ storage.getItem(AUTH_STATE_STORAGE_KEY),
438
+ storage.getItem(AUTH_STATE_TOKEN_STORAGE_KEY),
439
+ ]);
440
+ return composeState(durableRaw, warmRaw);
255
441
  } catch {
256
442
  return null;
257
443
  }
258
444
  },
259
445
  save: async (state) => {
260
446
  sessionMirror = state;
447
+ // Durable credential FIRST, then VERIFY. On Android SecureStore an
448
+ // oversize/failed write can resolve WITHOUT throwing, so a read-back is the
449
+ // only reliable proof. The mirror keeps the session live for this app run,
450
+ // but a failed DURABLE write means the mint credential will NOT survive a
451
+ // cold restart — surface it, never swallow.
452
+ let durablePersisted = false;
453
+ try {
454
+ const durableJson = serializeDurable(state);
455
+ await storage.setItem(AUTH_STATE_STORAGE_KEY, durableJson);
456
+ durablePersisted = (await storage.getItem(AUTH_STATE_STORAGE_KEY)) === durableJson;
457
+ if (!durablePersisted) {
458
+ logger.error(
459
+ '[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',
460
+ undefined,
461
+ { component: 'authStateStore' },
462
+ );
463
+ }
464
+ } catch (error) {
465
+ logger.error(
466
+ '[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',
467
+ error,
468
+ { component: 'authStateStore' },
469
+ );
470
+ }
471
+ // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
472
+ // durable credential re-mints a fresh token) and must never abort or
473
+ // corrupt the durable write above.
261
474
  try {
262
- await storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
475
+ const warmJson = serializeWarmToken(state);
476
+ if (warmJson) {
477
+ await storage.setItem(AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
478
+ } else {
479
+ await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
480
+ }
263
481
  } catch {
264
- // Non-fatal session stays live via the in-memory mirror.
482
+ // Locked / oversize keychain non-fatal warm-boot loss only.
265
483
  }
484
+ // Report ONLY the durable-credential landing; the warm-token outcome above
485
+ // is intentionally excluded (it is a best-effort optimization).
486
+ return durablePersisted;
266
487
  },
267
488
  clear: async () => {
268
489
  sessionMirror = null;
@@ -271,6 +492,11 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
271
492
  } catch {
272
493
  // Non-fatal.
273
494
  }
495
+ try {
496
+ await storage.removeItem(AUTH_STATE_TOKEN_STORAGE_KEY);
497
+ } catch {
498
+ // Non-fatal.
499
+ }
274
500
  },
275
501
  };
276
502
  }
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * Framework-free; no module-level mutable state.
22
22
  */
23
+ import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
23
24
  import type { OxyServices } from '../OxyServices';
24
25
  import type { AuthRefreshHandler, AuthRefreshReason } from '../HttpService';
25
26
  import type { AuthStateStore, PersistedAuthState } from './authStateStore';
@@ -71,68 +72,173 @@ export interface RefreshDeps {
71
72
  allowSharedKeyFallback?: boolean;
72
73
  }
73
74
 
75
+ /**
76
+ * The outcome of ONE device-secret mint attempt (arm 1). Discriminated so both
77
+ * the re-mint handler and the cold boot can react per the transport contract
78
+ * without re-classifying the raw error:
79
+ * - `ok` — minted, persisted the rotated secret, planted the token.
80
+ * - `no-secret` — the store holds no `deviceId` + `deviceSecret` to mint from.
81
+ * - `invalid-secret` — 401 `invalid_device_secret`: the presented secret
82
+ * diverged (another tab/device rotated it past the grace window).
83
+ * - `no-session` — 401 `no_active_session`: the device is known but has no live
84
+ * session (authoritative signed-out).
85
+ * - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
86
+ * - `persist-failed` — the mint succeeded (the SERVER rotated the secret) but
87
+ * the rotated `nextDeviceSecret` could NOT be durably persisted. The token is
88
+ * deliberately NOT planted: advertising a healthy session on a secret that
89
+ * will not survive a reload is exactly the divergence that logs users out.
90
+ */
91
+ export type DeviceSecretMintOutcome =
92
+ | { status: 'ok'; token: string; sessionId: string; userId: string }
93
+ | { status: 'no-secret' }
94
+ | { status: 'invalid-secret' }
95
+ | { status: 'no-session' }
96
+ | { status: 'transient' }
97
+ | { status: 'persist-failed' };
98
+
99
+ /**
100
+ * Arm 1 — the rotating device-secret mint, run under the owning client's
101
+ * PROCESS-WIDE single-flight (`httpService.runSingleFlightDeviceSecretMint`).
102
+ *
103
+ * The server ROTATES the presented `deviceSecret` on every successful mint and
104
+ * the just-presented secret is valid only for a short grace window. If two lanes
105
+ * (cold boot, the proactive scheduler, a request-time preflight, a 401 retry,
106
+ * the socket token transport, or a tab-focus reconcile) minted concurrently they
107
+ * would double-rotate the server and the durable store could converge on the
108
+ * SUPERSEDED secret — after the grace window the next cold boot mint 401s and the
109
+ * user is signed out. Routing EVERY lane through this one single-flight makes
110
+ * concurrent callers await the SAME in-flight mint and all receive its result, so
111
+ * there is exactly one rotation and the store always converges on the true
112
+ * `current` secret.
113
+ *
114
+ * On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
115
+ * the access token; a failed durable persist yields `persist-failed` WITHOUT
116
+ * planting. This function performs NO store mutation on failure — the caller
117
+ * applies the drop/clear policy (which differs web vs native) from the returned
118
+ * status.
119
+ */
120
+ export async function refreshDeviceSecretArm(deps: {
121
+ oxy: OxyServices;
122
+ store: AuthStateStore;
123
+ }): Promise<DeviceSecretMintOutcome> {
124
+ const { oxy, store } = deps;
125
+ return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
126
+ const persisted = await store.load();
127
+ if (!persisted?.deviceId || !persisted?.deviceSecret) {
128
+ return { status: 'no-secret' };
129
+ }
130
+
131
+ let mint: DeviceTokenMintResponse;
132
+ try {
133
+ mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
134
+ } catch (error) {
135
+ if (extractErrorStatus(error) === 401) {
136
+ // Structural read (not `instanceof Error`): the thrown value can be a
137
+ // plain ApiError-shaped object or come from another realm.
138
+ const message = (error as { message?: unknown })?.message;
139
+ return typeof message === 'string' && message.includes('no_active_session')
140
+ ? { status: 'no-session' }
141
+ : { status: 'invalid-secret' };
142
+ }
143
+ return { status: 'transient' };
144
+ }
145
+
146
+ const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
147
+ const next: PersistedAuthState = {
148
+ ...persisted,
149
+ deviceId: mint.state.deviceId,
150
+ deviceSecret: mint.nextDeviceSecret,
151
+ accessToken: mint.accessToken,
152
+ expiresAt: mint.expiresAt,
153
+ ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
154
+ };
155
+ // Rotation-in-use anti-loss: persist the NEXT secret and read-back-VERIFY it
156
+ // landed BEFORE planting the token. A failed durable persist must NOT plant.
157
+ const persistedOk = await store.save(next);
158
+ if (!persistedOk) {
159
+ return { status: 'persist-failed' };
160
+ }
161
+ oxy.setTokens(mint.accessToken);
162
+ return { status: 'ok', token: mint.accessToken, sessionId: next.sessionId, userId: next.userId };
163
+ });
164
+ }
165
+
74
166
  /**
75
167
  * Re-mint the persisted session and return the fresh access token, or `null`
76
168
  * when no arm could produce one.
77
169
  *
78
- * Arm 1 (`POST /session/device/token`): if the store holds a `deviceId` +
79
- * `deviceSecret`, mint on success plant + persist the rotated secret. A 401
80
- * means the secret is diverged (`invalid_device_secret`) or the device has no
81
- * live session (`no_active_session`): drop the secret so the mint lane stops (or
82
- * clear the store on web, where there is no fallback). A transient error leaves
83
- * the store and returns `null`.
170
+ * Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
171
+ * from the persisted `deviceId` + `deviceSecret`. On a 401 the secret is diverged
172
+ * or the device has no live session: drop the secret so the mint lane stops (or
173
+ * clear the store on web, where there is no fallback), then fall to arm 2 on
174
+ * native. A transient error or a durable-persist failure leaves the store and
175
+ * returns `null` WITHOUT falling to shared-key (those are not bad-secret signals).
84
176
  *
85
177
  * Arm 2 (native shared-keychain): when the secret is absent or was just rejected,
86
- * re-mint via `signInWithSharedIdentity` (which plants tokens). The shared
87
- * keychain not the per-origin store is the durable native credential, so this
88
- * arm does not write the store.
178
+ * re-mint via `signInWithSharedIdentity` (which plants tokens). On success the
179
+ * recovered `{deviceId, deviceSecret, …}` is PERSISTED so the fast device-secret
180
+ * lane is repopulated (mirrors the cold boot's `shared-key-signin` step) — an
181
+ * in-session shared-key recovery must not leave the fast-lane credential empty.
89
182
  */
90
183
  export async function refreshPersistedSession(deps: RefreshDeps): Promise<string | null> {
91
184
  const { oxy, store } = deps;
92
185
  const allowSharedKeyFallback = deps.allowSharedKeyFallback ?? isNative();
93
186
 
94
- const persisted = await store.load();
95
-
96
- if (persisted?.deviceId && persisted?.deviceSecret) {
97
- try {
98
- const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
99
- oxy.setTokens(mint.accessToken);
100
- const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
101
- const next: PersistedAuthState = {
102
- ...persisted,
103
- deviceId: mint.state.deviceId,
104
- deviceSecret: mint.nextDeviceSecret,
105
- accessToken: mint.accessToken,
106
- expiresAt: mint.expiresAt,
107
- ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
108
- };
109
- await store.save(next);
110
- return mint.accessToken;
111
- } catch (error) {
112
- if (extractErrorStatus(error) === 401) {
113
- // Secret diverged / no active session. On a shared-key device drop only
114
- // the secret so arm 2 below can recover; otherwise the session is over.
115
- if (allowSharedKeyFallback) {
187
+ const arm1 = await refreshDeviceSecretArm({ oxy, store });
188
+ switch (arm1.status) {
189
+ case 'ok':
190
+ return arm1.token;
191
+ case 'transient':
192
+ logger.debug(
193
+ 'Persisted deviceSecret mint failed (transient) keeping store',
194
+ { component: 'refresh', method: 'refreshPersistedSession' },
195
+ );
196
+ return null;
197
+ case 'persist-failed':
198
+ // The server rotated the secret but it did not durably persist. Do NOT fall
199
+ // to shared-key and do NOT plant — a later attempt re-mints (the process
200
+ // mirror still holds the rotated secret the server accepts) and can persist
201
+ // once storage recovers. Never advertise a session on an unsaved secret.
202
+ logger.error(
203
+ 'Device-secret mint rotated the secret but it could not be durably persisted — refusing to plant (a later attempt re-mints)',
204
+ undefined,
205
+ { component: 'refresh', method: 'refreshPersistedSession' },
206
+ );
207
+ return null;
208
+ case 'invalid-secret':
209
+ case 'no-session': {
210
+ // 401: secret diverged or no live session. On a shared-key device drop only
211
+ // the secret (keep the identity so arm 2 can recover); otherwise (web) the
212
+ // session is over — clear the store.
213
+ const persisted = await store.load();
214
+ if (allowSharedKeyFallback) {
215
+ if (persisted) {
116
216
  await store.save({ ...persisted, deviceSecret: undefined });
117
- } else {
118
- await store.clear();
119
217
  }
120
- // Fall through to the native shared-key arm.
121
218
  } else {
122
- logger.debug(
123
- 'Persisted deviceSecret mint failed (transient) — keeping store',
124
- { component: 'refresh', method: 'refreshPersistedSession' },
125
- error,
126
- );
127
- return null;
219
+ await store.clear();
128
220
  }
221
+ break;
129
222
  }
223
+ case 'no-secret':
224
+ break;
130
225
  }
131
226
 
132
227
  if (allowSharedKeyFallback) {
133
228
  try {
134
229
  const session = await oxy.signInWithSharedIdentity();
135
230
  if (session?.accessToken) {
231
+ // Repopulate the fast device-secret lane from the shared-key re-mint.
232
+ if (session.deviceId && session.deviceSecret) {
233
+ await store.save({
234
+ sessionId: session.sessionId,
235
+ userId: session.user.id,
236
+ deviceId: session.deviceId,
237
+ deviceSecret: session.deviceSecret,
238
+ accessToken: session.accessToken,
239
+ expiresAt: session.expiresAt,
240
+ });
241
+ }
136
242
  return session.accessToken;
137
243
  }
138
244
  } catch (error) {