@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
@@ -18,16 +18,38 @@
18
18
  * ESM-safe (no `require()`).
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.AUTH_STATE_STORAGE_KEY = void 0;
21
+ exports.AUTH_STATE_TOKEN_STORAGE_KEY = exports.AUTH_STATE_STORAGE_KEY = void 0;
22
22
  exports.createMemoryAuthStateStore = createMemoryAuthStateStore;
23
23
  exports.createWebAuthStateStore = createWebAuthStateStore;
24
24
  exports.createNativeAuthStateStore = createNativeAuthStateStore;
25
+ const loggerUtils_1 = require("../utils/loggerUtils");
25
26
  /**
26
- * Versioned storage key. The `.v1` suffix lets a future shape change ship a
27
- * `.v2` key without reading a stale/incompatible `.v1` blob. Distinct from the
28
- * `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
27
+ * Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
28
+ * (`sessionId`, `userId`, `deviceId`, `deviceSecret`) never the large JWT
29
+ * `accessToken`. Keeping this blob small (<2KB) matters on Android
30
+ * `expo-secure-store`, whose backing store can silently fail to persist an
31
+ * oversize value; bundling the token here previously took the mint credential
32
+ * down with it on every write, losing the session on cold restart.
33
+ *
34
+ * The `.v1` suffix lets a future shape change ship a `.v2` key without reading a
35
+ * stale/incompatible `.v1` blob. Distinct from the `oxy_shared_*` keychain keys
36
+ * in `KeyManager`, so it never collides.
37
+ *
38
+ * BACK-COMPAT: pre-split builds wrote the WHOLE state (including `accessToken` /
39
+ * `expiresAt`) into this single key. `load()` still reads those token fields
40
+ * from here when the warm key ({@link AUTH_STATE_TOKEN_STORAGE_KEY}) is absent,
41
+ * so upgrading users are not signed out; the next `save()` splits them apart.
29
42
  */
30
43
  exports.AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
