@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
@@ -13,6 +13,7 @@
13
13
  * - Request queuing
14
14
  */
15
15
  import type { OxyConfig } from './models/interfaces';
16
+ import type { DeviceSecretMintOutcome } from './session/refresh';
16
17
  export type AuthRefreshReason = 'preflight' | 'response-401';
17
18
  export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
18
19
  export type AccessTokenProvider = () => string | null;
@@ -77,6 +78,7 @@ export declare class HttpService {
77
78
  private tokenRefreshCooldownUntil;
78
79
  private authRefreshHandler;
79
80
  private accessTokenProvider;
81
+ private deviceSecretMintInFlight;
80
82
  /**
81
83
  * Epoch (ms) before which a cache-size telemetry warning must not be
82
84
  * re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
@@ -208,6 +210,25 @@ export declare class HttpService {
208
210
  */
209
211
  private getAuthHeader;
210
212
  refreshAccessToken(reason: AuthRefreshReason): Promise<string | null>;
213
+ /**
214
+ * PROCESS-WIDE single-flight for the rotating device-secret mint
215
+ * (`POST /session/device/token`).
216
+ *
217
+ * The server rotates the presented `deviceSecret` on every successful mint, so
218
+ * two concurrent mints would double-rotate and the durable store could end up
219
+ * holding a superseded secret → a later cold-boot mint 401s → the user is
220
+ * signed out. Every mint lane (the re-mint handler behind `refreshAccessToken`,
221
+ * the device-first cold boot, the socket token transport, the tab-focus
222
+ * reconcile) funnels its `refreshDeviceSecretArm` call through here, so
223
+ * concurrent callers await the SAME in-flight mint and all receive its result —
224
+ * exactly one server rotation.
225
+ *
226
+ * Distinct from {@link tokenRefreshPromise} (which dedups the FULL re-mint
227
+ * handler incl. the native shared-key arm + the failure cooldown): this inner
228
+ * guard serializes the rotation itself across BOTH the handler and the
229
+ * handler-independent cold boot, which never runs through `refreshAccessToken`.
230
+ */
231
+ runSingleFlightDeviceSecretMint(mint: () => Promise<DeviceSecretMintOutcome>): Promise<DeviceSecretMintOutcome>;
211
232
  /**
212
233
  * Unwrap standardized API response format
213
234
  */
@@ -7,12 +7,16 @@
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
  */
@@ -90,7 +90,7 @@ export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isOfficialWebOrigin
90
90
  export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync';
91
91
  export type { SyncHubAfterSignInOptions } from './session/hubSync';
92
92
  export { SessionClient } from './session/SessionClient';
93
- export type { TokenTransport, SessionClientHost, SessionClientOptions, DeviceCredential } from './session/SessionClient';
93
+ export type { TokenTransport, SessionClientHost, SessionClientOptions, DeviceCredential, SessionStateOrigin } from './session/SessionClient';
94
94
  export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
95
95
  export { createSessionClientHost } from './session/sessionClientHost';
96
96
  export { createSessionClient } from './session/createSessionClient';
@@ -101,8 +101,8 @@ export { AccountDialogController, createAccountDialogController, } from './sessi
101
101
  export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, SignInFlowPhase, SignInFlowState, } from './session/accountDialogController';
102
102
  export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore';
103
103
  export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './session/authStateStore';
