@oxy.so/core 7.11.0 → 7.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -225,3 +225,11 @@ optional/platform-specific modules.
225
225
  ## `verifyChallenge` Token Planting
226
226
 
227
227
  `OxyServices.verifyChallenge()` plants the freshly-minted access token internally before returning. Callers do not need to plant tokens manually after `verifyChallenge` — the SDK handles it.
228
+
229
+ The optional `@oxy.so/core/server/aws` entry requires the optional
230
+ `@aws-sdk/credential-provider-node` peer. Explicit profiles additionally require
231
+ `@aws-sdk/credential-provider-ini`, loaded lazily only when resolving that profile.
232
+ `createAwsWorkloadCredentialProvider({ profile })` resolves only that explicit profile
233
+ and fails if it cannot be resolved; it never falls back to an ambient identity. Omitting
234
+ `profile` retains the default AWS credential chain. Both paths require unexpired
235
+ temporary credentials with a session token.
@@ -39,6 +39,9 @@ const REQUESTER_PROOF_HEADERS = new Set([
39
39
  'x-oxy-presented-requester-context',
40
40
  'x-oxy-presented-service-token',
41
41
  ]);
42
+ function newCredentialGeneration() {
43
+ return Object.freeze({});
44
+ }
42
45
  /** Comparison only fences local async work; it never validates token authority. */