44
+ /**
45
+ * Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
46
+ * `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
47
+ * failure (quota / oversize keychain value) is swallowed because the session is
48
+ * fully re-mintable from the durable `deviceSecret`. Kept separate from
49
+ * {@link AUTH_STATE_STORAGE_KEY} so a failed token write can NEVER abort or
50
+ * corrupt the durable credential write.
51
+ */
52
+ exports.AUTH_STATE_TOKEN_STORAGE_KEY = 'oxy.auth.token.v1';
31
53
  /**
32
54
  * Parse + shape-validate a stored blob. Returns `null` for anything that is
33
55
  * not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
@@ -78,6 +100,95 @@ function deserialize(raw) {
78
100
  }
79
101
  return state;
80
102
  }
103
+ /**
104
+ * Parse the best-effort warm-token blob. Returns `null` for anything not a
105
+ * well-formed object so a corrupt warm entry simply forgoes the warm-boot
106
+ * optimization (the durable credential re-mints a fresh token).
107
+ */
108
+ function parseWarmToken(raw) {
109
+ if (!raw) {
110
+ return null;
111
+ }
112
+ let parsed;
113
+ try {
114
+ parsed = JSON.parse(raw);
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ if (!parsed || typeof parsed !== 'object') {
120
+ return null;
121
+ }
122
+ const candidate = parsed;
123
+ const warm = {};
124
+ if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
125
+ warm.accessToken = candidate.accessToken;
126
+ }
127
+ if (typeof candidate.expiresAt === 'string' && candidate.expiresAt.length > 0) {
128
+ warm.expiresAt = candidate.expiresAt;
129
+ }
130
+ return warm;
131
+ }
132
+ /** Serialize ONLY the small, re-mint-critical fields for the durable key. */
133
+ function serializeDurable(state) {
134
+ const durable = {
135
+ sessionId: state.sessionId,
136
+ userId: state.userId,
137
+ };
138
+ if (state.deviceId) {
139
+ durable.deviceId = state.deviceId;
140
+ }
141
+ if (state.deviceSecret) {
142
+ durable.deviceSecret = state.deviceSecret;
143
+ }
144
+ return JSON.stringify(durable);
145
+ }
146
+ /**
147
+ * Serialize the warm-token blob, or `null` when there is no token to persist
148
+ * (so the caller clears the warm key rather than writing an empty object).
149
+ */
150
+ function serializeWarmToken(state) {
151
+ const warm = {};
152
+ if (state.accessToken) {
153
+ warm.accessToken = state.accessToken;
154
+ }
155
+ if (state.expiresAt) {
156
+ warm.expiresAt = state.expiresAt;
157
+ }
158
+ if (!warm.accessToken && !warm.expiresAt) {
159
+ return null;
160
+ }
161
+ return JSON.stringify(warm);
162
+ }
163
+ /**
164
+ * Compose the unchanged {@link PersistedAuthState} return shape from the two
165
+ * on-disk keys. `accessToken` / `expiresAt` come from the warm key when it is
166
+ * present; when the warm key is ABSENT the token fields fall back to whatever
167
+ * the durable blob carried — the pre-split combined `oxy.auth.v1` blob (BACK-COMPAT).
168
+ */
169
+ function composeState(durableRaw, warmRaw) {
170
+ const state = deserialize(durableRaw);
171
+ if (!state) {
172
+ return null;
173
+ }
174
+ if (warmRaw !== null) {
175
+ // New split layout: the warm key is authoritative for the token fields.
176
+ // Drop anything the durable blob may have carried, then overlay the warm
177
+ // values (a warm key with no token → the session simply has no warm token).
178
+ delete state.accessToken;
179
+ delete state.expiresAt;
180
+ const warm = parseWarmToken(warmRaw);
181
+ if (warm?.accessToken) {
182
+ state.accessToken = warm.accessToken;
183
+ }
184
+ if (warm?.expiresAt) {
185
+ state.expiresAt = warm.expiresAt;
186
+ }
187
+ }
188
+ // else: BACK-COMPAT — the warm key is absent, so `deserialize` already applied
189
+ // any `accessToken` / `expiresAt` from the old combined blob.
190
+ return state;
191
+ }
81
192
  /**
82
193
  * A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
83
194
  * tests/SSR and as the degraded fallback of the web store when `localStorage`
@@ -89,7 +200,9 @@ function createMemoryAuthStateStore() {
89
200
  return {
90
201
  load: async () => current,
91
202
  save: async (state) => {
203
+ // Memory IS this store's durability backing — the write always lands.
92
204
  current = state;
205
+ return true;
93
206
  },
94
207
  clear: async () => {
95
208
  current = null;
@@ -115,8 +228,9 @@ function safeGetLocalStorage() {
115
228
  }
116
229
  }
117
230
  /**
118
- * A `localStorage`-backed {@link AuthStateStore} under the versioned
119
- * {@link AUTH_STATE_STORAGE_KEY}.
231
+ * A `localStorage`-backed {@link AuthStateStore} split across the durable
232
+ * {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the best-effort
233
+ * {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token).
120
234
  *
121
235
  * Resilience:
122
236
  * - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
@@ -143,7 +257,7 @@ function createWebAuthStateStore() {
143
257
  return sessionMirror;
144
258
  }
145
259
  try {
146
- return deserialize(storage.getItem(exports.AUTH_STATE_STORAGE_KEY));
260
+ return composeState(storage.getItem(exports.AUTH_STATE_STORAGE_KEY), storage.getItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY));
147
261
  }
148
262
  catch {
149
263
  return null;
@@ -151,13 +265,39 @@ function createWebAuthStateStore() {
151
265
  },
152
266
  save: async (state) => {
153
267
  sessionMirror = state; // mirror FIRST — authoritative even if persist fails
268
+ // Durable credential FIRST, then VERIFY it landed. The in-memory mirror
269
+ // keeps the session live for this page, but a failed DURABLE write means
270
+ // the mint credential will NOT survive a reload — surface it, never swallow.
271
+ let durablePersisted = false;
272
+ try {
273
+ const durableJson = serializeDurable(state);
274
+ storage.setItem(exports.AUTH_STATE_STORAGE_KEY, durableJson);
275
+ durablePersisted = storage.getItem(exports.AUTH_STATE_STORAGE_KEY) === durableJson;
276
+ if (!durablePersisted) {
277
+ loggerUtils_1.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' });
278
+ }
279
+ }
280
+ catch (error) {
281
+ loggerUtils_1.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' });
282
+ }
283
+ // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
284
+ // durable credential re-mints a fresh token) and must never abort or
285
+ // corrupt the durable write above.
154
286
  try {
155
- storage.setItem(exports.AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
287
+ const warmJson = serializeWarmToken(state);
288
+ if (warmJson) {
289
+ storage.setItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
290
+ }
291
+ else {
292
+ storage.removeItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY);
293
+ }
156
294
  }
157
295
  catch {
158
- // Quota / private-mode / disabled storage — non-fatal. The session
159
- // stays live via the in-memory mirror; only reload durability is lost.
296
+ // Quota / private-mode / disabled storage — non-fatal warm-boot loss only.
160
297
  }
298
+ // Report ONLY the durable-credential landing; the warm-token outcome above
299
+ // is intentionally excluded (it is a best-effort optimization).
300
+ return durablePersisted;
161
301
  },
162
302
  clear: async () => {
163
303
  sessionMirror = null;
@@ -167,6 +307,12 @@ function createWebAuthStateStore() {
167
307
  catch {
168
308
  // Non-fatal — see save().
169
309
  }
310
+ try {
311
+ storage.removeItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY);
312
+ }
313
+ catch {
314
+ // Non-fatal — see save().
315
+ }
170
316
  },
171
317
  };
172
318
  }
@@ -174,9 +320,12 @@ function createWebAuthStateStore() {
174
320
  * A native {@link AuthStateStore} over an injected async key/value store.
175
321
  *
176
322
  * `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
177
- * the SecureStore-backed adapter and passes it here. Every operation is wrapped
178
- * so a storage exception degrades gracefully (read `null`, write → swallowed)
179
- * exactly like the web store.
323
+ * the SecureStore-backed adapter and passes it here. Persistence is split across
324
+ * the durable {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the
325
+ * best-effort {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token) — the
326
+ * durable write is read-back-verified and its failure surfaced (not swallowed),
327
+ * while the warm-token write and all reads degrade gracefully exactly like the
328
+ * web store.
180
329
  */
