@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
@@ -7,21 +7,25 @@
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, type ColdBootOutcome, type ColdBootStep } from '../utils/coldBoot';
20
24
  import { isNative as detectNative } from '../utils/platform';
21
- import { extractErrorStatus } from '../utils/errorUtils';
22
25
  import { logger } from '../utils/loggerUtils';
26
+ import { TOKEN_REFRESH_LEAD_MS, refreshDeviceSecretArm } from '../session/refresh';
23
27
  import type { OxyServices } from '../OxyServices';
24
- import type { AuthStateStore, PersistedAuthState } from '../session/authStateStore';
28
+ import type { AuthStateStore } from '../session/authStateStore';
25
29
 
26
30
  /** The winning session shape a cold-boot step reports. */
27
31
  export interface DeviceBootSession {
@@ -45,34 +49,6 @@ export interface RunSessionColdBootOptions {
45
49
  onStepError?: (id: string, error: unknown) => void;
46
50
  }
47
51
 
48
- /**
49
- * How a `mintFromDeviceSecret` call failed, distinguished so the cold boot can
50
- * react per the transport contract:
51
- * - `invalid_secret` — the presented secret no longer matches (another tab/
52
- * device rotated it, or theft divergence). Drop it and fall back.
53
- * - `no_active_session` — the device is known but has no live session.
54
- * Authoritative signed-out.
55
- * - `transient` — network / 5xx. Keep the secret; a later attempt can succeed.
56
- *
57
- * The mint is bearer-less (`skipAuth`), so `HttpService` surfaces the server's
58
- * 401 body string (`invalid_device_secret` | `no_active_session`) as the thrown
59
- * error's `message`; any non-401 is transport/server failure.
60
- */
61
- type MintFailure = 'invalid_secret' | 'no_active_session' | 'transient';
62
-
63
- function classifyMintFailure(error: unknown): MintFailure {
64
- if (extractErrorStatus(error) === 401) {
65
- // Structural read (not `instanceof Error`): the thrown value can be a plain
66
- // ApiError-shaped object or come from another realm, where instanceof fails
67
- // and a `no_active_session` would be misread as a stale secret and dropped.
68
- const message = (error as { message?: unknown })?.message;
69
- return typeof message === 'string' && message.includes('no_active_session')
70
- ? 'no_active_session'
71
- : 'invalid_secret';
72
- }
73
- return 'transient';
74
- }
75
-
76
52
  /**
77
53
  * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
78
54
  * a side effect, invokes `onSession` (winning session, token already planted) or
@@ -90,64 +66,107 @@ export async function runSessionColdBoot(
90
66
 
91
67
  const steps: Array<ColdBootStep<DeviceBootSession>> = [];
92
68
 
93
- // 1. device-secret-mint (web + native) — the zero-cookie fast path. When the
94
- // origin persisted a deviceId + deviceSecret, mint a short access token with
95
- // a single bearer-less POST (no cookie, no navigation).
69
+ // 1. warm-token-plant (web + native) — the fastest path. When the persisted
70
+ // store already holds a still-valid warm access token (its expiry more than
71
+ // the refresh lead window away) plus its owning session identity, plant it
72
+ // and yield the session IMMEDIATELY, skipping the blocking mint round-trip on
73
+ // first paint. The token is used AS-IS: this step NEVER mints, rotates, or
74
+ // persists anything. The proactive `startTokenRefreshScheduler` + the
75
+ // request-time preflight (both wired in the services provider) rotate it in
76
+ // the background; a revoked token self-heals via the 401 -> re-mint -> clear
77
+ // path. This exposure is sanctioned by `authStateStore.ts` (~L30-36): the
78
+ // warm token is short-lived and adds nothing over the already-persisted
79
+ // `deviceSecret`.
96
80
  steps.push({
97
- id: 'device-secret-mint',
81
+ id: 'warm-token-plant',
98
82
  run: async () => {
99
83
  const persisted = await store.load();
100
- if (!persisted?.deviceId || !persisted?.deviceSecret) {
84
+ if (!persisted?.accessToken || !persisted.sessionId || !persisted.userId || !persisted.expiresAt) {
85
+ return { kind: 'skip' };
86
+ }
87
+ // Guard a malformed `expiresAt` (Date.parse -> NaN): treat as not-valid and
88
+ // fall through to the mint lane. A token still inside the refresh lead
89
+ // window (or already expired) is likewise skipped — let the mint lane get a
90
+ // fresh one rather than plant a token about to expire.
91
+ const expiresAtMs = new Date(persisted.expiresAt).getTime();
92
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() + TOKEN_REFRESH_LEAD_MS) {
101
93
  return { kind: 'skip' };
102
94
  }
103
- try {
104
- const mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
105
- // Rotation-in-use anti-loss: persist the NEXT secret (+ refreshed warm
106
- // fields, + the server's authoritative active account) BEFORE planting
107
- // the minted access token, so a multi-tab race that rotates again can
108
- // never strand this tab with a superseded secret.
109
- const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
110
- const next: PersistedAuthState = {
111
- ...persisted,
112
- deviceId: mint.state.deviceId,
113
- deviceSecret: mint.nextDeviceSecret,
114
- accessToken: mint.accessToken,
115
- expiresAt: mint.expiresAt,
116
- ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
117
- };
118
- await store.save(next);
119
- oxy.setTokens(mint.accessToken);
120
- return {
121
- kind: 'session',
122
- session: { sessionId: next.sessionId, userId: next.userId, accessToken: mint.accessToken },
123
- };
124
- } catch (error) {
125
- const failure = classifyMintFailure(error);
126
- if (failure === 'invalid_secret') {
95
+ oxy.setTokens(persisted.accessToken);
96
+ return {
97
+ kind: 'session',
98
+ session: {
99
+ sessionId: persisted.sessionId,
100
+ userId: persisted.userId,
101
+ accessToken: persisted.accessToken,
102
+ },
103
+ };
104
+ },
105
+ });
106
+
107
+ // 2. device-secret-mint (web + native) — the zero-cookie fast path. When the
108
+ // origin persisted a deviceId + deviceSecret, mint a short access token with
109
+ // a single bearer-less POST (no cookie, no navigation). The mint itself runs
110
+ // through `refreshDeviceSecretArm`, which acquires the client's PROCESS-WIDE
111
+ // single-flight, persists the rotated `nextDeviceSecret` BEFORE planting the
112
+ // token, and returns a classified outcome — so this step can never
113
+ // double-rotate the server against the scheduler/transport/401 lanes, and
114
+ // the durable store always converges on the true `current` secret.
115
+ steps.push({
116
+ id: 'device-secret-mint',
117
+ run: async () => {
118
+ const result = await refreshDeviceSecretArm({ oxy, store });
119
+ switch (result.status) {
120
+ case 'ok':
121
+ // The arm persisted the rotated secret and planted the token.
122
+ return {
123
+ kind: 'session',
124
+ session: {
125
+ sessionId: result.sessionId,
126
+ userId: result.userId,
127
+ accessToken: result.token,
128
+ },
129
+ };
130
+ case 'invalid-secret': {
127
131
  // Stale/diverged secret — drop it so the mint lane stops firing. On
128
132
  // native the shared-key step below can still recover; on web this ends
129
133
  // signed out. Setting it undefined drops the key on the store's JSON
130
134
  // serialization, and the mint guard treats undefined as absent.
131
- await store.save({ ...persisted, deviceSecret: undefined });
135
+ const persisted = await store.load();
136
+ if (persisted) {
137
+ await store.save({ ...persisted, deviceSecret: undefined });
138
+ }
132
139
  return { kind: 'skip' };
133
140
  }
134
- if (failure === 'no_active_session') {
135
- // Device known, no live session — authoritative signed-out.
141
+ case 'no-session':
142
+ // Device known, no live session — authoritative signed-out. Keep the
143
+ // secret (the device may sign in again).
136
144
  signedOutReason = 'no_session';
137
145
  return { kind: 'skip' };
138
- }
139
- // Transient (network / 5xx): keep the secret; a later attempt can succeed.
140
- logger.debug(
141
- 'device-secret mint failed (transient) keeping secret',
142
- { component: 'sessionColdBoot', method: 'device-secret-mint' },
143
- error,
144
- );
145
- return { kind: 'skip' };
146
+ case 'persist-failed':
147
+ // The mint rotated the secret but it could not be durably persisted —
148
+ // refuse to advertise a session that will not survive a reload. Keep
149
+ // the secret; a later boot/attempt re-mints once storage recovers.
150
+ logger.error(
151
+ 'device-secret mint rotated the secret but it could not be durably persisted — not planting',
152
+ undefined,
153
+ { component: 'sessionColdBoot', method: 'device-secret-mint' },
154
+ );
155
+ return { kind: 'skip' };
156
+ case 'transient':
157
+ // Network / 5xx: keep the secret; a later attempt can succeed.
158
+ logger.debug(
159
+ 'device-secret mint failed (transient) — keeping secret',
160
+ { component: 'sessionColdBoot', method: 'device-secret-mint' },
161
+ );
162
+ return { kind: 'skip' };
163
+ case 'no-secret':
164
+ return { kind: 'skip' };
146
165
  }
147
166
  },
148
167
  });
149
168
 
150
- // 2. shared-key-signin (native) — re-mint from the shared identity.
169
+ // 3. shared-key-signin (native) — re-mint from the shared identity.
151
170
  steps.push({
152
171
  id: 'shared-key-signin',
153
172
  enabled: () => isNative,
@@ -8,7 +8,7 @@
8
8
  import { ec as EC } from 'elliptic';
9
9
  import type { ECKeyPair } from 'elliptic';
10
10
  import { isWeb, isIOS, isAndroid } from '../utils/platform';
11
- import { type ExpoCryptoLike, type ExpoSecureStoreLike, isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore } from '@oxyhq/protocol';
11
+ import { type ExpoCryptoLike, type ExpoSecureStoreLike, isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxyhq/protocol';
12
12
  import { logger } from '../utils/loggerUtils';
13
13
  import { isDev } from '../shared/utils/debugUtils';
14
14
 
@@ -298,13 +298,20 @@ export class KeyManager {
298
298
  );
299
299
  }
300
300
  } else if (isAndroid()) {
301
- // Android: Store in secure store (accessible via sharedUserId)
302
- // Note: All Oxy apps must have the same sharedUserId in AndroidManifest.xml
303
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
304
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
305
- });
306
-
307
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
301
+ // Android: write through the cross-app bridge (`@oxyhq/expo-oxy-identity`)
302
+ // when present it persists into Commons's hardware-backed
303
+ // EncryptedSharedPreferences behind a signature-protected ContentProvider,
304
+ // so same-key Oxy apps can read it. When the bridge is not linked, fall
305
+ // back to the package-private secure store (no cross-app sharing).
306
+ const bridge = await loadSharedIdentityBridge();
307
+ if (bridge) {
308
+ await bridge.putShared(privateKey, publicKey);
309
+ } else {
310
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
311
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
312
+ });
313
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
314
+ }
308
315
  }
309
316
 
310
317
  // Update cache
@@ -341,7 +348,14 @@ export class KeyManager {
341
348
  const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
342
349
  publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
343
350
  } else if (isAndroid()) {
344
- publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
351
+ // Android reads through the cross-app bridge; when it is not linked, fall
352
+ // back to the package-private store the fallback write path used.
353
+ const bridge = await loadSharedIdentityBridge();
354
+ if (bridge) {
355
+ publicKey = (await bridge.getShared())?.publicKey ?? null;
356
+ } else {
357
+ publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
358
+ }
345
359
  }
346
360
 
347
361
  // Cache result
@@ -378,7 +392,14 @@ export class KeyManager {
378
392
  const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
379
393
  privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
380
394
  } else if (isAndroid()) {
381
- privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
395
+ // Android reads through the cross-app bridge; when it is not linked, fall
396
+ // back to the package-private store the fallback write path used.
397
+ const bridge = await loadSharedIdentityBridge();
398
+ if (bridge) {
399
+ privateKey = (await bridge.getShared())?.privateKey ?? null;
400
+ } else {
401
+ privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
402
+ }
382
403
  }
383
404
 
384
405
  return privateKey;
@@ -458,11 +479,17 @@ export class KeyManager {
458
479
  };
459
480
  await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
460
481
  } else if (isAndroid()) {
461
- await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, {
462
- keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
463
- });
464
-
465
- await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
482
+ // Android: write through the cross-app bridge when present; otherwise the
483
+ // package-private store (kept consistent with the read fallback).
484
+ const bridge = await loadSharedIdentityBridge();
485
+ if (bridge) {
486
+ await bridge.putShared(canonicalPrivate, publicKey);
487
+ } else {
488
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, {
489
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
490
+ });
491
+ await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
492
+ }
466
493
  }
467
494
 
468
495
  // Update cache
package/src/index.ts CHANGED
@@ -557,7 +557,7 @@ export type { SyncHubAfterSignInOptions } from './session/hubSync';
557
557
  // Session sync (device-scoped multi-account session client)
558
558
  // ---------------------------------------------------------------------------
559
559
  export { SessionClient } from './session/SessionClient';
560
- export type { TokenTransport, SessionClientHost, SessionClientOptions, DeviceCredential } from './session/SessionClient';
560
+ export type { TokenTransport, SessionClientHost, SessionClientOptions, DeviceCredential, SessionStateOrigin } from './session/SessionClient';
561
561
  // The injectable socket factory type: consumers that bundle socket.io-client
562
562
  // (services/auth-sdk) pass its `io` export as `socketFactory` so realtime sync
563
563
  // never relies on core's lazy dynamic import of a bare specifier.
@@ -629,12 +629,13 @@ export type {
629
629
 
630
630
  export {
631
631
  refreshPersistedSession,
632
+ refreshDeviceSecretArm,
632
633
  createAuthRefreshHandler,
633
634
  installAuthRefreshHandler,
634
635
  startTokenRefreshScheduler,
635
636
  TOKEN_REFRESH_LEAD_MS,
636
637
  } from './session/refresh';
637
- export type { RefreshDeps, TokenRefreshSchedulerHandle } from './session/refresh';
638
+ export type { RefreshDeps, TokenRefreshSchedulerHandle, DeviceSecretMintOutcome } from './session/refresh';
638
639
 
639
640
  export { runSessionColdBoot } from './boot/sessionColdBoot';
640
641
  export type {
@@ -13,6 +13,22 @@ export interface TokenTransport {
13
13
  ensureActiveToken(state: DeviceSessionState): Promise<void>;
14
14
  }
15
15
 
16
+ /**
17
+ * Where an applied device state came from, so consumers can decide how
18
+ * AUTHORITATIVE a zero-account ("signed out") verdict is:
19
+ * - `request` — the response to a direct REST call this client made
20
+ * (`bootstrap` / `switch` / `signOut` / `add`). A stable, server-authoritative
21
+ * verdict: an empty state here reflects a real sign-out or revocation, so the
22
+ * durable device credential MAY be erased.
23
+ * - `push` — an out-of-band Socket.IO `session_state` broadcast. Potentially
24
+ * transient (a reconnect race / another device's mutation), so an empty state
25
+ * here must NOT erase THIS origin's durable device credential — only clear the
26
+ * local UI session. A dead credential re-mints to `no_active_session` and
27
+ * resolves signed-out cleanly on the next boot; a wrongly-erased one cannot be
28
+ * recovered without a fresh sign-in.
29
+ */
30
+ export type SessionStateOrigin = 'request' | 'push';
31
+
16
32
  export interface DeviceCredential {
17
33
  deviceId: string;
18
34
  deviceSecret: string;
@@ -34,13 +50,20 @@ export interface SessionClientOptions {
34
50
  /**
35
51
  * Invoked when an APPLIED state has zero accounts — i.e. a device
36
52
  * signout-all removed the last account from this device set. Providers use
37
- * this to clear the persisted {@link AuthStateStore} so a reload does not
38
- * try to restore a session that no longer exists on the device.
53
+ * this to clear local session state and, for a `request`-origin verdict, the
54
+ * persisted {@link AuthStateStore} so a reload does not try to restore a
55
+ * session that no longer exists on the device.
56
+ *
57
+ * The {@link SessionStateOrigin} is passed so the consumer can gate the
58
+ * DESTRUCTIVE credential wipe: a `push`-origin empty state (a socket broadcast,
59
+ * possibly a transient reconnect artifact) must NOT erase the durable device
60
+ * credential — only a `request`-origin verdict (a direct REST sign-out /
61
+ * revocation) is authoritative enough for that.
39
62
  *
40
63
  * Only fires when a state is actually applied (revision advanced), never on
41
64
  * a stale/rejected push. Exceptions thrown by the callback are isolated.
42
65
  */
43
- onUnauthenticated?: () => void;
66
+ onUnauthenticated?: (origin: SessionStateOrigin) => void;
44
67
  /**
45
68
  * Statically-injected `socket.io-client` factory (its `io` export).
46
69
  * `@oxyhq/services` lists `socket.io-client` as a real dependency and
@@ -148,7 +171,7 @@ export class SessionClient {
148
171
  }
149
172
 
150
173
  /** Validate + last-writer-wins by revision. Returns true if applied. */
151
- protected applyState(raw: unknown): boolean {
174
+ protected applyState(raw: unknown, origin: SessionStateOrigin = 'push'): boolean {
152
175
  const next = safeParseContract(deviceSessionStateSchema, raw);
153
176
  if (!next) {
154
177
  logger.warn('[SessionClient] discarded invalid session state');
@@ -176,7 +199,7 @@ export class SessionClient {
176
199
  this.notify();
177
200
  if (next.accounts.length === 0 && this.options.onUnauthenticated) {
178
201
  try {
179
- this.options.onUnauthenticated();
202
+ this.options.onUnauthenticated(origin);
180
203
  } catch (error) {
181
204
  logger.error('[SessionClient] onUnauthenticated threw', error);
182
205
  }
@@ -224,7 +247,10 @@ export class SessionClient {
224
247
  logger.warn('[SessionClient] discarded invalid session sync', { component: 'SessionClient', issues, keys });
225
248
  return;
226
249
  }
227
- this.applyState(sync.state);
250
+ // A `sync` is always the response to a direct REST call this client made
251
+ // (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
252
+ // verdict.
253
+ this.applyState(sync.state, 'request');
228
254
  if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
229
255
  this.host.setTokens(sync.activeToken.accessToken);
230
256
  }
@@ -366,7 +392,9 @@ export class SessionClient {
366
392
  },
367
393
  });
368
394
  socket.on('session_state', (payload: unknown) => {
369
- const applied = this.applyState(payload);
395
+ // A socket broadcast is a `push`-origin — potentially transient, so an
396
+ // empty state here must not erase the durable device credential.
397
+ const applied = this.applyState(payload, 'push');
370
398
  if (!applied) return;
371
399
  // A push changed the active account on another device/tab — re-fetch state
372
400
  // to plant the access token for the newly-active account. When this tab is
@@ -1,5 +1,6 @@
1
1
  import type { DeviceSessionState } from '@oxyhq/contracts';
2
- import { SessionClient, type SessionClientHost } from '../SessionClient';
2
+ import { SessionClient, type SessionClientHost, type SessionStateOrigin } from '../SessionClient';
3
+ import { createMemoryAuthStateStore } from '../authStateStore';
3
4
 
4
5
  const stateWith = (rev: number, active: string | null, accountIds: string[]): DeviceSessionState => ({
5
6
  deviceId: 'd1',
@@ -27,6 +28,10 @@ class TestClient extends SessionClient {
27
28
  public apply(raw: unknown): boolean {
28
29
  return this.applyState(raw);
29
30
  }
31
+
32
+ public applyWith(raw: unknown, origin: SessionStateOrigin): boolean {
33
+ return this.applyState(raw, origin);
34
+ }
30
35
  }
31
36
 
32
37
  describe('SessionClient.registerAndActivate', () => {
@@ -90,4 +95,56 @@ describe('SessionClient onUnauthenticated', () => {
90
95
  c.apply(stateWith(4, null, []));
91
96
  expect(onUnauthenticated).not.toHaveBeenCalled();
92
97
  });
98
+
99
+ it('passes the applied-state ORIGIN through to onUnauthenticated', () => {
100
+ const onUnauthenticated = jest.fn();
101
+ const c = new TestClient(makeHost(jest.fn()), { onUnauthenticated });
102
+
103
+ // A socket-pushed empty state → `push` origin.
104
+ c.applyWith(stateWith(1, null, []), 'push');
105
+ expect(onUnauthenticated).toHaveBeenLastCalledWith('push');
106
+
107
+ // A direct REST response empty state → `request` origin.
108
+ c.applyWith(stateWith(2, null, []), 'request');
109
+ expect(onUnauthenticated).toHaveBeenLastCalledWith('request');
110
+ });
111
+ });
112
+
113
+ describe('SessionClient onUnauthenticated — durable credential guard (bug #4)', () => {
114
+ const CRED = { sessionId: 's1', userId: 'a1', deviceId: 'dev-1', deviceSecret: 'ds-1' };
115
+
116
+ // Mirror the provider's origin-gated wipe: erase the durable credential ONLY on
117
+ // a `request`-origin verdict, never on a (possibly transient) `push`.
118
+ function wireGuardedStore() {
119
+ const store = createMemoryAuthStateStore();
120
+ const onUnauthenticated = (origin: SessionStateOrigin) => {
121
+ if (origin === 'request') void store.clear();
122
+ };
123
+ return { store, onUnauthenticated };
124
+ }
125
+
126
+ it('a transient socket-pushed accounts===0 does NOT wipe the durable credential', async () => {
127
+ const { store, onUnauthenticated } = wireGuardedStore();
128
+ await store.save(CRED);
129
+ const c = new TestClient(makeHost(jest.fn()), { onUnauthenticated });
130
+
131
+ // A `push`-origin empty state (e.g. a reconnect race on another device).
132
+ c.applyWith(stateWith(2, null, []), 'push');
133
+ await Promise.resolve();
134
+
135
+ // The device credential survives — a reload can still restore the session.
136
+ expect(await store.load()).toEqual(CRED);
137
+ });
138
+
139
+ it('a real (request-origin) sign-out DOES wipe the durable credential', async () => {
140
+ const { store, onUnauthenticated } = wireGuardedStore();
141
+ await store.save(CRED);
142
+ const c = new TestClient(makeHost(jest.fn()), { onUnauthenticated });
143
+
144
+ // A `request`-origin empty state = the REST sign-out response.
145
+ c.applyWith(stateWith(2, null, []), 'request');
146
+ await Promise.resolve();
147
+
148
+ expect(await store.load()).toBeNull();
149
+ });
93
150
  });
@@ -131,4 +131,36 @@ describe('SessionClient socket', () => {
131
131
  c.stop();
132
132
  expect(fakeSocket.connected).toBe(false);
133
133
  });
134
+
135
+ it('a socket-pushed empty state fires onUnauthenticated with the PUSH origin (bug #4)', async () => {
136
+ const onUnauthenticated = jest.fn();
137
+ const c = new SessionClient(makeHost(), { onUnauthenticated });
138
+ await c.start();
139
+
140
+ const EMPTY: DeviceSessionState = { deviceId: 'd1', accounts: [], activeAccountId: null, revision: 9, updatedAt: 1720000000001 };
141
+ fakeSocket.trigger('session_state', EMPTY);
142
+
143
+ expect(onUnauthenticated).toHaveBeenCalledWith('push');
144
+ c.stop();
145
+ });
146
+
147
+ it('a REST signOut-all empty response fires onUnauthenticated with the REQUEST origin', async () => {
148
+ const onUnauthenticated = jest.fn();
149
+ const EMPTY_SYNC = {
150
+ state: { deviceId: 'd1', accounts: [], activeAccountId: null, revision: 9, updatedAt: 1720000000001 },
151
+ activeToken: null,
152
+ };
153
+ // bootstrap during start() returns a populated state; the signout (and any
154
+ // stray reconcile) returns empty.
155
+ const makeRequest = jest.fn().mockResolvedValue(EMPTY_SYNC).mockResolvedValueOnce(SYNC(1));
156
+ const c = new SessionClient(makeHost({ makeRequest }), { onUnauthenticated });
157
+ await c.start();
158
+
159
+ await c.signOut({ all: true });
160
+ // Flush any fire-and-forget post-commit reconcile before asserting/teardown.
161
+ await Promise.resolve();
162
+
163
+ expect(onUnauthenticated).toHaveBeenCalledWith('request');
164
+ c.stop();
165
+ });
134
166
  });
@@ -134,7 +134,8 @@ describe('createWebAuthStateStore', () => {
134
134
 
135
135
  // Construction must not throw, and the store must still function (in-memory).
136
136
  const store = createWebAuthStateStore();
137
- await expect(store.save(SAMPLE)).resolves.toBeUndefined();
137
+ // A degraded in-memory store IS its own durability backing → reports `true`.
138
+ await expect(store.save(SAMPLE)).resolves.toBe(true);
138
139
  expect(await store.load()).toEqual(SAMPLE);
139
140
  });
140
141
 
@@ -143,13 +144,46 @@ describe('createWebAuthStateStore', () => {
143
144
  const store = createWebAuthStateStore();
144
145
  const withCreds: PersistedAuthState = { ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' };
145
146
 
146
- await expect(store.save(withCreds)).resolves.toBeUndefined();
147
+ // The durable write threw → the store reports the persist did NOT land.
148
+ await expect(store.save(withCreds)).resolves.toBe(false);
147
149
  // The write never reached storage...
148
150
  expect(localStorage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
149
151
  // ...but the in-memory mirror keeps the session (incl. the mint credential) live.
150
152
  expect(await store.load()).toEqual(withCreds);
151
153
  });
152
154
 
155
+ it('reports false when the durable write SILENTLY no-ops (read-back mismatch, no throw)', async () => {
156
+ // A backing store whose setItem neither throws nor persists — the exact
157
+ // failure the read-back guards. The mirror keeps the session live, but the
158
+ // credential did not land, so save() must report false.
159
+ const map = new Map<string, string>();
160
+ const storage = {
161
+ getItem: (k: string) => map.get(k) ?? null,
162
+ setItem: (k: string, v: string) => {
163
+ // Only the WARM key writes; the durable credential silently vanishes.
164
+ if (k === AUTH_STATE_TOKEN_STORAGE_KEY) map.set(k, v);
165
+ },
166
+ removeItem: (k: string) => {
167
+ map.delete(k);
168
+ },
169
+ clear: () => map.clear(),
170
+ key: (i: number) => Array.from(map.keys())[i] ?? null,
171
+ get length() {
172
+ return map.size;
173
+ },
174
+ } as Storage;
175
+ installLocalStorage(storage);
176
+ const store = createWebAuthStateStore();
177
+
178
+ await expect(
179
+ store.save({ ...SAMPLE, deviceId: 'dev-x', deviceSecret: 'ds-x' }),
180
+ ).resolves.toBe(false);
181
+ // The durable blob never landed…
182
+ expect(storage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
183
+ // …but the mirror still serves the session for this page's lifetime.
184
+ expect(await store.load()).toMatchObject({ deviceId: 'dev-x', deviceSecret: 'ds-x' });
185
+ });
186
+
153
187
  it('a cleared session reads null even if storage later holds a stale blob (mirror wins)', async () => {
154
188
  const storage = makeFakeStorage();
155
189
  installLocalStorage(storage);
@@ -222,7 +256,7 @@ describe('createWebAuthStateStore', () => {
222
256
 
223
257
  await expect(
224
258
  store.save({ ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' }),
225
- ).resolves.toBeUndefined();
259
+ ).resolves.toBe(true);
226
260
 
227
261
  // The durable mint credential landed despite the warm-token write throwing.
228
262
  const durableRaw = storage.getItem(AUTH_STATE_STORAGE_KEY);
@@ -304,11 +338,35 @@ describe('createNativeAuthStateStore', () => {
304
338
  throw new Error('secure store locked');
305
339
  },
306
340
  });
307
- await expect(store.save(SAMPLE)).resolves.toBeUndefined();
308
- // The write threw, but the in-memory mirror preserves the session.
341
+ // The durable write threw → reports the persist did NOT land…
342
+ await expect(store.save(SAMPLE)).resolves.toBe(false);
343
+ // …but the in-memory mirror preserves the session for this app run.
309
344
  expect(await store.load()).toEqual(SAMPLE);
310
345
  });
311
346
 
347
+ it('reports false when the durable SecureStore write SILENTLY no-ops (oversize value, no throw)', async () => {
348
+ // Android SecureStore can resolve a write WITHOUT throwing yet not persist an
349
+ // oversize value — the read-back is the only reliable proof. save() must
350
+ // report false so a rotating-secret lane refuses to plant on it.
351
+ const map = new Map<string, string>();
352
+ const storage: NativeKeyValueStorage = {
353
+ getItem: async (k) => map.get(k) ?? null,
354
+ setItem: async (k, v) => {
355
+ if (k === AUTH_STATE_TOKEN_STORAGE_KEY) map.set(k, v); // durable silently dropped
356
+ },
357
+ removeItem: async (k) => {
358
+ map.delete(k);
359
+ },
360
+ };
361
+ const store = createNativeAuthStateStore(storage);
362
+
363
+ await expect(
364
+ store.save({ ...SAMPLE, deviceId: 'dev-x', deviceSecret: 'ds-x' }),
365
+ ).resolves.toBe(false);
366
+ expect(map.get(AUTH_STATE_STORAGE_KEY)).toBeUndefined();
367
+ expect(await store.load()).toMatchObject({ deviceId: 'dev-x', deviceSecret: 'ds-x' });
368
+ });
369
+
312
370
  it('persists the durable credential even when the warm-token write fails (oversize SecureStore value)', async () => {
313
371
  const map = new Map<string, string>();
314
372
  const storage: NativeKeyValueStorage = {
@@ -329,7 +387,7 @@ describe('createNativeAuthStateStore', () => {
329
387
 
330
388
  await expect(
331
389
  store.save({ ...SAMPLE, deviceId: 'dev-n', deviceSecret: 'ds-n' }),
332
- ).resolves.toBeUndefined();
390
+ ).resolves.toBe(true);
333
391
 
334
392
  // The durable mint credential landed to disk.
335
393
  expect(map.get(AUTH_STATE_STORAGE_KEY)).toBeTruthy();