@oxyhq/core 12.5.4 → 12.7.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 (60) 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/crypto/keyManager.js +50 -0
  6. package/dist/cjs/i18n/locales/en-US.json +7 -0
  7. package/dist/cjs/i18n/locales/es-ES.json +7 -0
  8. package/dist/cjs/i18n/locales/locales/en-US.json +7 -0
  9. package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
  10. package/dist/cjs/index.js +5 -4
  11. package/dist/cjs/mixins/OxyServices.assets.js +175 -25
  12. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +319 -0
  13. package/dist/cjs/mixins/index.js +4 -0
  14. package/dist/cjs/session/SessionClient.js +57 -8
  15. package/dist/cjs/utils/redactUrl.js +29 -0
  16. package/dist/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/HttpService.js +4 -1
  18. package/dist/esm/OxyServices.errors.js +40 -0
  19. package/dist/esm/OxyServices.js +2 -2
  20. package/dist/esm/crypto/keyManager.js +50 -0
  21. package/dist/esm/i18n/locales/en-US.json +7 -0
  22. package/dist/esm/i18n/locales/es-ES.json +7 -0
  23. package/dist/esm/i18n/locales/locales/en-US.json +7 -0
  24. package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
  25. package/dist/esm/index.js +1 -1
  26. package/dist/esm/mixins/OxyServices.assets.js +175 -25
  27. package/dist/esm/mixins/OxyServices.deviceTransfer.js +317 -0
  28. package/dist/esm/mixins/index.js +4 -0
  29. package/dist/esm/session/SessionClient.js +57 -8
  30. package/dist/esm/utils/redactUrl.js +26 -0
  31. package/dist/types/.tsbuildinfo +1 -1
  32. package/dist/types/OxyServices.d.ts +2 -2
  33. package/dist/types/OxyServices.errors.d.ts +40 -0
  34. package/dist/types/crypto/keyManager.d.ts +20 -0
  35. package/dist/types/index.d.ts +3 -2
  36. package/dist/types/mixins/OxyServices.assets.d.ts +103 -13
  37. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
  38. package/dist/types/mixins/index.d.ts +2 -1
  39. package/dist/types/models/interfaces.d.ts +18 -0
  40. package/dist/types/session/SessionClient.d.ts +19 -2
  41. package/dist/types/utils/redactUrl.d.ts +17 -0
  42. package/package.json +1 -1
  43. package/src/HttpService.ts +4 -1
  44. package/src/OxyServices.errors.ts +51 -0
  45. package/src/OxyServices.ts +2 -2
  46. package/src/crypto/__tests__/scopedSeed.test.ts +126 -0
  47. package/src/crypto/keyManager.ts +55 -0
  48. package/src/i18n/locales/en-US.json +7 -0
  49. package/src/i18n/locales/es-ES.json +7 -0
  50. package/src/index.ts +7 -1
  51. package/src/mixins/OxyServices.assets.ts +192 -28
  52. package/src/mixins/OxyServices.deviceTransfer.ts +397 -0
  53. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +270 -0
  54. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +265 -1
  55. package/src/mixins/index.ts +6 -0
  56. package/src/models/interfaces.ts +20 -0
  57. package/src/session/SessionClient.ts +59 -8
  58. package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +170 -0
  59. package/src/utils/__tests__/redactUrl.test.ts +33 -0
  60. package/src/utils/redactUrl.ts +28 -0
@@ -12,7 +12,35 @@
12
12
  * private access should use `getFileDownloadUrlAsync()` for a scoped URL.
13
13
  */
14
14
 
15
- import { OxyServices } from '../../OxyServices';
15
+ import { AssetUrlResolutionError, OxyServices } from '../../OxyServices';
16
+
17
+ /**
18
+ * Build a non-verified JWT whose payload decodes to the given claims.
19
+ * `jwtDecode` only base64url-decodes the middle segment (no signature check),
20
+ * so this is enough to give the HTTP cache a distinct per-user identity tag.
21
+ */
22
+ function makeJwt(payload: Record<string, unknown>): string {
23
+ const b64url = (obj: Record<string, unknown>): string =>
24
+ Buffer.from(JSON.stringify(obj)).toString('base64url');
25
+ const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
26
+ return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
27
+ }
28
+
29
+ /** A JSON `Response` mimicking the API's `{ data: ... }` success envelope. */
30
+ function jsonResponse(data: unknown, status = 200): Response {
31
+ return new Response(JSON.stringify({ data }), {
32
+ status,
33
+ headers: { 'content-type': 'application/json' },
34
+ });
35
+ }
36
+
37
+ /** An error `Response` mimicking the API's error body. */
38
+ function errorResponse(status: number, code: string): Response {
39
+ return new Response(JSON.stringify({ error: code, message: code }), {
40
+ status,
41
+ headers: { 'content-type': 'application/json' },
42
+ });
43
+ }
16
44
 
