@oxy.so/core 7.11.1 → 7.13.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 (56) hide show
  1. package/dist/cjs/HttpService.js +70 -1
  2. package/dist/cjs/OxyServices.js +1 -1
  3. package/dist/cjs/api/apps.js +1 -1
  4. package/dist/cjs/api/auth.js +1 -1
  5. package/dist/cjs/api/billing.js +4 -3
  6. package/dist/cjs/api/devices.js +5 -4
  7. package/dist/cjs/api/follows.js +11 -10
  8. package/dist/cjs/api/holderEvidence.js +12 -0
  9. package/dist/cjs/api/privacy.js +8 -7
  10. package/dist/cjs/api/session.js +128 -7
  11. package/dist/cjs/api/topics.js +4 -3
  12. package/dist/cjs/api/users.js +5 -4
  13. package/dist/cjs/i18n/locales/compact/es-ES.generated.js +1 -1
  14. package/dist/cjs/i18n/locales/en-US.js +40 -15
  15. package/dist/cjs/i18n/locales/es-ES.js +40 -15
  16. package/dist/cjs/index.js +6 -4
  17. package/dist/esm/HttpService.js +70 -1
  18. package/dist/esm/OxyServices.js +1 -1
  19. package/dist/esm/api/apps.js +1 -1
  20. package/dist/esm/api/auth.js +1 -1
  21. package/dist/esm/api/billing.js +4 -3
  22. package/dist/esm/api/devices.js +5 -4
  23. package/dist/esm/api/follows.js +11 -10
  24. package/dist/esm/api/holderEvidence.js +9 -0
  25. package/dist/esm/api/privacy.js +8 -7
  26. package/dist/esm/api/session.js +128 -7
  27. package/dist/esm/api/topics.js +4 -3
  28. package/dist/esm/api/users.js +5 -4
  29. package/dist/esm/i18n/locales/compact/es-ES.generated.js +1 -1
  30. package/dist/esm/i18n/locales/en-US.js +40 -15
  31. package/dist/esm/i18n/locales/es-ES.js +40 -15
  32. package/dist/esm/index.js +1 -0
  33. package/dist/types/HttpService.d.ts +25 -1
  34. package/dist/types/api/holderEvidence.d.ts +45 -0
  35. package/dist/types/api/session.d.ts +21 -0
  36. package/dist/types/i18n/locales/en-US.d.ts +37 -12
  37. package/dist/types/i18n/locales/es-ES.d.ts +37 -12
  38. package/dist/types/index.d.ts +3 -1
  39. package/dist/types/server/safety.d.ts +9 -9
  40. package/package.json +2 -2
  41. package/src/HttpService.ts +108 -1
  42. package/src/OxyServices.ts +4 -1
  43. package/src/api/apps.ts +1 -1
  44. package/src/api/auth.ts +9 -4
  45. package/src/api/billing.ts +12 -5
  46. package/src/api/devices.ts +6 -4
  47. package/src/api/follows.ts +12 -10
  48. package/src/api/holderEvidence.ts +66 -0
  49. package/src/api/privacy.ts +9 -7
  50. package/src/api/session.ts +163 -9
  51. package/src/api/topics.ts +5 -3
  52. package/src/api/users.ts +6 -4
  53. package/src/i18n/locales/compact/es-ES.generated.ts +1 -1
  54. package/src/i18n/locales/en-US.ts +41 -15
  55. package/src/i18n/locales/es-ES.ts +42 -15
  56. package/src/index.ts +8 -0
@@ -161,6 +161,9 @@ declare const dict: {
161
161
  requestExpired: string;
162
162
  secondFactorInvalid: string;
163
163
  originNotAllowed: string;
164
+ network: string;
165
+ invalidInput: string;
166
+ server: string;
164
167
  generic: string;
165
168
  };
166
169
  subtitleToApp: string;
@@ -223,6 +226,31 @@ declare const dict: {
223
226
  invalidDescription: string;
224
227
  };
225
228
  };
