@oxyhq/core 5.4.2 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/mixins/OxyServices.user.js +30 -8
- package/dist/cjs/session/SessionClient.js +4 -1
- package/dist/cjs/session/createSessionClient.js +8 -2
- package/dist/cjs/utils/ssoBounce.js +22 -0
- package/dist/cjs/utils/ssoReturn.js +8 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.user.js +30 -8
- package/dist/esm/session/SessionClient.js +4 -1
- package/dist/esm/session/createSessionClient.js +8 -2
- package/dist/esm/utils/ssoBounce.js +21 -0
- package/dist/esm/utils/ssoReturn.js +8 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mixins/OxyServices.user.d.ts +32 -6
- package/dist/types/session/SessionClient.d.ts +13 -1
- package/dist/types/session/createSessionClient.d.ts +8 -1
- package/dist/types/utils/ssoBounce.d.ts +18 -0
- package/dist/types/utils/ssoReturn.d.ts +11 -1
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/mixins/OxyServices.user.ts +30 -8
- package/src/mixins/__tests__/userReadCacheBypass.test.ts +121 -0
- package/src/session/SessionClient.ts +17 -2
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +79 -0
- package/src/session/createSessionClient.ts +9 -1
- package/src/utils/__tests__/ssoReturn.test.ts +25 -0
- package/src/utils/ssoBounce.ts +22 -0
- package/src/utils/ssoReturn.ts +18 -1
package/dist/types/index.d.ts
CHANGED
|
@@ -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';
|
|
@@ -60,9 +60,22 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
60
60
|
_serviceApiKey: string | null;
|
|
61
61
|
_serviceApiSecret: string | null;
|
|
62
62
|
/**
|
|
63
|
-
* Get profile by username
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
* Get profile by username.
|
|
64
|
+
*
|
|
65
|
+
* @param username - The profile's username.
|
|
66
|
+
* @param options.cache - Defaults to `true` (5-minute TTL), matching prior
|
|
67
|
+
* behavior. Pass `{ cache: false }` to force a registry-fresh read: the
|
|
68
|
+
* request bypasses BOTH the cache lookup and the post-fetch cache write
|
|
69
|
+
* (see {@link HttpService.request}'s `cache` handling), so it neither
|
|
70
|
+
* serves nor overwrites any entry already cached for this key — a
|
|
71
|
+
* previously cached response (if one exists) is left in place until its
|
|
72
|
+
* own TTL expires or is explicitly invalidated elsewhere. Use this when a
|
|
73
|
+
* caller must observe a just-written change (e.g. a privacy/consent flag)
|
|
74
|
+
* that would otherwise be masked by the TTL window.
|
|
75
|
+
*/
|
|
76
|
+
getProfileByUsername(username: string, options?: {
|
|
77
|
+
cache?: boolean;
|
|
78
|
+
}): Promise<User>;
|
|
66
79
|
/**
|
|
67
80
|
* Lightweight username lookup for login flows.
|
|
68
81
|
* Returns minimal public info: exists, color, avatar, name.displayName.
|
|
@@ -134,9 +147,22 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
134
147
|
*/
|
|
135
148
|
getSimilarProfiles(userId: string, limit?: number): Promise<User[]>;
|
|
136
149
|
/**
|
|
137
|
-
* Get user by ID
|
|
138
|
-
|
|
139
|
-
|
|
150
|
+
* Get user by ID.
|
|
151
|
+
*
|
|
152
|
+
* @param userId - The target user's id.
|
|
153
|
+
* @param options.cache - Defaults to `true` (5-minute TTL), matching prior
|
|
154
|
+
* behavior. Pass `{ cache: false }` to force a registry-fresh read: the
|
|
155
|
+
* request bypasses BOTH the cache lookup and the post-fetch cache write
|
|
156
|
+
* (see {@link HttpService.request}'s `cache` handling), so it neither
|
|
157
|
+
* serves nor overwrites any entry already cached for this key — a
|
|
158
|
+
* previously cached response (if one exists) is left in place until its
|
|
159
|
+
* own TTL expires or is explicitly invalidated elsewhere. Use this when a
|
|
160
|
+
* caller must observe a just-written change (e.g. a privacy/consent flag)
|
|
161
|
+
* that would otherwise be masked by the TTL window.
|
|
162
|
+
*/
|
|
163
|
+
getUserById(userId: string, options?: {
|
|
164
|
+
cache?: boolean;
|
|
165
|
+
}): Promise<User>;
|
|
140
166
|
/**
|
|
141
167
|
* Fetch many users by id in one round-trip per chunk.
|
|
142
168
|
*
|
|
@@ -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
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
|
|
@@ -101,12 +101,23 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
101
101
|
declare _serviceApiSecret: string | null;
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
* Get profile by username
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
* Get profile by username.
|
|
105
|
+
*
|
|
106
|
+
* @param username - The profile's username.
|
|
107
|
+
* @param options.cache - Defaults to `true` (5-minute TTL), matching prior
|
|
108
|
+
* behavior. Pass `{ cache: false }` to force a registry-fresh read: the
|
|
109
|
+
* request bypasses BOTH the cache lookup and the post-fetch cache write
|
|
110
|
+
* (see {@link HttpService.request}'s `cache` handling), so it neither
|
|
111
|
+
* serves nor overwrites any entry already cached for this key — a
|
|
112
|
+
* previously cached response (if one exists) is left in place until its
|
|
113
|
+
* own TTL expires or is explicitly invalidated elsewhere. Use this when a
|
|
114
|
+
* caller must observe a just-written change (e.g. a privacy/consent flag)
|
|
115
|
+
* that would otherwise be masked by the TTL window.
|
|
116
|
+
*/
|
|
117
|
+
async getProfileByUsername(username: string, options?: { cache?: boolean }): Promise<User> {
|
|
107
118
|
try {
|
|
108
119
|
const user = await this.makeRequest<User>('GET', `/profiles/username/${username}`, undefined, {
|
|
109
|
-
cache: true,
|
|
120
|
+
cache: options?.cache ?? true,
|
|
110
121
|
cacheTTL: 5 * 60 * 1000, // 5 minutes cache for profiles
|
|
111
122
|
});
|
|
112
123
|
return normalizeUserIdentity(user);
|
|
@@ -335,12 +346,23 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
335
346
|
}
|
|
336
347
|
|
|
337
348
|
/**
|
|
338
|
-
* Get user by ID
|
|
339
|
-
|
|
340
|
-
|
|
349
|
+
* Get user by ID.
|
|
350
|
+
*
|
|
351
|
+
* @param userId - The target user's id.
|
|
352
|
+
* @param options.cache - Defaults to `true` (5-minute TTL), matching prior
|
|
353
|
+
* behavior. Pass `{ cache: false }` to force a registry-fresh read: the
|
|
354
|
+
* request bypasses BOTH the cache lookup and the post-fetch cache write
|
|
355
|
+
* (see {@link HttpService.request}'s `cache` handling), so it neither
|
|
356
|
+
* serves nor overwrites any entry already cached for this key — a
|
|
357
|
+
* previously cached response (if one exists) is left in place until its
|
|
358
|
+
* own TTL expires or is explicitly invalidated elsewhere. Use this when a
|
|
359
|
+
* caller must observe a just-written change (e.g. a privacy/consent flag)
|
|
360
|
+
* that would otherwise be masked by the TTL window.
|
|
361
|
+
*/
|
|
362
|
+
async getUserById(userId: string, options?: { cache?: boolean }): Promise<User> {
|
|
341
363
|
try {
|
|
342
364
|
const user = await this.makeRequest<User>('GET', `/users/${userId}`, undefined, {
|
|
343
|
-
cache: true,
|
|
365
|
+
cache: options?.cache ?? true,
|
|
344
366
|
cacheTTL: 5 * 60 * 1000, // 5 minutes cache
|
|
345
367
|
});
|
|
346
368
|
return normalizeUserIdentity(user);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-call cache-bypass tests for `getUserById` / `getProfileByUsername`.
|
|
3
|
+
*
|
|
4
|
+
* Both mixin methods cache their GET response for 5 minutes via
|
|
5
|
+
* `HttpService`'s identity-scoped TTL cache. A consumer that just wrote a
|
|
6
|
+
* setting readable through one of these endpoints (e.g. Mention's federation
|
|
7
|
+
* consent flag) needs a way to force a registry-fresh read instead of
|
|
8
|
+
* silently getting served the pre-write snapshot for up to 5 minutes.
|
|
9
|
+
*
|
|
10
|
+
* These tests pin down that:
|
|
11
|
+
* - default behavior is UNCHANGED: a second call within the TTL is served
|
|
12
|
+
* from cache (no second network call),
|
|
13
|
+
* - `{ cache: false }` always hits the network, even immediately after a
|
|
14
|
+
* cached call, and never overwrites the still-live cached entry — a
|
|
15
|
+
* subsequent default-cache call keeps serving the ORIGINAL cached value.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { OxyServices } from '../../OxyServices';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a non-verified JWT whose payload decodes to the given claims.
|
|
22
|
+
* `jwtDecode` only base64url-decodes the middle segment (no signature check).
|
|
23
|
+
*/
|
|
24
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
25
|
+
const b64url = (obj: Record<string, unknown>): string =>
|
|
26
|
+
Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
27
|
+
const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
|
|
28
|
+
return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A JSON `Response` mimicking the API's `{ data: ... }` success envelope. */
|
|
32
|
+
function jsonResponse(data: unknown): Response {
|
|
33
|
+
return new Response(JSON.stringify({ data }), {
|
|
34
|
+
status: 200,
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('getUserById / getProfileByUsername cache bypass', () => {
|
|
40
|
+
let originalFetch: typeof globalThis.fetch;
|
|
41
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
42
|
+
let oxy: OxyServices;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
originalFetch = globalThis.fetch;
|
|
46
|
+
fetchMock = jest.fn();
|
|
47
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
48
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
49
|
+
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
globalThis.fetch = originalFetch;
|
|
54
|
+
jest.clearAllMocks();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('getUserById', () => {
|
|
58
|
+
it('default call: a second read within the TTL is served from cache', async () => {
|
|
59
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice' }));
|
|
60
|
+
const first = await oxy.getUserById('user-1');
|
|
61
|
+
expect(first.username).toBe('alice');
|
|
62
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
63
|
+
|
|
64
|
+
const second = await oxy.getUserById('user-1');
|
|
65
|
+
expect(second.username).toBe('alice');
|
|
66
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('{ cache: false } always hits the network, even right after a cached call', async () => {
|
|
70
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice' }));
|
|
71
|
+
await oxy.getUserById('user-1');
|
|
72
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
73
|
+
|
|
74
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice-renamed' }));
|
|
75
|
+
const fresh = await oxy.getUserById('user-1', { cache: false });
|
|
76
|
+
expect(fresh.username).toBe('alice-renamed');
|
|
77
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('{ cache: false } does not overwrite the still-live cached entry', async () => {
|
|
81
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice' }));
|
|
82
|
+
await oxy.getUserById('user-1');
|
|
83
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
84
|
+
|
|
85
|
+
// Bypass read observes server-side truth that has since changed...
|
|
86
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice-renamed' }));
|
|
87
|
+
await oxy.getUserById('user-1', { cache: false });
|
|
88
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
89
|
+
|
|
90
|
+
// ...but a plain cached read afterward still serves the ORIGINAL cached
|
|
91
|
+
// value — the bypass call never wrote to the cache slot.
|
|
92
|
+
const cached = await oxy.getUserById('user-1');
|
|
93
|
+
expect(cached.username).toBe('alice');
|
|
94
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('getProfileByUsername', () => {
|
|
99
|
+
it('default call: a second read within the TTL is served from cache', async () => {
|
|
100
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-2', username: 'bob' }));
|
|
101
|
+
const first = await oxy.getProfileByUsername('bob');
|
|
102
|
+
expect(first.id).toBe('user-2');
|
|
103
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
104
|
+
|
|
105
|
+
const second = await oxy.getProfileByUsername('bob');
|
|
106
|
+
expect(second.id).toBe('user-2');
|
|
107
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('{ cache: false } always hits the network, even right after a cached call', async () => {
|
|
111
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-2', username: 'bob', bio: 'old' }));
|
|
112
|
+
await oxy.getProfileByUsername('bob');
|
|
113
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
114
|
+
|
|
115
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-2', username: 'bob', bio: 'new' }));
|
|
116
|
+
const fresh = await oxy.getProfileByUsername('bob', { cache: false });
|
|
117
|
+
expect((fresh as unknown as { bio: string }).bio).toBe('new');
|
|
118
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -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
|
-
|
|
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)', () => {
|
package/src/utils/ssoBounce.ts
CHANGED
|
@@ -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
|
*
|
package/src/utils/ssoReturn.ts
CHANGED
|
@@ -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;
|