@oxyhq/core 12.5.2 → 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 (53) 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/mixins/OxyServices.user.js +15 -1
  8. package/dist/cjs/session/SessionClient.js +57 -8
  9. package/dist/cjs/utils/displayNamePolicyRanges.generated.js +35 -0
  10. package/dist/cjs/utils/redactUrl.js +29 -0
  11. package/dist/cjs/utils/textNormalization.js +9 -4
  12. package/dist/cjs/utils/validationUtils.js +49 -41
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/HttpService.js +4 -1
  15. package/dist/esm/OxyServices.errors.js +40 -0
  16. package/dist/esm/OxyServices.js +2 -2
  17. package/dist/esm/index.js +1 -1
  18. package/dist/esm/mixins/OxyServices.assets.js +175 -25
  19. package/dist/esm/mixins/OxyServices.user.js +15 -1
  20. package/dist/esm/session/SessionClient.js +57 -8
  21. package/dist/esm/utils/displayNamePolicyRanges.generated.js +32 -0
  22. package/dist/esm/utils/redactUrl.js +26 -0
  23. package/dist/esm/utils/textNormalization.js +9 -4
  24. package/dist/esm/utils/validationUtils.js +50 -42
  25. package/dist/types/.tsbuildinfo +1 -1
  26. package/dist/types/OxyServices.d.ts +2 -2
  27. package/dist/types/OxyServices.errors.d.ts +40 -0
  28. package/dist/types/index.d.ts +2 -2
  29. package/dist/types/mixins/OxyServices.assets.d.ts +103 -13
  30. package/dist/types/models/interfaces.d.ts +18 -0
  31. package/dist/types/session/SessionClient.d.ts +19 -2
  32. package/dist/types/utils/displayNamePolicyRanges.generated.d.ts +32 -0
  33. package/dist/types/utils/redactUrl.d.ts +17 -0
  34. package/dist/types/utils/validationUtils.d.ts +48 -34
  35. package/package.json +5 -3
  36. package/src/HttpService.ts +4 -1
  37. package/src/OxyServices.errors.ts +51 -0
  38. package/src/OxyServices.ts +2 -2
  39. package/src/index.ts +3 -1
  40. package/src/mixins/OxyServices.assets.ts +192 -28
  41. package/src/mixins/OxyServices.user.ts +15 -1
  42. package/src/mixins/__tests__/followCacheInvalidation.test.ts +5 -0
  43. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +265 -1
  44. package/src/mixins/__tests__/userReadCacheBypass.test.ts +8 -0
  45. package/src/models/interfaces.ts +20 -0
  46. package/src/session/SessionClient.ts +59 -8
  47. package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +170 -0
  48. package/src/utils/__tests__/redactUrl.test.ts +33 -0
  49. package/src/utils/__tests__/validationUtils.test.ts +106 -3
  50. package/src/utils/displayNamePolicyRanges.generated.ts +40 -0
  51. package/src/utils/redactUrl.ts +28 -0
  52. package/src/utils/textNormalization.ts +9 -4
  53. package/src/utils/validationUtils.ts +55 -42
@@ -23,6 +23,7 @@ const jwt_decode_1 = require("jwt-decode");
23
23
  const platform_1 = require("./utils/platform");
24
24
  const protocol_1 = require("@oxyhq/protocol");
25
25
  const cacheKey_1 = require("./utils/cacheKey");
26
+ const redactUrl_1 = require("./utils/redactUrl");
26
27
  /**
27
28
  * Check if we're running in a native app environment (React Native, not web)
28
29
  * This is used to determine CSRF handling mode
@@ -263,7 +264,9 @@ class HttpService {
263
264
  const cached = this.cache.get(cacheKey);
264
265
  if (cached !== null) {
265
266
  this.requestMetrics.cacheHits++;
266
- this.logger.debug('Cache hit:', url);
267
+ // Redact the query string: an asset stream URL passed here carries a
268
+ // scoped `mt=` media token that must never reach a log sink.
269
+ this.logger.debug('Cache hit:', (0, redactUrl_1.redactUrlQuery)(url));
267
270
  return cached;
268
271
  }
269
272
  this.requestMetrics.cacheMisses++;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = void 0;
3
+ exports.OxyAuthenticationTimeoutError = exports.AssetUrlResolutionError = exports.OxyAuthenticationError = void 0;
4
4
  /**
5
5
  * Custom error types for better error handling
6
6
  */
@@ -13,6 +13,47 @@ class OxyAuthenticationError extends Error {
13
13
  }
14
14
  }
15
15
  exports.OxyAuthenticationError = OxyAuthenticationError;
