@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
@@ -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
@@ -56,12 +78,23 @@ export declare class SessionClient {
56
78
  private started;
57
79
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
58
80
  private channel;
81
+ /** App-facing subscriptions to named server-pushed socket events. */
82
+ private readonly serverEvents;
83
+ /** Event names already bound on the CURRENT socket instance. */
84
+ private readonly boundServerEvents;
59
85
  constructor(host: SessionClientHost, options?: SessionClientOptions);
60
86
  getState(): DeviceSessionState | null;
61
87
  subscribe(listener: StateListener): () => void;
88
+ /**
89
+ * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
90
+ * Listeners survive reconnects and socket re-creation; the returned function
91
+ * unsubscribes. Payloads are delivered as-is — callers validate shape.
92
+ */
93
+ onServerEvent(event: string, listener: (payload: unknown) => void): () => void;
94
+ private bindServerEvent;
62
95
  protected notify(): void;
63
96
  /** Validate + last-writer-wins by revision. Returns true if applied. */
64
- protected applyState(raw: unknown): boolean;
97
+ protected applyState(raw: unknown, origin?: SessionStateOrigin): boolean;
65
98
  /**
66
99
  * Validate `{ state, activeToken }`, apply the state, and plant the active token host-side.
67
100
  * Token-planting is decoupled from whether `applyState` advanced the revision: a socket push
@@ -111,6 +111,16 @@ export interface AccountDialogControllerOptions {
111
111
  * `Linking.openURL`). Headless core never touches `window`/`Linking` itself.
112
112
  */
113
113
  openUrl?: (url: string) => void;
114
+ /**
115
+ * Optional "can this app open this URL scheme?" probe, symmetric to
116
+ * {@link openUrl}. When provided, `showQr` uses it to detect an installed
117
+ * Commons (`oxycommons://`) and, if present, deep-links straight into its
118
+ * approve screen via {@link openUrl} — while KEEPING the QR/polling active as
119
+ * the fallback. Injected by the provider (native: `Linking.canOpenURL`; web:
120
+ * absent/false). Headless core never touches `Linking` itself; when absent
121
+ * `showQr` behaves exactly as before (render QR only).
122
+ */
123
+ canOpenApp?: (url: string) => Promise<boolean>;
114
124
  }
115
125
  type SnapshotListener = (snapshot: AccountDialogSnapshot) => void;
