@oxyhq/core 5.4.3 → 5.5.0

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.
@@ -95,12 +95,13 @@ export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './uti
95
95
  export { generateSsoState } from './mixins/OxyServices.sso';
96
96
  export { establishIdpSessionAfterClaim } from './utils/ssoEstablish';
97
97
  export type { SsoEstablishClient, EstablishAfterClaimDeps } from './utils/ssoEstablish';
98
- export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoPriorSessionKey, ssoSignedOutKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, silentRestoreSuppressed, allowSsoBounce, } from './utils/ssoBounce';
98
+ export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoPriorSessionKey, ssoSignedOutKey, ssoOutcomeKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, silentRestoreSuppressed, allowSsoBounce, } from './utils/ssoBounce';
99
99
  export type { SsoBounceGate } from './utils/ssoBounce';
100
100
  export { runColdBoot } from './utils/coldBoot';
101
101
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
102
102
  export { SessionClient } from './session/SessionClient';
103
103
  export type { TokenTransport, SessionClientHost, SessionClientOptions } from './session/SessionClient';
104
+ export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
104
105
  export { createSessionClientHost } from './session/sessionClientHost';
105
106
  export { createSessionClient } from './session/createSessionClient';
106
107
  export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountIdsOf, } from './session/projectSessionState';
@@ -1,5 +1,5 @@
1
1
  import { type DeviceSessionState } from '@oxyhq/contracts';