181
330
  function createNativeAuthStateStore(storage) {
182
331
  // Same in-memory mirror as the web store — a locked/failed SecureStore write
@@ -188,7 +337,11 @@ function createNativeAuthStateStore(storage) {
188
337
  return sessionMirror;
189
338
  }
190
339
  try {
191
- return deserialize(await storage.getItem(exports.AUTH_STATE_STORAGE_KEY));
340
+ const [durableRaw, warmRaw] = await Promise.all([
341
+ storage.getItem(exports.AUTH_STATE_STORAGE_KEY),
342
+ storage.getItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY),
343
+ ]);
344
+ return composeState(durableRaw, warmRaw);
192
345
  }
193
346
  catch {
194
347
  return null;
@@ -196,12 +349,41 @@ function createNativeAuthStateStore(storage) {
196
349
  },
197
350
  save: async (state) => {
198
351
  sessionMirror = state;
352
+ // Durable credential FIRST, then VERIFY. On Android SecureStore an
353
+ // oversize/failed write can resolve WITHOUT throwing, so a read-back is the
354
+ // only reliable proof. The mirror keeps the session live for this app run,
355
+ // but a failed DURABLE write means the mint credential will NOT survive a
356
+ // cold restart — surface it, never swallow.
357
+ let durablePersisted = false;
199
358
  try {
200
- await storage.setItem(exports.AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
359
+ const durableJson = serializeDurable(state);
360
+ await storage.setItem(exports.AUTH_STATE_STORAGE_KEY, durableJson);
361
+ durablePersisted = (await storage.getItem(exports.AUTH_STATE_STORAGE_KEY)) === durableJson;
362
+ if (!durablePersisted) {
363
+ loggerUtils_1.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' });
364
+ }
365
+ }
366
+ catch (error) {
367
+ loggerUtils_1.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' });
368
+ }
369
+ // Warm token AFTER, best-effort. Its failure is genuinely non-fatal (the
370
+ // durable credential re-mints a fresh token) and must never abort or
371
+ // corrupt the durable write above.
372
+ try {
373
+ const warmJson = serializeWarmToken(state);
374
+ if (warmJson) {
375
+ await storage.setItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY, warmJson);
376
+ }
377
+ else {
378
+ await storage.removeItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY);
379
+ }
201
380
  }
202
381
  catch {
203
- // Non-fatal session stays live via the in-memory mirror.
382
+ // Locked / oversize keychain non-fatal warm-boot loss only.
204
383
  }
384
+ // Report ONLY the durable-credential landing; the warm-token outcome above
385
+ // is intentionally excluded (it is a best-effort optimization).
386
+ return durablePersisted;
205
387
  },
206
388
  clear: async () => {
207
389
  sessionMirror = null;
@@ -211,6 +393,12 @@ function createNativeAuthStateStore(storage) {
211
393
  catch {
212
394
  // Non-fatal.
213
395
  }
396
+ try {
397
+ await storage.removeItem(exports.AUTH_STATE_TOKEN_STORAGE_KEY);
398
+ }
399
+ catch {
400
+ // Non-fatal.
401
+ }
214
402
  },
215
403
  };
