@oxyhq/core 9.2.2 → 9.2.4

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 (36) 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/session/SessionClient.js +9 -4
  7. package/dist/cjs/session/authStateStore.js +8 -0
  8. package/dist/cjs/session/refresh.js +110 -37
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/HttpService.js +27 -0
  11. package/dist/esm/boot/sessionColdBoot.js +83 -53
  12. package/dist/esm/crypto/keyManager.js +46 -13
  13. package/dist/esm/index.js +1 -1
  14. package/dist/esm/session/SessionClient.js +9 -4
  15. package/dist/esm/session/authStateStore.js +8 -0
  16. package/dist/esm/session/refresh.js +109 -37
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/HttpService.d.ts +21 -0
  19. package/dist/types/boot/sessionColdBoot.d.ts +7 -3
  20. package/dist/types/index.d.ts +3 -3
  21. package/dist/types/session/SessionClient.d.ts +26 -4
  22. package/dist/types/session/authStateStore.d.ts +15 -1
  23. package/dist/types/session/refresh.d.ts +67 -31
  24. package/package.json +2 -2
  25. package/src/HttpService.ts +31 -0
  26. package/src/boot/__tests__/sessionColdBoot.test.ts +119 -0
  27. package/src/boot/sessionColdBoot.ts +93 -74
  28. package/src/crypto/keyManager.ts +42 -15
  29. package/src/index.ts +3 -2
  30. package/src/session/SessionClient.ts +35 -7
  31. package/src/session/__tests__/SessionClient.additive.test.ts +58 -1
  32. package/src/session/__tests__/SessionClient.socket.test.ts +32 -0
  33. package/src/session/__tests__/authStateStore.test.ts +64 -6
  34. package/src/session/__tests__/refresh.test.ts +141 -2
  35. package/src/session/authStateStore.ts +23 -1
  36. package/src/session/refresh.ts +146 -40
