@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
@@ -1,4 +1,5 @@
1
1
  import { buildPaginationParams, buildQueryParams, buildUrl, } from '../utils/apiUtils.js';
2
+ const enc = encodeURIComponent;
2
3
  const GRAPH_TTL = 2 * 60 * 1000;
3
4
  const STATUS_TTL = 60 * 1000;
4
5
  /**
@@ -13,7 +14,7 @@ export class FollowsApi {
13
14
  // ── People ───────────────────────────────────────────────────────────────
14
15
  /** Follow a user. */
15
16
  async follow(userId) {
16
- const result = await this.ctx.request('POST', `/users/${userId}/follow`, undefined, { cache: false });
17
+ const result = await this.ctx.request('POST', `/users/${enc(userId)}/follow`, undefined, { cache: false });
17
18
  this.invalidate([userId]);
18
19
  return result;
19
20
  }
@@ -27,7 +28,7 @@ export class FollowsApi {
27
28
  }
28
29
  /** Unfollow a user. */
29
30
  async unfollow(userId) {
30
- const result = await this.ctx.request('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
31
+ const result = await this.ctx.request('DELETE', `/users/${enc(userId)}/follow`, undefined, { cache: false });
31
32
  this.invalidate([userId]);
32
33
  return result;
33
34
  }
@@ -41,7 +42,7 @@ export class FollowsApi {
41
42
  }
42
43
  /** Whether the signed-in user follows `userId`. Cached 1 minute, busted by every write. */
43
44
  async status(userId) {
44
- return this.ctx.request('GET', `/users/${userId}/follow-status`, undefined, {
45
+ return this.ctx.request('GET', `/users/${enc(userId)}/follow-status`, undefined, {
45
46
  cache: true,
46
47
  cacheTTL: STATUS_TTL,
47
48
  });
@@ -71,12 +72,12 @@ export class FollowsApi {
71
72
  * `limit`/`offset`/`sort` combination is its own cache entry (2 minutes).
72
73
  */
73
74
  async followers(userId, params) {
74
- const { data, total, hasMore } = await this.userPage(`/users/${userId}/followers`, params);
75
+ const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/followers`, params);
75
76
  return { followers: data, total, hasMore };
76
77
  }
77
78
  /** Who a user follows. `sort` as in `followers`. */
78
79
  async following(userId, params) {
79
- const { data, total, hasMore } = await this.userPage(`/users/${userId}/following`, params);
80
+ const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/following`, params);
80
81
  return { following: data, total, hasMore };
81
82
  }
82
83
  /**
@@ -84,7 +85,7 @@ export class FollowsApi {
84
85
  * `userId`.
85
86
  */
86
87
  async mutuals(userId, params) {
87
- const { data, total, hasMore } = await this.userPage(`/users/${userId}/mutuals`, params);
88
+ const { data, total, hasMore } = await this.userPage(`/users/${enc(userId)}/mutuals`, params);
88
89
  return { mutuals: data, total, hasMore };
89
90
  }
90
91
  /**
@@ -219,16 +220,16 @@ export class FollowsApi {
219
220
  const keys = ['GET:/users/me/graph'];
220
221
  const prefixes = ['GET:/profiles/username/', 'GET:/profiles/resolve'];
221
222
  for (const id of targetUserIds) {
222
- keys.push(`GET:/users/${id}/follow-status`);
223
+ keys.push(`GET:/users/${enc(id)}/follow-status`);
223
224
  // Profiles embed the viewer-relative `relationship`.
224
- keys.push(`GET:/users/${id}`);
225
+ keys.push(`GET:/users/${enc(id)}`);
225
226
  // The target gained or lost a follower, and "followers you know" with it.
226
- prefixes.push(`GET:/users/${id}/followers`, `GET:/users/${id}/mutuals`);
227
+ prefixes.push(`GET:/users/${enc(id)}/followers`, `GET:/users/${enc(id)}/mutuals`);
227
228
  }
228
229
  // The viewer's own following list changed.
229
230
  const viewerId = this.ctx.oxy.session.userId;
230
231
  if (viewerId)
231
- prefixes.push(`GET:/users/${viewerId}/following`);
232
+ prefixes.push(`GET:/users/${enc(viewerId)}/following`);
232
233
  this.ctx.http.invalidateCache({ keys, prefixes });
233
234
  }
234
235
  }
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import { evictOxyIdentityCache } from '../utils/identityCacheSweep.js';
2
+ const enc = encodeURIComponent;
2
3
  const LIST_TTL = 60 * 1000;
3
4
  const SETTINGS_TTL = 2 * 60 * 1000;
4
5
  const refId = (ref) => (typeof ref === 'string' ? ref : ref._id);
@@ -10,7 +11,7 @@ export class PrivacyApi {
10
11
  /** A user's privacy settings (default: the signed-in user's). */
11
12
  async settings(userId) {
12
13
  const id = await this.resolveUserId(userId);
13
- return this.ctx.request('GET', `/privacy/${id}/privacy`, undefined, {
14
+ return this.ctx.request('GET', `/privacy/${enc(id)}/privacy`, undefined, {
14
15
  cache: true,
15
16
  cacheTTL: SETTINGS_TTL,
16
17
  });
@@ -18,10 +19,10 @@ export class PrivacyApi {
18
19
  /** Update privacy settings (default: the signed-in user's). */
19
20
  async updateSettings(settings, userId) {
20
21
  const id = await this.resolveUserId(userId);
21
- const res = await this.ctx.request('PATCH', `/privacy/${id}/privacy`, settings, { cache: false });
22
+ const res = await this.ctx.request('PATCH', `/privacy/${enc(id)}/privacy`, settings, { cache: false });
22
23
  // Privacy settings ride the user DTO, so every identity read goes stale too.
23
24
  evictOxyIdentityCache(this.ctx.http, id);
24
- this.ctx.oxy.cache.delete(`GET:/privacy/${id}/privacy`);
25
+ this.ctx.oxy.cache.delete(`GET:/privacy/${enc(id)}/privacy`);
25
26
  return res;
26
27
  }
27
28
  // ── Blocked ──────────────────────────────────────────────────────────────
@@ -35,14 +36,14 @@ export class PrivacyApi {
35
36
  /** Block a user. */
36
37
  async block(userId) {
37
38
  requireId(userId);
38
- const res = await this.ctx.request('POST', `/privacy/blocked/${userId}`, undefined, { cache: false });
39
+ const res = await this.ctx.request('POST', `/privacy/blocked/${enc(userId)}`, undefined, { cache: false });
39
40
  this.invalidate('GET:/privacy/blocked');
40
41
  return res;
41
42
  }
42
43
  /** Unblock a user. */
43
44
  async unblock(userId) {
44
45
  requireId(userId);
45
- const res = await this.ctx.request('DELETE', `/privacy/blocked/${userId}`, undefined, { cache: false });
46
+ const res = await this.ctx.request('DELETE', `/privacy/blocked/${enc(userId)}`, undefined, { cache: false });
46
47
  this.invalidate('GET:/privacy/blocked');
47
48
  return res;
48
49
  }
@@ -68,14 +69,14 @@ export class PrivacyApi {
68
69
  /** Restrict a user: limit their interactions without blocking them. */
69
70
  async restrict(userId) {
70
71
  requireId(userId);
71
- const res = await this.ctx.request('POST', `/privacy/restricted/${userId}`, undefined, { cache: false });
72
+ const res = await this.ctx.request('POST', `/privacy/restricted/${enc(userId)}`, undefined, { cache: false });
72
73
  this.invalidate('GET:/privacy/restricted');
73
74
  return res;
74
75
  }
75
76
  /** Lift a restriction. */
76
77
  async unrestrict(userId) {
77
78
  requireId(userId);
78
- const res = await this.ctx.request('DELETE', `/privacy/restricted/${userId}`, undefined, { cache: false });
79
+ const res = await this.ctx.request('DELETE', `/privacy/restricted/${enc(userId)}`, undefined, { cache: false });
79
80
  this.invalidate('GET:/privacy/restricted');
80
81
  return res;
81
82
  }
@@ -8,12 +8,22 @@
8
8
  import { jwtDecode } from 'jwt-decode';
9
9
  import { normalizeUserIdentity } from '../utils/userIdentity.js';
10
10
  import { OxyApiError } from '../OxyServices.errors.js';
11
+ import { checkContract, holderContextEvidenceV1ResponseCheck } from '@oxy.so/contracts/client';
12
+ const enc = encodeURIComponent;
13
+ /** How long a refusal (disabled, rate limited, unreachable) is remembered per installation. */
14
+ const EVIDENCE_REFUSAL_MS = 5000;
15
+ const unavailable = (reason) => ({
16
+ status: 'unavailable',
17
+ reason,
18
+ });
11
19
  export class SessionApi {
12
20
  constructor(ctx) {
13
21
  this.ctx = ctx;
14
22
  this.deviceCredentialProvider = null;
15
23
  this.decodedFor = null;
16
24
  this.decoded = null;
25
+ /** One server round trip per installed bearer; the key is the opaque generation. */
26
+ this.evidence = new WeakMap();
17
27
  }
18
28
  // ── Local state ──────────────────────────────────────────────────────────
19
29
  /** The current access token, or `null` when signed out. */
@@ -118,6 +128,115 @@ export class SessionApi {
118
128
  return null;
119
129
  }
120
130
  }
131
+ // ── Holder-context evidence (ADR 0053) ───────────────────────────────────
132
+ /**
133
+ * Ask the server which shared-device context the INSTALLED bearer belongs to,
134
+ * and bind the answer to that exact installation.
135
+ *
136
+ * The result is honored only if the credential generation captured before
137
+ * the request is still current when the answer is committed, so a refresh,
138
+ * setter, account or actor switch, cross-tab change, restore or A to B to A
139
+ * during the request yields `changed`. The answer must also agree with the
140
+ * claims of the bearer that was captured; a disagreement is
141
+ * `binding_unavailable`. Nothing here refreshes, clears or mints a credential,
142
+ * and no outcome is a revocation verdict: callers keep their existing checks.
143
+ * Concurrent callers share one request per installation.
144
+ */
145
+ observeHolderEvidence() {
146
+ const generation = this.ctx.http.captureCredentialGeneration();
147
+ const token = this.ctx.http.getAccessToken();
148
+ if (!generation || !token)
149
+ return Promise.resolve(unavailable('no_credential'));
150
+ const known = this.evidence.get(generation);
151
+ if (known) {
152
+ const refused = known.settledAt !== undefined;
153
+ if (!refused || Date.now() - known.settledAt < EVIDENCE_REFUSAL_MS) {
154
+ return known.result.then((result) => result.status === 'ok' && result.evidence.expiresAtMs <= Date.now()
155
+ ? this.refreshEvidence(generation, token)
156
+ : result);
157
+ }
158
+ }
159
+ return this.refreshEvidence(generation, token);
160
+ }
161
+ /** Whether evidence still names the installed bearer and has not expired. */
162
+ isHolderEvidenceCurrent(evidence) {
163
+ return (Date.now() < evidence.expiresAtMs &&
164
+ this.ctx.http.isCredentialGenerationCurrent(evidence.generation));
165
+ }
166
+ refreshEvidence(generation, token) {
167
+ const entry = {
168
+ result: this.acquireEvidence(generation, token).then((result) => {
169
+ if (result.status !== 'ok')
170
+ entry.settledAt = Date.now();
171
+ return result;
172
+ }),
173
+ };
174
+ this.evidence.set(generation, entry);
175
+ return entry.result;
176
+ }
177
+ async acquireEvidence(generation, token) {
178
+ let claims;
179
+ try {
180
+ claims = jwtDecode(token);
181
+ }
182
+ catch {
183
+ return unavailable('unsupported');
184
+ }
185
+ const proof = await this.readDeviceProof();
186
+ if (!proof)
187
+ return unavailable('no_holder');
188
+ let response;
189
+ try {
190
+ response = await this.ctx.request('POST', '/session/device/context-evidence-v1', proof, {
191
+ cache: false,
192
+ retry: false,
193
+ expectedCredentialGeneration: generation,
194
+ });
195
+ }
196
+ catch (error) {
197
+ if (error?.code === 'AUTH_SESSION_CHANGED') {
198
+ return unavailable('changed');
199
+ }
200
+ const status = error instanceof OxyApiError ? error.status : 0;
201
+ if (status === 403)
202
+ return unavailable('unsupported');
203
+ if (status === 409)
204
+ return unavailable('binding_unavailable');
205
+ return unavailable('unavailable');
206
+ }
207
+ const parsed = checkContract(holderContextEvidenceV1ResponseCheck, response);
208
+ if (!parsed)
209
+ return unavailable('unavailable');
210
+ // Commit synchronously: nothing may replace the bearer between this check
211
+ // and the evidence becoming visible to its caller.
212
+ if (!this.ctx.http.isCredentialGenerationCurrent(generation))
213
+ return unavailable('changed');
214
+ const expiresAtMs = Date.parse(parsed.expiresAt);
215
+ if (parsed.deviceId !== proof.deviceId ||
216
+ parsed.accountId !== claims.userId ||
217
+ parsed.sessionId !== claims.sessionId ||
218
+ (claims.act?.sub !== undefined && parsed.actorId !== claims.act.sub) ||
219
+ (claims.device_context_id !== undefined && parsed.contextId !== claims.device_context_id)) {
220
+ return unavailable('binding_unavailable');
221
+ }
222
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
223
+ return unavailable('unavailable');
224
+ }
225
+ const evidence = Object.freeze({
226
+ generation,
227
+ identity: Object.freeze({
228
+ deviceId: parsed.deviceId,
229
+ deviceSessionId: parsed.deviceSessionId,
230
+ contextId: parsed.contextId,
231
+ actorId: parsed.actorId,
232
+ accountId: parsed.accountId,
233
+ sessionId: parsed.sessionId,
234
+ }),
235
+ deviceRevision: parsed.revision,
236
+ expiresAtMs,
237
+ });
238
+ return { status: 'ok', evidence };
239
+ }
121
240
  // ── The server session ───────────────────────────────────────────────────
122
241
  /** Ask the server whether the current access token is valid. */
123
242
  async validateToken() {
@@ -172,7 +291,7 @@ export class SessionApi {
172
291
  : {}),
173
292
  },
174
293
  })
175
- : await this.ctx.request('GET', `/session/validate/${sessionId}`, {
294
+ : await this.ctx.request('GET', `/session/validate/${enc(sessionId)}`, {
176
295
  ...(options.deviceFingerprint
177
296
  ? { deviceFingerprint: options.deviceFingerprint }
178
297
  : {}),
@@ -199,7 +318,7 @@ export class SessionApi {
199
318
  const invalid = candidate?.status === 401 && candidate.code === 'OXY_LIVE_SESSION_INVALID_V1';
200
319
  const denied = candidate?.status === 403 && candidate.code === 'OXY_LIVE_SESSION_POLICY_V1';
201
320
  if (invalid)
202
- this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
321
+ this.ctx.oxy.cache.delete(`GET:/session/user/${enc(sessionId)}`);
203
322
  if (invalid || denied)
204
323
  throw error;
205
324
  throw new OxyApiError('Live session authority is unavailable', {
@@ -209,26 +328,28 @@ export class SessionApi {
209
328
  });
210
329
  }
211
330
  // The session is gone: drop any user cached for it (#196).
212
- this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
331
+ this.ctx.oxy.cache.delete(`GET:/session/user/${enc(sessionId)}`);
213
332
  throw error;
214
333
  }
215
334
  }
216
335
  /** The sessions that belong with `sessionId` (same account set). */
217
336
  async list(sessionId) {
218
- return this.ctx.request('GET', `/session/sessions/${sessionId}`, undefined, {
337
+ return this.ctx.request('GET', `/session/sessions/${enc(sessionId)}`, undefined, {
219
338
  cache: false,
220
339
  });
221
340
  }
222
341
  /** Sign out `sessionId`, or — with `targetSessionId` — one of the sessions beside it. */
223
342
  async logout(sessionId, targetSessionId) {
224
343
  const url = targetSessionId
225
- ? `/session/logout/${sessionId}/${targetSessionId}`
226
- : `/session/logout/${sessionId}`;
344
+ ? `/session/logout/${enc(sessionId)}/${enc(targetSessionId)}`
345
+ : `/session/logout/${enc(sessionId)}`;
227
346
  await this.ctx.request('POST', url, undefined, { cache: false });
228
347
  }
229
348
  /** Sign out the other sessions for this account; preserve `sessionId`. */
230
349
  async logoutAll(sessionId) {
231
- await this.ctx.request('POST', `/session/logout-all/${sessionId}`, undefined, { cache: false });
350
+ await this.ctx.request('POST', `/session/logout-all/${enc(sessionId)}`, undefined, {
351
+ cache: false,
352
+ });
232
353
  }
233
354
  /** Decoded claims of the current token, memoised per token string. */
234
355
  claims() {
@@ -1,3 +1,4 @@
1
+ const enc = encodeURIComponent;
1
2
  const SHORT = 60 * 1000;
2
3
  const LONG = 5 * 60 * 1000;
3
4
  const EXTRA_LONG = 30 * 60 * 1000;
@@ -41,7 +42,7 @@ export class TopicsApi {
41
42
  }
42
43
  /** One topic by slug. */
43
44
  async get(slug) {
44
- return this.ctx.request('GET', `/topics/${slug}`, undefined, {
45
+ return this.ctx.request('GET', `/topics/${enc(slug)}`, undefined, {
45
46
  cache: true,
46
47
  cacheTTL: LONG,
47
48
  });
@@ -60,10 +61,10 @@ export class TopicsApi {
60
61
  }
61
62
  /** Update a topic's description and translations. Staff or service only. */
62
63
  async update(slug, data) {
63
- const topic = await this.ctx.request('PATCH', `/topics/${slug}`, data, {
64
+ const topic = await this.ctx.request('PATCH', `/topics/${enc(slug)}`, data, {
64
65
  cache: false,
65
66
  });
66
- this.ctx.oxy.cache.delete(`GET:/topics/${slug}`);
67
+ this.ctx.oxy.cache.delete(`GET:/topics/${enc(slug)}`);
67
68
  return topic;
68
69
  }
69
70
  }
@@ -6,6 +6,7 @@ import { OXY_ACCOUNT_LIST_CACHE_KEY, OXY_ACCOUNT_LIST_CACHE_QUERY_PREFIX, oxyAcc
6
6
  import { extractErrorStatus } from '../utils/errorUtils.js';
7
7
  import { runBatchLookup } from '../utils/batchLookup.js';
8
8
  import { logger } from '../logger/index.js';
9
+ const enc = encodeURIComponent;
9
10
  const PROFILE_TTL = 5 * 60 * 1000;
10
11
  const SESSION_USER_TTL = 2 * 60 * 1000;
11
12
  const ME_TTL = 60 * 1000;
@@ -25,7 +26,7 @@ export class UsersApi {
25
26
  * registry-fresh read that neither serves nor overwrites a cached entry.
26
27
  */
27
28
  async get(userId, options) {
28
- const user = await this.ctx.request('GET', `/users/${userId}`, undefined, {
29
+ const user = await this.ctx.request('GET', `/users/${enc(userId)}`, undefined, {
29
30
  cache: options?.cache ?? true,
30
31
  cacheTTL: PROFILE_TTL,
31
32
  });
@@ -107,7 +108,7 @@ export class UsersApi {
107
108
  }
108
109
  /** The user a session belongs to. */
109
110
  async bySession(sessionId) {
110
- const user = await this.ctx.request('GET', `/session/user/${sessionId}`, undefined, {
111
+ const user = await this.ctx.request('GET', `/session/user/${enc(sessionId)}`, undefined, {
111
112
  cache: true,
112
113
  cacheTTL: SESSION_USER_TTL,
113
114
  });
@@ -183,7 +184,7 @@ export class UsersApi {
183
184
  const pagination = typeof limitOrPagination === 'number'
184
185
  ? { limit: limitOrPagination }
185
186
  : (limitOrPagination ?? {});
186
- const users = await this.ctx.request('GET', `/profiles/${userId}/similar`, buildQueryParams(pagination), {
187
+ const users = await this.ctx.request('GET', `/profiles/${enc(userId)}/similar`, buildQueryParams(pagination), {
187
188
  cache: true,
188
189
  cacheTTL: PROFILE_TTL,
189
190
  });
@@ -195,7 +196,7 @@ export class UsersApi {
195
196
  * account. Cached 5 minutes.
196
197
  */
197
198
  async moderators(communityId) {
198
- const moderators = await this.ctx.request('GET', `/users/${communityId}/moderators`, undefined, { cache: true, cacheTTL: PROFILE_TTL });
199
+ const moderators = await this.ctx.request('GET', `/users/${enc(communityId)}/moderators`, undefined, { cache: true, cacheTTL: PROFILE_TTL });
199
200
  return moderators.map((user) => normalizeUserIdentity(user));
200
201
  }
201
202
  /**