17
45
  describe('OxyServices.getFileDownloadUrl', () => {
18
46
  describe('public assets (no token, no expiresIn) → CDN', () => {
@@ -80,3 +108,239 @@ describe('OxyServices.getFileDownloadUrl', () => {
80
108
  });
81
109
  });
82
110
  });
111
+
112
+ describe('OxyServices.getFileDownloadUrlAsync', () => {
113
+ let originalFetch: typeof globalThis.fetch;
114
+ let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
115
+
116
+ beforeEach(() => {
117
+ originalFetch = globalThis.fetch;
118
+ fetchMock = jest.fn();
119
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
120
+ });
121
+
122
+ afterEach(() => {
123
+ globalThis.fetch = originalFetch;
124
+ jest.clearAllMocks();
125
+ });
126
+
127
+ it('passes the API-scoped private stream URL through UNCHANGED', async () => {
128
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
129
+ oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
130
+
131
+ const scopedUrl =
132
+ 'https://api.oxy.so/assets/priv1/stream?variant=thumb&mt=SCOPED-MEDIA-TOKEN';
133
+ fetchMock.mockResolvedValueOnce(jsonResponse({ url: scopedUrl, variant: 'thumb', expiresIn: 600 }));
134
+
135
+ const resolved = await oxy.getFileDownloadUrlAsync('priv1', 'thumb');
136
+
137
+ // Returned exactly as the API produced it — never rewritten to the CDN.
138
+ expect(resolved).toBe(scopedUrl);
139
+ expect(resolved).not.toContain('cloud.oxy.so');
140
+ // It hit the authorized resolution endpoint, not the public CDN builder.
141
+ const [url] = fetchMock.mock.calls[0] as [string];
142
+ expect(url).toContain('/assets/priv1/url');
143
+ });
144
+
145
+ it('resolves a public asset to the CDN URL the API returns', async () => {
146
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
147
+ oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
148
+
149
+ fetchMock.mockResolvedValueOnce(
150
+ jsonResponse({ url: 'https://cloud.oxy.so/pub1?variant=thumb', variant: 'thumb', expiresIn: 3600 }),
151
+ );
152
+
153
+ const resolved = await oxy.getFileDownloadUrlAsync('pub1', 'thumb');
154
+ expect(resolved).toBe('https://cloud.oxy.so/pub1?variant=thumb');
155
+ });
156
+
157
+ it('THROWS rather than returning a known-404 CDN URL when the API denies access', async () => {
158
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
159
+ oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
160
+
161
+ fetchMock.mockResolvedValue(errorResponse(403, 'Access denied'));
162
+
163
+ await expect(oxy.getFileDownloadUrlAsync('priv1', 'thumb')).rejects.toBeInstanceOf(
164
+ AssetUrlResolutionError,
165
+ );
166
+
167
+ // Prove the failure was surfaced instead of a silent public-CDN fallback.
168
+ const err = await oxy
169
+ .getFileDownloadUrlAsync('priv1', 'thumb')
170
+ .catch((e: unknown) => e as AssetUrlResolutionError);
171
+ expect(err).toBeInstanceOf(AssetUrlResolutionError);
172
+ expect(err.fileId).toBe('priv1');
173
+ expect(err.variant).toBe('thumb');
174
+ expect(err.status).toBe(403);
175
+ // The error must not leak the scoped media token.
176
+ expect(err.message).not.toContain('mt=');
177
+ });
178
+
179
+ it('THROWS when the API returns an empty URL body', async () => {
180
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
181
+ oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
182
+
183
+ fetchMock.mockResolvedValueOnce(jsonResponse({ url: '', variant: undefined, expiresIn: 600 }));
184
+
185
+ await expect(oxy.getFileDownloadUrlAsync('priv1')).rejects.toBeInstanceOf(
186
+ AssetUrlResolutionError,
187
+ );
188
+ });
189
+ });
190
+
191
+ describe('OxyServices asset URL cache TTL', () => {
192
+ const MEDIA_TOKEN_TTL_MS = 10 * 60 * 1000;
193
+
194
+ it('never caches a resolved URL for as long as the media-token TTL', () => {
195
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
196
+
197
+ // Default (no explicit expiry) and an over-long explicit expiry both stay
198
+ // comfortably under the token lifetime.
199
+ expect(oxy.getAssetUrlCacheTTL()).toBeLessThan(MEDIA_TOKEN_TTL_MS);
200
+ expect(oxy.getAssetUrlCacheTTL(3600)).toBeLessThan(MEDIA_TOKEN_TTL_MS);
201
+ // Half of the 10-min bound.
202
+ expect(oxy.getAssetUrlCacheTTL(3600)).toBe(5 * 60 * 1000);
203
+ });
204
+
205
+ it('scales a short requested expiry down proportionally', () => {
206
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
207
+ expect(oxy.getAssetUrlCacheTTL(60)).toBe(30 * 1000);
208
+ });
209
+ });
210
+
211
+ describe('OxyServices.getFileDownloadUrls (variant-aware batch)', () => {
212
+ let originalFetch: typeof globalThis.fetch;
213
+ let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
214
+
215
+ beforeEach(() => {
216
+ originalFetch = globalThis.fetch;
217
+ fetchMock = jest.fn();
218
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
219
+ });
220
+
221
+ afterEach(() => {
222
+ globalThis.fetch = originalFetch;
223
+ jest.clearAllMocks();
224
+ });
225
+
226
+ it('sends a per-file {fileId, variant} list plus expiresIn and keeps only usable URLs', async () => {
227
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
228
+ oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
229
+
230
+ fetchMock.mockResolvedValueOnce(
231
+ jsonResponse({
232
+ results: {
233
+ img1: {
234
+ allowed: true,
235
+ url: 'https://api.oxy.so/assets/img1/stream?variant=thumb&mt=TKN',
236
+ visibility: 'private',
237
+ mime: 'image/jpeg',
238
+ },
239
+ vid1: {
240
+ allowed: true,
241
+ url: 'https://cloud.oxy.so/vid1?variant=poster',
242
+ visibility: 'public',
243
+ },
244
+ gone: { allowed: false, error: 'Access denied' },
245
+ },
246
+ }),
247
+ );
248
+
249
+ const urls = await oxy.getFileDownloadUrls(
250
+ [
251
+ { fileId: 'img1', variant: 'thumb' },
252
+ { fileId: 'vid1', variant: 'poster' },
253
+ { fileId: 'gone' },
254
+ ],
255
+ { expiresIn: 600, context: 'file-manager' },
256
+ );
257
+
258
+ // Denied/missing ids are OMITTED (never an empty-string value).
259
+ expect(urls).toEqual({
260
+ img1: 'https://api.oxy.so/assets/img1/stream?variant=thumb&mt=TKN',
261
+ vid1: 'https://cloud.oxy.so/vid1?variant=poster',
262
+ });
263
+ expect('gone' in urls).toBe(false);
264
+
265
+ // The request carried the per-file variants + expiresIn on the POST body.
266
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
267
+ const body = JSON.parse(String(init.body));
268
+ expect(body.files).toEqual([
269
+ { fileId: 'img1', variant: 'thumb' },
270
+ { fileId: 'vid1', variant: 'poster' },
271
+ { fileId: 'gone' },
272
+ ]);
273
+ expect(body.expiresIn).toBe(600);
274
+ expect(body.context).toBe('file-manager');
275
+ });
276
+
277
+ it('drops blank ids and collapses exact (fileId, variant) duplicates before sending', async () => {
278
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
279
+ oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
280
+
281
+ fetchMock.mockResolvedValueOnce(jsonResponse({ results: {} }));
282
+
283
+ await oxy.getFileDownloadUrls([
284
+ { fileId: 'img1', variant: 'thumb' },
285
+ { fileId: 'img1', variant: 'thumb' },
286
+ { fileId: ' ' },
287
+ { fileId: 'img1' },
288
+ ]);
289
+
290
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
291
+ const body = JSON.parse(String(init.body));
292
+ // Dedup on (fileId, variant): the two thumb entries collapse; the
293
+ // variant-less img1 is a DIFFERENT request and survives; blank id dropped.
294
+ expect(body.files).toEqual([
295
+ { fileId: 'img1', variant: 'thumb' },
296
+ { fileId: 'img1' },
297
+ ]);
298
+ });
299
+
300
+ it('makes NO network call for an all-empty request list', async () => {
301
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
302
+ oxy.setTokens(makeJwt({ userId: 'viewer-A' }));
303
+
304
+ const urls = await oxy.getFileDownloadUrls([{ fileId: '' }, { fileId: ' ' }]);
305
+ expect(urls).toEqual({});
306
+ expect(fetchMock).not.toHaveBeenCalled();
307
+ });
308
+ });
309
+
310
+ describe('OxyServices asset URL cache isolation across accounts', () => {
311
+ let originalFetch: typeof globalThis.fetch;
312
+ let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
313
+
314
+ beforeEach(() => {
315
+ originalFetch = globalThis.fetch;
316
+ fetchMock = jest.fn();
317
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
318
+ });
319
+
320
+ afterEach(() => {
321
+ globalThis.fetch = originalFetch;
322
+ jest.clearAllMocks();
323
+ });
324
+
325
+ it('never serves account A’s scoped URL to account B after a switch', async () => {
326
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
327
+
328
+ // Account A resolves the asset; response is cached under A's identity.
329
+ oxy.setTokens(makeJwt({ userId: 'account-A' }));
330
+ const urlForA = 'https://api.oxy.so/assets/priv1/stream?mt=TOKEN-FOR-A';
331
+ fetchMock.mockResolvedValueOnce(jsonResponse({ url: urlForA, expiresIn: 600 }));
332
+ expect(await oxy.getFileDownloadUrlAsync('priv1')).toBe(urlForA);
333
+ expect(fetchMock).toHaveBeenCalledTimes(1);
334
+
335
+ // Switching to account B mints a new access token → new identity tag. B's
336
+ // read must MISS A's cache entry and hit the network for its own scoped URL.
337
+ oxy.setTokens(makeJwt({ userId: 'account-B' }));
338
+ const urlForB = 'https://api.oxy.so/assets/priv1/stream?mt=TOKEN-FOR-B';
339
+ fetchMock.mockResolvedValueOnce(jsonResponse({ url: urlForB, expiresIn: 600 }));
340
+
341
+ const resolvedForB = await oxy.getFileDownloadUrlAsync('priv1');
342
+ expect(resolvedForB).toBe(urlForB);
343
+ expect(resolvedForB).not.toBe(urlForA);
344
+ expect(fetchMock).toHaveBeenCalledTimes(2);
345
+ });
346
+ });
@@ -30,6 +30,7 @@ import { OxyServicesCivicMixin } from './OxyServices.civic';
30
30
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
31
31
  import { OxyServicesLinksMixin } from './OxyServices.links';