16
+ /**
17
+ * Thrown when an asset's authorized download URL cannot be resolved.
18
+ *
19
+ * `getFileDownloadUrlAsync` asks the API for a URL that is valid for the
20
+ * CALLER and the asset's actual visibility. When that resolution fails there is
21
+ * no honest fallback: the public CDN origin only serves `public` assets, so
22
+ * handing back `https://cloud.oxy.so/<id>` for an unresolved asset produces a
23
+ * hard 404 at render time and hides the real failure from the caller. This
24
+ * error surfaces the failure instead.
25
+ *
26
+ * The message and fields deliberately carry only the asset id, the requested
27
+ * variant and the HTTP status — never the resolved URL, which embeds a scoped
28
+ * media token.
29
+ *
30
+ * ## `status` lets a caller decide whether a CDN fallback is safe
31
+ *
32
+ * Core itself never falls back to the public CDN builder, because it has no
33
+ * knowledge of the asset's visibility and that URL is a guaranteed 404 for a
34
+ * private asset. A CALLER that knows an asset is public MAY choose to fall back
35
+ * to `getFileDownloadUrl(id, variant)` — but only for a TRANSIENT failure, not
36
+ * a definitive denial:
37
+ * - `status` 401/403/404 → definitive: the asset is private/denied/missing.
38
+ * Never CDN-fall-back — it will 404.
39
+ * - `status` undefined (network error) or 5xx → transient: resolution itself
40
+ * failed. If the caller independently knows the asset is public, a CDN
41
+ * fallback is defensible best-effort.
42
+ */
43
+ class AssetUrlResolutionError extends Error {
44
+ constructor(fileId, variant, status, cause) {
45
+ const variantSuffix = variant ? ` (variant "${variant}")` : '';
46
+ const statusSuffix = typeof status === 'number' ? ` — status ${status}` : '';
47
+ super(`Could not resolve a download URL for asset "${fileId}"${variantSuffix}${statusSuffix}`);
48
+ this.code = 'ASSET_URL_UNRESOLVED';
49
+ this.name = 'AssetUrlResolutionError';
50
+ this.fileId = fileId;
51
+ this.variant = variant;
52
+ this.status = status;
53
+ this.cause = cause;
54
+ }
55
+ }
56
+ exports.AssetUrlResolutionError = AssetUrlResolutionError;
16
57
  class OxyAuthenticationTimeoutError extends OxyAuthenticationError {
17
58
  constructor(operationName, timeoutMs) {
18
59
  super(`Authentication timeout (${timeoutMs}ms): ${operationName} requires user authentication. Please ensure the user is logged in before calling this method.`, 'AUTH_TIMEOUT', 408);
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.oxyClient = exports.OXY_API_URL = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
3
+ exports.oxyClient = exports.OXY_API_URL = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.AssetUrlResolutionError = exports.OxyServices = void 0;
4
4
  const OxyServices_errors_1 = require("./OxyServices.errors");
5
+ Object.defineProperty(exports, "AssetUrlResolutionError", { enumerable: true, get: function () { return OxyServices_errors_1.AssetUrlResolutionError; } });
5
6
  Object.defineProperty(exports, "OxyAuthenticationError", { enumerable: true, get: function () { return OxyServices_errors_1.OxyAuthenticationError; } });
6
7
  Object.defineProperty(exports, "OxyAuthenticationTimeoutError", { enumerable: true, get: function () { return OxyServices_errors_1.OxyAuthenticationTimeoutError; } });
7
8
  // Import mixin composition helper
package/dist/cjs/index.js CHANGED
@@ -18,10 +18,10 @@
18
18
  * If a symbol does not appear here, it is NOT part of the public API.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = exports.getBaseLanguage = exports.FALLBACK_LOCALE = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.deriveSharedSecret = exports.AEAD_NONCE_LENGTH = exports.AEAD_KEY_LENGTH = exports.decryptAead = exports.encryptAead = exports.hkdfSha256 = exports.RecoveryPhraseService = exports.SignatureService = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.verifyPublicCardAttestation = exports.parseAttestPayload = exports.parseIdPayload = exports.buildUserDid = exports.ORGANIZATION_CATEGORIES = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.OxyAppDataIdentifierError = exports.ServiceCredentialMismatchError = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
22
- exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.translate = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = void 0;
23
- exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.runColdBoot = exports.isOxyRpOrigin = exports.CENTRAL_IDP_APEX = exports.registrableApex = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = exports.resetLoggerConfig = exports.getLoggerConfig = exports.configureLogger = exports.createLogger = exports.logger = exports.normalizeMultilineText = exports.normalizeInlineText = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.DISPLAY_NAME_ORPHANED_MARK_SOURCE = exports.DISPLAY_NAME_DISALLOWED_SOURCE = exports.DISPLAY_NAME_ALLOWED_SCRIPTS = exports.isValidDisplayName = exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = void 0;
24
- exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.switchableAccountIds = exports.projectSwitchableAccounts = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.redeemHubTicketOnHub = exports.syncHubAfterSignIn = exports.parseHubSyncReturnUrl = exports.normalizeOfficialReturnOrigin = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isLoopbackOrigin = exports.isIdpHubOrigin = exports.buildHubSyncUrl = exports.buildIdpHubOrigin = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.normalizeOAuthRedirectUri = exports.OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = exports.OXY_SILENT_OAUTH_ATTEMPTED_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = void 0;
21
+ exports.getLanguageName = exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = exports.getBaseLanguage = exports.FALLBACK_LOCALE = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.deriveSharedSecret = exports.AEAD_NONCE_LENGTH = exports.AEAD_KEY_LENGTH = exports.decryptAead = exports.encryptAead = exports.hkdfSha256 = exports.RecoveryPhraseService = exports.SignatureService = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.verifyPublicCardAttestation = exports.parseAttestPayload = exports.parseIdPayload = exports.buildUserDid = exports.ORGANIZATION_CATEGORIES = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.OxyAppDataIdentifierError = exports.ServiceCredentialMismatchError = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.AssetUrlResolutionError = exports.OxyServices = void 0;
22
+ exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.translate = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = exports.getNativeLanguageName = void 0;
23
+ exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.runColdBoot = exports.isOxyRpOrigin = exports.CENTRAL_IDP_APEX = exports.registrableApex = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = exports.resetLoggerConfig = exports.getLoggerConfig = exports.configureLogger = exports.createLogger = exports.logger = exports.normalizeMultilineText = exports.normalizeInlineText = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.DISPLAY_NAME_ORPHANED_MARK_SOURCE = exports.DISPLAY_NAME_DISALLOWED_SOURCE = exports.DISPLAY_NAME_ALLOWED_SCRIPTS = exports.isValidDisplayName = exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = void 0;
24
+ exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.switchableAccountIds = exports.projectSwitchableAccounts = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.redeemHubTicketOnHub = exports.syncHubAfterSignIn = exports.parseHubSyncReturnUrl = exports.normalizeOfficialReturnOrigin = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isLoopbackOrigin = exports.isIdpHubOrigin = exports.buildHubSyncUrl = exports.buildIdpHubOrigin = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.normalizeOAuthRedirectUri = exports.OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = exports.OXY_SILENT_OAUTH_ATTEMPTED_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = void 0;
25
25
  // Ensure crypto polyfills are loaded before anything else
26
26
  require("./crypto/polyfill");
27
27
  // ---------------------------------------------------------------------------
@@ -29,6 +29,7 @@ require("./crypto/polyfill");
29
29
  // ---------------------------------------------------------------------------
30
30
  var OxyServices_1 = require("./OxyServices");
31
31
  Object.defineProperty(exports, "OxyServices", { enumerable: true, get: function () { return OxyServices_1.OxyServices; } });
32
+ Object.defineProperty(exports, "AssetUrlResolutionError", { enumerable: true, get: function () { return OxyServices_1.AssetUrlResolutionError; } });
32
33
  Object.defineProperty(exports, "OxyAuthenticationError", { enumerable: true, get: function () { return OxyServices_1.OxyAuthenticationError; } });
33
34
  Object.defineProperty(exports, "OxyAuthenticationTimeoutError", { enumerable: true, get: function () { return OxyServices_1.OxyAuthenticationTimeoutError; } });
34
35
  var OxyServices_2 = require("./OxyServices");
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OxyServicesAssetsMixin = OxyServicesAssetsMixin;
4
4
  const protocol_1 = require("@oxyhq/protocol");
5
5
  const logger_1 = require("../logger");
6
+ const OxyServices_errors_1 = require("../OxyServices.errors");
6
7
  const errorUtils_1 = require("../utils/errorUtils");
7
8
  /**
8
9
  * Maximum number of ids sent per `POST /assets/service/by-ids` request. Matches
@@ -24,6 +25,37 @@ const SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE = 100;
24
25
  * never 400s an otherwise-valid chunk on the server.
25
26
  */
26
27
  const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
28
+ /**
29
+ * Conservative lower bound (10 min) the SDK assumes for the lifetime of the
30
+ * scoped media token (`mt`) the API embeds in the stream URL it returns for a
31
+ * private asset. The SDK never mints or inspects that token; this constant
32
+ * exists only so the SDK's own URL cache can be sized safely BELOW the token's
33
+ * real lifetime.
34
+ *
35
+ * The API currently mints tokens for 900s (`MEDIA_TOKEN_TTL_SECONDS` in
36
+ * `packages/api/src/utils/mediaToken.ts`). Core deliberately assumes a shorter
37
+ * 10-min floor rather than copying 15: core and the API are separate packages,
38
+ * so core cannot observe a server-side TTL change at runtime. Under-assuming the
39
+ * lifetime only ever shortens the cache (more refetches, never a dead URL), so
40
+ * it stays correct even if the server lowers its TTL toward this floor. The
41
+ * {@link ASSET_URL_CACHE_LIFETIME_FRACTION} discount is applied on top.
42
+ */
43
+ const ASSET_MEDIA_TOKEN_TTL_MS = 10 * 60 * 1000;
44
+ /**
45
+ * Fraction of a resolved URL's remaining lifetime the SDK is willing to keep it
46
+ * cached for. A resolved URL stops working the moment its media token expires,
47
+ * so caching it for its full nominal lifetime guarantees a window in which the
48
+ * cache hands out an already-dead URL (clock skew, time spent in the render
49
+ * pipeline, an image request queued behind others). Half the lifetime leaves a
50
+ * margin at least as large as the entry's own age.
51
+ */
52
+ const ASSET_URL_CACHE_LIFETIME_FRACTION = 0.5;
53
+ /**
54
+ * Fallback URL lifetime, in seconds, assumed when the caller does not request
55
+ * an explicit `expiresIn`. Mirrors the API's default signed-URL expiry; the
56
+ * effective value is still clamped by {@link ASSET_MEDIA_TOKEN_TTL_MS}.
57
+ */
58
+ const DEFAULT_ASSET_URL_EXPIRES_IN_SECONDS = 3600;
27
59
  function OxyServicesAssetsMixin(Base) {
28
60
  return class extends Base {
29
61
  constructor(...args) {
@@ -41,14 +73,33 @@ function OxyServicesAssetsMixin(Base) {
41
73
  }
42
74
  }
43
75
  /**
44
- * Build a synchronous, `<img src>`-ready file URL from an Oxy asset id.
76
+ * Build a synchronous, `<img src>`-ready URL for a **PUBLIC** Oxy asset.
77
+ *
78
+ * ## Contract — read before calling
45
79
  *
46
- * This method must never embed the caller's general access token in the
47
- * returned URL. The URL is commonly rendered into DOM attributes, browser
48
- * network panels, caches, and logs. Public asset URLs use the clean CDN
49
- * origin; callers that need authorized/private access should use
50
- * {@link getFileDownloadUrlAsync}, which asks the API for a scoped download
51
- * URL instead of exposing the in-memory bearer token in a query string.
80
+ * This is a pure string builder. It performs no network call and therefore
81
+ * has **no knowledge of the asset's visibility**. It always produces the
82
+ * public form: `${cloudURL}/<id>[?variant=…]`, which the CDN serves from
83
+ * the public media origin only.
84
+ *
85
+ * Consequently:
86
+ * - Call it ONLY when the asset is known to be `public` — e.g. avatars and
87
+ * profile banners, which {@link uploadAvatar} / {@link uploadProfileBanner}
88
+ * upload with `visibility: 'public'`.
89
+ * - For an asset that may be `private` or `unlisted` — anything uploaded
90
+ * through the generic {@link assetUpload} path, whose server-side default
91
+ * is private — this URL resolves to a hard **404**. Use
92
+ * {@link getFileDownloadUrlAsync}, which asks the API for a URL scoped to
93
+ * the current caller.
94
+ * - It must never guess visibility, and must never embed the caller's
95
+ * bearer token: the returned string is rendered into DOM attributes,
96
+ * browser network panels, HTTP caches, and logs.
97
+ *
98
+ * Passing `expiresIn` switches to the API-origin stream form
99
+ * (`${baseURL}/assets/<id>/stream?…`) WITHOUT any credential. That form
100
+ * still only serves what an unauthenticated request may see — it is not a
101
+ * synchronous private-asset path, and none exists: authorization for a
102
+ * private asset requires the round-trip in {@link getFileDownloadUrlAsync}.
52
103
  */
53
104
  getFileDownloadUrl(fileId, variant, expiresIn) {
54
105
  // Never embed the in-memory bearer token: this URL is rendered into DOM
@@ -69,16 +120,40 @@ function OxyServicesAssetsMixin(Base) {
69
120
  return `${base}/assets/${encodeURIComponent(fileId)}/stream${qs ? `?${qs}` : ''}`;
70
121
  }
71
122
  /**
72
- * Get file download URL asynchronously (returns signed URL directly from CDN)
123
+ * Resolve an asset id to a URL that is valid for the CURRENT caller,
124
+ * whatever the asset's visibility.
125
+ *
126
+ * Asks the API (`GET /assets/:id/url`) rather than guessing: a `public`
127
+ * asset resolves to the CDN form, while a `private`/`unlisted` asset the
128
+ * caller may read resolves to an API-origin stream URL carrying a scoped,
129
+ * short-lived media token. The returned URL is passed through **unchanged**
130
+ * — the SDK never rewrites, re-signs, or strips it.
131
+ *
132
+ * ## Failure behaviour — no CDN fallback
133
+ *
134
+ * Throws {@link AssetUrlResolutionError} when the API returns no URL or the
135
+ * request fails (including 401/403/404). It deliberately does NOT fall back
136
+ * to {@link getFileDownloadUrl}: that builder only produces the public CDN
137
+ * form, so falling back would hand the caller a URL that renders as a hard
138
+ * 404 for every private asset and would silently swallow the real failure.
139
+ * A caller that knows an asset is public should call the synchronous
140
+ * builder directly instead of relying on a fallback here.
141
+ *
142
+ * The resolved URL is cached per identity for well under the media token's
143
+ * lifetime — see {@link getAssetUrlCacheTTL}.
73
144
  */
74
145
  async getFileDownloadUrlAsync(fileId, variant, expiresIn) {
146
+ let url;
75
147
  try {
76
- const url = await this.fetchAssetDownloadUrl(fileId, variant, this.getAssetUrlCacheTTL(expiresIn), expiresIn);
77
- return url || this.getFileDownloadUrl(fileId, variant, expiresIn);
148
+ url = await this.fetchAssetDownloadUrl(fileId, variant, this.getAssetUrlCacheTTL(expiresIn), expiresIn);
78
149
  }
79
150
  catch (error) {
80
- return this.getFileDownloadUrl(fileId, variant, expiresIn);
151
+ throw new OxyServices_errors_1.AssetUrlResolutionError(fileId, variant, (0, errorUtils_1.extractErrorStatus)(error), error);
152
+ }
153
+ if (!url) {
154
+ throw new OxyServices_errors_1.AssetUrlResolutionError(fileId, variant, undefined);
81
155
  }
156
+ return url;
82
157
  }
83
158
  /**
84
159
  * List user files
@@ -142,13 +217,36 @@ function OxyServicesAssetsMixin(Base) {
142
217
  }
143
218
  }
144
219
  /**
145
- * Get batch access to multiple files
220
+ * Resolve access + a caller-scoped URL for many assets — each with its OWN
221
+ * requested variant — in ONE round trip via `POST /assets/batch-access`.
222
+ *
223
+ * `requests` is a per-file `{ fileId, variant? }` list (a `variant` of
224
+ * `undefined` asks for the original). `options.expiresIn` sets the requested
225
+ * media-token / signed-URL lifetime (seconds); `options.context` is the
226
+ * server-side access-check context. Entries with a blank `fileId` are
227
+ * dropped and exact `(fileId, variant)` duplicates are collapsed before the
228
+ * request; an empty effective list performs no network call.
229
+ *
230
+ * The server caps the batch at 100 entries — callers that page beyond that
231
+ * must chunk. Returns the raw per-file envelope (see
232
+ * {@link BatchFileAccessResponse}); most callers want {@link getFileDownloadUrls},
233
+ * which flattens it to just the usable URLs.
146
234
  */
147
- async getBatchFileAccess(fileIds, context) {
235
+ async getBatchFileAccess(requests, options) {
236
+ const files = dedupeFileAccessRequests(requests);
237
+ if (files.length === 0) {
238
+ return { results: {} };
239
+ }
240
+ const body = {
241
+ files,
242
+ };
243
+ if (typeof options?.expiresIn === 'number')
244
+ body.expiresIn = options.expiresIn;
245
+ if (typeof options?.context === 'string')
246
+ body.context = options.context;
148
247
  try {
149
- return await this.makeRequest('POST', '/assets/batch-access', {
150
- fileIds,
151
- context
248
+ return await this.makeRequest('POST', '/assets/batch-access', body, {
249
+ cache: false,
152
250
  });
153
251
  }
154
252
  catch (error) {
@@ -156,13 +254,27 @@ function OxyServicesAssetsMixin(Base) {
156
254
  }
157
255
  }
158
256
  /**
159
- * Get download URLs for multiple files efficiently
257
+ * Resolve many assets each with its OWN variant — to caller-scoped,
258
+ * `<img src>`-ready URLs in one round trip. The batch counterpart of
259
+ * {@link getFileDownloadUrlAsync}, built to resolve a whole grid page at once.
260
+ *
261
+ * `requests` is a per-file `{ fileId, variant? }` list (e.g. `poster` for a
262
+ * video, `thumb` for an image); the per-file variant RULE lives in the
263
+ * caller — core just forwards what it is given. `options.expiresIn` /
264
+ * `options.context` are passed through to the endpoint.
265
+ *
266
+ * Each returned URL is the API's own scoped form, passed through unchanged:
267
+ * the public CDN URL for a public asset, or an API-origin
268
+ * `/assets/:id/stream?…&mt=<media token>` URL for a private asset the caller
269
+ * may read. Ids the caller cannot access (or that do not exist) are simply
270
+ * OMITTED from the returned map — there is NO public-CDN fallback, so a grid
271
+ * never renders a known-404 URL. Callers detect a miss by the absent key
272
+ * (the map never contains an empty-string value). Keyed by `fileId`.
160
273
  */
161
- async getFileDownloadUrls(fileIds, context) {
162
- const response = await this.getBatchFileAccess(fileIds, context);
274
+ async getFileDownloadUrls(requests, options) {
275
+ const response = await this.getBatchFileAccess(requests, options);
163
276
  const urls = {};
164
- const results = response.results || {};
165
- for (const [id, result] of Object.entries(results)) {
277
+ for (const [id, result] of Object.entries(response.results ?? {})) {
166
278
  if (result.allowed && result.url) {
167
279
  urls[id] = result.url;
168
280
  }
@@ -461,7 +573,7 @@ function OxyServicesAssetsMixin(Base) {
461
573
  params.expiresIn = expiresIn;
462
574
  return await this.makeRequest('GET', `/assets/${fileId}/url`, params, {
463
575
  cache: true,
464
- cacheTTL: 10 * 60 * 1000,
576
+ cacheTTL: this.getAssetUrlCacheTTL(expiresIn),
465
577
  });
466
578
  }
467
579
  catch (error) {
@@ -549,9 +661,21 @@ function OxyServicesAssetsMixin(Base) {
549
661
  throw this.handleError(error);
550
662
  }
551
663
  }
664
+ /**
665
+ * How long a resolved asset URL may stay in the SDK's GET cache, in ms.
666
+ *
667
+ * A resolved private-asset URL dies the instant its scoped media token
668
+ * expires (~{@link ASSET_MEDIA_TOKEN_TTL_MS}). Caching it for its full
669
+ * nominal lifetime would leave a window where the cache serves an
670
+ * already-dead URL (clock skew, render-pipeline latency, an image request
671
+ * queued behind others). So the TTL is (a) never longer than the token's
672
+ * lifetime and (b) discounted to {@link ASSET_URL_CACHE_LIFETIME_FRACTION}
673
+ * of that bound — comfortably below the token TTL by construction.
674
+ */
552
675
  getAssetUrlCacheTTL(expiresIn) {
553
- const desiredTtlMs = (expiresIn ?? 3600) * 1000;
554
- return Math.min(desiredTtlMs, 10 * 60 * 1000);
676
+ const requestedLifetimeMs = (expiresIn ?? DEFAULT_ASSET_URL_EXPIRES_IN_SECONDS) * 1000;
677
+ const boundedLifetimeMs = Math.min(requestedLifetimeMs, ASSET_MEDIA_TOKEN_TTL_MS);
678
+ return Math.floor(boundedLifetimeMs * ASSET_URL_CACHE_LIFETIME_FRACTION);
555
679
  }
556
680
  async fetchAssetDownloadUrl(fileId, variant, cacheTTL, expiresIn) {
557
681
  const params = {};
@@ -561,7 +685,10 @@ function OxyServicesAssetsMixin(Base) {
561
685
  params.expiresIn = expiresIn;
562
686
  const urlRes = await this.makeRequest('GET', `/assets/${encodeURIComponent(fileId)}/url`, Object.keys(params).length ? params : undefined, {
563
687
  cache: true,
564
- cacheTTL: cacheTTL ?? 10 * 60 * 1000,
688
+ // Cap the cached URL well below the media token's lifetime. The
689
+ // response body is a scoped, expiring URL; over-caching it serves a
690
+ // dead URL after the token expires (see getAssetUrlCacheTTL).
691
+ cacheTTL: cacheTTL ?? this.getAssetUrlCacheTTL(expiresIn),
565
692
  });
566
693
  return urlRes?.url || null;
567
694
  }
@@ -576,6 +703,29 @@ function OxyServicesAssetsMixin(Base) {
576
703
  }
577
704
  };
578
705
  }
706
+ /**
707
+ * Normalize the per-file batch-access request list: drop entries with a blank
708
+ * `fileId` and collapse exact `(fileId, variant)` duplicates (first occurrence
709
+ * wins, preserving order). Two entries for the SAME `fileId` with DIFFERENT
710
+ * variants are intentionally kept — but note the response is keyed by `fileId`,
711
+ * so a caller that needs two variants of one file must issue separate calls.
712
+ */
713
+ function dedupeFileAccessRequests(requests) {
714
+ const seen = new Set();
715
+ const out = [];
716
+ for (const req of requests) {
717
+ if (typeof req?.fileId !== 'string' || req.fileId.trim().length === 0) {
718
+ continue;
719
+ }
720
+ const key = `${req.fileId}\u0000${req.variant ?? ''}`;
721
+ if (seen.has(key)) {
722
+ continue;
723
+ }
724
+ seen.add(key);
725
+ out.push(req.variant === undefined ? { fileId: req.fileId } : { fileId: req.fileId, variant: req.variant });
726
+ }
727
+ return out;
728
+ }
579
729
  /**
580
730
  * Only send ambient credentials (cookies) when the asset URL is same-origin with
581
731
  * the configured API base. Caller-supplied cross-origin asset URLs must not leak
@@ -40,7 +40,7 @@ function OxyServicesUserMixin(Base) {
40
40
  */
41
41
  async getProfileByUsername(username, options) {
42
42
  try {
43
- const user = await this.makeRequest('GET', `/profiles/username/${username}`, undefined, {
43
+ const user = await this.makeRequest('GET', `/profiles/username/${encodeURIComponent(username)}`, undefined, {
44
44
  cache: options?.cache ?? true,
45
45
  cacheTTL: 5 * 60 * 1000, // 5 minutes cache for profiles
46
46
  });
@@ -537,6 +537,11 @@ function OxyServicesUserMixin(Base) {
537
537
  try {
538
538
  const result = await this.makeRequest('POST', `/users/${userId}/follow`, undefined, { cache: false });
539
539
  this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
540
+ // Profile fetches embed viewer-relative `relationship` — bust so a
541
+ // remount doesn't serve a stale isFollowing for up to 5 minutes.
542
+ this.clearCacheEntry(`GET:/users/${userId}`);
543
+ this.clearCacheByPrefix('GET:/profiles/username/');
544
+ this.clearCacheByPrefix('GET:/profiles/resolve');
540
545
  // The follow changed the viewer's graph — bust the cached consolidated
541
546
  // `GET /users/me/graph` so the next read reflects the new following/
542
547
  // mutual set instead of the stale pre-write snapshot.
@@ -564,7 +569,10 @@ function OxyServicesUserMixin(Base) {
564
569
  // Bust each affected user's cached follow-status (see `followUser`).
565
570
  for (const id of userIds) {
566
571
  this.clearCacheEntry(`GET:/users/${id}/follow-status`);
572
+ this.clearCacheEntry(`GET:/users/${id}`);
567
573
  }
574
+ this.clearCacheByPrefix('GET:/profiles/username/');
575
+ this.clearCacheByPrefix('GET:/profiles/resolve');
568
576
  // The batch changed the viewer's graph — bust the consolidated cache.
569
577
  this.clearCacheEntry('GET:/users/me/graph');
570
578
  return result;
@@ -590,7 +598,10 @@ function OxyServicesUserMixin(Base) {
590
598
  // Bust each affected user's cached follow-status (see `followUser`).
591
599
  for (const id of userIds) {
592
600
  this.clearCacheEntry(`GET:/users/${id}/follow-status`);
601
+ this.clearCacheEntry(`GET:/users/${id}`);
593
602
  }
603
+ this.clearCacheByPrefix('GET:/profiles/username/');
604
+ this.clearCacheByPrefix('GET:/profiles/resolve');
594
605
  // The batch changed the viewer's graph — bust the consolidated cache.
595
606
  this.clearCacheEntry('GET:/users/me/graph');
596
607
  return result;
@@ -607,6 +618,9 @@ function OxyServicesUserMixin(Base) {
607
618
  const result = await this.makeRequest('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
608
619
  // Bust the cached follow-status so a remount reads fresh truth (see `followUser`).
609
620
  this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
621
+ this.clearCacheEntry(`GET:/users/${userId}`);
622
+ this.clearCacheByPrefix('GET:/profiles/username/');
623
+ this.clearCacheByPrefix('GET:/profiles/resolve');
610
624
  // The unfollow changed the viewer's graph — bust the consolidated cache.
611
625
  this.clearCacheEntry('GET:/users/me/graph');
612
626
  return result;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SessionClient = void 0;
4
4
  const contracts_1 = require("@oxyhq/contracts");
5
5
  const logger_1 = require("../logger");
6
+ const cacheKey_1 = require("../utils/cacheKey");
6
7
  const socketLoader_1 = require("./socketLoader");
7
8
  /**
8
9
  * Same-origin `BroadcastChannel` name for instant, network-free session-state
@@ -83,8 +84,25 @@ class SessionClient {
83
84
  }
84
85
  }
85
86
  }
86
- /** Validate + last-writer-wins by revision. Returns true if applied. */
87
- applyState(raw, origin = 'push') {
87
+ /**
88
+ * Validate + last-writer-wins by revision. Returns true if applied.
89
+ *
90
+ * `activeToken` (sync path only) is the server-issued access token for
91
+ * `raw.activeAccountId`. When present and the state is applied, it is planted
92
+ * BEFORE any subscriber is notified so the bearer already belongs to the new
93
+ * active account — the local switch/bootstrap path then needs no redundant
94
+ * device-secret mint. Push-origin applies carry no token and rely on the
95
+ * mint-before-notify gate below.
96
+ *
97
+ * ORDERING INVARIANT: a subscriber must NEVER observe a newly-active account
98
+ * while the planted bearer still identifies the PREVIOUS one — otherwise a
99
+ * `useCurrentUser`-style refetch fires under the wrong account's token (the
100
+ * account-switch 404 race). So when a transport is available and the planted
101
+ * bearer does not already belong to `next.activeAccountId`, minting is awaited
102
+ * BEFORE `notify()`. This covers EVERY notify source (a switch push, a
103
+ * cross-device push, a cold mint), not just the initial "no bearer yet" case.
104
+ */
105
+ applyState(raw, origin = 'push', activeToken) {
88
106
  const next = (0, contracts_1.safeParseContract)(contracts_1.deviceSessionStateSchema, raw);
89
107
  if (!next) {
90
108
  logger_1.logger.warn('[SessionClient] discarded invalid session state');
@@ -101,9 +119,26 @@ class SessionClient {
101
119
  next.revision <= this.state.revision) {
102
120
  return false;
103
121
  }
122
+ const previousState = this.state;
104
123
  this.state = next;
124
+ // Plant the sync-supplied active token (it is for `next.activeAccountId`)
125
+ // now — before the notify below — so the bearer matches the new active
126
+ // account when subscribers observe it. Guarded on difference to avoid a
127
+ // redundant token-change notification on an unchanged token (bootstrap
128
+ // restate).
129
+ if (activeToken && next.activeAccountId !== null && activeToken !== this.host.getAccessToken()) {
130
+ this.host.setTokens(activeToken);
131
+ }
105
132
  const transport = this.options.transport;
106
- const needsMintBeforeNotify = transport != null && next.accounts.length > 0 && !this.host.getAccessToken();
133
+ const activeAccountId = next.activeAccountId;
134
+ // Mint before notifying when the bearer does not already belong to the new
135
+ // active account: no bearer at all, an opaque bearer, OR a bearer for a
136
+ // DIFFERENT account. `computeIdentityTag` yields the token's `userId`/`id`
137
+ // for a real JWT (comparable to the account id) and a non-account sentinel
138
+ // otherwise, so a mismatch always resolves to "mint".
139
+ const needsMintBeforeNotify = transport != null &&
140
+ next.accounts.length > 0 &&
141
+ (activeAccountId === null || (0, cacheKey_1.computeIdentityTag)(this.host.getAccessToken()) !== activeAccountId);
107
142
  const finishApply = () => {
108
143
  this.notify();
109
144
  if (next.accounts.length === 0 && this.options.onUnauthenticated) {
@@ -117,8 +152,10 @@ class SessionClient {
117
152
  };
118
153
  if (needsMintBeforeNotify) {
119
154
  void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
120
- logger_1.logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
121
- finishApply();
155
+ logger_1.logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
156
+ // Do NOT notify under a mismatched bearer. Revert to the last applied
157
+ // state so subscribers keep observing the account whose token is planted.
158
+ this.state = previousState ?? null;
122
159
  });
123
160
  }
124
161
  else {
@@ -156,9 +193,21 @@ class SessionClient {
156
193
  }
157
194
  // A `sync` is always the response to a direct REST call this client made
158
195
  // (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
159
- // verdict.
160
- this.applyState(sync.state, 'request');
161
- if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
196
+ // verdict. Hand the active token to `applyState`: in the applied path it is
197
+ // planted BEFORE notify (bearer matches the new active account when
198
+ // subscribers observe it, and no redundant device-secret mint is triggered).
199
+ const applied = this.applyState(sync.state, 'request', sync.activeToken?.accessToken);
200
+ // Equal-revision restate (this revision was already applied by a preceding
201
+ // socket push): `applyState` no-ops without planting, but the token still
202
+ // needs planting. Guard on the sync's active account STILL being the current
203
+ // active account so a stale response cannot adopt a token for an account a
204
+ // newer state already switched away from.
205
+ if (!applied &&
206
+ sync.activeToken &&
207
+ this.state &&
208
+ sync.state.activeAccountId !== null &&
209
+ sync.state.activeAccountId === this.state.activeAccountId &&
210
+ sync.activeToken.accessToken !== this.host.getAccessToken()) {
162
211
  this.host.setTokens(sync.activeToken.accessToken);
163
212
  }
164
213
  }