216
404
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TOKEN_REFRESH_LEAD_MS = void 0;
4
+ exports.refreshDeviceSecretArm = refreshDeviceSecretArm;
4
5
  exports.refreshPersistedSession = refreshPersistedSession;
5
6
  exports.createAuthRefreshHandler = createAuthRefreshHandler;
6
7
  exports.installAuthRefreshHandler = installAuthRefreshHandler;
@@ -37,64 +38,136 @@ const MIN_SCHEDULE_DELAY_MS = 1000;
37
38
  */
38
39
  const MIN_FAILURE_BACKOFF_MS = 5000;
39
40
  const MAX_FAILURE_BACKOFF_MS = 5 * 60000;
41
+ /**
42
+ * Arm 1 — the rotating device-secret mint, run under the owning client's
43
+ * PROCESS-WIDE single-flight (`httpService.runSingleFlightDeviceSecretMint`).
44
+ *
45
+ * The server ROTATES the presented `deviceSecret` on every successful mint and
46
+ * the just-presented secret is valid only for a short grace window. If two lanes
47
+ * (cold boot, the proactive scheduler, a request-time preflight, a 401 retry,
48
+ * the socket token transport, or a tab-focus reconcile) minted concurrently they
49
+ * would double-rotate the server and the durable store could converge on the
50
+ * SUPERSEDED secret — after the grace window the next cold boot mint 401s and the
51
+ * user is signed out. Routing EVERY lane through this one single-flight makes
52
+ * concurrent callers await the SAME in-flight mint and all receive its result, so
53
+ * there is exactly one rotation and the store always converges on the true
54
+ * `current` secret.
55
+ *
56
+ * On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
57
+ * the access token; a failed durable persist yields `persist-failed` WITHOUT
58
+ * planting. This function performs NO store mutation on failure — the caller
59
+ * applies the drop/clear policy (which differs web vs native) from the returned
60
+ * status.
61
+ */
62
+ async function refreshDeviceSecretArm(deps) {
63
+ const { oxy, store } = deps;
64
+ return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
65
+ const persisted = await store.load();
66
+ if (!persisted?.deviceId || !persisted?.deviceSecret) {
67
+ return { status: 'no-secret' };
68
+ }
69
+ let mint;
70
+ try {
71
+ mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
72
+ }
73
+ catch (error) {
74
+ if ((0, errorUtils_1.extractErrorStatus)(error) === 401) {
75
+ // Structural read (not `instanceof Error`): the thrown value can be a
76
+ // plain ApiError-shaped object or come from another realm.
77
+ const message = error?.message;
78
+ return typeof message === 'string' && message.includes('no_active_session')
79
+ ? { status: 'no-session' }
80
+ : { status: 'invalid-secret' };
81
+ }
82
+ return { status: 'transient' };
83
+ }
84
+ const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
85
+ const next = {
86
+ ...persisted,
87
+ deviceId: mint.state.deviceId,
88
+ deviceSecret: mint.nextDeviceSecret,
89
+ accessToken: mint.accessToken,
90
+ expiresAt: mint.expiresAt,
91
+ ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
92
+ };
93
+ // Rotation-in-use anti-loss: persist the NEXT secret and read-back-VERIFY it
94
+ // landed BEFORE planting the token. A failed durable persist must NOT plant.
95
+ const persistedOk = await store.save(next);
96
+ if (!persistedOk) {
97
+ return { status: 'persist-failed' };
98
+ }
99
+ oxy.setTokens(mint.accessToken);
100
+ return { status: 'ok', token: mint.accessToken, sessionId: next.sessionId, userId: next.userId };
101
+ });
102
+ }
40
103
  /**
41
104
  * Re-mint the persisted session and return the fresh access token, or `null`
42
105
  * when no arm could produce one.
43
106
  *
44
- * Arm 1 (`POST /session/device/token`): if the store holds a `deviceId` +
45
- * `deviceSecret`, mint on success plant + persist the rotated secret. A 401
46
- * means the secret is diverged (`invalid_device_secret`) or the device has no
47
- * live session (`no_active_session`): drop the secret so the mint lane stops (or
48
- * clear the store on web, where there is no fallback). A transient error leaves
49
- * the store and returns `null`.
107
+ * Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
108
+ * from the persisted `deviceId` + `deviceSecret`. On a 401 the secret is diverged
109
+ * or the device has no live session: drop the secret so the mint lane stops (or
110
+ * clear the store on web, where there is no fallback), then fall to arm 2 on
111
+ * native. A transient error or a durable-persist failure leaves the store and
112
+ * returns `null` WITHOUT falling to shared-key (those are not bad-secret signals).
50
113
  *
51
114
  * Arm 2 (native shared-keychain): when the secret is absent or was just rejected,
52
- * re-mint via `signInWithSharedIdentity` (which plants tokens). The shared
53
- * keychain not the per-origin store is the durable native credential, so this
54
- * arm does not write the store.
115
+ * re-mint via `signInWithSharedIdentity` (which plants tokens). On success the
116
+ * recovered `{deviceId, deviceSecret, …}` is PERSISTED so the fast device-secret
117
+ * lane is repopulated (mirrors the cold boot's `shared-key-signin` step) — an
118
+ * in-session shared-key recovery must not leave the fast-lane credential empty.
55
119
  */