43
46
  function sameTokenSession(left, right) {
44
47
  if (left === right)
@@ -249,14 +252,25 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
249
252
  class TokenStore {
250
253
  constructor() {
251
254
  this.accessToken = null;
255
+ this.generation = newCredentialGeneration();
252
256
  }
253
257
  setTokens(accessToken) {
258
+ this.generation = newCredentialGeneration();
254
259
  this.accessToken = accessToken;
255
260
  }
261
+ /** The identity of the bearer's current installation, never of its bytes. */
262
+ getGeneration() {
263
+ return this.generation;
264
+ }
265
+ /** Retire the current generation without touching the bearer (provider swaps). */
266
+ invalidateGeneration() {
267
+ this.generation = newCredentialGeneration();
268
+ }
256
269
  getAccessToken() {
257
270
  return this.accessToken;
258
271
  }
259
272
  clearTokens() {
273
+ this.generation = newCredentialGeneration();
260
274
  this.accessToken = null;
261
275
  }
262
276
  hasAccessToken() {
@@ -307,7 +321,7 @@ function linkAbort(parent, child) {
307
321
  * Uses native fetch instead of axios for smaller bundle size.
308
322
  */
309
323
  class HttpService {
310
- constructor(config) {
324
+ constructor(config, credentialSource = null) {
311
325
  // Only active calls retain a lease. Invalidation detaches matching leases,
312
326
  // so late results cannot warm the cache or attract new deduplicated callers.
313
327
  this.readLeases = new Map();
@@ -404,6 +418,7 @@ class HttpService {
404
418
  averageResponseTime: 0,
405
419
  };
406
420
  this.config = config;
421
+ this.credentialSource = credentialSource;
407
422
  this.baseURL = config.baseURL;
408
423
  this.tokenStore = new TokenStore();
409
424
  this.logger = new requestUtils_1.SimpleLogger(config.enableLogging || false, config.logLevel || 'error', 'HttpService');
@@ -520,6 +535,17 @@ class HttpService {
520
535
  async request(config) {
521
536
  if (config.signal?.aborted)
522
537
  throw (0, errorUtils_1.createCancelledError)('Request cancelled before it was sent');
538
+ if (config.expectedCredentialGeneration) {
539
+ // The prototype is deliberately limited to writes; it does not
540
+ // yet define leases for cached/deduplicated reads or raw response paths.
541
+ if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(config.method) ||
542
+ config.skipAuth ||
543
+ Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization')) {
544
+ throw new Error('A credential generation requires an implicitly authenticated write');
545
+ }
546
+ (this.credentialSource ?? this).assertCredentialGeneration(config.expectedCredentialGeneration);
547
+ config = { ...config, cache: false, deduplicate: false, retry: false };
548
+ }
523
549
  // The provider can change independently of the last request. Synchronize
524
550
  // before deriving the cache and deduplication identity, including hits.
525
551
  const explicitAuthorization = Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization');
@@ -609,6 +635,12 @@ class HttpService {
609
635
  const authHeader = usesSessionAuth
610
636
  ? await (0, requestSignal_1.awaitRequestSignal)(this.getAuthHeader(), signal)
611
637
  : null;
638
+ const assertExpectedCredential = () => {
639
+ if (config.expectedCredentialGeneration) {
640
+ (this.credentialSource ?? this).assertCredentialGeneration(config.expectedCredentialGeneration, authHeader);
641
+ }
642
+ };
643
+ assertExpectedCredential();
612
644
  const isOriginalSession = () => {
613
645
  if (this.sessionEpoch !== contextEpoch)
614
646
  return false;
@@ -658,6 +690,7 @@ class HttpService {
658
690
  if (usesSessionAuth && !isOriginalSession()) {
659
691
  throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
660
692
  }
693
+ assertExpectedCredential();
661
694
  /**
662
695
  * Whether OUR timer fired, so a timeout can be told apart from a
663
696
  * cancellation. Both reach us from `fetch` as an indistinguishable
@@ -754,6 +787,9 @@ class HttpService {
754
787
  Object.keys(headers).some((key) => REQUESTER_PROOF_HEADERS.has(key.toLowerCase()))) {
755
788
  throw new Error('Requester proofs require a transport that rejects redirects');
756
789
  }
790
+ // Header and source generation are checked together in this synchronous
791
+ // dispatch segment, after the preflight and any queue wait.
792
+ assertExpectedCredential();
757
793
  const response = useXhrForUpload
758
794
  ? await this.uploadViaXHR(fullUrl, method, headers, bodyValue, controller.signal, timeout, false)
759
795
  : await fetch(fullUrl, {
@@ -768,6 +804,13 @@ class HttpService {
768
804
  // it, including on every throw between here and there.
769
805
  if (timeoutId)
770
806
  clearTimeout(timeoutId);
807
+ try {
808
+ assertExpectedCredential();
809
+ }
810
+ catch (error) {
811
+ void discardResponseBody(response);
812
+ throw error;
813
+ }
771
814
  if (usesSessionAuth && !isOriginalSession()) {
772
815
  void discardResponseBody(response);
773
816
  throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
@@ -778,6 +821,7 @@ class HttpService {
778
821
  // once before giving up. HttpService deliberately does not know any
779
822
  // session routes; the refresh handler owns session rotation.
780
823
  if (response.status === 401 &&
824
+ !config.expectedCredentialGeneration &&
781
825
  !config._isAuthRetry &&
782
826
  usesSessionAuth &&
783
827
  isOriginalSession()) {
@@ -913,12 +957,14 @@ class HttpService {
913
957
  if (usesSessionAuth && !isOriginalSession()) {
914
958
  throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
915
959
  }
960
+ assertExpectedCredential();
916
961
  const duration = Date.now() - startTime;
917
962
  this.updateMetrics(true, duration);
918
963
  this.config.onRequestEnd?.(url, method, duration, true);
919
964
  return responseData;
920
965
  }
921
966
  catch (error) {
967
+ assertExpectedCredential();
922
968
  const duration = Date.now() - startTime;
923
969
  this.updateMetrics(false, duration);
924
970
  // A body reader can reject or finish parsing an error after the viewer
@@ -1030,6 +1076,7 @@ class HttpService {
1030
1076
  if (usesSessionAuth && !isOriginalSession()) {
1031
1077
  throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
1032
1078
  }
1079
+ assertExpectedCredential();
1033
1080
  // Cache the result if caching is enabled
1034
1081
  if (cache && cacheKey && lease.valid && result) {
1035
1082
  this.cache.set(cacheKey, result, cacheTTL);
@@ -1755,6 +1802,27 @@ class HttpService {
1755
1802
  getSessionEpoch() {
1756
1803
  return this.sessionEpoch;
1757
1804
  }
1805
+ /** Capture only the local generation; the caller must separately bind authority. */
1806
+ captureCredentialGeneration() {
1807
+ // A pull-only provider can change A→B→A between reads without notifying us.
1808
+ if (this.accessTokenProvider)
1809
+ return null;
1810
+ return this.tokenStore.getAccessToken() ? this.tokenStore.getGeneration() : null;
1811
+ }
1812
+ isCredentialGenerationCurrent(generation) {
1813
+ return (!this.accessTokenProvider &&
1814
+ this.tokenStore.getAccessToken() !== null &&
1815
+ generation === this.tokenStore.getGeneration());
1816
+ }
1817
+ assertCredentialGeneration(generation, selectedHeader) {
1818
+ const token = this.tokenStore.getAccessToken();
1819
+ if (this.accessTokenProvider ||
1820
+ !token ||
1821
+ generation !== this.tokenStore.getGeneration() ||
1822
+ (selectedHeader !== undefined && selectedHeader !== `Bearer ${token}`)) {
1823
+ throw new OxyServices_errors_1.OxyAuthenticationError('The request credential changed', 'AUTH_SESSION_CHANGED');
1824
+ }
1825
+ }
1758
1826
  /** Whether {@link endSession} ran and no token has been planted since. */
1759
1827
  hasSessionEnded() {
1760
1828
  return this.sessionEnded;
@@ -1763,6 +1831,7 @@ class HttpService {
1763
1831
  this.authRefreshHandler = handler;
1764
1832
  }
1765
1833
  setAccessTokenProvider(provider) {
1834
+ this.tokenStore.invalidateGeneration();
1766
1835
  this.accessTokenProvider = provider;
1767
1836
  }
1768
1837
  /**
@@ -117,7 +117,7 @@ class OxyServices {
117
117
  * Query). Pass `enableCache: true` to opt in.
118
118
  */
119
119
  createLinkedClient(config) {
120
- const client = new HttpService_1.HttpService({ ...config, enableCache: config.enableCache ?? false });
120
+ const client = new HttpService_1.HttpService({ ...config, enableCache: config.enableCache ?? false }, this.http);
121
121
  const syncToken = (accessToken) => {
122
122
  const current = client.getAccessToken();
123
123
  if (accessToken) {
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sameHolderIdentity = sameHolderIdentity;
4
+ /** The one comparison that decides whether two observations name the same viewer context. */
5
+ function sameHolderIdentity(a, b) {
6
+ return (a.deviceId === b.deviceId &&
7
+ a.deviceSessionId === b.deviceSessionId &&
8
+ a.contextId === b.contextId &&
9
+ a.actorId === b.actorId &&
10
+ a.accountId === b.accountId &&
11
+ a.sessionId === b.sessionId);
12
+ }
@@ -11,12 +11,21 @@ exports.SessionApi = void 0;
11
11
  const jwt_decode_1 = require("jwt-decode");
12
12
  const userIdentity_1 = require("../utils/userIdentity");
13
13
  const OxyServices_errors_1 = require("../OxyServices.errors");
14
+ const client_1 = require("@oxy.so/contracts/client");
15
+ /** How long a refusal (disabled, rate limited, unreachable) is remembered per installation. */
16
+ const EVIDENCE_REFUSAL_MS = 5000;
17
+ const unavailable = (reason) => ({
18
+ status: 'unavailable',
19
+ reason,
20
+ });
14
21
  class SessionApi {
15
22
  constructor(ctx) {
16
23
  this.ctx = ctx;
17
24
  this.deviceCredentialProvider = null;
18
25
  this.decodedFor = null;
19
26
  this.decoded = null;
27
+ /** One server round trip per installed bearer; the key is the opaque generation. */
28
+ this.evidence = new WeakMap();
20
29
  }
21
30
  // ── Local state ──────────────────────────────────────────────────────────
22
31
  /** The current access token, or `null` when signed out. */
@@ -121,6 +130,115 @@ class SessionApi {
121
130
  return null;
122
131
  }
123
132
  }
133
+ // ── Holder-context evidence (ADR 0053) ───────────────────────────────────
134
+ /**
135
+ * Ask the server which shared-device context the INSTALLED bearer belongs to,
136
+ * and bind the answer to that exact installation.
137
+ *
138
+ * The result is honored only if the credential generation captured before
139
+ * the request is still current when the answer is committed, so a refresh,
140
+ * setter, account or actor switch, cross-tab change, restore or A to B to A
141
+ * during the request yields `changed`. The answer must also agree with the
142
+ * claims of the bearer that was captured; a disagreement is
143
+ * `binding_unavailable`. Nothing here refreshes, clears or mints a credential,
144
+ * and no outcome is a revocation verdict: callers keep their existing checks.
145
+ * Concurrent callers share one request per installation.
146
+ */
147
+ observeHolderEvidence() {
148
+ const generation = this.ctx.http.captureCredentialGeneration();
149
+ const token = this.ctx.http.getAccessToken();
150
+ if (!generation || !token)
151
+ return Promise.resolve(unavailable('no_credential'));
152
+ const known = this.evidence.get(generation);
153
+ if (known) {
154
+ const refused = known.settledAt !== undefined;
155
+ if (!refused || Date.now() - known.settledAt < EVIDENCE_REFUSAL_MS) {
156
+ return known.result.then((result) => result.status === 'ok' && result.evidence.expiresAtMs <= Date.now()
157
+ ? this.refreshEvidence(generation, token)
158
+ : result);
159
+ }
160
+ }
161
+ return this.refreshEvidence(generation, token);
162
+ }
163
+ /** Whether evidence still names the installed bearer and has not expired. */
164
+ isHolderEvidenceCurrent(evidence) {
165
+ return (Date.now() < evidence.expiresAtMs &&
166
+ this.ctx.http.isCredentialGenerationCurrent(evidence.generation));
167
+ }
168
+ refreshEvidence(generation, token) {
169
+ const entry = {
170
+ result: this.acquireEvidence(generation, token).then((result) => {
171
+ if (result.status !== 'ok')
172
+ entry.settledAt = Date.now();
173
+ return result;
174
+ }),
175
+ };
176
+ this.evidence.set(generation, entry);
177
+ return entry.result;
178
+ }
179
+ async acquireEvidence(generation, token) {
180
+ let claims;
181
+ try {
182
+ claims = (0, jwt_decode_1.jwtDecode)(token);
183
+ }
184
+ catch {
185
+ return unavailable('unsupported');
186
+ }
187
+ const proof = await this.readDeviceProof();
188
+ if (!proof)
189
+ return unavailable('no_holder');
190
+ let response;
191
+ try {
192
+ response = await this.ctx.request('POST', '/session/device/context-evidence-v1', proof, {
193
+ cache: false,
194
+ retry: false,
195
+ expectedCredentialGeneration: generation,
196
+ });
197
+ }
198
+ catch (error) {
199
+ if (error?.code === 'AUTH_SESSION_CHANGED') {
200
+ return unavailable('changed');
201
+ }
202
+ const status = error instanceof OxyServices_errors_1.OxyApiError ? error.status : 0;
203
+ if (status === 403)
204
+ return unavailable('unsupported');
205
+ if (status === 409)
206
+ return unavailable('binding_unavailable');
207
+ return unavailable('unavailable');
208
+ }
209
+ const parsed = (0, client_1.checkContract)(client_1.holderContextEvidenceV1ResponseCheck, response);
210
+ if (!parsed)
211
+ return unavailable('unavailable');
212
+ // Commit synchronously: nothing may replace the bearer between this check
213
+ // and the evidence becoming visible to its caller.
214
+ if (!this.ctx.http.isCredentialGenerationCurrent(generation))
215
+ return unavailable('changed');
216
+ const expiresAtMs = Date.parse(parsed.expiresAt);
217
+ if (parsed.deviceId !== proof.deviceId ||
218
+ parsed.accountId !== claims.userId ||
219
+ parsed.sessionId !== claims.sessionId ||
220
+ (claims.act?.sub !== undefined && parsed.actorId !== claims.act.sub) ||
221
+ (claims.device_context_id !== undefined && parsed.contextId !== claims.device_context_id)) {
222
+ return unavailable('binding_unavailable');
223
+ }
224
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
225
+ return unavailable('unavailable');
226
+ }
227
+ const evidence = Object.freeze({
228
+ generation,
229
+ identity: Object.freeze({
230
+ deviceId: parsed.deviceId,
231
+ deviceSessionId: parsed.deviceSessionId,
232
+ contextId: parsed.contextId,
233
+ actorId: parsed.actorId,
234
+ accountId: parsed.accountId,
235
+ sessionId: parsed.sessionId,
236
+ }),
237
+ deviceRevision: parsed.revision,
238
+ expiresAtMs,
239
+ });
240
+ return { status: 'ok', evidence };
241
+ }
124
242
  // ── The server session ───────────────────────────────────────────────────
125
243
  /** Ask the server whether the current access token is valid. */
126
244
  async validateToken() {
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.createAwsWorkloadCredentialProvider = createAwsWorkloadCredentialProvider;
4
37
  /** Optional AWS SDK integration. Never re-export this module from another entrypoint. */
@@ -9,8 +42,21 @@ const credential_provider_node_1 = require("@aws-sdk/credential-provider-node");
9
42
  * Long-lived IAM user access keys are deliberately not a local service identity.
10
43
  */
11
44
  function createAwsWorkloadCredentialProvider(options = {}) {
12
- const provider = (0, credential_provider_node_1.defaultProvider)(options);
45
+ // An explicitly selected profile is an authority boundary. defaultProvider
46
+ // falls through a missing profile to ambient ECS/IMDS credentials, which may
47
+ // name another application. fromIni resolves only the selected profile and
48
+ // its declared source chain, or rejects; there is no ambient fallback.
49
+ if (options.profile !== undefined && !options.profile.trim())
50
+ throw new Error('An explicit AWS workload profile must not be empty');
51
+ const ambient = options.profile === undefined ? (0, credential_provider_node_1.defaultProvider)(options) : undefined;
52
+ const resolveProfile = async () => (await Promise.resolve().then(() => __importStar(require('@aws-sdk/credential-provider-ini')))).fromIni(options);
53
+ let selectedProfile;
13
54
  return async () => {
55
+ let provider = ambient;
56
+ if (!provider) {
57
+ selectedProfile ?? (selectedProfile = resolveProfile());
58
+ provider = await selectedProfile;
59
+ }
14
60
  const credentials = await provider();
15
61
  if (!credentials.sessionToken ||
16
62
  !credentials.expiration ||
package/dist/cjs/index.js CHANGED
@@ -21,10 +21,10 @@
21
21
  * If a symbol does not appear here, it is NOT part of the public API.
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
- exports.getBaseLanguage = exports.FALLBACK_LOCALE = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.commonsDeliveryPlatform = exports.pushTargetsFromDelivery = exports.selectCommonsDelivery = exports.parseCommonsApprovalExpiresAt = exports.getCommonsApprovalBlockingReason = exports.OxyAppDataIdentifierError = exports.AccountNotOnDeviceError = exports.REPUTATION_CACHE_PREFIX = exports.StoreInstallerError = exports.setStoreInstaller = exports.getStoreInstaller = exports.kindAcceptsAccountCategories = exports.isSelectableAccountCategoryId = exports.SELECTABLE_ACCOUNT_CATEGORY_IDS = exports.MAX_ACCOUNT_CATEGORIES = exports.ACCOUNT_CATEGORY_IDS = exports.buildUserDid = exports.DirectUploadInterruptedError = exports.MAX_UPLOAD_BYTES = exports.assetUrlCacheTTL = exports.ASSET_MULTIPART_UPLOAD_MAX_BYTES = exports.ServiceAssetMetadataError = exports.AssetUrlResolutionError = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyApiError = exports.OXY_CLOUD_URL = exports.OXY_API_URL = exports.OxyServices = void 0;
25
- exports.isLocaleLoaded = exports.loadLocale = 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.coerceToSupportedLocale = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = void 0;
26
- 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_UNFLANKED_SEPARATOR_SOURCE = exports.DISPLAY_NAME_ORPHANED_MARK_SOURCE = exports.DISPLAY_NAME_DISALLOWED_SOURCE = exports.DISPLAY_NAME_ALLOWED_SCRIPTS = exports.accountNameInvalidKey = exports.accountNameInvalidMessage = exports.accountNameMaxLength = exports.isValidAccountName = exports.isValidDisplayName = exports.isValidPassword = exports.isValidEmail = exports.ACCOUNT_TITLE_INVALID_MESSAGE = exports.DISPLAY_NAME_INVALID_MESSAGE = exports.MAX_DISPLAY_NAME_LENGTH = exports.PASSWORD_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.parseHttpErrorBody = exports.isHttpRequestError = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.buildQueryParams = exports.trustTierLabel = exports.reputationCategoryLabel = exports.accountRoleLabel = exports.accountCategoryLabel = exports.getLocalesVersion = exports.subscribeLocales = void 0;
27
- exports.packageInfo = exports.consumeOAuthReturnPath = exports.persistOAuthReturnPath = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.canonicalizeOAuthRedirectUri = exports.normalizeOAuthRedirectUri = exports.OXY_OAUTH_RETURN_PATH_STORAGE_KEY = exports.OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.CENTRAL_IDP_APEX = exports.AUTH_WEB_ORIGIN = 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 = void 0;
24
+ exports.FALLBACK_LOCALE = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.commonsDeliveryPlatform = exports.pushTargetsFromDelivery = exports.selectCommonsDelivery = exports.parseCommonsApprovalExpiresAt = exports.getCommonsApprovalBlockingReason = exports.OxyAppDataIdentifierError = exports.AccountNotOnDeviceError = exports.REPUTATION_CACHE_PREFIX = exports.StoreInstallerError = exports.setStoreInstaller = exports.getStoreInstaller = exports.kindAcceptsAccountCategories = exports.isSelectableAccountCategoryId = exports.SELECTABLE_ACCOUNT_CATEGORY_IDS = exports.MAX_ACCOUNT_CATEGORIES = exports.ACCOUNT_CATEGORY_IDS = exports.buildUserDid = exports.DirectUploadInterruptedError = exports.MAX_UPLOAD_BYTES = exports.assetUrlCacheTTL = exports.ASSET_MULTIPART_UPLOAD_MAX_BYTES = exports.sameHolderIdentity = exports.ServiceAssetMetadataError = exports.AssetUrlResolutionError = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyApiError = exports.OXY_CLOUD_URL = exports.OXY_API_URL = exports.OxyServices = void 0;
25
+ exports.loadLocale = 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.coerceToSupportedLocale = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = exports.getBaseLanguage = void 0;
26
+ 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_UNFLANKED_SEPARATOR_SOURCE = exports.DISPLAY_NAME_ORPHANED_MARK_SOURCE = exports.DISPLAY_NAME_DISALLOWED_SOURCE = exports.DISPLAY_NAME_ALLOWED_SCRIPTS = exports.accountNameInvalidKey = exports.accountNameInvalidMessage = exports.accountNameMaxLength = exports.isValidAccountName = exports.isValidDisplayName = exports.isValidPassword = exports.isValidEmail = exports.ACCOUNT_TITLE_INVALID_MESSAGE = exports.DISPLAY_NAME_INVALID_MESSAGE = exports.MAX_DISPLAY_NAME_LENGTH = exports.PASSWORD_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.parseHttpErrorBody = exports.isHttpRequestError = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.buildQueryParams = exports.trustTierLabel = exports.reputationCategoryLabel = exports.accountRoleLabel = exports.accountCategoryLabel = exports.getLocalesVersion = exports.subscribeLocales = exports.isLocaleLoaded = void 0;
27
+ exports.packageInfo = exports.consumeOAuthReturnPath = exports.persistOAuthReturnPath = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.canonicalizeOAuthRedirectUri = exports.normalizeOAuthRedirectUri = exports.OXY_OAUTH_RETURN_PATH_STORAGE_KEY = exports.OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.CENTRAL_IDP_APEX = exports.AUTH_WEB_ORIGIN = 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 = void 0;
28
28
  // The crypto shim MUST stay the first import. The root itself ships no crypto
29
29
  // library (`__tests__/rootEntryGraph.test.ts`), but `@oxy.so/protocol`'s root
30
30
  // entry (reached by the OAuth/PKCE and device helpers) does, and Metro does not
@@ -44,6 +44,8 @@ Object.defineProperty(exports, "OxyAuthenticationError", { enumerable: true, get
44
44
  Object.defineProperty(exports, "OxyAuthenticationTimeoutError", { enumerable: true, get: function () { return OxyServices_errors_1.OxyAuthenticationTimeoutError; } });
45
45
  Object.defineProperty(exports, "AssetUrlResolutionError", { enumerable: true, get: function () { return OxyServices_errors_1.AssetUrlResolutionError; } });
46
46
  Object.defineProperty(exports, "ServiceAssetMetadataError", { enumerable: true, get: function () { return OxyServices_errors_1.ServiceAssetMetadataError; } });
47
+ var holderEvidence_1 = require("./api/holderEvidence");
48
+ Object.defineProperty(exports, "sameHolderIdentity", { enumerable: true, get: function () { return holderEvidence_1.sameHolderIdentity; } });
47
49
  var assets_1 = require("./api/assets");
48
50
  Object.defineProperty(exports, "ASSET_MULTIPART_UPLOAD_MAX_BYTES", { enumerable: true, get: function () { return assets_1.ASSET_MULTIPART_UPLOAD_MAX_BYTES; } });
49
51
  Object.defineProperty(exports, "assetUrlCacheTTL", { enumerable: true, get: function () { return assets_1.assetUrlCacheTTL; } });
@@ -36,6 +36,9 @@ const REQUESTER_PROOF_HEADERS = new Set([
36
36
  'x-oxy-presented-requester-context',
37
37
  'x-oxy-presented-service-token',
38
38
  ]);
39
+ function newCredentialGeneration() {
40
+ return Object.freeze({});
41
+ }
39
42
  /** Comparison only fences local async work; it never validates token authority. */
40
43
  function sameTokenSession(left, right) {
41
44
  if (left === right)
@@ -246,14 +249,25 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
246
249
  class TokenStore {
247
250
  constructor() {
248
251
  this.accessToken = null;
252
+ this.generation = newCredentialGeneration();
249
253
  }
250
254
  setTokens(accessToken) {
255
+ this.generation = newCredentialGeneration();
251
256
  this.accessToken = accessToken;
252
257
  }
258
+ /** The identity of the bearer's current installation, never of its bytes. */
259
+ getGeneration() {
260
+ return this.generation;
261
+ }
262
+ /** Retire the current generation without touching the bearer (provider swaps). */
263
+ invalidateGeneration() {
264
+ this.generation = newCredentialGeneration();
265
+ }
253
266
  getAccessToken() {
254
267
  return this.accessToken;
255
268
  }
256
269
  clearTokens() {
270
+ this.generation = newCredentialGeneration();
257
271
  this.accessToken = null;
258
272
  }
259
273
  hasAccessToken() {
@@ -304,7 +318,7 @@ function linkAbort(parent, child) {
304
318
  * Uses native fetch instead of axios for smaller bundle size.
305
319
  */
306
320
  export class HttpService {
307
- constructor(config) {
321
+ constructor(config, credentialSource = null) {
308
322
  // Only active calls retain a lease. Invalidation detaches matching leases,
309
323
  // so late results cannot warm the cache or attract new deduplicated callers.
310
324
  this.readLeases = new Map();
@@ -401,6 +415,7 @@ export class HttpService {
401
415
  averageResponseTime: 0,
402
416
  };
403
417
  this.config = config;
418
+ this.credentialSource = credentialSource;
404
419
  this.baseURL = config.baseURL;
405
420
  this.tokenStore = new TokenStore();
406
421
  this.logger = new SimpleLogger(config.enableLogging || false, config.logLevel || 'error', 'HttpService');
@@ -517,6 +532,17 @@ export class HttpService {
517
532
  async request(config) {
518
533
  if (config.signal?.aborted)
519
534
  throw createCancelledError('Request cancelled before it was sent');
535
+ if (config.expectedCredentialGeneration) {
536
+ // The prototype is deliberately limited to writes; it does not
537
+ // yet define leases for cached/deduplicated reads or raw response paths.
538
+ if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(config.method) ||
539
+ config.skipAuth ||
540
+ Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization')) {
541
+ throw new Error('A credential generation requires an implicitly authenticated write');
542
+ }
543
+ (this.credentialSource ?? this).assertCredentialGeneration(config.expectedCredentialGeneration);
544
+ config = { ...config, cache: false, deduplicate: false, retry: false };
545
+ }
520
546
  // The provider can change independently of the last request. Synchronize
521
547
  // before deriving the cache and deduplication identity, including hits.
522
548
  const explicitAuthorization = Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization');
@@ -606,6 +632,12 @@ export class HttpService {
606
632
  const authHeader = usesSessionAuth
607
633
  ? await awaitRequestSignal(this.getAuthHeader(), signal)
608
634
  : null;
635
+ const assertExpectedCredential = () => {
636
+ if (config.expectedCredentialGeneration) {
637
+ (this.credentialSource ?? this).assertCredentialGeneration(config.expectedCredentialGeneration, authHeader);
638
+ }
639
+ };
640
+ assertExpectedCredential();
609
641
  const isOriginalSession = () => {
610
642
  if (this.sessionEpoch !== contextEpoch)
611
643
  return false;
@@ -655,6 +687,7 @@ export class HttpService {
655
687
  if (usesSessionAuth && !isOriginalSession()) {
656
688
  throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
657
689
  }
690
+ assertExpectedCredential();
658
691
  /**
659
692
  * Whether OUR timer fired, so a timeout can be told apart from a
660
693
  * cancellation. Both reach us from `fetch` as an indistinguishable
@@ -751,6 +784,9 @@ export class HttpService {
751
784
  Object.keys(headers).some((key) => REQUESTER_PROOF_HEADERS.has(key.toLowerCase()))) {
752
785
  throw new Error('Requester proofs require a transport that rejects redirects');
753
786
  }
787
+ // Header and source generation are checked together in this synchronous
788
+ // dispatch segment, after the preflight and any queue wait.
789
+ assertExpectedCredential();
754
790
  const response = useXhrForUpload
755
791
  ? await this.uploadViaXHR(fullUrl, method, headers, bodyValue, controller.signal, timeout, false)
756
792
  : await fetch(fullUrl, {
@@ -765,6 +801,13 @@ export class HttpService {
765
801
  // it, including on every throw between here and there.
766
802
  if (timeoutId)
767
803
  clearTimeout(timeoutId);
804
+ try {
805
+ assertExpectedCredential();
806
+ }
807
+ catch (error) {
808
+ void discardResponseBody(response);
809
+ throw error;
810
+ }
768
811
  if (usesSessionAuth && !isOriginalSession()) {
769
812
  void discardResponseBody(response);
770
813
  throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
@@ -775,6 +818,7 @@ export class HttpService {
775
818
  // once before giving up. HttpService deliberately does not know any
776
819
  // session routes; the refresh handler owns session rotation.
777
820
  if (response.status === 401 &&
821
+ !config.expectedCredentialGeneration &&
778
822
  !config._isAuthRetry &&
779
823
  usesSessionAuth &&
780
824
  isOriginalSession()) {
@@ -910,12 +954,14 @@ export class HttpService {
910
954
  if (usesSessionAuth && !isOriginalSession()) {
911
955
  throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
912
956
  }
957
+ assertExpectedCredential();
913
958
  const duration = Date.now() - startTime;
914
959
  this.updateMetrics(true, duration);
915
960
  this.config.onRequestEnd?.(url, method, duration, true);
916
961
  return responseData;
917
962
  }
918
963
  catch (error) {
964
+ assertExpectedCredential();
919
965
  const duration = Date.now() - startTime;
920
966
  this.updateMetrics(false, duration);
921
967
  // A body reader can reject or finish parsing an error after the viewer
@@ -1027,6 +1073,7 @@ export class HttpService {
1027
1073
  if (usesSessionAuth && !isOriginalSession()) {
1028
1074
  throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
1029
1075
  }
1076
+ assertExpectedCredential();
1030
1077
  // Cache the result if caching is enabled
1031
1078
  if (cache && cacheKey && lease.valid && result) {
1032
1079
  this.cache.set(cacheKey, result, cacheTTL);
@@ -1752,6 +1799,27 @@ export class HttpService {
1752
1799
  getSessionEpoch() {
1753
1800
  return this.sessionEpoch;
1754
1801
  }
1802
+ /** Capture only the local generation; the caller must separately bind authority. */
1803
+ captureCredentialGeneration() {
1804
+ // A pull-only provider can change A→B→A between reads without notifying us.
1805
+ if (this.accessTokenProvider)
1806
+ return null;
1807
+ return this.tokenStore.getAccessToken() ? this.tokenStore.getGeneration() : null;
1808
+ }
1809
+ isCredentialGenerationCurrent(generation) {
1810
+ return (!this.accessTokenProvider &&
1811
+ this.tokenStore.getAccessToken() !== null &&
1812
+ generation === this.tokenStore.getGeneration());
1813
+ }
1814
+ assertCredentialGeneration(generation, selectedHeader) {
1815
+ const token = this.tokenStore.getAccessToken();
1816
+ if (this.accessTokenProvider ||
1817
+ !token ||
1818
+ generation !== this.tokenStore.getGeneration() ||
1819
+ (selectedHeader !== undefined && selectedHeader !== `Bearer ${token}`)) {
1820
+ throw new OxyAuthenticationError('The request credential changed', 'AUTH_SESSION_CHANGED');
1821
+ }
1822
+ }
1755
1823
  /** Whether {@link endSession} ran and no token has been planted since. */
1756
1824
  hasSessionEnded() {
1757
1825
  return this.sessionEnded;
@@ -1760,6 +1828,7 @@ export class HttpService {
1760
1828
  this.authRefreshHandler = handler;
1761
1829
  }
1762
1830
  setAccessTokenProvider(provider) {
1831
+ this.tokenStore.invalidateGeneration();
1763
1832
  this.accessTokenProvider = provider;
1764
1833
  }
1765
1834
  /**
@@ -114,7 +114,7 @@ export class OxyServices {
114
114
  * Query). Pass `enableCache: true` to opt in.
115
115
  */
116
116
  createLinkedClient(config) {
117
- const client = new HttpService({ ...config, enableCache: config.enableCache ?? false });
117
+ const client = new HttpService({ ...config, enableCache: config.enableCache ?? false }, this.http);
118
118
  const syncToken = (accessToken) => {
119
119
  const current = client.getAccessToken();
120
120
  if (accessToken) {
@@ -0,0 +1,9 @@
1
+ /** The one comparison that decides whether two observations name the same viewer context. */
2
+ export function sameHolderIdentity(a, b) {
3
+ return (a.deviceId === b.deviceId &&
4
+ a.deviceSessionId === b.deviceSessionId &&
5
+ a.contextId === b.contextId &&
6
+ a.actorId === b.actorId &&
7
+ a.accountId === b.accountId &&
8
+ a.sessionId === b.sessionId);
9
+ }