@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
@@ -28,6 +28,8 @@ import {
28
28
  type FollowGraphParams,
29
29
  } from '../utils/apiUtils';
30
30
 
31
+ const enc = encodeURIComponent;
32
+
31
33
  const GRAPH_TTL = 2 * 60 * 1000;
32
34
  const STATUS_TTL = 60 * 1000;
33
35
 
@@ -130,7 +132,7 @@ export class FollowsApi {
130
132
  async follow(userId: string): Promise<FollowMutationResult> {
131
133
  const result = await this.ctx.request<FollowMutationResult>(
132
134
  'POST',
133
- `/users/${userId}/follow`,
135
+ `/users/${enc(userId)}/follow`,
134
136
  undefined,
135
137
  { cache: false },
136
138
  );
@@ -155,7 +157,7 @@ export class FollowsApi {
155
157
  async unfollow(userId: string): Promise<FollowMutationResult> {
156
158
  const result = await this.ctx.request<FollowMutationResult>(
157
159
  'DELETE',
158
- `/users/${userId}/follow`,
160
+ `/users/${enc(userId)}/follow`,
159
161
  undefined,
160
162
  { cache: false },
161
163
  );
@@ -178,7 +180,7 @@ export class FollowsApi {
178
180
 
179
181
  /** Whether the signed-in user follows `userId`. Cached 1 minute, busted by every write. */
180
182
  async status(userId: string): Promise<{ isFollowing: boolean }> {
181
- return this.ctx.request('GET', `/users/${userId}/follow-status`, undefined, {
183
+ return this.ctx.request('GET', `/users/${enc(userId)}/follow-status`, undefined, {
182
184
  cache: true,
183
185
  cacheTTL: STATUS_TTL,
184
186
  });
@@ -221,13 +223,13 @@ export class FollowsApi {
221
223
  * `limit`/`offset`/`sort` combination is its own cache entry (2 minutes).
222
224
  */
223
225
  async followers(userId: string, params?: FollowGraphParams): Promise<UserPage<'followers'>> {
224
- const { data, total, hasMore } = await this.userPage(`/users/${userId}/followers`, params);
226
+ const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/followers`, params);
225
227
  return { followers: data, total, hasMore };
226
228
  }
227
229
 
228
230
  /** Who a user follows. `sort` as in `followers`. */
229
231
  async following(userId: string, params?: FollowGraphParams): Promise<UserPage<'following'>> {
230
- const { data, total, hasMore } = await this.userPage(`/users/${userId}/following`, params);
232
+ const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/following`, params);
231
233
  return { following: data, total, hasMore };
232
234
  }
233
235
 
@@ -236,7 +238,7 @@ export class FollowsApi {
236
238
  * `userId`.
237
239
  */
238
240
  async mutuals(userId: string, params?: FollowGraphParams): Promise<UserPage<'mutuals'>> {
239
- const { data, total, hasMore } = await this.userPage(`/users/${userId}/mutuals`, params);
241
+ const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/mutuals`, params);
240
242
  return { mutuals: data, total, hasMore };
241
243
  }
242
244
 
@@ -443,15 +445,15 @@ export class FollowsApi {
443
445
  const keys: string[] = ['GET:/users/me/graph'];
444
446
  const prefixes: string[] = ['GET:/profiles/username/', 'GET:/profiles/resolve'];
445
447
  for (const id of targetUserIds) {
446
- keys.push(`GET:/users/${id}/follow-status`);
448
+ keys.push(`GET:/users/${enc(id)}/follow-status`);
447
449
  // Profiles embed the viewer-relative `relationship`.
448
- keys.push(`GET:/users/${id}`);
450
+ keys.push(`GET:/users/${enc(id)}`);
449
451
  // The target gained or lost a follower, and "followers you know" with it.
450
- prefixes.push(`GET:/users/${id}/followers`, `GET:/users/${id}/mutuals`);
452
+ prefixes.push(`GET:/users/${enc(id)}/followers`, `GET:/users/${enc(id)}/mutuals`);
451
453
  }
452
454
  // The viewer's own following list changed.
453
455
  const viewerId = this.ctx.oxy.session.userId;
454
- if (viewerId) prefixes.push(`GET:/users/${viewerId}/following`);
456
+ if (viewerId) prefixes.push(`GET:/users/${enc(viewerId)}/following`);
455
457
  this.ctx.http.invalidateCache({ keys, prefixes });
456
458
  }
457
459
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Holder-context evidence: the server's point-in-time observation of which
3
+ * shared-device context a bearer is installed in, bound to ONE installation of
4
+ * that bearer in this client (ADR 0053).
5
+ *
6
+ * Evidence is never authorization. It lets a caller that already captured a
7
+ * viewer intent prove, before a write is dispatched, that the bearer the write
8
+ * will carry belongs to that viewer's account and actor, and then pin the write
9
+ * to the exact installation with `expectedCredentialGeneration`. Anything that
10
+ * cannot be proven is `unavailable`, and the caller keeps its existing checks.
11
+ */
12
+ import type { HolderContextEvidenceV1Response } from '@oxy.so/contracts';
13
+ import type { CredentialGeneration } from '../HttpService';
14
+
15
+ /** The identifiers the server observed; none of them is a credential. */
16
+ export type HolderEvidenceIdentity = Readonly<
17
+ Pick<
18
+ HolderContextEvidenceV1Response,
19
+ 'deviceId' | 'deviceSessionId' | 'contextId' | 'actorId' | 'accountId' | 'sessionId'
20
+ >
21
+ >;
22
+
23
+ export interface HolderEvidence {
24
+ /** The installation of the bearer this evidence was observed for. */
25
+ readonly generation: CredentialGeneration;
26
+ readonly identity: HolderEvidenceIdentity;
27
+ /** The device-session revision the server observed. */
28
+ readonly deviceRevision: number;
29
+ /** Epoch milliseconds: the earlier of the bearer and session expiry. */
30
+ readonly expiresAtMs: number;
31
+ }
32
+
33
+ /**
34
+ * Why no evidence exists. Every reason means "keep the existing checks": none of
35
+ * them says the session is revoked, and none may clear or refresh credentials.
36
+ *
37
+ * - `no_credential`: no bearer is installed, or it comes from a pull-only provider.
38
+ * - `no_holder`: this client holds no device credential to prove the holder with.
39
+ * - `changed`: the bearer was replaced while the evidence was being acquired.
40
+ * - `unsupported`: a delegated, OAuth or legacy bearer, or the endpoint is not offered.
41
+ * - `binding_unavailable`: the server could not match holder, context and bearer.
42
+ * - `unavailable`: the endpoint is off, rate limited, unreachable or answered wrongly.
43
+ */
44
+ export type HolderEvidenceUnavailableReason =
45
+ | 'no_credential'
46
+ | 'no_holder'
47
+ | 'changed'
48
+ | 'unsupported'
49
+ | 'binding_unavailable'
50
+ | 'unavailable';
51
+
52
+ export type HolderEvidenceResult =
53
+ | { readonly status: 'ok'; readonly evidence: HolderEvidence }
54
+ | { readonly status: 'unavailable'; readonly reason: HolderEvidenceUnavailableReason };
55
+
56
+ /** The one comparison that decides whether two observations name the same viewer context. */
57
+ export function sameHolderIdentity(a: HolderEvidenceIdentity, b: HolderEvidenceIdentity): boolean {
58
+ return (
59
+ a.deviceId === b.deviceId &&
60
+ a.deviceSessionId === b.deviceSessionId &&
61
+ a.contextId === b.contextId &&
62
+ a.actorId === b.actorId &&
63
+ a.accountId === b.accountId &&
64
+ a.sessionId === b.sessionId
65
+ );
66
+ }
@@ -5,6 +5,8 @@ import type { BlockedUser, PrivacySettings, RestrictedUser } from '../models/int
5
5
  import type { OxyContext } from '../client/context';
6
6
  import { evictOxyIdentityCache } from '../utils/identityCacheSweep';
7
7
 
8
+ const enc = encodeURIComponent;
9
+
8
10
  const LIST_TTL = 60 * 1000;
9
11
  const SETTINGS_TTL = 2 * 60 * 1000;
10
12
 
@@ -20,7 +22,7 @@ export class PrivacyApi {
20
22
  /** A user's privacy settings (default: the signed-in user's). */
21
23
  async settings(userId?: string): Promise<PrivacySettings> {
22
24
  const id = await this.resolveUserId(userId);
23
- return this.ctx.request<PrivacySettings>('GET', `/privacy/${id}/privacy`, undefined, {
25
+ return this.ctx.request<PrivacySettings>('GET', `/privacy/${enc(id)}/privacy`, undefined, {
24
26
  cache: true,
25
27
  cacheTTL: SETTINGS_TTL,
26
28
  });
@@ -34,13 +36,13 @@ export class PrivacyApi {
34
36
  const id = await this.resolveUserId(userId);
35
37
  const res = await this.ctx.request<PrivacySettings>(
36
38
  'PATCH',
37
- `/privacy/${id}/privacy`,
39
+ `/privacy/${enc(id)}/privacy`,
38
40
  settings,
39
41
  { cache: false },
40
42
  );
41
43
  // Privacy settings ride the user DTO, so every identity read goes stale too.
42
44
  evictOxyIdentityCache(this.ctx.http, id);
43
- this.ctx.oxy.cache.delete(`GET:/privacy/${id}/privacy`);
45
+ this.ctx.oxy.cache.delete(`GET:/privacy/${enc(id)}/privacy`);
44
46
  return res;
45
47
  }
46
48
 
@@ -59,7 +61,7 @@ export class PrivacyApi {
59
61
  requireId(userId);
60
62
  const res = await this.ctx.request<{ message: string }>(
61
63
  'POST',
62
- `/privacy/blocked/${userId}`,
64
+ `/privacy/blocked/${enc(userId)}`,
63
65
  undefined,
64
66
  { cache: false },
65
67
  );
@@ -72,7 +74,7 @@ export class PrivacyApi {
72
74
  requireId(userId);
73
75
  const res = await this.ctx.request<{ message: string }>(
74
76
  'DELETE',
75
- `/privacy/blocked/${userId}`,
77
+ `/privacy/blocked/${enc(userId)}`,
76
78
  undefined,
77
79
  { cache: false },
78
80
  );
@@ -105,7 +107,7 @@ export class PrivacyApi {
105
107
  requireId(userId);
106
108
  const res = await this.ctx.request<{ message: string }>(
107
109
  'POST',
108
- `/privacy/restricted/${userId}`,
110
+ `/privacy/restricted/${enc(userId)}`,
109
111
  undefined,
110
112
  { cache: false },
111
113
  );
@@ -118,7 +120,7 @@ export class PrivacyApi {
118
120
  requireId(userId);
119
121
  const res = await this.ctx.request<{ message: string }>(
120
122
  'DELETE',
121
- `/privacy/restricted/${userId}`,
123
+ `/privacy/restricted/${enc(userId)}`,
122
124
  undefined,
123
125
  { cache: false },
124
126
  );
@@ -12,6 +12,22 @@ import type { ClientSession } from '../models/session';
12
12
  import type { User } from '../models/interfaces';
13
13
  import { normalizeUserIdentity } from '../utils/userIdentity';
14
14
  import { OxyApiError } from '../OxyServices.errors';
15
+ import { checkContract, holderContextEvidenceV1ResponseCheck } from '@oxy.so/contracts/client';
16
+ import type { CredentialGeneration } from '../HttpService';
17
+ import type {
18
+ HolderEvidence,
19
+ HolderEvidenceResult,
20
+ HolderEvidenceUnavailableReason,
21
+ } from './holderEvidence';
22
+
23
+ const enc = encodeURIComponent;
24
+
25
+ /** How long a refusal (disabled, rate limited, unreachable) is remembered per installation. */
26
+ const EVIDENCE_REFUSAL_MS = 5_000;
27
+ const unavailable = (reason: HolderEvidenceUnavailableReason): HolderEvidenceResult => ({
28
+ status: 'unavailable',
29
+ reason,
30
+ });
15
31
 
16
32
  /**
17
33
  * Reads the device credential this client holds, if any — `{ deviceId,
@@ -42,12 +58,21 @@ interface AccessTokenClaims {
42
58
  exp?: number;
43
59
  userId?: string;
44
60
  id?: string;
61
+ sessionId?: string;
62
+ deviceId?: string;
63
+ device_context_id?: string;
64
+ act?: { sub?: string };
45
65
  }
46
66
 
47
67
  export class SessionApi {
48
68
  private deviceCredentialProvider: DeviceCredentialProvider | null = null;
49
69
  private decodedFor: string | null = null;
50
70
  private decoded: AccessTokenClaims | null = null;
71
+ /** One server round trip per installed bearer; the key is the opaque generation. */
72
+ private readonly evidence = new WeakMap<
73
+ CredentialGeneration,
74
+ { result: Promise<HolderEvidenceResult>; settledAt?: number }
75
+ >();
51
76
 
52
77
  constructor(protected readonly ctx: OxyContext) {}
53
78
 
@@ -163,6 +188,128 @@ export class SessionApi {
163
188
  }
164
189
  }
165
190
 
191
+ // ── Holder-context evidence (ADR 0053) ───────────────────────────────────
192
+
193
+ /**
194
+ * Ask the server which shared-device context the INSTALLED bearer belongs to,
195
+ * and bind the answer to that exact installation.
196
+ *
197
+ * The result is honored only if the credential generation captured before
198
+ * the request is still current when the answer is committed, so a refresh,
199
+ * setter, account or actor switch, cross-tab change, restore or A to B to A
200
+ * during the request yields `changed`. The answer must also agree with the
201
+ * claims of the bearer that was captured; a disagreement is
202
+ * `binding_unavailable`. Nothing here refreshes, clears or mints a credential,
203
+ * and no outcome is a revocation verdict: callers keep their existing checks.
204
+ * Concurrent callers share one request per installation.
205
+ */
206
+ observeHolderEvidence(): Promise<HolderEvidenceResult> {
207
+ const generation = this.ctx.http.captureCredentialGeneration();
208
+ const token = this.ctx.http.getAccessToken();
209
+ if (!generation || !token) return Promise.resolve(unavailable('no_credential'));
210
+ const known = this.evidence.get(generation);
211
+ if (known) {
212
+ const refused = known.settledAt !== undefined;
213
+ if (!refused || Date.now() - (known.settledAt as number) < EVIDENCE_REFUSAL_MS) {
214
+ return known.result.then((result) =>
215
+ result.status === 'ok' && result.evidence.expiresAtMs <= Date.now()
216
+ ? this.refreshEvidence(generation, token)
217
+ : result,
218
+ );
219
+ }
220
+ }
221
+ return this.refreshEvidence(generation, token);
222
+ }
223
+
224
+ /** Whether evidence still names the installed bearer and has not expired. */
225
+ isHolderEvidenceCurrent(evidence: HolderEvidence): boolean {
226
+ return (
227
+ Date.now() < evidence.expiresAtMs &&
228
+ this.ctx.http.isCredentialGenerationCurrent(evidence.generation)
229
+ );
230
+ }
231
+
232
+ private refreshEvidence(
233
+ generation: CredentialGeneration,
234
+ token: string,
235
+ ): Promise<HolderEvidenceResult> {
236
+ const entry: { result: Promise<HolderEvidenceResult>; settledAt?: number } = {
237
+ result: this.acquireEvidence(generation, token).then((result) => {
238
+ if (result.status !== 'ok') entry.settledAt = Date.now();
239
+ return result;
240
+ }),
241
+ };
242
+ this.evidence.set(generation, entry);
243
+ return entry.result;
244
+ }
245
+
246
+ private async acquireEvidence(
247
+ generation: CredentialGeneration,
248
+ token: string,
249
+ ): Promise<HolderEvidenceResult> {
250
+ let claims: AccessTokenClaims;
251
+ try {
252
+ claims = jwtDecode<AccessTokenClaims>(token);
253
+ } catch {
254
+ return unavailable('unsupported');
255
+ }
256
+ const proof = await this.readDeviceProof();
257
+ if (!proof) return unavailable('no_holder');
258
+ let response: unknown;
259
+ try {
260
+ response = await this.ctx.request<unknown>(
261
+ 'POST',
262
+ '/session/device/context-evidence-v1',
263
+ proof,
264
+ {
265
+ cache: false,
266
+ retry: false,
267
+ expectedCredentialGeneration: generation,
268
+ },
269
+ );
270
+ } catch (error) {
271
+ if ((error as { code?: unknown } | null)?.code === 'AUTH_SESSION_CHANGED') {
272
+ return unavailable('changed');
273
+ }
274
+ const status = error instanceof OxyApiError ? error.status : 0;
275
+ if (status === 403) return unavailable('unsupported');
276
+ if (status === 409) return unavailable('binding_unavailable');
277
+ return unavailable('unavailable');
278
+ }
279
+ const parsed = checkContract(holderContextEvidenceV1ResponseCheck, response);
280
+ if (!parsed) return unavailable('unavailable');
281
+ // Commit synchronously: nothing may replace the bearer between this check
282
+ // and the evidence becoming visible to its caller.
283
+ if (!this.ctx.http.isCredentialGenerationCurrent(generation)) return unavailable('changed');
284
+ const expiresAtMs = Date.parse(parsed.expiresAt);
285
+ if (
286
+ parsed.deviceId !== proof.deviceId ||
287
+ parsed.accountId !== claims.userId ||
288
+ parsed.sessionId !== claims.sessionId ||
289
+ (claims.act?.sub !== undefined && parsed.actorId !== claims.act.sub) ||
290
+ (claims.device_context_id !== undefined && parsed.contextId !== claims.device_context_id)
291
+ ) {
292
+ return unavailable('binding_unavailable');
293
+ }
294
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
295
+ return unavailable('unavailable');
296
+ }
297
+ const evidence: HolderEvidence = Object.freeze({
298
+ generation,
299
+ identity: Object.freeze({
300
+ deviceId: parsed.deviceId,
301
+ deviceSessionId: parsed.deviceSessionId,
302
+ contextId: parsed.contextId,
303
+ actorId: parsed.actorId,
304
+ accountId: parsed.accountId,
305
+ sessionId: parsed.sessionId,
306
+ }),
307
+ deviceRevision: parsed.revision,
308
+ expiresAtMs,
309
+ });
310
+ return { status: 'ok', evidence };
311
+ }
312
+
166
313
  // ── The server session ───────────────────────────────────────────────────
167
314
 
168
315
  /** Ask the server whether the current access token is valid. */
@@ -231,7 +378,7 @@ export class SessionApi {
231
378
  )
232
379
  : await this.ctx.request<SessionValidation>(
233
380
  'GET',
234
- `/session/validate/${sessionId}`,
381
+ `/session/validate/${enc(sessionId)}`,
235
382
  {
236
383
  ...(options.deviceFingerprint
237
384
  ? { deviceFingerprint: options.deviceFingerprint }
@@ -262,7 +409,7 @@ export class SessionApi {
262
409
  const invalid =
263
410
  candidate?.status === 401 && candidate.code === 'OXY_LIVE_SESSION_INVALID_V1';
264
411
  const denied = candidate?.status === 403 && candidate.code === 'OXY_LIVE_SESSION_POLICY_V1';
265
- if (invalid) this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
412
+ if (invalid) this.ctx.oxy.cache.delete(`GET:/session/user/${enc(sessionId)}`);
266
413
  if (invalid || denied) throw error;
267
414
  throw new OxyApiError('Live session authority is unavailable', {
268
415
  status: 503,
@@ -271,29 +418,36 @@ export class SessionApi {
271
418
  });
272
419
  }
273
420
  // The session is gone: drop any user cached for it (#196).
274
- this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
421
+ this.ctx.oxy.cache.delete(`GET:/session/user/${enc(sessionId)}`);
275
422
  throw error;
276
423
  }
277
424
  }
278
425
 
279
426
  /** The sessions that belong with `sessionId` (same account set). */
280
427
  async list(sessionId: string): Promise<ClientSession[]> {
281
- return this.ctx.request<ClientSession[]>('GET', `/session/sessions/${sessionId}`, undefined, {
282
- cache: false,
283
- });
428
+ return this.ctx.request<ClientSession[]>(
429
+ 'GET',
430
+ `/session/sessions/${enc(sessionId)}`,
431
+ undefined,
432
+ {
433
+ cache: false,
434
+ },
435
+ );
284
436
  }
285
437
 
286
438
  /** Sign out `sessionId`, or — with `targetSessionId` — one of the sessions beside it. */
287
439
  async logout(sessionId: string, targetSessionId?: string): Promise<void> {
288
440
  const url = targetSessionId
289
- ? `/session/logout/${sessionId}/${targetSessionId}`
290
- : `/session/logout/${sessionId}`;
441
+ ? `/session/logout/${enc(sessionId)}/${enc(targetSessionId)}`
442
+ : `/session/logout/${enc(sessionId)}`;
291
443
  await this.ctx.request('POST', url, undefined, { cache: false });
292
444
  }
293
445
 
294
446
  /** Sign out the other sessions for this account; preserve `sessionId`. */
295
447
  async logoutAll(sessionId: string): Promise<void> {
296
- await this.ctx.request('POST', `/session/logout-all/${sessionId}`, undefined, { cache: false });
448
+ await this.ctx.request('POST', `/session/logout-all/${enc(sessionId)}`, undefined, {
449
+ cache: false,
450
+ });
297
451
  }
298
452
 
299
453
  /** Decoded claims of the current token, memoised per token string. */
package/src/api/topics.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  import type { OxyContext } from '../client/context';
5
5
  import type { TopicData, TopicListResult, TopicTranslation } from '../models/Topic';
6
6
 
7
+ const enc = encodeURIComponent;
8
+
7
9
  const SHORT = 60 * 1000;
8
10
  const LONG = 5 * 60 * 1000;
9
11
  const EXTRA_LONG = 30 * 60 * 1000;
@@ -51,7 +53,7 @@ export class TopicsApi {
51
53
 
52
54
  /** One topic by slug. */
53
55
  async get(slug: string): Promise<TopicData> {
54
- return this.ctx.request<TopicData>('GET', `/topics/${slug}`, undefined, {
56
+ return this.ctx.request<TopicData>('GET', `/topics/${enc(slug)}`, undefined, {
55
57
  cache: true,
56
58
  cacheTTL: LONG,
57
59
  });
@@ -86,10 +88,10 @@ export class TopicsApi {
86
88
  slug: string,
87
89
  data: { description?: string; translations?: Record<string, TopicTranslation> },
88
90
  ): Promise<TopicData> {
89
- const topic = await this.ctx.request<TopicData>('PATCH', `/topics/${slug}`, data, {
91
+ const topic = await this.ctx.request<TopicData>('PATCH', `/topics/${enc(slug)}`, data, {
90
92
  cache: false,
91
93
  });
92
- this.ctx.oxy.cache.delete(`GET:/topics/${slug}`);
94
+ this.ctx.oxy.cache.delete(`GET:/topics/${enc(slug)}`);
93
95
  return topic;
94
96
  }
95
97
  }
package/src/api/users.ts CHANGED
@@ -22,6 +22,8 @@ import { extractErrorStatus } from '../utils/errorUtils';
22
22
  import { type BatchLookup, runBatchLookup } from '../utils/batchLookup';
23
23
  import { logger } from '../logger';
24
24
 
25
+ const enc = encodeURIComponent;
26
+
25
27
  const PROFILE_TTL = 5 * 60 * 1000;
26
28
  const SESSION_USER_TTL = 2 * 60 * 1000;
27
29
  const ME_TTL = 60 * 1000;
@@ -65,7 +67,7 @@ export class UsersApi {
65
67
  * registry-fresh read that neither serves nor overwrites a cached entry.
66
68
  */
67
69
  async get(userId: string, options?: { cache?: boolean }): Promise<User> {
68
- const user = await this.ctx.request<User>('GET', `/users/${userId}`, undefined, {
70
+ const user = await this.ctx.request<User>('GET', `/users/${enc(userId)}`, undefined, {
69
71
  cache: options?.cache ?? true,
70
72
  cacheTTL: PROFILE_TTL,
71
73
  });
@@ -166,7 +168,7 @@ export class UsersApi {
166
168
 
167
169
  /** The user a session belongs to. */
168
170
  async bySession(sessionId: string): Promise<User> {
169
- const user = await this.ctx.request<User>('GET', `/session/user/${sessionId}`, undefined, {
171
+ const user = await this.ctx.request<User>('GET', `/session/user/${enc(sessionId)}`, undefined, {
170
172
  cache: true,
171
173
  cacheTTL: SESSION_USER_TTL,
172
174
  });
@@ -271,7 +273,7 @@ export class UsersApi {
271
273
  : (limitOrPagination ?? {});
272
274
  const users = await this.ctx.request<User[]>(
273
275
  'GET',
274
- `/profiles/${userId}/similar`,
276
+ `/profiles/${enc(userId)}/similar`,
275
277
  buildQueryParams(pagination),
276
278
  {
277
279
  cache: true,
@@ -289,7 +291,7 @@ export class UsersApi {
289
291
  async moderators(communityId: string): Promise<User[]> {
290
292
  const moderators = await this.ctx.request<User[]>(
291
293
  'GET',
292
- `/users/${communityId}/moderators`,
294
+ `/users/${enc(communityId)}/moderators`,
293
295
  undefined,
294
296
  { cache: true, cacheTTL: PROFILE_TTL },
295
297
  );