32
32
  import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
33
+ import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
33
34
 
34
35
  /**
35
36
  * Instance shape of every mixin in the pipeline, intersected. The runtime
@@ -64,6 +65,7 @@ type AllMixinInstances =
64
65
  & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
65
66
  & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
66
67
  & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
68
+ & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>>
67
69
  & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
68
70
 
69
71
  /**
@@ -138,6 +140,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
138
140
  // (`mintFromDeviceSecret` → `POST /session/device/token`).
139
141
  OxyServicesDeviceBootMixin,
140
142
 
143
+ // Device-to-device identity transfer ("add a device"): E2E-encrypted key
144
+ // clone over a short-lived relay (b3 Feature 2).
145
+ OxyServicesDeviceTransferMixin,
146
+
141
147
  // Utility (last, can use all above)
142
148
  OxyServicesUtilityMixin,
143
149
  ];
@@ -517,6 +517,26 @@ export interface AssetUrlResponse {
517
517
  expiresIn: number;
518
518
  }
519
519
 
520
+ /**
521
+ * Per-file result of `POST /assets/batch-access`. `allowed` is authoritative:
522
+ * when `false` the entry carries an `error` string (e.g. `'Access denied'`,
523
+ * `'File not found'`) and no `url`. When `true`, `url` is a caller-scoped,
524
+ * `<img src>`-ready URL — the public CDN form for a public asset or an
525
+ * API-origin stream URL carrying a short-lived media token for a private one.
526
+ */
527
+ export interface BatchFileAccessEntry {
528
+ allowed: boolean;
529
+ url?: string;
530
+ visibility?: FileVisibility;
531
+ mime?: string;
532
+ error?: string;
533
+ }
534
+
535
+ /** Envelope returned by `POST /assets/batch-access`, keyed by file id. */
536
+ export interface BatchFileAccessResponse {
537
+ results: Record<string, BatchFileAccessEntry>;
538
+ }
539
+
520
540
  export interface AssetDeleteSummary {
521
541
  fileId: string;
522
542
  wouldDelete: boolean;
@@ -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
+ }