229
+ actionErrors: {
230
+ updateProfile: string;
231
+ uploadAvatar: string;
232
+ updateSettings: string;
233
+ updatePrivacy: string;
234
+ updateNotifications: string;
235
+ updatePreferences: string;
236
+ revokeConnectedApp: string;
237
+ switchSession: string;
238
+ logout: string;
239
+ logoutAll: string;
240
+ updateDeviceName: string;
241
+ removeDevice: string;
242
+ };
243
+ usernamePolicy: {
244
+ hint: string;
245
+ required: string;
246
+ tooShort: string;
247
+ tooLong: string;
248
+ noSpaces: string;
249
+ characters: string;
250
+ onlyNumbers: string;
251
+ reserved: string;
252
+ mustEndInBot: string;
253
+ };
226
254
  signup: {
227
255
  title: string;
228
256
  subtitle: string;
@@ -231,7 +259,6 @@ declare const dict: {
231
259
  username: {
232
260
  label: string;
233
261
  placeholder: string;
234
- required: string;
235
262
  taken: string;
236
263
  };
237
264
  email: {
@@ -303,8 +330,6 @@ declare const dict: {
303
330
  label: string;
304
331
  placeholder: string;
305
332
  choose: string;
306
- required: string;
307
- tooShort: string;
308
333
  };
309
334
  email: {
310
335
  title: string;
@@ -893,7 +918,6 @@ declare const dict: {
893
918
  firstPlaceholder: string;
894
919
  lastLabel: string;
895
920
  lastPlaceholder: string;
896
- saveFailed: string;
897
921
  };
898
922
  avatar: {
899
923
  title: string;
@@ -1828,9 +1852,6 @@ declare const dict: {
1828
1852
  label: string;
1829
1853
  available: string;
1830
1854
  taken: string;
1831
- tooShort: string;
1832
- invalidChars: string;
1833
- mustEndInBot: string;
1834
1855
  checkFailed: string;
1835
1856
  };
1836
1857
  accountCategory: {
@@ -1865,14 +1886,14 @@ declare const dict: {
1865
1886
  transfer: string;
1866
1887
  };
1867
1888
  errors: {
1868
- identifierRequired: string;
1889
+ emailRequired: string;
1890
+ emailInvalid: string;
1869
1891
  missingAccount: string;
1870
1892
  noPermission: string;
1871
- userNotFound: string;
1872
1893
  };
1873
1894
  invite: {
1874
1895
  title: string;
1875
- identifierLabel: string;
1896
+ emailLabel: string;
1876
1897
  roleLabel: string;
1877
1898
  submit: string;
1878
1899
  };
@@ -1885,8 +1906,9 @@ declare const dict: {
1885
1906
  description: string;
1886
1907
  };
1887
1908
  toasts: {
1888
- added: string;
1889
- addFailed: string;
1909
+ invited: string;
1910
+ inviteFailed: string;
1911
+ invitationUpdateFailed: string;
1890
1912
  removed: string;
1891
1913
  removeFailed: string;
1892
1914
  roleUpdated: string;
@@ -1948,6 +1970,7 @@ declare const dict: {
1948
1970
  title: string;
1949
1971
  warning: string;
1950
1972
  confirmLabel: string;
1973
+ confirmMismatch: string;
1951
1974
  confirm: string;
1952
1975
  error: string;
1953
1976
  handoff: {
@@ -2300,6 +2323,7 @@ declare const dict: {
2300
2323
  newLabel: string;
2301
2324
  repeatLabel: string;
2302
2325
  tooShort: string;
2326
+ tooLong: string;
2303
2327
  mismatch: string;
2304
2328
  signOutOthers: string;
2305
2329
  save: string;
@@ -2362,6 +2386,7 @@ declare const dict: {
2362
2386
  request: string;
2363
2387
  notYet: string;
2364
2388
  remove: string;
2389
+ invalid: string;
2365
2390
  };
2366
2391
  remove: {
2367
2392
  title: string;
@@ -24,7 +24,9 @@ export { OxyServices, OXY_API_URL, OXY_CLOUD_URL } from './OxyServices';
24
24
  export type { OxyConfig, LinkedHttpClient } from './OxyServices';
25
25
  export { OxyApiError, OxyAuthenticationError, OxyAuthenticationTimeoutError, AssetUrlResolutionError, ServiceAssetMetadataError, } from './OxyServices.errors';
26
26
  export type { HttpMethod } from './client/context';
27
- export type { RequestOptions, ResponsePayload, ResponseRequest, ResponseTransport, AuthenticatedResponseRequest, AuthRefreshReason, AuthRefreshHandler, } from './HttpService';
27
+ export type { RequestOptions, ResponsePayload, ResponseRequest, ResponseTransport, AuthenticatedResponseRequest, AuthRefreshReason, AuthRefreshHandler, CredentialGeneration, } from './HttpService';
28
+ export { sameHolderIdentity } from './api/holderEvidence';
29
+ export type { HolderEvidence, HolderEvidenceIdentity, HolderEvidenceResult, HolderEvidenceUnavailableReason, } from './api/holderEvidence';
28
30
  export type { SessionApi, DeviceCredentialProvider, SessionValidation } from './api/session';
29
31
  export type { AuthApi, ChallengeResponse, RegistrationRequest, ChallengeVerifyRequest, PublicKeyCheckResponse, OAuthUserInfoResponse, OAuthTokenExchangeResult, CommonsSignInHandle, CommonsSignInStatus, CommonsSignInPurpose, CommonsOAuthContext, CommonsApprovalInfo, CommonsApprovalSubjectAccount, CommonsSignInActionResult, CommonsOAuthFinalizeResult, CommonsDeliveryResult, SignInDeviceOptions, ClaimedSession, } from './api/auth';
30
32
  export type { UsersApi, ResolveExternalUserInput, DeleteAccountProof } from './api/users';
@@ -17,7 +17,7 @@ export declare class ServerSafetyApi extends SafetyApi {
17
17
  /** The original personal bearer, a verified MCP connection proof, or explicit anonymous access. */
18
18
  profileAccess(input: ProfileAccessInput, options?: SafetyRequester): Promise<{
19
19
  allowed: boolean;
20
- reason: "blocked" | "private" | "public" | "owner" | "unavailable";
20
+ reason: "blocked" | "private" | "public" | "unavailable" | "owner";
21
21
  policyVersion: "1";
22
22
  }>;
23
23
  profileAccessBatch(input: ProfileAccessBatchInput, options: SafetyRequester): Promise<{
@@ -25,14 +25,14 @@ export declare class ServerSafetyApi extends SafetyApi {
25
25
  subjectAccountId: string;
26
26
  decision: {
27
27
  allowed: boolean;
28
- reason: "blocked" | "private" | "public" | "owner" | "unavailable" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
28
+ reason: "blocked" | "private" | "public" | "unavailable" | "owner" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
29
29
  policyVersion: "2";
30
30
  };
31
31
  }[];
32
32
  }>;
33
33
  messageAccess(input: MessageAccessInput, options: SafetyRequester): Promise<{
34
34
  allowed: boolean;
35
- reason: "blocked" | "private" | "public" | "owner" | "unavailable" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
35
+ reason: "blocked" | "private" | "public" | "unavailable" | "owner" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
36
36
  policyVersion: "2";
37
37
  }>;
38
38
  messageAccessBatch(input: MessageAccessBatchInput, options: SafetyRequester): Promise<{
@@ -40,7 +40,7 @@ export declare class ServerSafetyApi extends SafetyApi {
40
40
  resourceId: string;
41
41
  decision: {
42
42
  allowed: boolean;
43
- reason: "blocked" | "private" | "public" | "owner" | "unavailable" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
43
+ reason: "blocked" | "private" | "public" | "unavailable" | "owner" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
44
44
  policyVersion: "2";
45
45
  };
46
46
  }[];
@@ -60,7 +60,7 @@ export declare class ServerSafetyApi extends SafetyApi {
60
60
  resourceId: string;
61
61
  decision: {
62
62
  allowed: boolean;
63
- reason: "blocked" | "private" | "public" | "owner" | "unavailable" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
63
+ reason: "blocked" | "private" | "public" | "unavailable" | "owner" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
64
64
  policyVersion: "2";
65
65
  };
66
66
  }[];
@@ -70,7 +70,7 @@ export declare class ServerSafetyApi extends SafetyApi {
70
70
  targetAccountId: string;
71
71
  decision: {
72
72
  allowed: boolean;
73
- reason: "blocked" | "private" | "public" | "owner" | "unavailable" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
73
+ reason: "blocked" | "private" | "public" | "unavailable" | "owner" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
74
74
  policyVersion: "2";
75
75
  };
76
76
  }[];
@@ -96,7 +96,7 @@ export declare class ServerSafetyApi extends SafetyApi {
96
96
  }>;
97
97
  contentAccess(input: ContentAccessInput, options: SafetyRequester): Promise<{
98
98
  allowed: boolean;
99
- reason: "blocked" | "private" | "public" | "owner" | "unavailable" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
99
+ reason: "blocked" | "private" | "public" | "unavailable" | "owner" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
100
100
  policyVersion: "2";
101
101
  }>;
102
102
  contentAccessBatch(input: ContentAccessBatchInput, options: SafetyRequester): Promise<{
@@ -104,14 +104,14 @@ export declare class ServerSafetyApi extends SafetyApi {
104
104
  resourceId: string;
105
105
  decision: {
106
106
  allowed: boolean;
107
- reason: "blocked" | "private" | "public" | "owner" | "unavailable" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
107
+ reason: "blocked" | "private" | "public" | "unavailable" | "owner" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
108
108
  policyVersion: "2";
109
109
  };
110
110
  }[];
111
111
  }>;
112
112
  deliveryAccess(input: DeliveryAccessInput): Promise<{
113
113
  allowed: boolean;
114
- reason: "blocked" | "private" | "public" | "owner" | "unavailable" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
114
+ reason: "blocked" | "private" | "public" | "unavailable" | "owner" | "allowed" | "unsupported_consumer" | "contact_not_approved" | "mature_content" | "unrated_content" | "approval_required";
115
115
  policyVersion: "2";
116
116
  }>;
117
117
  requestPurchaseApproval(input: PurchaseEffects, options: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.11.1",
3
+ "version": "7.13.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -181,7 +181,7 @@
181
181
  "dependencies": {
182
182
  "@noble/ciphers": "^1.3.0",
183
183
  "@noble/hashes": "^1.8.0",
184
- "@oxy.so/contracts": "6.3.0",
184
+ "@oxy.so/contracts": "6.5.0",
185
185
  "@oxy.so/protocol": "^1.5.1",
186
186
  "@oxy.so/telemetry": "^2.1.0",
187
187
  "@scure/bip39": "^1.6.0",
@@ -56,6 +56,21 @@ export type AccessTokenProvider = () => string | null;
56
56
  */
57
57
  export type AnonymousAuthProvider = () => Promise<string | null>;
58
58
 
59
+ declare const credentialGenerationBrand: unique symbol;
60
+ /**
61
+ * An opaque, immutable token for ONE installation of a bearer in a client.
62
+ *
63
+ * `TokenStore` swaps it on every write path (setter, refresh, account or actor
64
+ * switch, cross-tab or restored token, clear), so two installations of the same
65
+ * bytes (A, B, A) are different generations. It names a local sequence only: it
66
+ * is not evidence of an account, a scope or authorization.
67
+ */
68
+ export type CredentialGeneration = { readonly [credentialGenerationBrand]: true };
69
+
70
+ function newCredentialGeneration(): CredentialGeneration {
71
+ return Object.freeze({}) as CredentialGeneration;
72
+ }
73
+
59
74
  /** Comparison only fences local async work; it never validates token authority. */
60
75
  function sameTokenSession(left: string | null, right: string | null): boolean {
61
76
  if (left === right) return true;
@@ -190,6 +205,13 @@ interface FormDataLike {
190
205
  }
191
206
 
192
207
  export interface RequestOptions {
208
+ /**
209
+ * Bind a write to ONE installation of the source's bearer. The transport
210
+ * rejects (`AUTH_SESSION_CHANGED`) when the generation was replaced before
211
+ * dispatch, after queueing, or before the result is returned. Implies no cache,
212
+ * deduplication or retry, and a 401 neither refreshes nor clears the session.
213
+ */
214
+ expectedCredentialGeneration?: CredentialGeneration;
193
215
  cache?: boolean;
194
216
  cacheTTL?: number;
195
217
  deduplicate?: boolean;
@@ -434,16 +456,29 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
434
456
  */
435
457
  class TokenStore {
436
458
  private accessToken: string | null = null;
459
+ private generation: CredentialGeneration = newCredentialGeneration();
437
460
 
438
461
  setTokens(accessToken: string): void {
462
+ this.generation = newCredentialGeneration();
439
463
  this.accessToken = accessToken;
440
464
  }
441
465
 
466
+ /** The identity of the bearer's current installation, never of its bytes. */
467
+ getGeneration(): CredentialGeneration {
468
+ return this.generation;
469
+ }
470
+
471
+ /** Retire the current generation without touching the bearer (provider swaps). */
472
+ invalidateGeneration(): void {
473
+ this.generation = newCredentialGeneration();
474
+ }
475
+
442
476
  getAccessToken(): string | null {
443
477
  return this.accessToken;
444
478
  }
445
479
 
446
480
  clearTokens(): void {
481
+ this.generation = newCredentialGeneration();
447
482
  this.accessToken = null;
448
483
  }
449
484
 
@@ -576,6 +611,7 @@ export class HttpService {
576
611
  * bearer — and through the token listeners, the account — back.
577
612
  */
578
613
  private sessionEpoch = 0;
614
+ private readonly credentialSource: HttpService | null;
579
615
  /** Preserve a known device across legacy rotations which omit the claim. */
580
616
  private sessionDeviceId: string | null = null;
581
617
  /** Preserve known actor and context hints across rotations without those claims. */
@@ -618,8 +654,9 @@ export class HttpService {
618
654
  averageResponseTime: 0,
619
655
  };
620
656
 
621
- constructor(config: OxyConfig) {
657
+ constructor(config: OxyConfig, credentialSource: HttpService | null = null) {
622
658
  this.config = config;
659
+ this.credentialSource = credentialSource;
623
660
  this.baseURL = config.baseURL;
624
661
  this.tokenStore = new TokenStore();
625
662
 
@@ -760,6 +797,21 @@ export class HttpService {
760
797
  */
761
798
  async request<T = unknown>(config: RequestConfig): Promise<ResponsePayload<T>> {
762
799
  if (config.signal?.aborted) throw createCancelledError('Request cancelled before it was sent');
800
+ if (config.expectedCredentialGeneration) {
801
+ // The prototype is deliberately limited to writes; it does not
802
+ // yet define leases for cached/deduplicated reads or raw response paths.
803
+ if (
804
+ !['POST', 'PUT', 'PATCH', 'DELETE'].includes(config.method) ||
805
+ config.skipAuth ||
806
+ Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization')
807
+ ) {
808
+ throw new Error('A credential generation requires an implicitly authenticated write');
809
+ }
810
+ (this.credentialSource ?? this).assertCredentialGeneration(
811
+ config.expectedCredentialGeneration,
812
+ );
813
+ config = { ...config, cache: false, deduplicate: false, retry: false };
814
+ }
763
815
  // The provider can change independently of the last request. Synchronize
764
816
  // before deriving the cache and deduplication identity, including hits.
765
817
  const explicitAuthorization = Object.keys(config.headers ?? {}).some(
@@ -878,6 +930,15 @@ export class HttpService {
878
930
  const authHeader = usesSessionAuth
879
931
  ? await awaitRequestSignal(this.getAuthHeader(), signal)
880
932
  : null;
933
+ const assertExpectedCredential = () => {
934
+ if (config.expectedCredentialGeneration) {
935
+ (this.credentialSource ?? this).assertCredentialGeneration(
936
+ config.expectedCredentialGeneration,
937
+ authHeader,
938
+ );
939
+ }
940
+ };
941
+ assertExpectedCredential();
881
942
  const isOriginalSession = () => {
882
943
  if (this.sessionEpoch !== contextEpoch) return false;
883
944
  const currentToken = this.syncAccessTokenFromProvider();
@@ -929,6 +990,7 @@ export class HttpService {
929
990
  if (usesSessionAuth && !isOriginalSession()) {
930
991
  throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
931
992
  }
993
+ assertExpectedCredential();
932
994
 
933
995
  /**
934
996
  * Whether OUR timer fired, so a timeout can be told apart from a
@@ -1042,6 +1104,9 @@ export class HttpService {
1042
1104
  throw new Error('Requester proofs require a transport that rejects redirects');
1043
1105
  }
1044
1106
 
1107
+ // Header and source generation are checked together in this synchronous
1108
+ // dispatch segment, after the preflight and any queue wait.
1109
+ assertExpectedCredential();
1045
1110
  const response = useXhrForUpload
1046
1111
  ? await this.uploadViaXHR(
1047
1112
  fullUrl,
@@ -1065,6 +1130,13 @@ export class HttpService {
1065
1130
  // it, including on every throw between here and there.
1066
1131
  if (timeoutId) clearTimeout(timeoutId);
1067
1132
 
1133
+ try {
1134
+ assertExpectedCredential();
1135
+ } catch (error) {
1136
+ void discardResponseBody(response);
1137
+ throw error;
1138
+ }
1139
+
1068
1140
  if (usesSessionAuth && !isOriginalSession()) {
1069
1141
  void discardResponseBody(response);
1070
1142
  throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
@@ -1077,6 +1149,7 @@ export class HttpService {
1077
1149
  // session routes; the refresh handler owns session rotation.
1078
1150
  if (
1079
1151
  response.status === 401 &&
1152
+ !config.expectedCredentialGeneration &&
1080
1153
  !config._isAuthRetry &&
1081
1154
  usesSessionAuth &&
1082
1155
  isOriginalSession()
@@ -1218,12 +1291,14 @@ export class HttpService {
1218
1291
  if (usesSessionAuth && !isOriginalSession()) {
1219
1292
  throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
1220
1293
  }
1294
+ assertExpectedCredential();
1221
1295
  const duration = Date.now() - startTime;
1222
1296
  this.updateMetrics(true, duration);
1223
1297
  this.config.onRequestEnd?.(url, method, duration, true);
1224
1298
 
1225
1299
  return responseData as T;
1226
1300
  } catch (error: unknown) {
1301
+ assertExpectedCredential();
1227
1302
  const duration = Date.now() - startTime;
1228
1303
  this.updateMetrics(false, duration);
1229
1304
  // A body reader can reject or finish parsing an error after the viewer
@@ -1348,6 +1423,7 @@ export class HttpService {
1348
1423
  if (usesSessionAuth && !isOriginalSession()) {
1349
1424
  throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
1350
1425
  }
1426
+ assertExpectedCredential();
1351
1427
 
1352
1428
  // Cache the result if caching is enabled
1353
1429
  if (cache && cacheKey && lease.valid && result) {
@@ -2192,6 +2268,36 @@ export class HttpService {
2192
2268
  return this.sessionEpoch;
2193
2269
  }
2194
2270
 
2271
+ /** Capture only the local generation; the caller must separately bind authority. */
2272
+ captureCredentialGeneration(): CredentialGeneration | null {
2273
+ // A pull-only provider can change A→B→A between reads without notifying us.
2274
+ if (this.accessTokenProvider) return null;
2275
+ return this.tokenStore.getAccessToken() ? this.tokenStore.getGeneration() : null;
2276
+ }
2277
+
2278
+ isCredentialGenerationCurrent(generation: CredentialGeneration): boolean {
2279
+ return (
2280
+ !this.accessTokenProvider &&
2281
+ this.tokenStore.getAccessToken() !== null &&
2282
+ generation === this.tokenStore.getGeneration()
2283
+ );
2284
+ }
2285
+
2286
+ private assertCredentialGeneration(
2287
+ generation: CredentialGeneration,
2288
+ selectedHeader?: string | null,
2289
+ ): void {
2290
+ const token = this.tokenStore.getAccessToken();
2291
+ if (
2292
+ this.accessTokenProvider ||
2293
+ !token ||
2294
+ generation !== this.tokenStore.getGeneration() ||
2295
+ (selectedHeader !== undefined && selectedHeader !== `Bearer ${token}`)
2296
+ ) {
2297
+ throw new OxyAuthenticationError('The request credential changed', 'AUTH_SESSION_CHANGED');
2298
+ }
2299
+ }
2300
+
2195
2301
  /** Whether {@link endSession} ran and no token has been planted since. */
2196
2302
  hasSessionEnded(): boolean {
2197
2303
  return this.sessionEnded;
@@ -2202,6 +2308,7 @@ export class HttpService {
2202
2308
  }
2203
2309
 
2204
2310
  setAccessTokenProvider(provider: AccessTokenProvider | null): void {
2311
+ this.tokenStore.invalidateGeneration();
2205
2312
  this.accessTokenProvider = provider;
2206
2313
  }
2207
2314
 
@@ -159,7 +159,10 @@ export class OxyServices {
159
159
  * Query). Pass `enableCache: true` to opt in.
160
160
  */
161
161
  createLinkedClient(config: OxyConfig): LinkedHttpClient {
162
- const client = new HttpService({ ...config, enableCache: config.enableCache ?? false });
162
+ const client = new HttpService(
163
+ { ...config, enableCache: config.enableCache ?? false },
164
+ this.http,
165
+ );
163
166
 
164
167
  const syncToken = (accessToken: string | null): void => {
165
168
  const current = client.getAccessToken();
package/src/api/apps.ts CHANGED
@@ -494,7 +494,7 @@ export class ConnectedAppsApi {
494
494
  async revoke(applicationId: string): Promise<void> {
495
495
  await this.ctx.request<{ revoked: boolean }>(
496
496
  'DELETE',
497
- `/auth/grants/${applicationId}`,
497
+ `/auth/grants/${enc(applicationId)}`,
498
498
  undefined,
499
499
  { cache: false },
500
500
  );
package/src/api/auth.ts CHANGED
@@ -1622,10 +1622,15 @@ export class AuthApi {
1622
1622
  */
1623
1623
  async checkUsername(username: string): Promise<{ available: boolean; message: string }> {
1624
1624
  // Public availability lookup (pre-session) — skip the bearer preflight.
1625
- return this.ctx.request('GET', `/auth/check-username/${username}`, undefined, {
1626
- cache: false,
1627
- skipAuth: true,
1628
- });
1625
+ return this.ctx.request(
1626
+ 'GET',
1627
+ `/auth/check-username/${encodeURIComponent(username)}`,
1628
+ undefined,
1629
+ {
1630
+ cache: false,
1631
+ skipAuth: true,
1632
+ },
1633
+ );
1629
1634
  }
1630
1635
 
1631
1636
  /**
@@ -40,6 +40,8 @@ import {
40
40
  } from '@oxy.so/contracts/client';
41
41
  import type { OxyContext } from '../client/context';
42
42
 
43
+ const enc = encodeURIComponent;
44
+
43
45
  const SUBSCRIPTION_TTL = 2 * 60 * 1000;
44
46
  const WALLET_TTL = 60 * 1000;
45
47
 
@@ -282,7 +284,7 @@ export class BillingApi {
282
284
  /** A user's subscription (default: the signed-in user's). */
283
285
  async subscription(userId?: string): Promise<Subscription> {
284
286
  const id = this.resolveUserId(userId);
285
- return this.ctx.request<Subscription>('GET', `/subscription/${id}`, undefined, {
287
+ return this.ctx.request<Subscription>('GET', `/subscription/${enc(id)}`, undefined, {
286
288
  cache: true,
287
289
  cacheTTL: SUBSCRIPTION_TTL,
288
290
  });
@@ -291,7 +293,7 @@ export class BillingApi {
291
293
  /** A user's wallet (default: the signed-in user's). Cached briefly: the balance moves. */
292
294
  async wallet(userId?: string): Promise<Wallet> {
293
295
  const id = this.resolveUserId(userId);
294
- return this.ctx.request<Wallet>('GET', `/wallet/${id}`, undefined, {
296
+ return this.ctx.request<Wallet>('GET', `/wallet/${enc(id)}`, undefined, {
295
297
  cache: true,
296
298
  cacheTTL: WALLET_TTL,
297
299
  });
@@ -305,9 +307,14 @@ export class BillingApi {
305
307
  const params: Record<string, number> = {};
306
308
  if (options.limit) params.limit = options.limit;
307
309
  if (options.offset) params.offset = options.offset;
308
- return this.ctx.request<WalletTransactionsPage>('GET', `/wallet/transactions/${id}`, params, {
309
- cache: false,
310
- });
310
+ return this.ctx.request<WalletTransactionsPage>(
311
+ 'GET',
312
+ `/wallet/transactions/${enc(id)}`,
313
+ params,
314
+ {
315
+ cache: false,
316
+ },
317
+ );
311
318
  }
312
319
 
313
320
  private resolveUserId(userId?: string): string {
@@ -40,6 +40,8 @@ import type {
40
40
  import { logger } from '../logger';
41
41
  import { extractErrorStatus } from '../utils/errorUtils';
42
42
 
43
+ const enc = encodeURIComponent;
44
+
43
45
  /** One of the signed-in user's devices (`GET /devices`). */
44
46
  export interface UserDevice {
45
47
  id: string;
@@ -139,7 +141,7 @@ export class DevicesApi {
139
141
  * @param deviceId - The device ID to remove
140
142
  */
141
143
  async remove(deviceId: string): Promise<void> {
142
- await this.ctx.request('DELETE', `/devices/${deviceId}`, undefined, { cache: false });
144
+ await this.ctx.request('DELETE', `/devices/${enc(deviceId)}`, undefined, { cache: false });
143
145
  }
144
146
 
145
147
  /**
@@ -152,7 +154,7 @@ export class DevicesApi {
152
154
  // Cache disabled by default to ensure fresh session data
153
155
  return this.ctx.request<DeviceLinkedSession[]>(
154
156
  'GET',
155
- `/session/device/sessions/${sessionId}`,
157
+ `/session/device/sessions/${enc(sessionId)}`,
156
158
  undefined,
157
159
  {
158
160
  cache: false, // Don't cache sessions - always get fresh data
@@ -178,7 +180,7 @@ export class DevicesApi {
178
180
  if (excludeCurrent) urlParams.excludeCurrent = 'true';
179
181
  return this.ctx.request<DeviceLinkedSessionLogoutResponse>(
180
182
  'POST',
181
- `/session/device/logout-all/${sessionId}`,
183
+ `/session/device/logout-all/${enc(sessionId)}`,
182
184
  urlParams,
183
185
  { cache: false },
184
186
  );
@@ -196,7 +198,7 @@ export class DevicesApi {
196
198
  ): Promise<{ success: boolean; message: string; deviceName: string }> {
197
199
  return this.ctx.request(
198
200
  'PUT',
199
- `/session/device/name/${sessionId}`,
201
+ `/session/device/name/${enc(sessionId)}`,
200
202
  { deviceName },
201
203
  { cache: false },
202
204
  );