2
- import type { MinimalSocket } from './socketLoader';
2
+ import type { MinimalSocket, SocketIOFactory } from './socketLoader';
3
3
  export interface TokenTransport {
4
4
  /** Ensure this app holds a per-domain access token for state.activeAccountId (mint via FedCM/silent/sso/keychain). Best-effort. */
5
5
  ensureActiveToken(state: DeviceSessionState): Promise<void>;
@@ -16,6 +16,18 @@ export interface SessionClientHost {
16
16
  }
17
17
  export interface SessionClientOptions {
18
18
  transport?: TokenTransport;
19
+ /**
20
+ * Statically-injected `socket.io-client` factory (its `io` export).
21
+ * `@oxyhq/services` and `@oxyhq/auth` list `socket.io-client` as a real
22
+ * dependency and pass `io` in directly, so realtime session sync never
23
+ * depends on a runtime dynamic `import('socket.io-client')` of a bare
24
+ * specifier — which is bundler-fragile in Metro/Expo-web and Vite when
25
+ * `@oxyhq/core` is consumed as its published dist (the import resolves to
26
+ * nothing → `connectSocket` warns and falls back to REST-only). When this is
27
+ * provided, `connectSocket` uses it and never touches the lazy loader; when
28
+ * absent it falls back to `getSocketIO()`.
29
+ */
30
+ socketFactory?: SocketIOFactory;
19
31
  }
20
32
  type StateListener = (state: DeviceSessionState | null) => void;
21
33
  export declare class SessionClient {
@@ -1,5 +1,6 @@
1
1
  import type { OxyServices } from '../OxyServices';
2
2
  import { SessionClient, type TokenTransport } from './SessionClient';
3
+ import type { SocketIOFactory } from './socketLoader';
3
4
  import { createSessionClientHost } from './sessionClientHost';
4
5
  /**
5
6
  * Wires a `SessionClient` over the given `OxyServices` instance: builds the
@@ -16,8 +17,14 @@ import { createSessionClientHost } from './sessionClientHost';
16
17
  * The host is returned alongside the client (not just the client) so the
17
18
  * caller can call `host.setCurrentAccountId(...)` as the active account
18
19
  * changes.
20
+ *
21
+ * `socketFactory` is the statically-injected `socket.io-client` `io` export.
22
+ * Consumers that bundle socket.io-client as a real dependency pass it so
23
+ * realtime sync never depends on core's lazy dynamic import of a bare
24
+ * specifier (bundler-fragile in Metro/Expo-web and Vite against the published
25
+ * dist). When omitted, the client falls back to the lazy loader.
19
26
  */
20
- export declare function createSessionClient(oxyServices: OxyServices, transport: TokenTransport): {
27
+ export declare function createSessionClient(oxyServices: OxyServices, transport: TokenTransport, socketFactory?: SocketIOFactory): {
21
28
  client: SessionClient;
22
29
  host: ReturnType<typeof createSessionClientHost>;
23
30
  };
@@ -122,6 +122,24 @@ export declare function ssoPriorSessionKey(origin: string): string;
122
122
  * clears it first, so the user can always sign back in.
123
123
  */
124
124
  export declare function ssoSignedOutKey(origin: string): string;
125
+ /**
126
+ * Per-origin key holding the LAST consumed SSO-return outcome (`ok` | `none` |
127
+ * `error`, plus an optional machine-readable `reason` on the non-`ok` outcomes).
128
+ *
129
+ * Lives in per-tab `sessionStorage` like the other loop-breaker keys, and for
130
+ * the same reason: a `none`/`error` return HARD-navigates the RP off the
131
+ * internal callback path back to its real destination (a fresh document load),
132
+ * so the outcome an RP wants to render ("the central IdP had no session — show a
133
+ * branded sign-in screen instead of bouncing again") must survive that
134
+ * round-trip. The RP reads it on the destination load to decide whether an
135
+ * AUTOMATIC (guard-driven) sign-in should re-bounce or defer to a user gesture.
136
+ *
137
+ * Written as a small JSON blob (`{kind, reason?}`). Set whenever a return is
138
+ * consumed; cleared on a successful session commit and on an explicit
139
+ * user-gesture sign-in / full sign-out (so a deliberate retry is never
140
+ * suppressed by a prior automatic none/error).
141
+ */
142
+ export declare function ssoOutcomeKey(origin: string): string;
125
143
  /**
126
144
  * Per-origin marker written by the pre-hydration callback bootstrap.
127
145
  *
@@ -29,12 +29,22 @@ export type SsoReturnKind = 'ok' | 'none' | 'error';
29
29
  * The parsed result of an SSO return fragment.
30
30
  *
31
31
  * `code` is present only for `kind: 'ok'`. `state` echoes the CSRF state the RP
32
- * generated for the bounce (when the IdP round-tripped it).
32
+ * generated for the bounce (when the IdP round-tripped it). `reason` is present
33
+ * only for a NON-`ok` outcome when the IdP supplied one.
33
34
  */
34
35
  export interface SsoReturnResult {
35
36
  kind: SsoReturnKind;
36
37
  code?: string;
37
38
  state?: string;
39
+ /**
40
+ * Machine-readable reason accompanying a NON-`ok` outcome, when the central
41
+ * IdP supplies one (e.g. `no_cookie` | `stale_session` | `no_grant` |
42
+ * `no_grant_establish` on a `none` bounce). Purely informational: it lets a
43
+ * Relying Party surface WHY a silent probe returned no session (e.g. to brand
44
+ * a "sign in" screen) without re-deriving it. Absent on `ok`, and whenever the
45
+ * IdP did not include a `reason` param.
46
+ */
47
+ reason?: string;
38
48
  }
39
49
  /**
40
50
  * Parse an SSO return fragment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "5.4.3",
3
+ "version": "5.5.0",
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",
package/src/index.ts CHANGED
@@ -545,6 +545,7 @@ export {
545
545
  ssoAttemptedKey,
546
546
  ssoPriorSessionKey,
547
547
  ssoSignedOutKey,
548
+ ssoOutcomeKey,
548
549
  ssoCallbackBootstrapKey,
549
550
  ssoNavigate,
550
551
  getSsoCallbackBootstrapScript,
@@ -571,6 +572,10 @@ export type {
571
572
  // ---------------------------------------------------------------------------
572
573
  export { SessionClient } from './session/SessionClient';
573
574
  export type { TokenTransport, SessionClientHost, SessionClientOptions } from './session/SessionClient';
575
+ // The injectable socket factory type: consumers that bundle socket.io-client
576
+ // (services/auth-sdk) pass its `io` export as `socketFactory` so realtime sync
577
+ // never relies on core's lazy dynamic import of a bare specifier.
578
+ export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
574
579
 
575
580
  // Shared SessionClient integration layer: the host adapter, the pure
576
581
  // DeviceSessionState projection helpers, and the client factory are defined
@@ -6,7 +6,7 @@ import {
6
6
  } from '@oxyhq/contracts';
7
7
  import { logger } from '../utils/loggerUtils';
8
8
  import { getSocketIO } from './socketLoader';
9
- import type { MinimalSocket } from './socketLoader';
9
+ import type { MinimalSocket, SocketIOFactory } from './socketLoader';
10
10
 
11
11
  export interface TokenTransport {
12
12
  /** Ensure this app holds a per-domain access token for state.activeAccountId (mint via FedCM/silent/sso/keychain). Best-effort. */
@@ -24,6 +24,18 @@ export interface SessionClientHost {
24
24
 
25
25
  export interface SessionClientOptions {
26
26
  transport?: TokenTransport;
27
+ /**
28
+ * Statically-injected `socket.io-client` factory (its `io` export).
29
+ * `@oxyhq/services` and `@oxyhq/auth` list `socket.io-client` as a real
30
+ * dependency and pass `io` in directly, so realtime session sync never
31
+ * depends on a runtime dynamic `import('socket.io-client')` of a bare
32
+ * specifier — which is bundler-fragile in Metro/Expo-web and Vite when
33
+ * `@oxyhq/core` is consumed as its published dist (the import resolves to
34
+ * nothing → `connectSocket` warns and falls back to REST-only). When this is
35
+ * provided, `connectSocket` uses it and never touches the lazy loader; when
36
+ * absent it falls back to `getSocketIO()`.
37
+ */
38
+ socketFactory?: SocketIOFactory;
27
39
  }
28
40
 
29
41
  type StateListener = (state: DeviceSessionState | null) => void;
@@ -157,7 +169,10 @@ export class SessionClient {
157
169
  }
158
170
 
159
171
  private async connectSocket(): Promise<void> {
160
- const io = await getSocketIO();
172
+ // Prefer a statically-injected factory (services/auth-sdk bundle
173
+ // socket.io-client as a real dep); fall back to the lazy loader — and warn
174
+ // if THAT yields nothing — only when no factory was injected.
175
+ const io = this.options.socketFactory ?? (await getSocketIO());
161
176
  if (!io) {
162
177
  logger.warn('[SessionClient] no socket.io-client; running REST-only (no realtime sync)', { component: 'SessionClient' });
163
178
  return;
@@ -0,0 +1,79 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import * as socketLoader from '../socketLoader';
3
+ import type { MinimalSocket, SocketIOFactory } from '../socketLoader';
4
+ import { SessionClient, type SessionClientHost } from '../SessionClient';
5
+
6
+ /**
7
+ * P0 (bundler-fragility): SessionClient must NOT depend on a runtime dynamic
8
+ * `import('socket.io-client')` when a consumer (services/auth-sdk) injects the
9
+ * `io` factory statically. These tests pin the two branches of `connectSocket`:
10
+ *
11
+ * 1. `socketFactory` injected → the injected factory is used and the lazy
12
+ * loader (`getSocketIO`) is NEVER invoked (the exact call that fails in the
13
+ * Metro/Expo-web + Vite published-dist consumers).
14
+ * 2. no `socketFactory` → the lazy loader IS invoked (fallback).
15
+ */
16
+
17
+ type Handler = (...args: unknown[]) => void;
18
+ class FakeSocket implements MinimalSocket {
19
+ connected = false;
20
+ handlers = new Map<string, Handler[]>();
21
+ on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
22
+ off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
23
+ connect() { this.connected = true; }
24
+ disconnect() { this.connected = false; }
25
+ }
26
+
27
+ const STATE = (rev: number): DeviceSessionState => ({ deviceId: 'd1', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: rev, updatedAt: 1720000000000 });
28
+ const SYNC = (rev: number) => ({ state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } });
29
+
30
+ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
31
+ return {
32
+ makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
33
+ getBaseURL: () => 'http://test.invalid',
34
+ getAccessToken: () => 'tok',
35
+ onTokensChanged: () => () => undefined,
36
+ setTokens: jest.fn(),
37
+ getCurrentAccountId: () => 'a1',
38
+ ...over,
39
+ };
40
+ }
41
+
42
+ describe('SessionClient injected socketFactory', () => {
43
+ afterEach(() => {
44
+ jest.restoreAllMocks();
45
+ });
46
+
47
+ it('uses the injected factory and NEVER invokes the lazy loader', async () => {
48
+ const loaderSpy = jest.spyOn(socketLoader, 'getSocketIO');
49
+ let created: FakeSocket | null = null;
50
+ const factory: SocketIOFactory = jest.fn((_uri: string) => {
51
+ created = new FakeSocket();
52
+ created.connected = true;
53
+ return created;
54
+ });
55
+
56
+ const client = new SessionClient(makeHost(), { socketFactory: factory });
57
+ await client.start();
58
+
59
+ expect(factory).toHaveBeenCalledTimes(1);
60
+ expect(factory).toHaveBeenCalledWith('http://test.invalid', expect.objectContaining({ transports: ['websocket'] }));
61
+ expect(loaderSpy).not.toHaveBeenCalled();
62
+ expect(created).not.toBeNull();
63
+ client.stop();
64
+ });
65
+
66
+ it('falls back to the lazy loader when no factory is injected', async () => {
67
+ const fake = new FakeSocket();
68
+ fake.connected = true;
69
+ const lazyFactory: SocketIOFactory = jest.fn(() => fake);
70
+ const loaderSpy = jest.spyOn(socketLoader, 'getSocketIO').mockResolvedValue(lazyFactory);
71
+
72
+ const client = new SessionClient(makeHost());
73
+ await client.start();
74
+
75
+ expect(loaderSpy).toHaveBeenCalledTimes(1);
76
+ expect(lazyFactory).toHaveBeenCalledWith('http://test.invalid', expect.objectContaining({ transports: ['websocket'] }));
77
+ client.stop();
78
+ });
79
+ });
@@ -1,5 +1,6 @@
1
1
  import type { OxyServices } from '../OxyServices';
2
2
  import { SessionClient, type TokenTransport } from './SessionClient';
3
+ import type { SocketIOFactory } from './socketLoader';
3
4
  import { createSessionClientHost } from './sessionClientHost';
4
5
 
5
6
  /**
@@ -17,15 +18,22 @@ import { createSessionClientHost } from './sessionClientHost';
17
18
  * The host is returned alongside the client (not just the client) so the
18
19
  * caller can call `host.setCurrentAccountId(...)` as the active account
19
20
  * changes.
21
+ *
22
+ * `socketFactory` is the statically-injected `socket.io-client` `io` export.
23
+ * Consumers that bundle socket.io-client as a real dependency pass it so
24
+ * realtime sync never depends on core's lazy dynamic import of a bare
25
+ * specifier (bundler-fragile in Metro/Expo-web and Vite against the published
26
+ * dist). When omitted, the client falls back to the lazy loader.
20
27
  */
21
28
  export function createSessionClient(
22
29
  oxyServices: OxyServices,
23
30
  transport: TokenTransport,
31
+ socketFactory?: SocketIOFactory,
24
32
  ): {
25
33
  client: SessionClient;
26
34
  host: ReturnType<typeof createSessionClientHost>;
27
35
  } {
28
36
  const host = createSessionClientHost(oxyServices);
29
- const client = new SessionClient(host, { transport });
37
+ const client = new SessionClient(host, { transport, socketFactory });
30
38
  return { client, host };
31
39
  }
@@ -70,6 +70,13 @@ describe('parseSsoReturnFragment', () => {
70
70
 
71
71
  expect(result).toEqual({ kind: 'ok', code: 'a+b/c', state: 's t' });
72
72
  });
73
+
74
+ it('ignores a stray reason on an ok outcome', () => {
75
+ const result = parseSsoReturnFragment('#oxy_sso=ok&code=abc123&state=xyz&reason=no_cookie');
76
+
77
+ expect(result).toEqual({ kind: 'ok', code: 'abc123', state: 'xyz' });
78
+ expect(result?.reason).toBeUndefined();
79
+ });
73
80
  });
74
81
 
75
82
  describe('none', () => {
@@ -85,6 +92,18 @@ describe('parseSsoReturnFragment', () => {
85
92
  expect(result).toEqual({ kind: 'none', state: 'xyz' });
86
93
  expect(result?.code).toBeUndefined();
87
94
  });
95
+
96
+ it('carries a machine-readable reason on a none outcome', () => {
97
+ const result = parseSsoReturnFragment('#oxy_sso=none&reason=no_cookie&state=xyz');
98
+
99
+ expect(result).toEqual({ kind: 'none', state: 'xyz', reason: 'no_cookie' });
100
+ });
101
+
102
+ it('omits reason when the none outcome carries none', () => {
103
+ const result = parseSsoReturnFragment('#oxy_sso=none&state=xyz');
104
+
105
+ expect(result?.reason).toBeUndefined();
106
+ });
88
107
  });
89
108
 
90
109
  describe('error', () => {
@@ -100,6 +119,12 @@ describe('parseSsoReturnFragment', () => {
100
119
  expect(result).toEqual({ kind: 'error' });
101
120
  expect(result?.code).toBeUndefined();
102
121
  });
122
+
123
+ it('carries a machine-readable reason on an error outcome', () => {
124
+ const result = parseSsoReturnFragment('#oxy_sso=error&reason=no_grant_establish');
125
+
126
+ expect(result).toEqual({ kind: 'error', reason: 'no_grant_establish' });
127
+ });
103
128
  });
104
129
 
105
130
  describe('null (not an oxy_sso fragment)', () => {
@@ -70,6 +70,7 @@ const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
70
70
  const CALLBACK_BOOTSTRAP_KEY_PREFIX = 'oxy_sso_callback_bootstrap:';
71
71
  const PRIOR_SESSION_KEY_PREFIX = 'oxy_sso_prior_session:';
72
72
  const SIGNED_OUT_KEY_PREFIX = 'oxy_signed_out:';
73
+ const OUTCOME_KEY_PREFIX = 'oxy_sso_outcome:';
73
74
 
74
75
  /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
75
76
  export function ssoStateKey(origin: string): string {
@@ -157,6 +158,27 @@ export function ssoSignedOutKey(origin: string): string {
157
158
  return `${SIGNED_OUT_KEY_PREFIX}${origin}`;
158
159
  }
159
160
 
161
+ /**
162
+ * Per-origin key holding the LAST consumed SSO-return outcome (`ok` | `none` |
163
+ * `error`, plus an optional machine-readable `reason` on the non-`ok` outcomes).
164
+ *
165
+ * Lives in per-tab `sessionStorage` like the other loop-breaker keys, and for
166
+ * the same reason: a `none`/`error` return HARD-navigates the RP off the
167
+ * internal callback path back to its real destination (a fresh document load),
168
+ * so the outcome an RP wants to render ("the central IdP had no session — show a
169
+ * branded sign-in screen instead of bouncing again") must survive that
170
+ * round-trip. The RP reads it on the destination load to decide whether an
171
+ * AUTOMATIC (guard-driven) sign-in should re-bounce or defer to a user gesture.
172
+ *
173
+ * Written as a small JSON blob (`{kind, reason?}`). Set whenever a return is
174
+ * consumed; cleared on a successful session commit and on an explicit
175
+ * user-gesture sign-in / full sign-out (so a deliberate retry is never
176
+ * suppressed by a prior automatic none/error).
177
+ */
178
+ export function ssoOutcomeKey(origin: string): string {
179
+ return `${OUTCOME_KEY_PREFIX}${origin}`;
180
+ }
181
+
160
182
  /**
161
183
  * Per-origin marker written by the pre-hydration callback bootstrap.
162
184
  *
@@ -41,12 +41,22 @@ export type SsoReturnKind = 'ok' | 'none' | 'error';
41
41
  * The parsed result of an SSO return fragment.
42
42
  *
43
43
  * `code` is present only for `kind: 'ok'`. `state` echoes the CSRF state the RP
44
- * generated for the bounce (when the IdP round-tripped it).
44
+ * generated for the bounce (when the IdP round-tripped it). `reason` is present
45
+ * only for a NON-`ok` outcome when the IdP supplied one.
45
46
  */
46
47
  export interface SsoReturnResult {
47
48
  kind: SsoReturnKind;
48
49
  code?: string;
49
50
  state?: string;
51
+ /**
52
+ * Machine-readable reason accompanying a NON-`ok` outcome, when the central
53
+ * IdP supplies one (e.g. `no_cookie` | `stale_session` | `no_grant` |
54
+ * `no_grant_establish` on a `none` bounce). Purely informational: it lets a
55
+ * Relying Party surface WHY a silent probe returned no session (e.g. to brand
56
+ * a "sign in" screen) without re-deriving it. Absent on `ok`, and whenever the
57
+ * IdP did not include a `reason` param.
58
+ */
59
+ reason?: string;
50
60
  }
51
61
 
52
62
  const VALID_KINDS: ReadonlySet<string> = new Set<SsoReturnKind>(['ok', 'none', 'error']);
@@ -99,6 +109,13 @@ export function parseSsoReturnFragment(hash: string | undefined | null): SsoRetu
99
109
  if (code !== null && code.length > 0) {
100
110
  result.code = code;
101
111
  }
112
+ } else {
113
+ // A machine-readable reason accompanies a NON-`ok` outcome when the IdP
114
+ // supplies one. Success carries no reason, so it is only read here.
115
+ const reason = params.get('reason');
116
+ if (reason !== null && reason.length > 0) {
117
+ result.reason = reason;
118
+ }
102
119
  }
103
120
 
104
121
  return result;