116
126
  export declare class AccountDialogController {
@@ -124,6 +134,7 @@ export declare class AccountDialogController {
124
134
  private readonly authRedirectUri;
125
135
  private readonly pollIntervalMs;
126
136
  private readonly openUrl?;
137
+ private readonly canOpenApp?;
127
138
  private readonly listeners;
128
139
  private view;
129
140
  private graph;
@@ -232,6 +243,12 @@ export declare class AccountDialogController {
232
243
  * session committed. Requires `clientId`.
233
244
  */
234
245
  showQr(): Promise<void>;
246
+ /**
247
+ * When a `canOpenApp` probe is injected and reports Commons installed, open the
248
+ * approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
249
+ * probe/open failure is logged and swallowed — the QR/polling fallback remains.
250
+ */
251
+ private maybeOpenCommons;
235
252
  /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
236
253
  cancelSignIn(): void;
237
254
  /**
@@ -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
  /**
@@ -77,11 +91,32 @@ export interface NativeKeyValueStorage {
77
91
  removeItem(key: string): Promise<void>;
78
92
  }
79
93
  /**
80
- * Versioned storage key. The `.v1` suffix lets a future shape change ship a
81
- * `.v2` key without reading a stale/incompatible `.v1` blob. Distinct from the
82
- * `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
94
+ * Versioned DURABLE storage key. Holds ONLY the small, re-mint-critical fields
95
+ * (`sessionId`, `userId`, `deviceId`, `deviceSecret`) never the large JWT
96
+ * `accessToken`. Keeping this blob small (<2KB) matters on Android
97
+ * `expo-secure-store`, whose backing store can silently fail to persist an
98
+ * oversize value; bundling the token here previously took the mint credential
99
+ * down with it on every write, losing the session on cold restart.
100
+ *
101
+ * The `.v1` suffix lets a future shape change ship a `.v2` key without reading a
102
+ * stale/incompatible `.v1` blob. Distinct from the `oxy_shared_*` keychain keys
103
+ * in `KeyManager`, so it never collides.
104
+ *
105
+ * BACK-COMPAT: pre-split builds wrote the WHOLE state (including `accessToken` /
106
+ * `expiresAt`) into this single key. `load()` still reads those token fields
107
+ * from here when the warm key ({@link AUTH_STATE_TOKEN_STORAGE_KEY}) is absent,
108
+ * so upgrading users are not signed out; the next `save()` splits them apart.
83
109
  */
84
110
  export declare const AUTH_STATE_STORAGE_KEY = "oxy.auth.v1";
111
+ /**
112
+ * Versioned BEST-EFFORT warm-token storage key. Holds the short-lived
113
+ * `{ accessToken, expiresAt }` pair only. Its write is genuinely non-fatal — a
114
+ * failure (quota / oversize keychain value) is swallowed because the session is
115
+ * fully re-mintable from the durable `deviceSecret`. Kept separate from
116
+ * {@link AUTH_STATE_STORAGE_KEY} so a failed token write can NEVER abort or
117
+ * corrupt the durable credential write.
118
+ */
119
+ export declare const AUTH_STATE_TOKEN_STORAGE_KEY = "oxy.auth.token.v1";
85
120
  /**
86
121
  * A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
87
122
  * tests/SSR and as the degraded fallback of the web store when `localStorage`
@@ -90,8 +125,9 @@ export declare const AUTH_STATE_STORAGE_KEY = "oxy.auth.v1";
90
125
  */
91
126
  export declare function createMemoryAuthStateStore(): AuthStateStore;
92
127
  /**
93
- * A `localStorage`-backed {@link AuthStateStore} under the versioned
94
- * {@link AUTH_STATE_STORAGE_KEY}.
128
+ * A `localStorage`-backed {@link AuthStateStore} split across the durable
129
+ * {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the best-effort
130
+ * {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token).
95
131
  *
96
132
  * Resilience:
97
133
  * - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
@@ -107,8 +143,11 @@ export declare function createWebAuthStateStore(): AuthStateStore;
107
143
  * A native {@link AuthStateStore} over an injected async key/value store.
108
144
  *
109
145
  * `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
110
- * the SecureStore-backed adapter and passes it here. Every operation is wrapped
111
- * so a storage exception degrades gracefully (read `null`, write → swallowed)
112
- * exactly like the web store.
146
+ * the SecureStore-backed adapter and passes it here. Persistence is split across
147
+ * the durable {@link AUTH_STATE_STORAGE_KEY} (mint credential) and the
148
+ * best-effort {@link AUTH_STATE_TOKEN_STORAGE_KEY} (warm access token) — the
149
+ * durable write is read-back-verified and its failure surfaced (not swallowed),
150
+ * while the warm-token write and all reads degrade gracefully exactly like the
151
+ * web store.
113
152
  */
114
153
  export declare function createNativeAuthStateStore(storage: NativeKeyValueStorage): AuthStateStore;
@@ -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.1",
3
+ "version": "9.2.3",
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",
@@ -94,7 +94,7 @@
94
94
  }
95
95
  },
96
96
  "dependencies": {
97
- "@oxyhq/contracts": "^0.13.1",
97
+ "@oxyhq/contracts": "^0.13.2",
98
98
  "@oxyhq/protocol": "^0.1.3",
99
99
  "bip39": "^3.1.0",
100
100
  "buffer": "^6.0.3",
@@ -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();