56
120
  async function refreshPersistedSession(deps) {
57
121
  const { oxy, store } = deps;
58
122
  const allowSharedKeyFallback = deps.allowSharedKeyFallback ?? (0, platform_1.isNative)();
59
- const persisted = await store.load();
60
- if (persisted?.deviceId && persisted?.deviceSecret) {
61
- try {
62
- const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
63
- oxy.setTokens(mint.accessToken);
64
- const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
65
- const next = {
66
- ...persisted,
67
- deviceId: mint.state.deviceId,
68
- deviceSecret: mint.nextDeviceSecret,
69
- accessToken: mint.accessToken,
70
- expiresAt: mint.expiresAt,
71
- ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
72
- };
73
- await store.save(next);
74
- return mint.accessToken;
75
- }
76
- catch (error) {
77
- if ((0, errorUtils_1.extractErrorStatus)(error) === 401) {
78
- // Secret diverged / no active session. On a shared-key device drop only
79
- // the secret so arm 2 below can recover; otherwise the session is over.
80
- if (allowSharedKeyFallback) {
123
+ const arm1 = await refreshDeviceSecretArm({ oxy, store });
124
+ switch (arm1.status) {
125
+ case 'ok':
126
+ return arm1.token;
127
+ case 'transient':
128
+ loggerUtils_1.logger.debug('Persisted deviceSecret mint failed (transient) keeping store', { component: 'refresh', method: 'refreshPersistedSession' });
129
+ return null;
130
+ case 'persist-failed':
131
+ // The server rotated the secret but it did not durably persist. Do NOT fall
132
+ // to shared-key and do NOT plant — a later attempt re-mints (the process
133
+ // mirror still holds the rotated secret the server accepts) and can persist
134
+ // once storage recovers. Never advertise a session on an unsaved secret.
135
+ loggerUtils_1.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' });
136
+ return null;
137
+ case 'invalid-secret':
138
+ case 'no-session': {
139
+ // 401: secret diverged or no live session. On a shared-key device drop only
140
+ // the secret (keep the identity so arm 2 can recover); otherwise (web) the
141
+ // session is over clear the store.
142
+ const persisted = await store.load();
143
+ if (allowSharedKeyFallback) {
144
+ if (persisted) {
81
145
  await store.save({ ...persisted, deviceSecret: undefined });
82
146
  }
83
- else {
84
- await store.clear();
85
- }
86
- // Fall through to the native shared-key arm.
87
147
  }
88
148
  else {
89
- loggerUtils_1.logger.debug('Persisted deviceSecret mint failed (transient) — keeping store', { component: 'refresh', method: 'refreshPersistedSession' }, error);
90
- return null;
149
+ await store.clear();
91
150
  }
151
+ break;
92
152
  }
153
+ case 'no-secret':
154
+ break;
93
155
  }
94
156
  if (allowSharedKeyFallback) {
95
157
  try {
96
158
  const session = await oxy.signInWithSharedIdentity();
97
159
  if (session?.accessToken) {
160
+ // Repopulate the fast device-secret lane from the shared-key re-mint.
161
+ if (session.deviceId && session.deviceSecret) {
162
+ await store.save({
163
+ sessionId: session.sessionId,
164
+ userId: session.user.id,
165
+ deviceId: session.deviceId,
166
+ deviceSecret: session.deviceSecret,
167
+ accessToken: session.accessToken,
168
+ expiresAt: session.expiresAt,
169
+ });
170
+ }
98
171
  return session.accessToken;
99
172
  }
100
173
  }