@@ -132,6 +132,7 @@ export class HttpService {
132
132
  this.tokenRefreshCooldownUntil = 0;
133
133
  this.authRefreshHandler = null;
134
134
  this.accessTokenProvider = null;
135
+ this.deviceSecretMintInFlight = null;
135
136
  /**
136
137
  * Epoch (ms) before which a cache-size telemetry warning must not be
137
138
  * re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
@@ -834,6 +835,32 @@ export class HttpService {
834
835
  }
835
836
  return this.tokenRefreshPromise;
836
837
  }
838
+ /**
839
+ * PROCESS-WIDE single-flight for the rotating device-secret mint
840
+ * (`POST /session/device/token`).
841
+ *
842
+ * The server rotates the presented `deviceSecret` on every successful mint, so
843
+ * two concurrent mints would double-rotate and the durable store could end up
844
+ * holding a superseded secret → a later cold-boot mint 401s → the user is
845
+ * signed out. Every mint lane (the re-mint handler behind `refreshAccessToken`,
846
+ * the device-first cold boot, the socket token transport, the tab-focus
847
+ * reconcile) funnels its `refreshDeviceSecretArm` call through here, so
848
+ * concurrent callers await the SAME in-flight mint and all receive its result —
849
+ * exactly one server rotation.
850
+ *
851
+ * Distinct from {@link tokenRefreshPromise} (which dedups the FULL re-mint
852
+ * handler incl. the native shared-key arm + the failure cooldown): this inner
853
+ * guard serializes the rotation itself across BOTH the handler and the
854
+ * handler-independent cold boot, which never runs through `refreshAccessToken`.
855
+ */
856
+ runSingleFlightDeviceSecretMint(mint) {
857
+ if (!this.deviceSecretMintInFlight) {
858
+ this.deviceSecretMintInFlight = mint().finally(() => {
859
+ this.deviceSecretMintInFlight = null;
860
+ });
861
+ }
862
+ return this.deviceSecretMintInFlight;
863
+ }
837
864
  /**
838
865
  * Unwrap standardized API response format
839
866
  */
@@ -7,31 +7,23 @@
7
7
  * the app renders with a "Sign in with Oxy" button.
8
8
  *
9
9
  * Ordered steps (first to yield a session wins):
10
- * 1. `device-secret-mint` (web + native) — the zero-cookie transport: when the
10
+ * 1. `warm-token-plant` (web + native) — the fastest path: when the persisted
11
+ * store still holds a warm access token that is valid for more than the
12
+ * refresh lead window, plant it and yield the session with NO network
13
+ * round-trip. The background scheduler rotates it shortly after.
14
+ * 2. `device-secret-mint` (web + native) — the zero-cookie transport: when the
11
15
  * origin persisted a `deviceId` + `deviceSecret`, mint a short access token
12
16
  * with a single bearer-less POST to `/session/device/token` (no cookie, no
13
17
  * navigation) and rotate the secret in-use.
14
- * 2. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
15
- * 3. Signed out.
18
+ * 3. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
19
+ * 4. Signed out.
16
20
  *
17
21
  * ESM-safe (no `require()`); no react/react-native/expo imports.
18
22
  */
19
23
  import { runColdBoot } from '../utils/coldBoot.js';
20
24
  import { isNative as detectNative } from '../utils/platform.js';
21
- import { extractErrorStatus } from '../utils/errorUtils.js';
22
25
  import { logger } from '../utils/loggerUtils.js';
23
- function classifyMintFailure(error) {
24
- if (extractErrorStatus(error) === 401) {
25
- // Structural read (not `instanceof Error`): the thrown value can be a plain
26
- // ApiError-shaped object or come from another realm, where instanceof fails
27
- // and a `no_active_session` would be misread as a stale secret and dropped.
28
- const message = error?.message;
29
- return typeof message === 'string' && message.includes('no_active_session')
30
- ? 'no_active_session'
31
- : 'invalid_secret';
32
- }
33
- return 'transient';
34
- }
26
+ import { TOKEN_REFRESH_LEAD_MS, refreshDeviceSecretArm } from '../session/refresh.js';
35
27
  /**
36
28
  * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
37
29
  * a side effect, invokes `onSession` (winning session, token already planted) or
@@ -44,60 +36,98 @@ export async function runSessionColdBoot(opts) {
44
36
  // bundler re-evaluation.
45
37
  let signedOutReason = 'no_session';
46
38
  const steps = [];
47
- // 1. device-secret-mint (web + native) — the zero-cookie fast path. When the
48
- // origin persisted a deviceId + deviceSecret, mint a short access token with
49
- // a single bearer-less POST (no cookie, no navigation).
39
+ // 1. warm-token-plant (web + native) — the fastest path. When the persisted
40
+ // store already holds a still-valid warm access token (its expiry more than
41
+ // the refresh lead window away) plus its owning session identity, plant it
42
+ // and yield the session IMMEDIATELY, skipping the blocking mint round-trip on
43
+ // first paint. The token is used AS-IS: this step NEVER mints, rotates, or
44
+ // persists anything. The proactive `startTokenRefreshScheduler` + the
45
+ // request-time preflight (both wired in the services provider) rotate it in
46
+ // the background; a revoked token self-heals via the 401 -> re-mint -> clear
47
+ // path. This exposure is sanctioned by `authStateStore.ts` (~L30-36): the
48
+ // warm token is short-lived and adds nothing over the already-persisted
49
+ // `deviceSecret`.
50
50
  steps.push({
51
- id: 'device-secret-mint',
51
+ id: 'warm-token-plant',
52
52
  run: async () => {
53
53
  const persisted = await store.load();
54
- if (!persisted?.deviceId || !persisted?.deviceSecret) {
54
+ if (!persisted?.accessToken || !persisted.sessionId || !persisted.userId || !persisted.expiresAt) {
55
55
  return { kind: 'skip' };
56
56
  }
57
- try {
58
- const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
59
- // Rotation-in-use anti-loss: persist the NEXT secret (+ refreshed warm
60
- // fields, + the server's authoritative active account) BEFORE planting
61
- // the minted access token, so a multi-tab race that rotates again can
62
- // never strand this tab with a superseded secret.
63
- const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
64
- const next = {
65
- ...persisted,
66
- deviceId: mint.state.deviceId,
67
- deviceSecret: mint.nextDeviceSecret,
68
- accessToken: mint.accessToken,
69
- expiresAt: mint.expiresAt,
70
- ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
71
- };
72
- await store.save(next);
73
- oxy.setTokens(mint.accessToken);
74
- return {
75
- kind: 'session',
76
- session: { sessionId: next.sessionId, userId: next.userId, accessToken: mint.accessToken },
77
- };
57
+ // Guard a malformed `expiresAt` (Date.parse -> NaN): treat as not-valid and
58
+ // fall through to the mint lane. A token still inside the refresh lead
59
+ // window (or already expired) is likewise skipped — let the mint lane get a
60
+ // fresh one rather than plant a token about to expire.
61
+ const expiresAtMs = new Date(persisted.expiresAt).getTime();
62
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() + TOKEN_REFRESH_LEAD_MS) {
63
+ return { kind: 'skip' };
78
64
  }
79
- catch (error) {
80
- const failure = classifyMintFailure(error);
81
- if (failure === 'invalid_secret') {
65
+ oxy.setTokens(persisted.accessToken);
66
+ return {
67
+ kind: 'session',
68
+ session: {
69
+ sessionId: persisted.sessionId,
70
+ userId: persisted.userId,
71
+ accessToken: persisted.accessToken,
72
+ },
73
+ };
74
+ },
75
+ });
76
+ // 2. device-secret-mint (web + native) — the zero-cookie fast path. When the
77
+ // origin persisted a deviceId + deviceSecret, mint a short access token with
78
+ // a single bearer-less POST (no cookie, no navigation). The mint itself runs
79
+ // through `refreshDeviceSecretArm`, which acquires the client's PROCESS-WIDE
80
+ // single-flight, persists the rotated `nextDeviceSecret` BEFORE planting the
81
+ // token, and returns a classified outcome — so this step can never
82
+ // double-rotate the server against the scheduler/transport/401 lanes, and
83
+ // the durable store always converges on the true `current` secret.
84
+ steps.push({
85
+ id: 'device-secret-mint',
86
+ run: async () => {
87
+ const result = await refreshDeviceSecretArm({ oxy, store });
88
+ switch (result.status) {
89
+ case 'ok':
90
+ // The arm persisted the rotated secret and planted the token.
91
+ return {
92
+ kind: 'session',
93
+ session: {
94
+ sessionId: result.sessionId,
95
+ userId: result.userId,
96
+ accessToken: result.token,
97
+ },
98
+ };
99
+ case 'invalid-secret': {
82
100
  // Stale/diverged secret — drop it so the mint lane stops firing. On
83
101
  // native the shared-key step below can still recover; on web this ends
84
102
  // signed out. Setting it undefined drops the key on the store's JSON
85
103
  // serialization, and the mint guard treats undefined as absent.
86
- await store.save({ ...persisted, deviceSecret: undefined });
104
+ const persisted = await store.load();
105
+ if (persisted) {
106
+ await store.save({ ...persisted, deviceSecret: undefined });
107
+ }
87
108
  return { kind: 'skip' };
88
109
  }
89
- if (failure === 'no_active_session') {
90
- // Device known, no live session — authoritative signed-out.
110
+ case 'no-session':
111
+ // Device known, no live session — authoritative signed-out. Keep the
112
+ // secret (the device may sign in again).
91
113
  signedOutReason = 'no_session';
92
114
  return { kind: 'skip' };
93
- }
94
- // Transient (network / 5xx): keep the secret; a later attempt can succeed.
95
- logger.debug('device-secret mint failed (transient) keeping secret', { component: 'sessionColdBoot', method: 'device-secret-mint' }, error);
96
- return { kind: 'skip' };
115
+ case 'persist-failed':
116
+ // The mint rotated the secret but it could not be durably persisted —
117
+ // refuse to advertise a session that will not survive a reload. Keep
118
+ // the secret; a later boot/attempt re-mints once storage recovers.
119
+ logger.error('device-secret mint rotated the secret but it could not be durably persisted — not planting', undefined, { component: 'sessionColdBoot', method: 'device-secret-mint' });
120
+ return { kind: 'skip' };
121
+ case 'transient':
122
+ // Network / 5xx: keep the secret; a later attempt can succeed.
123
+ logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'sessionColdBoot', method: 'device-secret-mint' });
124
+ return { kind: 'skip' };
125
+ case 'no-secret':
126
+ return { kind: 'skip' };
97
127
  }
98
128
  },
99
129
  });
100
- // 2. shared-key-signin (native) — re-mint from the shared identity.
130
+ // 3. shared-key-signin (native) — re-mint from the shared identity.
101
131
  steps.push({
102
132
  id: 'shared-key-signin',
103
133
  enabled: () => isNative,
@@ -7,7 +7,7 @@
7
7
  import _cjs_elliptic from 'elliptic';
8
8
  const { ec: EC } = _cjs_elliptic;
9
9
  import { isWeb, isIOS, isAndroid } from '../utils/platform.js';
10
- import { isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore } from '@oxyhq/protocol';
10
+ import { isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxyhq/protocol';
11
11
  import { logger } from '../utils/loggerUtils.js';
12
12
  import { isDev } from '../shared/utils/debugUtils.js';
13
13
  /**
@@ -230,12 +230,21 @@ export class KeyManager {
230
230
  }
231
231
  }
232
232
  else if (isAndroid()) {
233
- // Android: Store in secure store (accessible via sharedUserId)
234
- // Note: All Oxy apps must have the same sharedUserId in AndroidManifest.xml
235
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
236
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
237
- });
238
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
233
+ // Android: write through the cross-app bridge (`@oxyhq/expo-oxy-identity`)
234
+ // when present it persists into Commons's hardware-backed
235
+ // EncryptedSharedPreferences behind a signature-protected ContentProvider,
236
+ // so same-key Oxy apps can read it. When the bridge is not linked, fall
237
+ // back to the package-private secure store (no cross-app sharing).
238
+ const bridge = await loadSharedIdentityBridge();
239
+ if (bridge) {
240
+ await bridge.putShared(privateKey, publicKey);
241
+ }
242
+ else {
243
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
244
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
245
+ });
246
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
247
+ }
239
248
  }
240
249
  // Update cache
241
250
  KeyManager.cachedSharedPublicKey = publicKey;
@@ -266,7 +275,15 @@ export class KeyManager {
266
275
  publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
267
276
  }
268
277
  else if (isAndroid()) {
269
- publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
278
+ // Android reads through the cross-app bridge; when it is not linked, fall
279
+ // back to the package-private store the fallback write path used.
280
+ const bridge = await loadSharedIdentityBridge();
281
+ if (bridge) {
282
+ publicKey = (await bridge.getShared())?.publicKey ?? null;
283
+ }
284
+ else {
285
+ publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
286
+ }
270
287
  }
271
288
  // Cache result
272
289
  KeyManager.cachedSharedPublicKey = publicKey;
@@ -300,7 +317,15 @@ export class KeyManager {
300
317
  privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
301
318
  }
302
319
  else if (isAndroid()) {
303
- privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
320
+ // Android reads through the cross-app bridge; when it is not linked, fall
321
+ // back to the package-private store the fallback write path used.
322
+ const bridge = await loadSharedIdentityBridge();
323
+ if (bridge) {
324
+ privateKey = (await bridge.getShared())?.privateKey ?? null;
325
+ }
326
+ else {
327
+ privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
328
+ }
304
329
  }
305
330
  return privateKey;
306
331
  }
@@ -373,10 +398,18 @@ export class KeyManager {
373
398
  await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
374
399
  }
375
400
  else if (isAndroid()) {
376
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, {
377
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
378
- });
379
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
401
+ // Android: write through the cross-app bridge when present; otherwise the
402
+ // package-private store (kept consistent with the read fallback).
403
+ const bridge = await loadSharedIdentityBridge();
404
+ if (bridge) {
405
+ await bridge.putShared(canonicalPrivate, publicKey);
406
+ }
407
+ else {
408
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, {
409
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
410
+ });
411
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
412
+ }
380
413
  }
381
414
  // Update cache
382
415
  KeyManager.cachedSharedPublicKey = publicKey;
package/dist/esm/index.js CHANGED
@@ -166,7 +166,7 @@ export { AccountDialogController, createAccountDialogController, } from './sessi
166
166
  // via `POST /session/device/token`.
167
167
  // ---------------------------------------------------------------------------
168
168
  export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore.js';
169
- export { refreshPersistedSession, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh.js';
169
+ export { refreshPersistedSession, refreshDeviceSecretArm, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh.js';
170
170
  export { runSessionColdBoot } from './boot/sessionColdBoot.js';
171
171
  // API response contracts (request/response Zod schemas + inferred types) live in
172
172
  // `@oxyhq/contracts` — the single source of truth shared by the backend and every
@@ -81,7 +81,7 @@ export class SessionClient {
81
81
  }
82
82
  }
83
83
  /** Validate + last-writer-wins by revision. Returns true if applied. */
84
- applyState(raw) {
84
+ applyState(raw, origin = 'push') {
85
85
  const next = safeParseContract(deviceSessionStateSchema, raw);
86
86
  if (!next) {
87
87
  logger.warn('[SessionClient] discarded invalid session state');
@@ -105,7 +105,7 @@ export class SessionClient {
105
105
  this.notify();
106
106
  if (next.accounts.length === 0 && this.options.onUnauthenticated) {
107
107
  try {
108
- this.options.onUnauthenticated();
108
+ this.options.onUnauthenticated(origin);
109
109
  }
110
110
  catch (error) {
111
111
  logger.error('[SessionClient] onUnauthenticated threw', error);
@@ -151,7 +151,10 @@ export class SessionClient {
151
151
  logger.warn('[SessionClient] discarded invalid session sync', { component: 'SessionClient', issues, keys });
152
152
  return;
153
153
  }
154
- this.applyState(sync.state);
154
+ // A `sync` is always the response to a direct REST call this client made
155
+ // (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
156
+ // verdict.
157
+ this.applyState(sync.state, 'request');
155
158
  if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
156
159
  this.host.setTokens(sync.activeToken.accessToken);
157
160
  }
@@ -289,7 +292,9 @@ export class SessionClient {
289
292
  },
290
293
  });
291
294
  socket.on('session_state', (payload) => {
292
- const applied = this.applyState(payload);
295
+ // A socket broadcast is a `push`-origin — potentially transient, so an
296
+ // empty state here must not erase the durable device credential.
297
+ const applied = this.applyState(payload, 'push');
293
298
  if (!applied)
294
299
  return;
295
300
  // A push changed the active account on another device/tab — re-fetch state
@@ -194,7 +194,9 @@ export function createMemoryAuthStateStore() {
194
194
  return {
195
195
  load: async () => current,
196
196
  save: async (state) => {
197
+ // Memory IS this store's durability backing — the write always lands.
197
198
  current = state;
199
+ return true;
198
200
  },
199
201
  clear: async () => {
200
202
  current = null;
@@ -287,6 +289,9 @@ export function createWebAuthStateStore() {
287
289
  catch {
288
290
  // Quota / private-mode / disabled storage — non-fatal warm-boot loss only.
289
291
  }
292
+ // Report ONLY the durable-credential landing; the warm-token outcome above
293
+ // is intentionally excluded (it is a best-effort optimization).
294
+ return durablePersisted;
290
295
  },
291
296
  clear: async () => {
292
297
  sessionMirror = null;
@@ -370,6 +375,9 @@ export function createNativeAuthStateStore(storage) {
370
375
  catch {
371
376
  // Locked / oversize keychain — non-fatal warm-boot loss only.
372
377
  }
378
+ // Report ONLY the durable-credential landing; the warm-token outcome above
379
+ // is intentionally excluded (it is a best-effort optimization).
380
+ return durablePersisted;
373
381
  },
374
382
  clear: async () => {
375
383
  sessionMirror = null;
@@ -30,64 +30,136 @@ const MIN_SCHEDULE_DELAY_MS = 1000;
30
30
  */
31
31
  const MIN_FAILURE_BACKOFF_MS = 5000;
32
32
  const MAX_FAILURE_BACKOFF_MS = 5 * 60000;
33
+ /**
34
+ * Arm 1 — the rotating device-secret mint, run under the owning client's
35
+ * PROCESS-WIDE single-flight (`httpService.runSingleFlightDeviceSecretMint`).
36
+ *
37
+ * The server ROTATES the presented `deviceSecret` on every successful mint and
38
+ * the just-presented secret is valid only for a short grace window. If two lanes
39
+ * (cold boot, the proactive scheduler, a request-time preflight, a 401 retry,
40
+ * the socket token transport, or a tab-focus reconcile) minted concurrently they
41
+ * would double-rotate the server and the durable store could converge on the
42
+ * SUPERSEDED secret — after the grace window the next cold boot mint 401s and the
43
+ * user is signed out. Routing EVERY lane through this one single-flight makes
44
+ * concurrent callers await the SAME in-flight mint and all receive its result, so
45
+ * there is exactly one rotation and the store always converges on the true
46
+ * `current` secret.
47
+ *
48
+ * On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
49
+ * the access token; a failed durable persist yields `persist-failed` WITHOUT
50
+ * planting. This function performs NO store mutation on failure — the caller
51
+ * applies the drop/clear policy (which differs web vs native) from the returned
52
+ * status.
53
+ */
54
+ export async function refreshDeviceSecretArm(deps) {
55
+ const { oxy, store } = deps;
56
+ return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
57
+ const persisted = await store.load();
58
+ if (!persisted?.deviceId || !persisted?.deviceSecret) {
59
+ return { status: 'no-secret' };
60
+ }
61
+ let mint;
62
+ try {
63
+ mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
64
+ }
65
+ catch (error) {
66
+ if (extractErrorStatus(error) === 401) {
67
+ // Structural read (not `instanceof Error`): the thrown value can be a
68
+ // plain ApiError-shaped object or come from another realm.
69
+ const message = error?.message;
70
+ return typeof message === 'string' && message.includes('no_active_session')
71
+ ? { status: 'no-session' }
72
+ : { status: 'invalid-secret' };
73
+ }
74
+ return { status: 'transient' };
75
+ }
76
+ const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
77
+ const next = {
78
+ ...persisted,
79
+ deviceId: mint.state.deviceId,
80
+ deviceSecret: mint.nextDeviceSecret,
81
+ accessToken: mint.accessToken,
82
+ expiresAt: mint.expiresAt,
83
+ ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
84
+ };
85
+ // Rotation-in-use anti-loss: persist the NEXT secret and read-back-VERIFY it
86
+ // landed BEFORE planting the token. A failed durable persist must NOT plant.
87
+ const persistedOk = await store.save(next);
88
+ if (!persistedOk) {
89
+ return { status: 'persist-failed' };
90
+ }
91
+ oxy.setTokens(mint.accessToken);
92
+ return { status: 'ok', token: mint.accessToken, sessionId: next.sessionId, userId: next.userId };
93
+ });
94
+ }
33
95
  /**
34
96
  * Re-mint the persisted session and return the fresh access token, or `null`
35
97
  * when no arm could produce one.
36
98
  *
37
- * Arm 1 (`POST /session/device/token`): if the store holds a `deviceId` +
38
- * `deviceSecret`, mint on success plant + persist the rotated secret. A 401
39
- * means the secret is diverged (`invalid_device_secret`) or the device has no
40
- * live session (`no_active_session`): drop the secret so the mint lane stops (or
41
- * clear the store on web, where there is no fallback). A transient error leaves
42
- * the store and returns `null`.
99
+ * Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
100
+ * from the persisted `deviceId` + `deviceSecret`. On a 401 the secret is diverged
101
+ * or the device has no live session: drop the secret so the mint lane stops (or
102
+ * clear the store on web, where there is no fallback), then fall to arm 2 on
103
+ * native. A transient error or a durable-persist failure leaves the store and
104
+ * returns `null` WITHOUT falling to shared-key (those are not bad-secret signals).
43
105
  *
44
106
  * Arm 2 (native shared-keychain): when the secret is absent or was just rejected,
45
- * re-mint via `signInWithSharedIdentity` (which plants tokens). The shared
46
- * keychain not the per-origin store is the durable native credential, so this
47
- * arm does not write the store.
107
+ * re-mint via `signInWithSharedIdentity` (which plants tokens). On success the
108
+ * recovered `{deviceId, deviceSecret, …}` is PERSISTED so the fast device-secret
109
+ * lane is repopulated (mirrors the cold boot's `shared-key-signin` step) — an
110
+ * in-session shared-key recovery must not leave the fast-lane credential empty.
48
111
  */
49
112
  export async function refreshPersistedSession(deps) {
50
113
  const { oxy, store } = deps;
51
114
  const allowSharedKeyFallback = deps.allowSharedKeyFallback ?? isNative();
52
- const persisted = await store.load();
53
- if (persisted?.deviceId && persisted?.deviceSecret) {
54
- try {
55
- const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
56
- oxy.setTokens(mint.accessToken);
57
- const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
58
- const next = {
59
- ...persisted,
60
- deviceId: mint.state.deviceId,
61
- deviceSecret: mint.nextDeviceSecret,
62
- accessToken: mint.accessToken,
63
- expiresAt: mint.expiresAt,
64
- ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
65
- };
66
- await store.save(next);
67
- return mint.accessToken;
68
- }
69
- catch (error) {
70
- if (extractErrorStatus(error) === 401) {
71
- // Secret diverged / no active session. On a shared-key device drop only
72
- // the secret so arm 2 below can recover; otherwise the session is over.
73
- if (allowSharedKeyFallback) {
115
+ const arm1 = await refreshDeviceSecretArm({ oxy, store });
116
+ switch (arm1.status) {
117
+ case 'ok':
118
+ return arm1.token;
119
+ case 'transient':
120
+ logger.debug('Persisted deviceSecret mint failed (transient) keeping store', { component: 'refresh', method: 'refreshPersistedSession' });
121
+ return null;
122
+ case 'persist-failed':
123
+ // The server rotated the secret but it did not durably persist. Do NOT fall
124
+ // to shared-key and do NOT plant — a later attempt re-mints (the process
125
+ // mirror still holds the rotated secret the server accepts) and can persist
126
+ // once storage recovers. Never advertise a session on an unsaved secret.
127
+ logger.error('Device-secret mint rotated the secret but it could not be durably persisted — refusing to plant (a later attempt re-mints)', undefined, { component: 'refresh', method: 'refreshPersistedSession' });
128
+ return null;
129
+ case 'invalid-secret':
130
+ case 'no-session': {
131
+ // 401: secret diverged or no live session. On a shared-key device drop only
132
+ // the secret (keep the identity so arm 2 can recover); otherwise (web) the
133
+ // session is over — clear the store.
134
+ const persisted = await store.load();
135
+ if (allowSharedKeyFallback) {
136
+ if (persisted) {
74
137
  await store.save({ ...persisted, deviceSecret: undefined });
75
138
  }
76
- else {
77
- await store.clear();
78
- }
79
- // Fall through to the native shared-key arm.
80
139
  }
81
140
  else {
82
- logger.debug('Persisted deviceSecret mint failed (transient) — keeping store', { component: 'refresh', method: 'refreshPersistedSession' }, error);
83
- return null;
141
+ await store.clear();
84
142
  }
143
+ break;
85
144
  }
145
+ case 'no-secret':
146
+ break;
86
147
  }
87
148
  if (allowSharedKeyFallback) {
88
149
  try {
89
150
  const session = await oxy.signInWithSharedIdentity();
90
151
  if (session?.accessToken) {
152
+ // Repopulate the fast device-secret lane from the shared-key re-mint.
153
+ if (session.deviceId && session.deviceSecret) {
154
+ await store.save({
155
+ sessionId: session.sessionId,
156
+ userId: session.user.id,
157
+ deviceId: session.deviceId,
158
+ deviceSecret: session.deviceSecret,
159
+ accessToken: session.accessToken,
160
+ expiresAt: session.expiresAt,
161
+ });
162
+ }
91
163
  return session.accessToken;
92
164
  }
93
165
  }