104
- export { refreshPersistedSession, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh';
105
- export type { RefreshDeps, TokenRefreshSchedulerHandle } from './session/refresh';
104
+ export { refreshPersistedSession, refreshDeviceSecretArm, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh';
105
+ export type { RefreshDeps, TokenRefreshSchedulerHandle, DeviceSecretMintOutcome } from './session/refresh';
106
106
  export { runSessionColdBoot } from './boot/sessionColdBoot';
107
107
  export type { RunSessionColdBootOptions, SignedOutReason, DeviceBootSession, } from './boot/sessionColdBoot';
108
108
  export { packageInfo } from './constants/version';
@@ -4,6 +4,21 @@ export interface TokenTransport {
4
4
  /** Ensure this app holds a per-domain access token for state.activeAccountId (mint via the persisted refresh family / shared keychain). Best-effort. */
5
5
  ensureActiveToken(state: DeviceSessionState): Promise<void>;
6
6
  }
7
+ /**
8
+ * Where an applied device state came from, so consumers can decide how
9
+ * AUTHORITATIVE a zero-account ("signed out") verdict is:
10
+ * - `request` — the response to a direct REST call this client made
11
+ * (`bootstrap` / `switch` / `signOut` / `add`). A stable, server-authoritative
12
+ * verdict: an empty state here reflects a real sign-out or revocation, so the
13
+ * durable device credential MAY be erased.
14
+ * - `push` — an out-of-band Socket.IO `session_state` broadcast. Potentially
15
+ * transient (a reconnect race / another device's mutation), so an empty state
16
+ * here must NOT erase THIS origin's durable device credential — only clear the
17
+ * local UI session. A dead credential re-mints to `no_active_session` and
18
+ * resolves signed-out cleanly on the next boot; a wrongly-erased one cannot be
19
+ * recovered without a fresh sign-in.
20
+ */
21
+ export type SessionStateOrigin = 'request' | 'push';
7
22
  export interface DeviceCredential {
8
23
  deviceId: string;
9
24
  deviceSecret: string;
@@ -25,13 +40,20 @@ export interface SessionClientOptions {
25
40
  /**
26
41
  * Invoked when an APPLIED state has zero accounts — i.e. a device
27
42
  * signout-all removed the last account from this device set. Providers use
28
- * this to clear the persisted {@link AuthStateStore} so a reload does not
29
- * try to restore a session that no longer exists on the device.
43
+ * this to clear local session state and, for a `request`-origin verdict, the
44
+ * persisted {@link AuthStateStore} so a reload does not try to restore a
45
+ * session that no longer exists on the device.
46
+ *
47
+ * The {@link SessionStateOrigin} is passed so the consumer can gate the
48
+ * DESTRUCTIVE credential wipe: a `push`-origin empty state (a socket broadcast,
49
+ * possibly a transient reconnect artifact) must NOT erase the durable device
50
+ * credential — only a `request`-origin verdict (a direct REST sign-out /
51
+ * revocation) is authoritative enough for that.
30
52
  *
31
53
  * Only fires when a state is actually applied (revision advanced), never on
32
54
  * a stale/rejected push. Exceptions thrown by the callback are isolated.
33
55
  */
34
- onUnauthenticated?: () => void;
56
+ onUnauthenticated?: (origin: SessionStateOrigin) => void;
35
57
  /**
36
58
  * Statically-injected `socket.io-client` factory (its `io` export).
37
59
  * `@oxyhq/services` lists `socket.io-client` as a real dependency and
@@ -72,7 +94,7 @@ export declare class SessionClient {
72
94
  private bindServerEvent;
73
95
  protected notify(): void;
74
96
  /** Validate + last-writer-wins by revision. Returns true if applied. */
75
- protected applyState(raw: unknown): boolean;
97
+ protected applyState(raw: unknown, origin?: SessionStateOrigin): boolean;
76
98
  /**
77
99
  * Validate `{ state, activeToken }`, apply the state, and plant the active token host-side.
78
100
  * Token-planting is decoupled from whether `applyState` advanced the revision: a socket push
@@ -64,7 +64,21 @@ export interface PersistedAuthState {
64
64
  */
65
65
  export interface AuthStateStore {
66
66
  load(): Promise<PersistedAuthState | null>;
67
- save(state: PersistedAuthState): Promise<void>;
67
+ /**
68
+ * Persist the credential blob and report whether it durably landed.
69
+ *
70
+ * Resolves `true` when the state is retained consistent with this store's
71
+ * durability guarantee — a durable backing whose read-back matched, or a
72
+ * degraded/in-memory store that held it in memory. Resolves `false` when a
73
+ * DURABLE backing was expected but the write did NOT land (read-back mismatch
74
+ * or a thrown write); the in-memory mirror still keeps the session live for
75
+ * this process, but it will be lost on reload.
76
+ *
77
+ * A lane persisting a ROTATED device secret (the mint's `nextDeviceSecret`)
78
+ * MUST treat `false` as fatal for that mint: it must NOT plant/advertise a
79
+ * session built on a secret that will not survive a reload.
80
+ */
81
+ save(state: PersistedAuthState): Promise<boolean>;
68
82
  clear(): Promise<void>;
69
83
  }
70
84
  /**
@@ -1,25 +1,3 @@
1
- /**
2
- * Unified token refresh — THE single access-token re-mint for web + native.
3
- *
4
- * The access token is short-lived; there is no refresh token. To keep a session
5
- * alive past the access token's TTL the client re-mints via the zero-cookie
6
- * device transport:
7
- *
8
- * - `refreshPersistedSession` — arm 1 mints a fresh access token from the
9
- * persisted `deviceId` + `deviceSecret` (`POST /session/device/token`),
10
- * planting + persisting the rotated secret; arm 2 (native only) re-mints via
11
- * the shared-keychain identity when there is no usable secret. It is used BOTH
12
- * reactively (wrapped as the `AuthRefreshHandler` installed on `HttpService`)
13
- * AND proactively (the scheduler below calls it).
14
- * - `createAuthRefreshHandler` / `installAuthRefreshHandler` wire arm 1+2 into
15
- * `HttpService.setAuthRefreshHandler`, keeping that layer's single-flight
16
- * dedup + cooldown (this module does NOT reimplement them).
17
- * - `startTokenRefreshScheduler` — a proactive scheduler decoupled from any
18
- * React type: re-mints ~60s before `exp`, re-arms on token change + web
19
- * tab-focus, `.unref?.()`s its timer in Node.
20
- *
21
- * Framework-free; no module-level mutable state.
22
- */
23
1
  import type { OxyServices } from '../OxyServices';
24
2
  import type { AuthRefreshHandler } from '../HttpService';
25
3
  import type { AuthStateStore } from './authStateStore';
@@ -40,21 +18,79 @@ export interface RefreshDeps {
40
18
  */
41
19
  allowSharedKeyFallback?: boolean;
42
20
  }
21
+ /**
22
+ * The outcome of ONE device-secret mint attempt (arm 1). Discriminated so both
23
+ * the re-mint handler and the cold boot can react per the transport contract
24
+ * without re-classifying the raw error:
25
+ * - `ok` — minted, persisted the rotated secret, planted the token.
26
+ * - `no-secret` — the store holds no `deviceId` + `deviceSecret` to mint from.
27
+ * - `invalid-secret` — 401 `invalid_device_secret`: the presented secret
28
+ * diverged (another tab/device rotated it past the grace window).
29
+ * - `no-session` — 401 `no_active_session`: the device is known but has no live
30
+ * session (authoritative signed-out).
31
+ * - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
32
+ * - `persist-failed` — the mint succeeded (the SERVER rotated the secret) but
33
+ * the rotated `nextDeviceSecret` could NOT be durably persisted. The token is
34
+ * deliberately NOT planted: advertising a healthy session on a secret that
35
+ * will not survive a reload is exactly the divergence that logs users out.
36
+ */
37
+ export type DeviceSecretMintOutcome = {
38
+ status: 'ok';
39
+ token: string;
40
+ sessionId: string;
41
+ userId: string;
42
+ } | {
43
+ status: 'no-secret';
44
+ } | {
45
+ status: 'invalid-secret';
46
+ } | {
47
+ status: 'no-session';
48
+ } | {
49
+ status: 'transient';
50
+ } | {
51
+ status: 'persist-failed';
52
+ };
53
+ /**
54
+ * Arm 1 — the rotating device-secret mint, run under the owning client's
55
+ * PROCESS-WIDE single-flight (`httpService.runSingleFlightDeviceSecretMint`).
56
+ *
57
+ * The server ROTATES the presented `deviceSecret` on every successful mint and
58
+ * the just-presented secret is valid only for a short grace window. If two lanes
59
+ * (cold boot, the proactive scheduler, a request-time preflight, a 401 retry,
60
+ * the socket token transport, or a tab-focus reconcile) minted concurrently they
61
+ * would double-rotate the server and the durable store could converge on the
62
+ * SUPERSEDED secret — after the grace window the next cold boot mint 401s and the
63
+ * user is signed out. Routing EVERY lane through this one single-flight makes
64
+ * concurrent callers await the SAME in-flight mint and all receive its result, so
65
+ * there is exactly one rotation and the store always converges on the true
66
+ * `current` secret.
67
+ *
68
+ * On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
69
+ * the access token; a failed durable persist yields `persist-failed` WITHOUT
70
+ * planting. This function performs NO store mutation on failure — the caller
71
+ * applies the drop/clear policy (which differs web vs native) from the returned
72
+ * status.
73
+ */
74
+ export declare function refreshDeviceSecretArm(deps: {
75
+ oxy: OxyServices;
76
+ store: AuthStateStore;
77
+ }): Promise<DeviceSecretMintOutcome>;
43
78
  /**
44
79
  * Re-mint the persisted session and return the fresh access token, or `null`
45
80
  * when no arm could produce one.
46
81
  *
47
- * Arm 1 (`POST /session/device/token`): if the store holds a `deviceId` +
48
- * `deviceSecret`, mint on success plant + persist the rotated secret. A 401
49
- * means the secret is diverged (`invalid_device_secret`) or the device has no
50
- * live session (`no_active_session`): drop the secret so the mint lane stops (or
51
- * clear the store on web, where there is no fallback). A transient error leaves
52
- * the store and returns `null`.
82
+ * Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
83
+ * from the persisted `deviceId` + `deviceSecret`. On a 401 the secret is diverged
84
+ * or the device has no live session: drop the secret so the mint lane stops (or
85
+ * clear the store on web, where there is no fallback), then fall to arm 2 on
86
+ * native. A transient error or a durable-persist failure leaves the store and
87
+ * returns `null` WITHOUT falling to shared-key (those are not bad-secret signals).
53
88
  *
54
89
  * Arm 2 (native shared-keychain): when the secret is absent or was just rejected,
55
- * re-mint via `signInWithSharedIdentity` (which plants tokens). The shared
56
- * keychain not the per-origin store is the durable native credential, so this
57
- * arm does not write the store.
90
+ * re-mint via `signInWithSharedIdentity` (which plants tokens). On success the
91
+ * recovered `{deviceId, deviceSecret, …}` is PERSISTED so the fast device-secret
92
+ * lane is repopulated (mirrors the cold boot's `shared-key-signin` step) — an
93
+ * in-session shared-key recovery must not leave the fast-lane credential empty.
58
94
  */
59
95
  export declare function refreshPersistedSession(deps: RefreshDeps): Promise<string | null>;
60
96
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "9.2.2",
3
+ "version": "9.2.4",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -95,7 +95,7 @@
95
95
  },
96
96
  "dependencies": {
97
97
  "@oxyhq/contracts": "^0.13.2",
98
- "@oxyhq/protocol": "^0.1.3",
98
+ "@oxyhq/protocol": "^0.1.5",
99
99
  "bip39": "^3.1.0",
100
100
  "buffer": "^6.0.3",
101
101
  "elliptic": "^6.6.1",
@@ -22,6 +22,7 @@ import { isNative, getPlatformOS } from './utils/platform';
22
22
  import { isReactNative } from '@oxyhq/protocol';
23
23
  import { computeIdentityTag, fnv1a32 } from './utils/cacheKey';
24
24
  import type { OxyConfig } from './models/interfaces';
25
+ import type { DeviceSecretMintOutcome } from './session/refresh';
25
26
 
26
27
  /**
27
28
  * Check if we're running in a native app environment (React Native, not web)
@@ -234,6 +235,7 @@ export class HttpService {
234
235
  private tokenRefreshCooldownUntil: number = 0;
235
236
  private authRefreshHandler: AuthRefreshHandler | null = null;
236
237
  private accessTokenProvider: AccessTokenProvider | null = null;
238
+ private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
237
239
 
238
240
  /**
239
241
  * Epoch (ms) before which a cache-size telemetry warning must not be
@@ -1061,6 +1063,35 @@ export class HttpService {
1061
1063
  return this.tokenRefreshPromise;
1062
1064
  }
1063
1065
 
1066
+ /**
1067
+ * PROCESS-WIDE single-flight for the rotating device-secret mint
1068
+ * (`POST /session/device/token`).
1069
+ *
1070
+ * The server rotates the presented `deviceSecret` on every successful mint, so
1071
+ * two concurrent mints would double-rotate and the durable store could end up
1072
+ * holding a superseded secret → a later cold-boot mint 401s → the user is
1073
+ * signed out. Every mint lane (the re-mint handler behind `refreshAccessToken`,
1074
+ * the device-first cold boot, the socket token transport, the tab-focus
1075
+ * reconcile) funnels its `refreshDeviceSecretArm` call through here, so
1076
+ * concurrent callers await the SAME in-flight mint and all receive its result —
1077
+ * exactly one server rotation.
1078
+ *
1079
+ * Distinct from {@link tokenRefreshPromise} (which dedups the FULL re-mint
1080
+ * handler incl. the native shared-key arm + the failure cooldown): this inner
1081
+ * guard serializes the rotation itself across BOTH the handler and the
1082
+ * handler-independent cold boot, which never runs through `refreshAccessToken`.
1083
+ */
1084
+ runSingleFlightDeviceSecretMint(
1085
+ mint: () => Promise<DeviceSecretMintOutcome>,
1086
+ ): Promise<DeviceSecretMintOutcome> {
1087
+ if (!this.deviceSecretMintInFlight) {
1088
+ this.deviceSecretMintInFlight = mint().finally(() => {
1089
+ this.deviceSecretMintInFlight = null;
1090
+ });
1091
+ }
1092
+ return this.deviceSecretMintInFlight;
1093
+ }
1094
+
1064
1095
  /**
1065
1096
  * Unwrap standardized API response format
1066
1097
  */
@@ -10,6 +10,7 @@ import type { OxyServices } from '../../OxyServices';
10
10
  import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
11
11
  import type { SessionLoginResponse } from '../../models/session';
12
12
  import { runSessionColdBoot } from '../sessionColdBoot';
13
+ import type { DeviceSecretMintOutcome } from '../../session/refresh';
13
14
  import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
14
15
 
15
16
  interface OxyOverrides {
@@ -17,6 +18,19 @@ interface OxyOverrides {
17
18
  mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
18
19
  }
19
20
 
21
+ /** A real device-secret mint single-flight matching HttpService's. */
22
+ function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
23
+ let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
24
+ return (mint) => {
25
+ if (!inFlight) {
26
+ inFlight = mint().finally(() => {
27
+ inFlight = null;
28
+ });
29
+ }
30
+ return inFlight;
31
+ };
32
+ }
33
+
20
34
  function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
21
35
  const setTokens = jest.fn();
22
36
  const oxy = {
@@ -30,6 +44,8 @@ function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: j
30
44
  ?? (async () => {
31
45
  throw new Error('mintFromDeviceSecret not stubbed');
32
46
  }),
47
+ // The device-secret-mint step runs through the client's single-flight.
48
+ httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
33
49
  } as unknown as OxyServices;
34
50
  return { oxy, setTokens };
35
51
  }
@@ -72,6 +88,109 @@ function seedCredStore(extra: Partial<PersistedAuthState> = {}) {
72
88
  };
73
89
  }
74
90
 
91
+ describe('runSessionColdBoot — warm-token-plant', () => {
92
+ /** Comfortably beyond the 60s refresh lead window. */
93
+ const farFuture = () => new Date(Date.now() + 3_600_000).toISOString();
94
+
95
+ it('plants a still-valid warm token FIRST, skipping the mint round-trip', async () => {
96
+ const { store, seed } = seedCredStore({ accessToken: 'warm-access', expiresAt: farFuture() });
97
+ await seed();
98
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
99
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
100
+ const saveSpy = jest.spyOn(store, 'save');
101
+ const onSession = jest.fn();
102
+
103
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB, onSession });
104
+
105
+ expect(outcome).toEqual({ kind: 'session', via: 'warm-token-plant', session: expect.any(Object) });
106
+ expect(setTokens).toHaveBeenCalledWith('warm-access');
107
+ // The warm plant won before the mint lane — no network round-trip on first paint.
108
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
109
+ // Session identity comes straight from the persisted owning session.
110
+ expect(onSession).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ sessionId: 'sess-old',
113
+ userId: 'user-old',
114
+ accessToken: 'warm-access',
115
+ via: 'warm-token-plant',
116
+ }),
117
+ );
118
+ // Used AS-IS: the step never mints, rotates, or persists.
119
+ expect(saveSpy).not.toHaveBeenCalled();
120
+ expect(await store.load()).toMatchObject({ deviceSecret: 'ds-secret-orig', accessToken: 'warm-access' });
121
+ });
122
+
123
+ it('plants on native too (the step runs on both platforms)', async () => {
124
+ const { store, seed } = seedCredStore({ accessToken: 'warm-access', expiresAt: farFuture() });
125
+ await seed();
126
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
127
+ const signInWithSharedIdentity = jest.fn(async () => null);
128
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret, signInWithSharedIdentity });
129
+
130
+ const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
131
+
132
+ expect(outcome).toMatchObject({ kind: 'session', via: 'warm-token-plant' });
133
+ expect(setTokens).toHaveBeenCalledWith('warm-access');
134
+ expect(mintFromDeviceSecret).not.toHaveBeenCalled();
135
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it.each([
139
+ ['within the refresh lead window', () => new Date(Date.now() + 30_000).toISOString()],
140
+ ['already expired', () => new Date(Date.now() - 1_000).toISOString()],
141
+ ['malformed (Date.parse NaN)', () => 'not-a-real-date'],
142
+ ])('skips to the mint lane when the warm token is %s', async (_label, expiresAt) => {
143
+ const { store, seed } = seedCredStore({ accessToken: 'warm-access', expiresAt: expiresAt() });
144
+ await seed();
145
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
146
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
147
+
148
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB });
149
+
150
+ // Warm plant skipped → the unchanged mint lane resolved the session.
151
+ expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
152
+ expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
153
+ expect(setTokens).toHaveBeenCalledWith('access-minted');
154
+ // The stale warm token was NOT planted.
155
+ expect(setTokens).not.toHaveBeenCalledWith('warm-access');
156
+ });
157
+
158
+ it('skips (regression guard) when no accessToken is persisted → mint lane runs', async () => {
159
+ const { store, seed } = seedCredStore(); // deviceId/deviceSecret only, no warm token
160
+ await seed();
161
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
162
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
163
+
164
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB });
165
+
166
+ expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
167
+ expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
168
+ expect(setTokens).toHaveBeenCalledWith('access-minted');
169
+ });
170
+
171
+ it('skips when a warm token is present but the owning sessionId/userId is missing', async () => {
172
+ // Device-only bootstrap blob: deviceId + deviceSecret but empty session identity.
173
+ const store = createMemoryAuthStateStore();
174
+ await store.save({
175
+ sessionId: '',
176
+ userId: '',
177
+ deviceId: 'dev-mint',
178
+ deviceSecret: 'ds-secret-orig',
179
+ accessToken: 'warm-access',
180
+ expiresAt: farFuture(),
181
+ });
182
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
183
+ const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
184
+
185
+ const outcome = await runSessionColdBoot({ oxy, store, platform: WEB });
186
+
187
+ // No owning session → warm plant skips → the mint lane resolves it.
188
+ expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
189
+ expect(setTokens).not.toHaveBeenCalledWith('warm-access');
190
+ expect(mintFromDeviceSecret).toHaveBeenCalled();
191
+ });
192
+ });
193
+
75
194
  describe('runSessionColdBoot — device-secret-mint', () => {
76
195
  it('wins FIRST via the mint, persisting nextDeviceSecret BEFORE planting the token', async () => {
77
196
  const { store, seed } = seedCredStore();