@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
@@ -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) {
@@ -85,7 +85,7 @@ class ConnectedAppsApi {
85
85
  * `ConnectedApp.applicationId`, NOT a credential/client id).
86
86
  */
87
87
  async revoke(applicationId) {
88
- await this.ctx.request('DELETE', `/auth/grants/${applicationId}`, undefined, { cache: false });
88
+ await this.ctx.request('DELETE', `/auth/grants/${enc(applicationId)}`, undefined, { cache: false });
89
89
  this.ctx.oxy.cache.delete('GET:/auth/grants');
90
90
  }
91
91
  /** Resource-bound external MCP connections for the active Oxy account. */
@@ -974,7 +974,7 @@ class AuthApi {
974
974
  */
975
975
  async checkUsername(username) {
976
976
  // Public availability lookup (pre-session) — skip the bearer preflight.
977
- return this.ctx.request('GET', `/auth/check-username/${username}`, undefined, {
977
+ return this.ctx.request('GET', `/auth/check-username/${encodeURIComponent(username)}`, undefined, {
978
978
  cache: false,
979
979
  skipAuth: true,
980
980
  });
@@ -13,6 +13,7 @@
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.BillingApi = void 0;
15
15
  const client_1 = require("@oxy.so/contracts/client");
16
+ const enc = encodeURIComponent;
16
17
  const SUBSCRIPTION_TTL = 2 * 60 * 1000;
17
18
  const WALLET_TTL = 60 * 1000;
18
19
  class BillingApi {
@@ -76,7 +77,7 @@ class BillingApi {
76
77
  /** A user's subscription (default: the signed-in user's). */
77
78
  async subscription(userId) {
78
79
  const id = this.resolveUserId(userId);
79
- return this.ctx.request('GET', `/subscription/${id}`, undefined, {
80
+ return this.ctx.request('GET', `/subscription/${enc(id)}`, undefined, {
80
81
  cache: true,
81
82
  cacheTTL: SUBSCRIPTION_TTL,
82
83
  });
@@ -84,7 +85,7 @@ class BillingApi {
84
85
  /** A user's wallet (default: the signed-in user's). Cached briefly: the balance moves. */
85
86
  async wallet(userId) {
86
87
  const id = this.resolveUserId(userId);
87
- return this.ctx.request('GET', `/wallet/${id}`, undefined, {
88
+ return this.ctx.request('GET', `/wallet/${enc(id)}`, undefined, {
88
89
  cache: true,
89
90
  cacheTTL: WALLET_TTL,
90
91
  });
@@ -97,7 +98,7 @@ class BillingApi {
97
98
  params.limit = options.limit;
98
99
  if (options.offset)
99
100
  params.offset = options.offset;
100
- return this.ctx.request('GET', `/wallet/transactions/${id}`, params, {
101
+ return this.ctx.request('GET', `/wallet/transactions/${enc(id)}`, params, {
101
102
  cache: false,
102
103
  });
103
104
  }
@@ -4,6 +4,7 @@ exports.DevicesApi = exports.AccountNotOnDeviceError = void 0;
4
4
  const client_1 = require("@oxy.so/contracts/client");
5
5
  const logger_1 = require("../logger");
6
6
  const errorUtils_1 = require("../utils/errorUtils");
7
+ const enc = encodeURIComponent;
7
8
  /**
8
9
  * The server's `401 account_not_on_device` for a PINNED mint: the requested
9
10
  * `accountId` is not (or is no longer) a live account of this device session.
@@ -75,7 +76,7 @@ class DevicesApi {
75
76
  * @param deviceId - The device ID to remove
76
77
  */
77
78
  async remove(deviceId) {
78
- await this.ctx.request('DELETE', `/devices/${deviceId}`, undefined, { cache: false });
79
+ await this.ctx.request('DELETE', `/devices/${enc(deviceId)}`, undefined, { cache: false });
79
80
  }
80
81
  /**
81
82
  * Get device sessions for a given session ID
@@ -85,7 +86,7 @@ class DevicesApi {
85
86
  */
86
87
  async sessions(sessionId) {
87
88
  // Cache disabled by default to ensure fresh session data
88
- return this.ctx.request('GET', `/session/device/sessions/${sessionId}`, undefined, {
89
+ return this.ctx.request('GET', `/session/device/sessions/${enc(sessionId)}`, undefined, {
89
90
  cache: false, // Don't cache sessions - always get fresh data
90
91
  deduplicate: true, // Deduplicate concurrent requests for same sessionId
91
92
  });
@@ -103,7 +104,7 @@ class DevicesApi {
103
104
  urlParams.deviceId = deviceId;
104
105
  if (excludeCurrent)
105
106
  urlParams.excludeCurrent = 'true';
106
- return this.ctx.request('POST', `/session/device/logout-all/${sessionId}`, urlParams, { cache: false });
107
+ return this.ctx.request('POST', `/session/device/logout-all/${enc(sessionId)}`, urlParams, { cache: false });
107
108
  }
108
109
  /**
109
110
  * Update device name
@@ -112,7 +113,7 @@ class DevicesApi {
112
113
  * @returns Updated device object
113
114
  */
114
115
  async rename(sessionId, deviceName) {
115
- return this.ctx.request('PUT', `/session/device/name/${sessionId}`, { deviceName }, { cache: false });
116
+ return this.ctx.request('PUT', `/session/device/name/${enc(sessionId)}`, { deviceName }, { cache: false });
116
117
  }
117
118
  /**
118
119
  * Get security information
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FollowsApi = void 0;
4
4
  const apiUtils_1 = require("../utils/apiUtils");
5
+ const enc = encodeURIComponent;
5
6
  const GRAPH_TTL = 2 * 60 * 1000;
6
7
  const STATUS_TTL = 60 * 1000;
7
8
  /**
@@ -16,7 +17,7 @@ class FollowsApi {
16
17
  // ── People ───────────────────────────────────────────────────────────────
17
18
  /** Follow a user. */
18
19
  async follow(userId) {
19
- const result = await this.ctx.request('POST', `/users/${userId}/follow`, undefined, { cache: false });
20
+ const result = await this.ctx.request('POST', `/users/${enc(userId)}/follow`, undefined, { cache: false });
20
21
  this.invalidate([userId]);
21
22
  return result;
22
23
  }
@@ -30,7 +31,7 @@ class FollowsApi {
30
31
  }
31
32
  /** Unfollow a user. */
32
33
  async unfollow(userId) {
33
- const result = await this.ctx.request('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
34
+ const result = await this.ctx.request('DELETE', `/users/${enc(userId)}/follow`, undefined, { cache: false });
34
35
  this.invalidate([userId]);
35
36
  return result;
36
37
  }
@@ -44,7 +45,7 @@ class FollowsApi {
44
45
  }
45
46
  /** Whether the signed-in user follows `userId`. Cached 1 minute, busted by every write. */
46
47
  async status(userId) {
47
- return this.ctx.request('GET', `/users/${userId}/follow-status`, undefined, {
48
+ return this.ctx.request('GET', `/users/${enc(userId)}/follow-status`, undefined, {
48
49
  cache: true,
49
50
  cacheTTL: STATUS_TTL,
50
51
  });
@@ -74,12 +75,12 @@ class FollowsApi {
74
75
  * `limit`/`offset`/`sort` combination is its own cache entry (2 minutes).
75
76
  */
76
77
  async followers(userId, params) {
77
- const { data, total, hasMore } = await this.userPage(`/users/${userId}/followers`, params);
78
+ const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/followers`, params);
78
79
  return { followers: data, total, hasMore };
79
80
  }
80
81
  /** Who a user follows. `sort` as in `followers`. */
81
82
  async following(userId, params) {
82
- const { data, total, hasMore } = await this.userPage(`/users/${userId}/following`, params);
83
+ const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/following`, params);
83
84
  return { following: data, total, hasMore };
84
85
  }
85
86
  /**
@@ -87,7 +88,7 @@ class FollowsApi {
87
88
  * `userId`.
88
89
  */
89
90
  async mutuals(userId, params) {
90
- const { data, total, hasMore } = await this.userPage(`/users/${userId}/mutuals`, params);
91
+ const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/mutuals`, params);
91
92
  return { mutuals: data, total, hasMore };
92
93
  }
93
94
  /**
@@ -222,16 +223,16 @@ class FollowsApi {
222
223
  const keys = ['GET:/users/me/graph'];
223
224
  const prefixes = ['GET:/profiles/username/', 'GET:/profiles/resolve'];
224
225
  for (const id of targetUserIds) {
225
- keys.push(`GET:/users/${id}/follow-status`);
226
+ keys.push(`GET:/users/${enc(id)}/follow-status`);
226
227
  // Profiles embed the viewer-relative `relationship`.
227
- keys.push(`GET:/users/${id}`);
228
+ keys.push(`GET:/users/${enc(id)}`);
228
229
  // The target gained or lost a follower, and "followers you know" with it.
229
- prefixes.push(`GET:/users/${id}/followers`, `GET:/users/${id}/mutuals`);
230
+ prefixes.push(`GET:/users/${enc(id)}/followers`, `GET:/users/${enc(id)}/mutuals`);
230
231
  }
231
232
  // The viewer's own following list changed.
232
233
  const viewerId = this.ctx.oxy.session.userId;
233
234
  if (viewerId)
234
- prefixes.push(`GET:/users/${viewerId}/following`);
235
+ prefixes.push(`GET:/users/${enc(viewerId)}/following`);
235
236
  this.ctx.http.invalidateCache({ keys, prefixes });
236
237
  }
237
238
  }
@@ -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
+ }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PrivacyApi = void 0;
4
4
  const identityCacheSweep_1 = require("../utils/identityCacheSweep");
5
+ const enc = encodeURIComponent;
5
6
  const LIST_TTL = 60 * 1000;
6
7
  const SETTINGS_TTL = 2 * 60 * 1000;
7
8
  const refId = (ref) => (typeof ref === 'string' ? ref : ref._id);
@@ -13,7 +14,7 @@ class PrivacyApi {
13
14
  /** A user's privacy settings (default: the signed-in user's). */
14
15
  async settings(userId) {
15
16
  const id = await this.resolveUserId(userId);
16
- return this.ctx.request('GET', `/privacy/${id}/privacy`, undefined, {
17
+ return this.ctx.request('GET', `/privacy/${enc(id)}/privacy`, undefined, {
17
18
  cache: true,
18
19
  cacheTTL: SETTINGS_TTL,
19
20
  });
@@ -21,10 +22,10 @@ class PrivacyApi {
21
22
  /** Update privacy settings (default: the signed-in user's). */
22
23
  async updateSettings(settings, userId) {
23
24
  const id = await this.resolveUserId(userId);
24
- const res = await this.ctx.request('PATCH', `/privacy/${id}/privacy`, settings, { cache: false });
25
+ const res = await this.ctx.request('PATCH', `/privacy/${enc(id)}/privacy`, settings, { cache: false });
25
26
  // Privacy settings ride the user DTO, so every identity read goes stale too.
26
27
  (0, identityCacheSweep_1.evictOxyIdentityCache)(this.ctx.http, id);
27
- this.ctx.oxy.cache.delete(`GET:/privacy/${id}/privacy`);
28
+ this.ctx.oxy.cache.delete(`GET:/privacy/${enc(id)}/privacy`);
28
29
  return res;
29
30
  }
30
31
  // ── Blocked ──────────────────────────────────────────────────────────────
@@ -38,14 +39,14 @@ class PrivacyApi {
38
39
  /** Block a user. */
39
40
  async block(userId) {
40
41
  requireId(userId);
41
- const res = await this.ctx.request('POST', `/privacy/blocked/${userId}`, undefined, { cache: false });
42
+ const res = await this.ctx.request('POST', `/privacy/blocked/${enc(userId)}`, undefined, { cache: false });
42
43
  this.invalidate('GET:/privacy/blocked');
43
44
  return res;
44
45
  }
45
46
  /** Unblock a user. */
46
47
  async unblock(userId) {
47
48
  requireId(userId);
48
- const res = await this.ctx.request('DELETE', `/privacy/blocked/${userId}`, undefined, { cache: false });
49
+ const res = await this.ctx.request('DELETE', `/privacy/blocked/${enc(userId)}`, undefined, { cache: false });
49
50
  this.invalidate('GET:/privacy/blocked');
50
51
  return res;
51
52
  }
@@ -71,14 +72,14 @@ class PrivacyApi {
71
72
  /** Restrict a user: limit their interactions without blocking them. */
72
73
  async restrict(userId) {
73
74
  requireId(userId);
74
- const res = await this.ctx.request('POST', `/privacy/restricted/${userId}`, undefined, { cache: false });
75
+ const res = await this.ctx.request('POST', `/privacy/restricted/${enc(userId)}`, undefined, { cache: false });
75
76
  this.invalidate('GET:/privacy/restricted');
76
77
  return res;
77
78
  }
78
79
  /** Lift a restriction. */
79
80
  async unrestrict(userId) {
80
81
  requireId(userId);
81
- const res = await this.ctx.request('DELETE', `/privacy/restricted/${userId}`, undefined, { cache: false });
82
+ const res = await this.ctx.request('DELETE', `/privacy/restricted/${enc(userId)}`, undefined, { cache: false });
82
83
  this.invalidate('GET:/privacy/restricted');
83
84
  return res;
84
85
  }
@@ -11,12 +11,22 @@ 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
+ const enc = encodeURIComponent;
16
+ /** How long a refusal (disabled, rate limited, unreachable) is remembered per installation. */
17
+ const EVIDENCE_REFUSAL_MS = 5000;
18
+ const unavailable = (reason) => ({
19
+ status: 'unavailable',
20
+ reason,
21
+ });
14
22
  class SessionApi {
15
23
  constructor(ctx) {
16
24
  this.ctx = ctx;
17
25
  this.deviceCredentialProvider = null;
18
26
  this.decodedFor = null;
19
27
  this.decoded = null;
28
+ /** One server round trip per installed bearer; the key is the opaque generation. */
29
+ this.evidence = new WeakMap();
20
30
  }
21
31
  // ── Local state ──────────────────────────────────────────────────────────
22
32
  /** The current access token, or `null` when signed out. */
@@ -121,6 +131,115 @@ class SessionApi {
121
131
  return null;
122
132
  }
123
133
  }
134
+ // ── Holder-context evidence (ADR 0053) ───────────────────────────────────
135
+ /**
136
+ * Ask the server which shared-device context the INSTALLED bearer belongs to,
137
+ * and bind the answer to that exact installation.
138
+ *
139
+ * The result is honored only if the credential generation captured before
140
+ * the request is still current when the answer is committed, so a refresh,
141
+ * setter, account or actor switch, cross-tab change, restore or A to B to A
142
+ * during the request yields `changed`. The answer must also agree with the
143
+ * claims of the bearer that was captured; a disagreement is
144
+ * `binding_unavailable`. Nothing here refreshes, clears or mints a credential,
145
+ * and no outcome is a revocation verdict: callers keep their existing checks.
146
+ * Concurrent callers share one request per installation.
147
+ */
148
+ observeHolderEvidence() {
149
+ const generation = this.ctx.http.captureCredentialGeneration();
150
+ const token = this.ctx.http.getAccessToken();
151
+ if (!generation || !token)
152
+ return Promise.resolve(unavailable('no_credential'));
153
+ const known = this.evidence.get(generation);
154
+ if (known) {
155
+ const refused = known.settledAt !== undefined;
156
+ if (!refused || Date.now() - known.settledAt < EVIDENCE_REFUSAL_MS) {
157
+ return known.result.then((result) => result.status === 'ok' && result.evidence.expiresAtMs <= Date.now()
158
+ ? this.refreshEvidence(generation, token)
159
+ : result);
160
+ }
161
+ }
162
+ return this.refreshEvidence(generation, token);
163
+ }
164
+ /** Whether evidence still names the installed bearer and has not expired. */
165
+ isHolderEvidenceCurrent(evidence) {
166
+ return (Date.now() < evidence.expiresAtMs &&
167
+ this.ctx.http.isCredentialGenerationCurrent(evidence.generation));
168
+ }
169
+ refreshEvidence(generation, token) {
170
+ const entry = {
171
+ result: this.acquireEvidence(generation, token).then((result) => {
172
+ if (result.status !== 'ok')
173
+ entry.settledAt = Date.now();
174
+ return result;
175
+ }),
176
+ };
177
+ this.evidence.set(generation, entry);
178
+ return entry.result;
179
+ }
180
+ async acquireEvidence(generation, token) {
181
+ let claims;
182
+ try {
183
+ claims = (0, jwt_decode_1.jwtDecode)(token);
184
+ }
185
+ catch {
186
+ return unavailable('unsupported');
187
+ }
188
+ const proof = await this.readDeviceProof();
189
+ if (!proof)
190
+ return unavailable('no_holder');
191
+ let response;
192
+ try {
193
+ response = await this.ctx.request('POST', '/session/device/context-evidence-v1', proof, {
194
+ cache: false,
195
+ retry: false,
196
+ expectedCredentialGeneration: generation,
197
+ });
198
+ }
199
+ catch (error) {
200
+ if (error?.code === 'AUTH_SESSION_CHANGED') {
201
+ return unavailable('changed');
202
+ }
203
+ const status = error instanceof OxyServices_errors_1.OxyApiError ? error.status : 0;
204
+ if (status === 403)
205
+ return unavailable('unsupported');
206
+ if (status === 409)
207
+ return unavailable('binding_unavailable');
208
+ return unavailable('unavailable');
209
+ }
210
+ const parsed = (0, client_1.checkContract)(client_1.holderContextEvidenceV1ResponseCheck, response);
211
+ if (!parsed)
212
+ return unavailable('unavailable');
213
+ // Commit synchronously: nothing may replace the bearer between this check
214
+ // and the evidence becoming visible to its caller.
215
+ if (!this.ctx.http.isCredentialGenerationCurrent(generation))
216
+ return unavailable('changed');
217
+ const expiresAtMs = Date.parse(parsed.expiresAt);
218
+ if (parsed.deviceId !== proof.deviceId ||
219
+ parsed.accountId !== claims.userId ||
220
+ parsed.sessionId !== claims.sessionId ||
221
+ (claims.act?.sub !== undefined && parsed.actorId !== claims.act.sub) ||
222
+ (claims.device_context_id !== undefined && parsed.contextId !== claims.device_context_id)) {
223
+ return unavailable('binding_unavailable');
224
+ }
225
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
226
+ return unavailable('unavailable');
227
+ }
228
+ const evidence = Object.freeze({
229
+ generation,
230
+ identity: Object.freeze({
231
+ deviceId: parsed.deviceId,
232
+ deviceSessionId: parsed.deviceSessionId,
233
+ contextId: parsed.contextId,
234
+ actorId: parsed.actorId,
235
+ accountId: parsed.accountId,
236
+ sessionId: parsed.sessionId,
237
+ }),
238
+ deviceRevision: parsed.revision,
239
+ expiresAtMs,
240
+ });
241
+ return { status: 'ok', evidence };
242
+ }
124
243
  // ── The server session ───────────────────────────────────────────────────
125
244
  /** Ask the server whether the current access token is valid. */
126
245
  async validateToken() {
@@ -175,7 +294,7 @@ class SessionApi {
175
294
  : {}),
176
295
  },
177
296
  })
178
- : await this.ctx.request('GET', `/session/validate/${sessionId}`, {
297
+ : await this.ctx.request('GET', `/session/validate/${enc(sessionId)}`, {
179
298
  ...(options.deviceFingerprint
180
299
  ? { deviceFingerprint: options.deviceFingerprint }
181
300
  : {}),
@@ -202,7 +321,7 @@ class SessionApi {
202
321
  const invalid = candidate?.status === 401 && candidate.code === 'OXY_LIVE_SESSION_INVALID_V1';
203
322
  const denied = candidate?.status === 403 && candidate.code === 'OXY_LIVE_SESSION_POLICY_V1';
204
323
  if (invalid)
205
- this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
324
+ this.ctx.oxy.cache.delete(`GET:/session/user/${enc(sessionId)}`);
206
325
  if (invalid || denied)
207
326
  throw error;
208
327
  throw new OxyServices_errors_1.OxyApiError('Live session authority is unavailable', {
@@ -212,26 +331,28 @@ class SessionApi {
212
331
  });
213
332
  }
214
333
  // The session is gone: drop any user cached for it (#196).
215
- this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
334
+ this.ctx.oxy.cache.delete(`GET:/session/user/${enc(sessionId)}`);
216
335
  throw error;
217
336
  }
218
337
  }
219
338
  /** The sessions that belong with `sessionId` (same account set). */
220
339
  async list(sessionId) {
221
- return this.ctx.request('GET', `/session/sessions/${sessionId}`, undefined, {
340
+ return this.ctx.request('GET', `/session/sessions/${enc(sessionId)}`, undefined, {
222
341
  cache: false,
223
342
  });
224
343
  }
225
344
  /** Sign out `sessionId`, or — with `targetSessionId` — one of the sessions beside it. */
226
345
  async logout(sessionId, targetSessionId) {
227
346
  const url = targetSessionId
228
- ? `/session/logout/${sessionId}/${targetSessionId}`
229
- : `/session/logout/${sessionId}`;
347
+ ? `/session/logout/${enc(sessionId)}/${enc(targetSessionId)}`
348
+ : `/session/logout/${enc(sessionId)}`;
230
349
  await this.ctx.request('POST', url, undefined, { cache: false });
231
350
  }
232
351
  /** Sign out the other sessions for this account; preserve `sessionId`. */
233
352
  async logoutAll(sessionId) {
234
- await this.ctx.request('POST', `/session/logout-all/${sessionId}`, undefined, { cache: false });
353
+ await this.ctx.request('POST', `/session/logout-all/${enc(sessionId)}`, undefined, {
354
+ cache: false,
355
+ });
235
356
  }
236
357
  /** Decoded claims of the current token, memoised per token string. */
237
358
  claims() {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TopicsApi = void 0;
4
+ const enc = encodeURIComponent;
4
5
  const SHORT = 60 * 1000;
5
6
  const LONG = 5 * 60 * 1000;
6
7
  const EXTRA_LONG = 30 * 60 * 1000;
@@ -44,7 +45,7 @@ class TopicsApi {
44
45
  }
45
46
  /** One topic by slug. */
46
47
  async get(slug) {
47
- return this.ctx.request('GET', `/topics/${slug}`, undefined, {
48
+ return this.ctx.request('GET', `/topics/${enc(slug)}`, undefined, {
48
49
  cache: true,
49
50
  cacheTTL: LONG,
50
51
  });
@@ -63,10 +64,10 @@ class TopicsApi {
63
64
  }
64
65
  /** Update a topic's description and translations. Staff or service only. */
65
66
  async update(slug, data) {
66
- const topic = await this.ctx.request('PATCH', `/topics/${slug}`, data, {
67
+ const topic = await this.ctx.request('PATCH', `/topics/${enc(slug)}`, data, {
67
68
  cache: false,
68
69
  });
69
- this.ctx.oxy.cache.delete(`GET:/topics/${slug}`);
70
+ this.ctx.oxy.cache.delete(`GET:/topics/${enc(slug)}`);
70
71
  return topic;
71
72
  }
72
73
  }