@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
@@ -20,6 +20,7 @@ import { jwtDecode } from 'jwt-decode';
20
20
  import { isNative, getPlatformOS } from './utils/platform.js';
21
21
  import { isReactNative } from '@oxyhq/protocol';
22
22
  import { computeIdentityTag, fnv1a32 } from './utils/cacheKey.js';
23
+ import { redactUrlQuery } from './utils/redactUrl.js';
23
24
  /**
24
25
  * Check if we're running in a native app environment (React Native, not web)
25
26
  * This is used to determine CSRF handling mode
@@ -260,7 +261,9 @@ export class HttpService {
260
261
  const cached = this.cache.get(cacheKey);
261
262
  if (cached !== null) {
262
263
  this.requestMetrics.cacheHits++;
263
- this.logger.debug('Cache hit:', url);
264
+ // Redact the query string: an asset stream URL passed here carries a
265
+ // scoped `mt=` media token that must never reach a log sink.
266
+ this.logger.debug('Cache hit:', redactUrlQuery(url));
264
267
  return cached;
265
268
  }
266
269
  this.requestMetrics.cacheMisses++;
@@ -9,6 +9,46 @@ export class OxyAuthenticationError extends Error {
9
9
  this.status = status;
10
10
  }
11
11
  }
12
+ /**
13
+ * Thrown when an asset's authorized download URL cannot be resolved.
14
+ *
15
+ * `getFileDownloadUrlAsync` asks the API for a URL that is valid for the
16
+ * CALLER and the asset's actual visibility. When that resolution fails there is
17
+ * no honest fallback: the public CDN origin only serves `public` assets, so
18
+ * handing back `https://cloud.oxy.so/<id>` for an unresolved asset produces a
19
+ * hard 404 at render time and hides the real failure from the caller. This
20
+ * error surfaces the failure instead.
21
+ *
22
+ * The message and fields deliberately carry only the asset id, the requested
23
+ * variant and the HTTP status — never the resolved URL, which embeds a scoped
24
+ * media token.
25
+ *
26
+ * ## `status` lets a caller decide whether a CDN fallback is safe
27
+ *
28
+ * Core itself never falls back to the public CDN builder, because it has no
29
+ * knowledge of the asset's visibility and that URL is a guaranteed 404 for a
30
+ * private asset. A CALLER that knows an asset is public MAY choose to fall back
31
+ * to `getFileDownloadUrl(id, variant)` — but only for a TRANSIENT failure, not
32
+ * a definitive denial:
33
+ * - `status` 401/403/404 → definitive: the asset is private/denied/missing.
34
+ * Never CDN-fall-back — it will 404.
35
+ * - `status` undefined (network error) or 5xx → transient: resolution itself
36
+ * failed. If the caller independently knows the asset is public, a CDN
37
+ * fallback is defensible best-effort.
38
+ */
39
+ export class AssetUrlResolutionError extends Error {
40
+ constructor(fileId, variant, status, cause) {
41
+ const variantSuffix = variant ? ` (variant "${variant}")` : '';
42
+ const statusSuffix = typeof status === 'number' ? ` — status ${status}` : '';
43
+ super(`Could not resolve a download URL for asset "${fileId}"${variantSuffix}${statusSuffix}`);
44
+ this.code = 'ASSET_URL_UNRESOLVED';
45
+ this.name = 'AssetUrlResolutionError';
46
+ this.fileId = fileId;
47
+ this.variant = variant;
48
+ this.status = status;
49
+ this.cause = cause;
50
+ }
51
+ }
12
52
  export class OxyAuthenticationTimeoutError extends OxyAuthenticationError {
13
53
  constructor(operationName, timeoutMs) {
14
54
  super(`Authentication timeout (${timeoutMs}ms): ${operationName} requires user authentication. Please ensure the user is logged in before calling this method.`, 'AUTH_TIMEOUT', 408);
@@ -1,4 +1,4 @@
1
- import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors.js';
1
+ import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors.js';
2
2
  // Import mixin composition helper
3
3
  import { composeOxyServices } from './mixins/index.js';
4
4
  /**
@@ -50,7 +50,7 @@ export class OxyServices extends OxyServicesComposed {
50
50
  }
51
51
  }
52
52
  // Re-export error classes for convenience
53
- export { OxyAuthenticationError, OxyAuthenticationTimeoutError };
53
+ export { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError };
54
54
  /**
55
55
  * Default Oxy Cloud URL — used when no `cloudURL` is provided to OxyServices.
56
56
  */
package/dist/esm/index.js CHANGED
@@ -21,7 +21,7 @@ import './crypto/polyfill.js';
21
21
  // ---------------------------------------------------------------------------
22
22
  // API client
23
23
  // ---------------------------------------------------------------------------
24
- export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.js';
24
+ export { OxyServices, AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.js';
25
25
  export { OXY_CLOUD_URL, oxyClient } from './OxyServices.js';
26
26
  // ---------------------------------------------------------------------------
27
27
  // Authentication
@@ -1,5 +1,6 @@
1
1
  import { isReactNative } from '@oxyhq/protocol';
2
2
  import { logger } from '../logger/index.js';
3
+ import { AssetUrlResolutionError } from '../OxyServices.errors.js';
3
4
  import { extractErrorStatus } from '../utils/errorUtils.js';
4
5
  /**
5
6
  * Maximum number of ids sent per `POST /assets/service/by-ids` request. Matches
@@ -21,6 +22,37 @@ const SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE = 100;
21
22
  * never 400s an otherwise-valid chunk on the server.
22
23
  */
23
24
  const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
25
+ /**
26
+ * Conservative lower bound (10 min) the SDK assumes for the lifetime of the
27
+ * scoped media token (`mt`) the API embeds in the stream URL it returns for a
28
+ * private asset. The SDK never mints or inspects that token; this constant
29
+ * exists only so the SDK's own URL cache can be sized safely BELOW the token's
30
+ * real lifetime.
31
+ *
32
+ * The API currently mints tokens for 900s (`MEDIA_TOKEN_TTL_SECONDS` in
33
+ * `packages/api/src/utils/mediaToken.ts`). Core deliberately assumes a shorter
34
+ * 10-min floor rather than copying 15: core and the API are separate packages,
35
+ * so core cannot observe a server-side TTL change at runtime. Under-assuming the
36
+ * lifetime only ever shortens the cache (more refetches, never a dead URL), so
37
+ * it stays correct even if the server lowers its TTL toward this floor. The
38
+ * {@link ASSET_URL_CACHE_LIFETIME_FRACTION} discount is applied on top.
39
+ */
40
+ const ASSET_MEDIA_TOKEN_TTL_MS = 10 * 60 * 1000;
41
+ /**
42
+ * Fraction of a resolved URL's remaining lifetime the SDK is willing to keep it
43
+ * cached for. A resolved URL stops working the moment its media token expires,
44
+ * so caching it for its full nominal lifetime guarantees a window in which the
45
+ * cache hands out an already-dead URL (clock skew, time spent in the render
46
+ * pipeline, an image request queued behind others). Half the lifetime leaves a
47
+ * margin at least as large as the entry's own age.
48
+ */
49
+ const ASSET_URL_CACHE_LIFETIME_FRACTION = 0.5;
50
+ /**
51
+ * Fallback URL lifetime, in seconds, assumed when the caller does not request
52
+ * an explicit `expiresIn`. Mirrors the API's default signed-URL expiry; the
53
+ * effective value is still clamped by {@link ASSET_MEDIA_TOKEN_TTL_MS}.
54
+ */
55
+ const DEFAULT_ASSET_URL_EXPIRES_IN_SECONDS = 3600;
24
56
  export function OxyServicesAssetsMixin(Base) {
25
57
  return class extends Base {
26
58
  constructor(...args) {
@@ -38,14 +70,33 @@ export function OxyServicesAssetsMixin(Base) {
38
70
  }
39
71
  }
40
72
  /**
41
- * Build a synchronous, `<img src>`-ready file URL from an Oxy asset id.
73
+ * Build a synchronous, `<img src>`-ready URL for a **PUBLIC** Oxy asset.
74
+ *
75
+ * ## Contract — read before calling
42
76
  *
43
- * This method must never embed the caller's general access token in the
44
- * returned URL. The URL is commonly rendered into DOM attributes, browser
45
- * network panels, caches, and logs. Public asset URLs use the clean CDN
46
- * origin; callers that need authorized/private access should use
47
- * {@link getFileDownloadUrlAsync}, which asks the API for a scoped download
48
- * URL instead of exposing the in-memory bearer token in a query string.
77
+ * This is a pure string builder. It performs no network call and therefore
78
+ * has **no knowledge of the asset's visibility**. It always produces the
79
+ * public form: `${cloudURL}/<id>[?variant=…]`, which the CDN serves from
80
+ * the public media origin only.
81
+ *
82
+ * Consequently:
83
+ * - Call it ONLY when the asset is known to be `public` — e.g. avatars and
84
+ * profile banners, which {@link uploadAvatar} / {@link uploadProfileBanner}
85
+ * upload with `visibility: 'public'`.
86
+ * - For an asset that may be `private` or `unlisted` — anything uploaded
87
+ * through the generic {@link assetUpload} path, whose server-side default
88
+ * is private — this URL resolves to a hard **404**. Use
89
+ * {@link getFileDownloadUrlAsync}, which asks the API for a URL scoped to
90
+ * the current caller.
91
+ * - It must never guess visibility, and must never embed the caller's
92
+ * bearer token: the returned string is rendered into DOM attributes,
93
+ * browser network panels, HTTP caches, and logs.
94
+ *
95
+ * Passing `expiresIn` switches to the API-origin stream form
96
+ * (`${baseURL}/assets/<id>/stream?…`) WITHOUT any credential. That form
97
+ * still only serves what an unauthenticated request may see — it is not a
98
+ * synchronous private-asset path, and none exists: authorization for a
99
+ * private asset requires the round-trip in {@link getFileDownloadUrlAsync}.
49
100
  */
50
101
  getFileDownloadUrl(fileId, variant, expiresIn) {
51
102
  // Never embed the in-memory bearer token: this URL is rendered into DOM
@@ -66,16 +117,40 @@ export function OxyServicesAssetsMixin(Base) {
66
117
  return `${base}/assets/${encodeURIComponent(fileId)}/stream${qs ? `?${qs}` : ''}`;
67
118
  }
68
119
  /**
69
- * Get file download URL asynchronously (returns signed URL directly from CDN)
120
+ * Resolve an asset id to a URL that is valid for the CURRENT caller,
121
+ * whatever the asset's visibility.
122
+ *
123
+ * Asks the API (`GET /assets/:id/url`) rather than guessing: a `public`
124
+ * asset resolves to the CDN form, while a `private`/`unlisted` asset the
125
+ * caller may read resolves to an API-origin stream URL carrying a scoped,
126
+ * short-lived media token. The returned URL is passed through **unchanged**
127
+ * — the SDK never rewrites, re-signs, or strips it.
128
+ *
129
+ * ## Failure behaviour — no CDN fallback
130
+ *
131
+ * Throws {@link AssetUrlResolutionError} when the API returns no URL or the
132
+ * request fails (including 401/403/404). It deliberately does NOT fall back
133
+ * to {@link getFileDownloadUrl}: that builder only produces the public CDN
134
+ * form, so falling back would hand the caller a URL that renders as a hard
135
+ * 404 for every private asset and would silently swallow the real failure.
136
+ * A caller that knows an asset is public should call the synchronous
137
+ * builder directly instead of relying on a fallback here.
138
+ *
139
+ * The resolved URL is cached per identity for well under the media token's
140
+ * lifetime — see {@link getAssetUrlCacheTTL}.
70
141
  */
71
142
  async getFileDownloadUrlAsync(fileId, variant, expiresIn) {
143
+ let url;
72
144
  try {
73
- const url = await this.fetchAssetDownloadUrl(fileId, variant, this.getAssetUrlCacheTTL(expiresIn), expiresIn);
74
- return url || this.getFileDownloadUrl(fileId, variant, expiresIn);
145
+ url = await this.fetchAssetDownloadUrl(fileId, variant, this.getAssetUrlCacheTTL(expiresIn), expiresIn);
75
146
  }
76
147
  catch (error) {
77
- return this.getFileDownloadUrl(fileId, variant, expiresIn);
148
+ throw new AssetUrlResolutionError(fileId, variant, extractErrorStatus(error), error);
149
+ }
150
+ if (!url) {
151
+ throw new AssetUrlResolutionError(fileId, variant, undefined);
78
152
  }
153
+ return url;
79
154
  }
80
155
  /**
81
156
  * List user files
@@ -139,13 +214,36 @@ export function OxyServicesAssetsMixin(Base) {
139
214
  }
140
215
  }
141
216
  /**
142
- * Get batch access to multiple files
217
+ * Resolve access + a caller-scoped URL for many assets — each with its OWN
218
+ * requested variant — in ONE round trip via `POST /assets/batch-access`.
219
+ *
220
+ * `requests` is a per-file `{ fileId, variant? }` list (a `variant` of
221
+ * `undefined` asks for the original). `options.expiresIn` sets the requested
222
+ * media-token / signed-URL lifetime (seconds); `options.context` is the
223
+ * server-side access-check context. Entries with a blank `fileId` are
224
+ * dropped and exact `(fileId, variant)` duplicates are collapsed before the
225
+ * request; an empty effective list performs no network call.
226
+ *
227
+ * The server caps the batch at 100 entries — callers that page beyond that
228
+ * must chunk. Returns the raw per-file envelope (see
229
+ * {@link BatchFileAccessResponse}); most callers want {@link getFileDownloadUrls},
230
+ * which flattens it to just the usable URLs.
143
231
  */
144
- async getBatchFileAccess(fileIds, context) {
232
+ async getBatchFileAccess(requests, options) {
233
+ const files = dedupeFileAccessRequests(requests);
234
+ if (files.length === 0) {
235
+ return { results: {} };
236
+ }
237
+ const body = {
238
+ files,
239
+ };
240
+ if (typeof options?.expiresIn === 'number')
241
+ body.expiresIn = options.expiresIn;
242
+ if (typeof options?.context === 'string')
243
+ body.context = options.context;
145
244
  try {
146
- return await this.makeRequest('POST', '/assets/batch-access', {
147
- fileIds,
148
- context
245
+ return await this.makeRequest('POST', '/assets/batch-access', body, {
246
+ cache: false,
149
247
  });
150
248
  }
151
249
  catch (error) {
@@ -153,13 +251,27 @@ export function OxyServicesAssetsMixin(Base) {
153
251
  }
154
252
  }
155
253
  /**
156
- * Get download URLs for multiple files efficiently
254
+ * Resolve many assets each with its OWN variant — to caller-scoped,
255
+ * `<img src>`-ready URLs in one round trip. The batch counterpart of
256
+ * {@link getFileDownloadUrlAsync}, built to resolve a whole grid page at once.
257
+ *
258
+ * `requests` is a per-file `{ fileId, variant? }` list (e.g. `poster` for a
259
+ * video, `thumb` for an image); the per-file variant RULE lives in the
260
+ * caller — core just forwards what it is given. `options.expiresIn` /
261
+ * `options.context` are passed through to the endpoint.
262
+ *
263
+ * Each returned URL is the API's own scoped form, passed through unchanged:
264
+ * the public CDN URL for a public asset, or an API-origin
265
+ * `/assets/:id/stream?…&mt=<media token>` URL for a private asset the caller
266
+ * may read. Ids the caller cannot access (or that do not exist) are simply
267
+ * OMITTED from the returned map — there is NO public-CDN fallback, so a grid
268
+ * never renders a known-404 URL. Callers detect a miss by the absent key
269
+ * (the map never contains an empty-string value). Keyed by `fileId`.
157
270
  */
158
- async getFileDownloadUrls(fileIds, context) {
159
- const response = await this.getBatchFileAccess(fileIds, context);
271
+ async getFileDownloadUrls(requests, options) {
272
+ const response = await this.getBatchFileAccess(requests, options);
160
273
  const urls = {};
161
- const results = response.results || {};
162
- for (const [id, result] of Object.entries(results)) {
274
+ for (const [id, result] of Object.entries(response.results ?? {})) {
163
275
  if (result.allowed && result.url) {
164
276
  urls[id] = result.url;
165
277
  }
@@ -458,7 +570,7 @@ export function OxyServicesAssetsMixin(Base) {
458
570
  params.expiresIn = expiresIn;
459
571
  return await this.makeRequest('GET', `/assets/${fileId}/url`, params, {
460
572
  cache: true,
461
- cacheTTL: 10 * 60 * 1000,
573
+ cacheTTL: this.getAssetUrlCacheTTL(expiresIn),
462
574
  });
463
575
  }
464
576
  catch (error) {
@@ -546,9 +658,21 @@ export function OxyServicesAssetsMixin(Base) {
546
658
  throw this.handleError(error);
547
659
  }
548
660
  }
661
+ /**
662
+ * How long a resolved asset URL may stay in the SDK's GET cache, in ms.
663
+ *
664
+ * A resolved private-asset URL dies the instant its scoped media token
665
+ * expires (~{@link ASSET_MEDIA_TOKEN_TTL_MS}). Caching it for its full
666
+ * nominal lifetime would leave a window where the cache serves an
667
+ * already-dead URL (clock skew, render-pipeline latency, an image request
668
+ * queued behind others). So the TTL is (a) never longer than the token's
669
+ * lifetime and (b) discounted to {@link ASSET_URL_CACHE_LIFETIME_FRACTION}
670
+ * of that bound — comfortably below the token TTL by construction.
671
+ */
549
672
  getAssetUrlCacheTTL(expiresIn) {
550
- const desiredTtlMs = (expiresIn ?? 3600) * 1000;
551
- return Math.min(desiredTtlMs, 10 * 60 * 1000);
673
+ const requestedLifetimeMs = (expiresIn ?? DEFAULT_ASSET_URL_EXPIRES_IN_SECONDS) * 1000;
674
+ const boundedLifetimeMs = Math.min(requestedLifetimeMs, ASSET_MEDIA_TOKEN_TTL_MS);
675
+ return Math.floor(boundedLifetimeMs * ASSET_URL_CACHE_LIFETIME_FRACTION);
552
676
  }
553
677
  async fetchAssetDownloadUrl(fileId, variant, cacheTTL, expiresIn) {
554
678
  const params = {};
@@ -558,7 +682,10 @@ export function OxyServicesAssetsMixin(Base) {
558
682
  params.expiresIn = expiresIn;
559
683
  const urlRes = await this.makeRequest('GET', `/assets/${encodeURIComponent(fileId)}/url`, Object.keys(params).length ? params : undefined, {
560
684
  cache: true,
561
- cacheTTL: cacheTTL ?? 10 * 60 * 1000,
685
+ // Cap the cached URL well below the media token's lifetime. The
686
+ // response body is a scoped, expiring URL; over-caching it serves a
687
+ // dead URL after the token expires (see getAssetUrlCacheTTL).
688
+ cacheTTL: cacheTTL ?? this.getAssetUrlCacheTTL(expiresIn),
562
689
  });
563
690
  return urlRes?.url || null;
564
691
  }
@@ -573,6 +700,29 @@ export function OxyServicesAssetsMixin(Base) {
573
700
  }
574
701
  };
575
702
  }
703
+ /**
704
+ * Normalize the per-file batch-access request list: drop entries with a blank
705
+ * `fileId` and collapse exact `(fileId, variant)` duplicates (first occurrence
706
+ * wins, preserving order). Two entries for the SAME `fileId` with DIFFERENT
707
+ * variants are intentionally kept — but note the response is keyed by `fileId`,
708
+ * so a caller that needs two variants of one file must issue separate calls.
709
+ */
710
+ function dedupeFileAccessRequests(requests) {
711
+ const seen = new Set();
712
+ const out = [];
713
+ for (const req of requests) {
714
+ if (typeof req?.fileId !== 'string' || req.fileId.trim().length === 0) {
715
+ continue;
716
+ }
717
+ const key = `${req.fileId}\u0000${req.variant ?? ''}`;
718
+ if (seen.has(key)) {
719
+ continue;
720
+ }
721
+ seen.add(key);
722
+ out.push(req.variant === undefined ? { fileId: req.fileId } : { fileId: req.fileId, variant: req.variant });
723
+ }
724
+ return out;
725
+ }
576
726
  /**
577
727
  * Only send ambient credentials (cookies) when the asset URL is same-origin with
578
728
  * the configured API base. Caller-supplied cross-origin asset URLs must not leak
@@ -1,5 +1,6 @@
1
1
  import { deviceSessionStateSchema, deviceSessionSyncSchema, safeParseContract, SESSION_ACCOUNTS_CHANGED_EVENT, sessionAccountsChangedEventSchema, } from '@oxyhq/contracts';
2
2
  import { logger } from '../logger/index.js';
3
+ import { computeIdentityTag } from '../utils/cacheKey.js';
3
4
  import { getSocketIO } from './socketLoader.js';
4
5
  /**
5
6
  * Same-origin `BroadcastChannel` name for instant, network-free session-state
@@ -80,8 +81,25 @@ export class SessionClient {
80
81
  }
81
82
  }
82
83
  }
83
- /** Validate + last-writer-wins by revision. Returns true if applied. */
84
- applyState(raw, origin = 'push') {
84
+ /**
85
+ * Validate + last-writer-wins by revision. Returns true if applied.
86
+ *
87
+ * `activeToken` (sync path only) is the server-issued access token for
88
+ * `raw.activeAccountId`. When present and the state is applied, it is planted
89
+ * BEFORE any subscriber is notified so the bearer already belongs to the new
90
+ * active account — the local switch/bootstrap path then needs no redundant
91
+ * device-secret mint. Push-origin applies carry no token and rely on the
92
+ * mint-before-notify gate below.
93
+ *
94
+ * ORDERING INVARIANT: a subscriber must NEVER observe a newly-active account
95
+ * while the planted bearer still identifies the PREVIOUS one — otherwise a
96
+ * `useCurrentUser`-style refetch fires under the wrong account's token (the
97
+ * account-switch 404 race). So when a transport is available and the planted
98
+ * bearer does not already belong to `next.activeAccountId`, minting is awaited
99
+ * BEFORE `notify()`. This covers EVERY notify source (a switch push, a
100
+ * cross-device push, a cold mint), not just the initial "no bearer yet" case.
101
+ */
102
+ applyState(raw, origin = 'push', activeToken) {
85
103
  const next = safeParseContract(deviceSessionStateSchema, raw);
86
104
  if (!next) {
87
105
  logger.warn('[SessionClient] discarded invalid session state');
@@ -98,9 +116,26 @@ export class SessionClient {
98
116
  next.revision <= this.state.revision) {
99
117
  return false;
100
118
  }
119
+ const previousState = this.state;
101
120
  this.state = next;
121
+ // Plant the sync-supplied active token (it is for `next.activeAccountId`)
122
+ // now — before the notify below — so the bearer matches the new active
123
+ // account when subscribers observe it. Guarded on difference to avoid a
124
+ // redundant token-change notification on an unchanged token (bootstrap
125
+ // restate).
126
+ if (activeToken && next.activeAccountId !== null && activeToken !== this.host.getAccessToken()) {
127
+ this.host.setTokens(activeToken);
128
+ }
102
129
  const transport = this.options.transport;
103
- const needsMintBeforeNotify = transport != null && next.accounts.length > 0 && !this.host.getAccessToken();
130
+ const activeAccountId = next.activeAccountId;
131
+ // Mint before notifying when the bearer does not already belong to the new
132
+ // active account: no bearer at all, an opaque bearer, OR a bearer for a
133
+ // DIFFERENT account. `computeIdentityTag` yields the token's `userId`/`id`
134
+ // for a real JWT (comparable to the account id) and a non-account sentinel
135
+ // otherwise, so a mismatch always resolves to "mint".
136
+ const needsMintBeforeNotify = transport != null &&
137
+ next.accounts.length > 0 &&
138
+ (activeAccountId === null || computeIdentityTag(this.host.getAccessToken()) !== activeAccountId);
104
139
  const finishApply = () => {
105
140
  this.notify();
106
141
  if (next.accounts.length === 0 && this.options.onUnauthenticated) {
@@ -114,8 +149,10 @@ export class SessionClient {
114
149
  };
115
150
  if (needsMintBeforeNotify) {
116
151
  void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
117
- logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
118
- finishApply();
152
+ logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
153
+ // Do NOT notify under a mismatched bearer. Revert to the last applied
154
+ // state so subscribers keep observing the account whose token is planted.
155
+ this.state = previousState ?? null;
119
156
  });
120
157
  }
121
158
  else {
@@ -153,9 +190,21 @@ export class SessionClient {
153
190
  }
154
191
  // A `sync` is always the response to a direct REST call this client made
155
192
  // (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
156
- // verdict.
157
- this.applyState(sync.state, 'request');
158
- if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
193
+ // verdict. Hand the active token to `applyState`: in the applied path it is
194
+ // planted BEFORE notify (bearer matches the new active account when
195
+ // subscribers observe it, and no redundant device-secret mint is triggered).
196
+ const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
197
+ // Equal-revision restate (this revision was already applied by a preceding
198
+ // socket push): `applyState` no-ops without planting, but the token still
199
+ // needs planting. Guard on the sync's active account STILL being the current
200
+ // active account so a stale response cannot adopt a token for an account a
201
+ // newer state already switched away from.
202
+ if (!applied &&
203
+ sync.activeToken &&
204
+ this.state &&
205
+ sync.state.activeAccountId !== null &&
206
+ sync.state.activeAccountId === this.state.activeAccountId &&
207
+ sync.activeToken.accessToken !== this.host.getAccessToken()) {
159
208
  this.host.setTokens(sync.activeToken.accessToken);
160
209
  }
161
210
  }
@@ -0,0 +1,26 @@
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) {
18
+ if (typeof url !== 'string' || url.length === 0) {
19
+ return url;
20
+ }
21
+ const queryIndex = url.indexOf('?');
22
+ if (queryIndex === -1) {
23
+ return url;
24
+ }
25
+ return `${url.slice(0, queryIndex)}?<redacted>`;
26
+ }