@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
@@ -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 {
@@ -5,9 +5,11 @@
5
5
  * and Express.js authentication middleware
6
6
  */
7
7
  import { jwtDecode } from 'jwt-decode';
8
+ import type { LinkPreview } from '@oxyhq/contracts';
8
9
  import type { ApiError, User } from '../models/interfaces';
9
10
  import type { OxyServicesBase } from '../OxyServices.base';
10
11
  import { loadNodeCrypto } from '@oxyhq/protocol';
12
+ import { buildUrl } from '../utils/apiUtils';
11
13
  import { logger } from '../utils/loggerUtils';
12
14
  import { CACHE_TIMES } from './mixinHelpers';
13
15
 
@@ -217,15 +219,14 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
217
219
  image?: string;
218
220
  }> {
219
221
  try {
220
- return await this.makeRequest<{
221
- url: string;
222
- title: string;
223
- description: string;
224
- image?: string;
225
- }>('GET', '/link-metadata', { url }, {
226
- cache: true,
227
- cacheTTL: CACHE_TIMES.EXTRA_LONG,
228
- });
222
+ const path = buildUrl('/links/preview', { url, wait: 1 });
223
+ const preview = await this.makeRequest<LinkPreview>('GET', path, undefined, { cache: false });
224
+ return {
225
+ url: preview.url,
226
+ title: preview.title?.trim() || preview.url.replace(/^https?:\/\//, '').replace(/\/$/, ''),
227
+ description: preview.description?.trim() || 'Link',
228
+ image: preview.image,
229
+ };
229
230
  } catch (error) {
230
231
  throw this.handleError(error);
231
232
  }
@@ -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
@@ -82,6 +105,10 @@ export class SessionClient {
82
105
  private started = false;
83
106
  /** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
84
107
  private channel: SessionBroadcastChannel | null = null;
108
+ /** App-facing subscriptions to named server-pushed socket events. */
109
+ private readonly serverEvents = new Map<string, Set<(payload: unknown) => void>>();
110
+ /** Event names already bound on the CURRENT socket instance. */
111
+ private readonly boundServerEvents = new Set<string>();
85
112
 
86
113
  constructor(
87
114
  protected readonly host: SessionClientHost,
@@ -99,6 +126,40 @@ export class SessionClient {
99
126
  };
100
127
  }
101
128
 
129
+ /**
130
+ * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
131
+ * Listeners survive reconnects and socket re-creation; the returned function
132
+ * unsubscribes. Payloads are delivered as-is — callers validate shape.
133
+ */
134
+ onServerEvent(event: string, listener: (payload: unknown) => void): () => void {
135
+ let listeners = this.serverEvents.get(event);
136
+ if (!listeners) {
137
+ listeners = new Set();
138
+ this.serverEvents.set(event, listeners);
139
+ }
140
+ listeners.add(listener);
141
+ this.bindServerEvent(event);
142
+ return () => {
143
+ listeners.delete(listener);
144
+ };
145
+ }
146
+
147
+ private bindServerEvent(event: string): void {
148
+ if (!this.socket || this.boundServerEvents.has(event)) return;
149
+ this.boundServerEvents.add(event);
150
+ this.socket.on(event, (payload: unknown) => {
151
+ const listeners = this.serverEvents.get(event);
152
+ if (!listeners) return;
153
+ for (const listener of [...listeners]) {
154
+ try {
155
+ listener(payload);
156
+ } catch (error) {
157
+ logger.warn('[SessionClient] server-event listener threw', { component: 'SessionClient' }, error);
158
+ }
159
+ }
160
+ });
161
+ }
162
+
102
163
  protected notify(): void {
103
164
  for (const listener of this.listeners) {
104
165
  try {
@@ -110,7 +171,7 @@ export class SessionClient {
110
171
  }
111
172
 
112
173
  /** Validate + last-writer-wins by revision. Returns true if applied. */
113
- protected applyState(raw: unknown): boolean {
174
+ protected applyState(raw: unknown, origin: SessionStateOrigin = 'push'): boolean {
114
175
  const next = safeParseContract(deviceSessionStateSchema, raw);
115
176
  if (!next) {
116
177
  logger.warn('[SessionClient] discarded invalid session state');
@@ -138,7 +199,7 @@ export class SessionClient {
138
199
  this.notify();
139
200
  if (next.accounts.length === 0 && this.options.onUnauthenticated) {
140
201
  try {
141
- this.options.onUnauthenticated();
202
+ this.options.onUnauthenticated(origin);
142
203
  } catch (error) {
143
204
  logger.error('[SessionClient] onUnauthenticated threw', error);
144
205
  }
@@ -186,7 +247,10 @@ export class SessionClient {
186
247
  logger.warn('[SessionClient] discarded invalid session sync', { component: 'SessionClient', issues, keys });
187
248
  return;
188
249
  }
189
- 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');
190
254
  if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
191
255
  this.host.setTokens(sync.activeToken.accessToken);
192
256
  }
@@ -287,6 +351,7 @@ export class SessionClient {
287
351
  if (this.socket) {
288
352
  this.socket.disconnect();
289
353
  this.socket = null;
354
+ this.boundServerEvents.clear();
290
355
  }
291
356
  }
292
357
 
@@ -327,7 +392,9 @@ export class SessionClient {
327
392
  },
328
393
  });
329
394
  socket.on('session_state', (payload: unknown) => {
330
- 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');
331
398
  if (!applied) return;
332
399
  // A push changed the active account on another device/tab — re-fetch state
333
400
  // to plant the access token for the newly-active account. When this tab is
@@ -341,6 +408,11 @@ export class SessionClient {
341
408
  }
342
409
  });
343
410
  this.socket = socket;
411
+ // (Re)bind app-facing server-event subscriptions on the fresh socket.
412
+ this.boundServerEvents.clear();
413
+ for (const event of this.serverEvents.keys()) {
414
+ this.bindServerEvent(event);
415
+ }
344
416
  }
345
417
 
346
418
  /**
@@ -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
  });