@oxyhq/core 12.5.4 → 12.6.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.
Files changed (36) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +4 -1
  3. package/dist/cjs/OxyServices.errors.js +42 -1
  4. package/dist/cjs/OxyServices.js +2 -1
  5. package/dist/cjs/index.js +5 -4
  6. package/dist/cjs/mixins/OxyServices.assets.js +175 -25
  7. package/dist/cjs/session/SessionClient.js +57 -8
  8. package/dist/cjs/utils/redactUrl.js +29 -0
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/HttpService.js +4 -1
  11. package/dist/esm/OxyServices.errors.js +40 -0
  12. package/dist/esm/OxyServices.js +2 -2
  13. package/dist/esm/index.js +1 -1
  14. package/dist/esm/mixins/OxyServices.assets.js +175 -25
  15. package/dist/esm/session/SessionClient.js +57 -8
  16. package/dist/esm/utils/redactUrl.js +26 -0
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/OxyServices.d.ts +2 -2
  19. package/dist/types/OxyServices.errors.d.ts +40 -0
  20. package/dist/types/index.d.ts +2 -2
  21. package/dist/types/mixins/OxyServices.assets.d.ts +103 -13
  22. package/dist/types/models/interfaces.d.ts +18 -0
  23. package/dist/types/session/SessionClient.d.ts +19 -2
  24. package/dist/types/utils/redactUrl.d.ts +17 -0
  25. package/package.json +1 -1
  26. package/src/HttpService.ts +4 -1
  27. package/src/OxyServices.errors.ts +51 -0
  28. package/src/OxyServices.ts +2 -2
  29. package/src/index.ts +3 -1
  30. package/src/mixins/OxyServices.assets.ts +192 -28
  31. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +265 -1
  32. package/src/models/interfaces.ts +20 -0
  33. package/src/session/SessionClient.ts +59 -8
  34. package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +170 -0
  35. package/src/utils/__tests__/redactUrl.test.ts +33 -0
  36. package/src/utils/redactUrl.ts +28 -0
@@ -7,6 +7,7 @@ import {
7
7
  type DeviceSessionState,
8
8
  } from '@oxyhq/contracts';
9
9
  import { logger } from '../logger';
10
+ import { computeIdentityTag } from '../utils/cacheKey';
10
11
  import { getSocketIO } from './socketLoader';
11
12
  import type { MinimalSocket, SocketIOFactory } from './socketLoader';
12
13
 
@@ -172,8 +173,25 @@ export class SessionClient {
172
173
  }
173
174
  }
174
175
 
175
- /** Validate + last-writer-wins by revision. Returns true if applied. */
176
- protected applyState(raw: unknown, origin: SessionStateOrigin = 'push'): boolean {
176
+ /**
177
+ * Validate + last-writer-wins by revision. Returns true if applied.
178
+ *
179
+ * `activeToken` (sync path only) is the server-issued access token for
180
+ * `raw.activeAccountId`. When present and the state is applied, it is planted
181
+ * BEFORE any subscriber is notified so the bearer already belongs to the new
182
+ * active account — the local switch/bootstrap path then needs no redundant
183
+ * device-secret mint. Push-origin applies carry no token and rely on the
184
+ * mint-before-notify gate below.
185
+ *
186
+ * ORDERING INVARIANT: a subscriber must NEVER observe a newly-active account
187
+ * while the planted bearer still identifies the PREVIOUS one — otherwise a
188
+ * `useCurrentUser`-style refetch fires under the wrong account's token (the
189
+ * account-switch 404 race). So when a transport is available and the planted
190
+ * bearer does not already belong to `next.activeAccountId`, minting is awaited
191
+ * BEFORE `notify()`. This covers EVERY notify source (a switch push, a
192
+ * cross-device push, a cold mint), not just the initial "no bearer yet" case.
193
+ */
194
+ protected applyState(raw: unknown, origin: SessionStateOrigin = 'push', activeToken?: string): boolean {
177
195
  const next = safeParseContract(deviceSessionStateSchema, raw);
178
196
  if (!next) {
179
197
  logger.warn('[SessionClient] discarded invalid session state');
@@ -192,10 +210,27 @@ export class SessionClient {
192
210
  ) {
193
211
  return false;
194
212
  }
213
+ const previousState = this.state;
195
214
  this.state = next;
215
+ // Plant the sync-supplied active token (it is for `next.activeAccountId`)
216
+ // now — before the notify below — so the bearer matches the new active
217
+ // account when subscribers observe it. Guarded on difference to avoid a
218
+ // redundant token-change notification on an unchanged token (bootstrap
219
+ // restate).
220
+ if (activeToken && next.activeAccountId !== null && activeToken !== this.host.getAccessToken()) {
221
+ this.host.setTokens(activeToken);
222
+ }
196
223
  const transport = this.options.transport;
224
+ const activeAccountId = next.activeAccountId;
225
+ // Mint before notifying when the bearer does not already belong to the new
226
+ // active account: no bearer at all, an opaque bearer, OR a bearer for a
227
+ // DIFFERENT account. `computeIdentityTag` yields the token's `userId`/`id`
228
+ // for a real JWT (comparable to the account id) and a non-account sentinel
229
+ // otherwise, so a mismatch always resolves to "mint".
197
230
  const needsMintBeforeNotify =
198
- transport != null && next.accounts.length > 0 && !this.host.getAccessToken();
231
+ transport != null &&
232
+ next.accounts.length > 0 &&
233
+ (activeAccountId === null || computeIdentityTag(this.host.getAccessToken()) !== activeAccountId);
199
234
 
200
235
  const finishApply = (): void => {
201
236
  this.notify();
@@ -210,8 +245,10 @@ export class SessionClient {
210
245
 
211
246
  if (needsMintBeforeNotify) {
212
247
  void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
213
- logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
214
- finishApply();
248
+ logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
249
+ // Do NOT notify under a mismatched bearer. Revert to the last applied
250
+ // state so subscribers keep observing the account whose token is planted.
251
+ this.state = previousState ?? null;
215
252
  });
216
253
  } else {
217
254
  if (transport) {
@@ -251,9 +288,23 @@ export class SessionClient {
251
288
  }
252
289
  // A `sync` is always the response to a direct REST call this client made
253
290
  // (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
254
- // verdict.
255
- this.applyState(sync.state, 'request');
256
- if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
291
+ // verdict. Hand the active token to `applyState`: in the applied path it is
292
+ // planted BEFORE notify (bearer matches the new active account when
293
+ // subscribers observe it, and no redundant device-secret mint is triggered).
294
+ const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
295
+ // Equal-revision restate (this revision was already applied by a preceding
296
+ // socket push): `applyState` no-ops without planting, but the token still
297
+ // needs planting. Guard on the sync's active account STILL being the current
298
+ // active account so a stale response cannot adopt a token for an account a
299
+ // newer state already switched away from.
300
+ if (
301
+ !applied &&
302
+ sync.activeToken &&
303
+ this.state &&
304
+ sync.state.activeAccountId !== null &&
305
+ sync.state.activeAccountId === this.state.activeAccountId &&
306
+ sync.activeToken.accessToken !== this.host.getAccessToken()
307
+ ) {
257
308
  this.host.setTokens(sync.activeToken.accessToken);
258
309
  }
259
310
  }
@@ -0,0 +1,170 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import { SessionClient, type SessionClientHost, type TokenTransport } from '../SessionClient';
3
+ import { computeIdentityTag } from '../../utils/cacheKey';
4
+
5
+ /**
6
+ * The account-switch 404 race (regression guard).
7
+ *
8
+ * When a `session_state` push re-elects the active account from A to B, the
9
+ * push carries NO token — the app still holds A's bearer. If a subscriber were
10
+ * notified before B's bearer is planted, a `useCurrentUser`-style refetch would
11
+ * fire under A's token against B's session and 404.
12
+ *
13
+ * INVARIANT: no subscriber is ever notified while the planted bearer identifies
14
+ * an account OTHER than the observed active account. This test records, at every
15
+ * notify, the observed active account alongside the account the CURRENT bearer
16
+ * belongs to (via the same `computeIdentityTag` derivation `applyState` uses)
17
+ * and asserts they always match — and that the switch notify is DEFERRED until
18
+ * the mint lands B's token.
19
+ */
20
+
21
+ /** A minimal jwt-decode-able token whose `userId` claim is `accountId`. */
22
+ function jwtFor(accountId: string): string {
23
+ const payload = Buffer.from(JSON.stringify({ userId: accountId })).toString('base64url');
24
+ return `h.${payload}.s`;
25
+ }
26
+
27
+ const stateWith = (rev: number, active: string): DeviceSessionState => ({
28
+ deviceId: 'd1',
29
+ accounts: [
30
+ { accountId: 'a1', sessionId: 's-a1', authuser: 0 },
31
+ { accountId: 'b1', sessionId: 's-b1', authuser: 1 },
32
+ ],
33
+ activeAccountId: active,
34
+ revision: rev,
35
+ updatedAt: 1720000000000,
36
+ });
37
+
38
+ class TestClient extends SessionClient {
39
+ public apply(raw: unknown): boolean {
40
+ return this.applyState(raw);
41
+ }
42
+ }
43
+
44
+ describe('SessionClient — no notify under a mismatched bearer on an account switch', () => {
45
+ it('defers the switch notify until the mint lands the new active account bearer', async () => {
46
+ // Mutable planted bearer, starting on account A.
47
+ let planted: string | null = jwtFor('a1');
48
+ const host: SessionClientHost = {
49
+ makeRequest: jest.fn(),
50
+ getBaseURL: () => 'http://test.invalid',
51
+ getAccessToken: () => planted,
52
+ getDeviceCredential: () => null,
53
+ onTokensChanged: () => () => undefined,
54
+ setTokens: (token) => {
55
+ planted = token;
56
+ },
57
+ getCurrentAccountId: () => null,
58
+ };
59
+
60
+ // The mint lands the ACTIVE account's bearer, asynchronously (models the
61
+ // real device-secret mint round trip).
62
+ const transport: TokenTransport = {
63
+ ensureActiveToken: jest.fn(async (state: DeviceSessionState) => {
64
+ await Promise.resolve();
65
+ if (state.activeAccountId) {
66
+ planted = jwtFor(state.activeAccountId);
67
+ }
68
+ }),
69
+ };
70
+
71
+ const c = new TestClient(host, { transport });
72
+
73
+ const observations: Array<{ active: string | null; bearer: string }> = [];
74
+ c.subscribe((s) => {
75
+ observations.push({
76
+ active: s?.activeAccountId ?? null,
77
+ bearer: computeIdentityTag(host.getAccessToken()),
78
+ });
79
+ });
80
+
81
+ // Apply A (bearer already A's) → matches → synchronous notify.
82
+ c.apply(stateWith(1, 'a1'));
83
+ expect(observations).toEqual([{ active: 'a1', bearer: 'a1' }]);
84
+
85
+ // A `session_state` push re-elects B while the bearer is still A's.
86
+ c.apply(stateWith(2, 'b1'));
87
+ // The switch notify MUST NOT have fired yet — the bearer is still A's, so a
88
+ // synchronous notify would let a subscriber observe B under A's token.
89
+ expect(observations).toEqual([{ active: 'a1', bearer: 'a1' }]);
90
+
91
+ // Flush the mint + deferred notify.
92
+ for (let i = 0; i < 5; i++) {
93
+ await Promise.resolve();
94
+ }
95
+
96
+ // The mint ran, and the switch notify fired only AFTER B's bearer was planted.
97
+ expect(transport.ensureActiveToken).toHaveBeenCalledWith(
98
+ expect.objectContaining({ activeAccountId: 'b1' }),
99
+ );
100
+ expect(observations).toContainEqual({ active: 'b1', bearer: 'b1' });
101
+
102
+ // At NO notify did the observed active account differ from the bearer's account.
103
+ for (const o of observations) {
104
+ expect(o.bearer).toBe(o.active);
105
+ }
106
+ });
107
+
108
+ it('does not defer when the bearer already belongs to the new active account', () => {
109
+ let planted: string | null = jwtFor('b1');
110
+ const host: SessionClientHost = {
111
+ makeRequest: jest.fn(),
112
+ getBaseURL: () => 'http://test.invalid',
113
+ getAccessToken: () => planted,
114
+ getDeviceCredential: () => null,
115
+ onTokensChanged: () => () => undefined,
116
+ setTokens: (token) => {
117
+ planted = token;
118
+ },
119
+ getCurrentAccountId: () => null,
120
+ };
121
+ const transport: TokenTransport = { ensureActiveToken: jest.fn().mockResolvedValue(undefined) };
122
+ const c = new TestClient(host, { transport });
123
+
124
+ const seen: Array<string | null> = [];
125
+ c.subscribe((s) => seen.push(s?.activeAccountId ?? null));
126
+
127
+ // Bearer is already B's → the notify is synchronous (no mint-before-notify).
128
+ c.apply(stateWith(3, 'b1'));
129
+ expect(seen).toEqual(['b1']);
130
+ });
131
+
132
+ it('reverts state and does not notify when minting fails on an account switch', async () => {
133
+ let planted: string | null = jwtFor('a1');
134
+ const host: SessionClientHost = {
135
+ makeRequest: jest.fn(),
136
+ getBaseURL: () => 'http://test.invalid',
137
+ getAccessToken: () => planted,
138
+ getDeviceCredential: () => null,
139
+ onTokensChanged: () => () => undefined,
140
+ setTokens: (token) => {
141
+ planted = token;
142
+ },
143
+ getCurrentAccountId: () => null,
144
+ };
145
+
146
+ const transport: TokenTransport = {
147
+ ensureActiveToken: jest.fn(async () => {
148
+ await Promise.resolve();
149
+ throw new Error('mint failed');
150
+ }),
151
+ };
152
+
153
+ const c = new TestClient(host, { transport });
154
+ const seen: Array<string | null> = [];
155
+ c.subscribe((s) => seen.push(s?.activeAccountId ?? null));
156
+
157
+ c.apply(stateWith(1, 'a1'));
158
+ expect(seen).toEqual(['a1']);
159
+
160
+ c.apply(stateWith(2, 'b1'));
161
+ for (let i = 0; i < 5; i++) {
162
+ await Promise.resolve();
163
+ }
164
+
165
+ // Still on A — no notify under A's bearer for B's active account.
166
+ expect(seen).toEqual(['a1']);
167
+ expect(c.getState()?.activeAccountId).toBe('a1');
168
+ expect(computeIdentityTag(host.getAccessToken())).toBe('a1');
169
+ });
170
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `redactUrlQuery` tests — the query-string scrubber used before any asset URL
3
+ * reaches a log sink. Asset stream URLs carry a scoped `mt=` media token that
4
+ * is a bearer credential; it must never be logged.
5
+ */
6
+
7
+ import { redactUrlQuery } from '../redactUrl';
8
+
9
+ describe('redactUrlQuery', () => {
10
+ it('strips the query string (including a media token) from an absolute URL', () => {
11
+ const redacted = redactUrlQuery(
12
+ 'https://api.oxy.so/assets/priv1/stream?variant=thumb&mt=SECRET-TOKEN',
13
+ );
14
+ expect(redacted).toBe('https://api.oxy.so/assets/priv1/stream?<redacted>');
15
+ expect(redacted).not.toContain('mt=');
16
+ expect(redacted).not.toContain('SECRET-TOKEN');
17
+ });
18
+
19
+ it('strips the query string from a relative path too', () => {
20
+ expect(redactUrlQuery('/assets/priv1/url?expiresIn=600&mt=SECRET')).toBe(
21
+ '/assets/priv1/url?<redacted>',
22
+ );
23
+ });
24
+
25
+ it('returns a URL without a query string unchanged', () => {
26
+ expect(redactUrlQuery('https://cloud.oxy.so/pub1')).toBe('https://cloud.oxy.so/pub1');
27
+ expect(redactUrlQuery('/assets')).toBe('/assets');
28
+ });
29
+
30
+ it('passes through empty input', () => {
31
+ expect(redactUrlQuery('')).toBe('');
32
+ });
33
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * URL redaction for logging.
3
+ *
4
+ * Asset URLs the API hands back for private assets carry a scoped, short-lived
5
+ * media token (`mt=…`) in their query string. That token is a bearer credential
6
+ * for the underlying object, so it must never land in a log line, breadcrumb,
7
+ * or metric — a captured log would otherwise grant read access until the token
8
+ * expires. Query strings on API URLs can also carry other sensitive params, so
9
+ * we redact the whole query rather than allow-listing one key.
10
+ *
11
+ * `redactUrlQuery` returns the URL's path portion with a `?<redacted>` marker
12
+ * when a query string is present, and the input unchanged otherwise. It is
13
+ * defensive: any input that does not parse as a URL is passed through as-is,
14
+ * except that a bare `?query` tail is still stripped so a relative path with a
15
+ * query never leaks.
16
+ */
17
+ export function redactUrlQuery(url: string): string {
18
+ if (typeof url !== 'string' || url.length === 0) {
19
+ return url;
20
+ }
21
+
22
+ const queryIndex = url.indexOf('?');
23
+ if (queryIndex === -1) {
24
+ return url;
25
+ }
26
+
27
+ return `${url.slice(0, queryIndex)}?<redacted>`;
28
+ }