@oxyhq/core 16.1.0 → 17.0.2

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 (41) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +28 -6
  3. package/dist/cjs/i18n/locales/en-US.json +5 -3
  4. package/dist/cjs/i18n/locales/es-ES.json +5 -3
  5. package/dist/cjs/i18n/locales/locales/en-US.json +5 -3
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +5 -3
  7. package/dist/cjs/index.js +4 -3
  8. package/dist/cjs/mixins/OxyServices.auth.js +57 -19
  9. package/dist/cjs/server/index.js +7 -1
  10. package/dist/cjs/server/userInvalidation.js +178 -0
  11. package/dist/cjs/utils/validationUtils.js +3 -1
  12. package/dist/esm/.tsbuildinfo +1 -1
  13. package/dist/esm/HttpService.js +28 -6
  14. package/dist/esm/i18n/locales/en-US.json +5 -3
  15. package/dist/esm/i18n/locales/es-ES.json +5 -3
  16. package/dist/esm/i18n/locales/locales/en-US.json +5 -3
  17. package/dist/esm/i18n/locales/locales/es-ES.json +5 -3
  18. package/dist/esm/index.js +1 -1
  19. package/dist/esm/mixins/OxyServices.auth.js +57 -19
  20. package/dist/esm/server/index.js +3 -0
  21. package/dist/esm/server/userInvalidation.js +173 -0
  22. package/dist/esm/utils/validationUtils.js +2 -0
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/HttpService.d.ts +13 -4
  25. package/dist/types/index.d.ts +2 -2
  26. package/dist/types/mixins/OxyServices.auth.d.ts +19 -0
  27. package/dist/types/server/index.d.ts +2 -0
  28. package/dist/types/server/userInvalidation.d.ts +133 -0
  29. package/dist/types/utils/validationUtils.d.ts +2 -0
  30. package/package.json +2 -2
  31. package/src/HttpService.ts +32 -7
  32. package/src/__tests__/httpServiceFormEncoded.test.ts +142 -0
  33. package/src/i18n/locales/en-US.json +5 -3
  34. package/src/i18n/locales/es-ES.json +5 -3
  35. package/src/index.ts +2 -1
  36. package/src/mixins/OxyServices.auth.ts +76 -20
  37. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +72 -11
  38. package/src/server/__tests__/userInvalidation.test.ts +220 -0
  39. package/src/server/index.ts +13 -0
  40. package/src/server/userInvalidation.ts +227 -0
  41. package/src/utils/validationUtils.ts +4 -0
@@ -235,10 +235,9 @@ export class HttpService {
235
235
  *
236
236
  * Why we explicitly reject `URLSearchParams`:
237
237
  * - `URLSearchParams` ALSO exposes `append` / `get` / `has`, so the
238
- * duck-type fallback below would have misidentified it as FormData.
239
- * - We want urlencoded payloads to take the JSON-stringify path so the
240
- * server receives them as `application/x-www-form-urlencoded` instead
241
- * of an empty multipart body.
238
+ * duck-type fallback below would have misidentified it as FormData and
239
+ * sent an empty multipart body.
240
+ * - It has its own encoding path instead — see {@link isUrlSearchParams}.
242
241
  */
243
242
  isFormData(data) {
244
243
  if (!data || typeof data !== 'object') {
@@ -272,6 +271,18 @@ export class HttpService {
272
271
  typeof candidate.getAll === 'function' &&
273
272
  typeof candidate.delete === 'function');
274
273
  }
274
+ /**
275
+ * True for an `application/x-www-form-urlencoded` payload.
276
+ *
277
+ * Needed because a handful of endpoints are defined by a standard that fixes
278
+ * their request encoding rather than by our own JSON conventions — today
279
+ * `POST /auth/oauth/token`, whose encoding RFC 6749 §4.1.3 mandates. Passing
280
+ * a `URLSearchParams` as `data` selects that encoding; everything else is
281
+ * still JSON.
282
+ */
283
+ isUrlSearchParams(data) {
284
+ return typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams;
285
+ }
275
286
  /**
276
287
  * Main request method - handles everything in one place
277
288
  */
@@ -319,6 +330,7 @@ export class HttpService {
319
330
  const fullUrl = this.buildURL(url, params);
320
331
  // Determine if data is FormData using robust detection
321
332
  const isFormData = this.isFormData(data);
333
+ const isUrlEncoded = this.isUrlSearchParams(data);
322
334
  // Make fetch request
323
335
  const controller = new AbortController();
324
336
  const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null;
@@ -330,7 +342,10 @@ export class HttpService {
330
342
  'Accept': 'application/json',
331
343
  };
332
344
  // Only set Content-Type for non-FormData requests (FormData sets it automatically with boundary)
333
- if (!isFormData) {
345
+ if (isUrlEncoded) {
346
+ headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
347
+ }
348
+ else if (!isFormData) {
334
349
  headers['Content-Type'] = 'application/json';
335
350
  }
336
351
  // Add authorization header if available
@@ -372,8 +387,11 @@ export class HttpService {
372
387
  headers[key] = value;
373
388
  });
374
389
  }
390
+ // `URLSearchParams` is serialised explicitly rather than handed to
391
+ // `fetch` as-is: RN's fetch does not consistently encode it, and doing
392
+ // it here keeps the body identical across every platform.
375
393
  const bodyValue = method !== 'GET' && data
376
- ? (isFormData ? data : JSON.stringify(data))
394
+ ? (isFormData ? data : isUrlEncoded ? data.toString() : JSON.stringify(data))
377
395
  : undefined;
378
396
  // React Native FormData workaround:
379
397
  // Expo SDK 56's "winter fetch" rejects RN file descriptors `{uri, type, name}`
@@ -439,6 +457,10 @@ export class HttpService {
439
457
  if (errorData?.message) {
440
458
  errorMessage = errorData.message;
441
459
  }
460
+ else if (errorData?.error_description) {
461
+ // RFC 6749 §5.2 / RFC 6750 §3 — OAuth endpoints surface human text here.
462
+ errorMessage = errorData.error_description;
463
+ }
442
464
  else if (errorData?.error) {
443
465
  errorMessage = errorData.error;
444
466
  }
@@ -678,7 +678,7 @@
678
678
  "firstNamePlaceholder": "Enter first name",
679
679
  "lastName": "Last Name",
680
680
  "lastNamePlaceholder": "Enter last name (optional)",
681
- "invalidChars": "Use letters and spaces only"
681
+ "invalidChars": "Use letters, spaces, apostrophes, and name separators only"
682
682
  },
683
683
  "username": {
684
684
  "title": "Username",
@@ -1752,7 +1752,8 @@
1752
1752
  "title": "Create account",
1753
1753
  "subtitle": "Create an account you control. It will have its own profile, members, and apps.",
1754
1754
  "displayName": {
1755
- "label": "Display name"
1755
+ "label": "Display name",
1756
+ "invalidChars": "Use letters, spaces, apostrophes, and name separators only"
1756
1757
  },
1757
1758
  "bio": {
1758
1759
  "label": "Bio (optional)"
@@ -1829,7 +1830,8 @@
1829
1830
  "subtitle": "Manage this account’s profile, members, and access.",
1830
1831
  "save": "Save changes",
1831
1832
  "displayName": {
1832
- "label": "Display name"
1833
+ "label": "Display name",
1834
+ "invalidChars": "Use letters, spaces, apostrophes, and name separators only"
1833
1835
  },
1834
1836
  "bio": {
1835
1837
  "label": "Bio (optional)"
@@ -203,7 +203,7 @@
203
203
  "firstNamePlaceholder": "Introduce tu nombre",
204
204
  "lastName": "Apellido",
205
205
  "lastNamePlaceholder": "Introduce tu apellido (opcional)",
206
- "invalidChars": "Usa solo letras y espacios"
206
+ "invalidChars": "Usa solo letras, espacios, apóstrofos y separadores de nombre"
207
207
  },
208
208
  "username": {
209
209
  "title": "Usuario",
@@ -1752,7 +1752,8 @@
1752
1752
  "title": "Crear cuenta",
1753
1753
  "subtitle": "Crea una cuenta que tú controlas. Tendrá su propio perfil, miembros y aplicaciones.",
1754
1754
  "displayName": {
1755
- "label": "Nombre visible"
1755
+ "label": "Nombre visible",
1756
+ "invalidChars": "Usa solo letras, espacios, apóstrofos y separadores de nombre"
1756
1757
  },
1757
1758
  "bio": {
1758
1759
  "label": "Biografía (opcional)"
@@ -1829,7 +1830,8 @@
1829
1830
  "subtitle": "Gestiona el perfil, los miembros y el acceso de esta cuenta.",
1830
1831
  "save": "Guardar cambios",
1831
1832
  "displayName": {
1832
- "label": "Nombre visible"
1833
+ "label": "Nombre visible",
1834
+ "invalidChars": "Usa solo letras, espacios, apóstrofos y separadores de nombre"
1833
1835
  },
1834
1836
  "bio": {
1835
1837
  "label": "Biografía (opcional)"
@@ -678,7 +678,7 @@
678
678
  "firstNamePlaceholder": "Enter first name",
679
679
  "lastName": "Last Name",
680
680
  "lastNamePlaceholder": "Enter last name (optional)",
681
- "invalidChars": "Use letters and spaces only"
681
+ "invalidChars": "Use letters, spaces, apostrophes, and name separators only"
682
682
  },
683
683
  "username": {
684
684
  "title": "Username",
@@ -1752,7 +1752,8 @@
1752
1752
  "title": "Create account",
1753
1753
  "subtitle": "Create an account you control. It will have its own profile, members, and apps.",
1754
1754
  "displayName": {
1755
- "label": "Display name"
1755
+ "label": "Display name",
1756
+ "invalidChars": "Use letters, spaces, apostrophes, and name separators only"
1756
1757
  },
1757
1758
  "bio": {
1758
1759
  "label": "Bio (optional)"
@@ -1829,7 +1830,8 @@
1829
1830
  "subtitle": "Manage this account’s profile, members, and access.",
1830
1831
  "save": "Save changes",
1831
1832
  "displayName": {
1832
- "label": "Display name"
1833
+ "label": "Display name",
1834
+ "invalidChars": "Use letters, spaces, apostrophes, and name separators only"
1833
1835
  },
1834
1836
  "bio": {
1835
1837
  "label": "Bio (optional)"
@@ -203,7 +203,7 @@
203
203
  "firstNamePlaceholder": "Introduce tu nombre",
204
204
  "lastName": "Apellido",
205
205
  "lastNamePlaceholder": "Introduce tu apellido (opcional)",
206
- "invalidChars": "Usa solo letras y espacios"
206
+ "invalidChars": "Usa solo letras, espacios, apóstrofos y separadores de nombre"
207
207
  },
208
208
  "username": {
209
209
  "title": "Usuario",
@@ -1752,7 +1752,8 @@
1752
1752
  "title": "Crear cuenta",
1753
1753
  "subtitle": "Crea una cuenta que tú controlas. Tendrá su propio perfil, miembros y aplicaciones.",
1754
1754
  "displayName": {
1755
- "label": "Nombre visible"
1755
+ "label": "Nombre visible",
1756
+ "invalidChars": "Usa solo letras, espacios, apóstrofos y separadores de nombre"
1756
1757
  },
1757
1758
  "bio": {
1758
1759
  "label": "Biografía (opcional)"
@@ -1829,7 +1830,8 @@
1829
1830
  "subtitle": "Gestiona el perfil, los miembros y el acceso de esta cuenta.",
1830
1831
  "save": "Guardar cambios",
1831
1832
  "displayName": {
1832
- "label": "Nombre visible"
1833
+ "label": "Nombre visible",
1834
+ "invalidChars": "Usa solo letras, espacios, apóstrofos y separadores de nombre"
1833
1835
  },
1834
1836
  "bio": {
1835
1837
  "label": "Biografía (opcional)"
package/dist/esm/index.js CHANGED
@@ -119,7 +119,7 @@ export { retryAsync } from './utils/asyncUtils.js';
119
119
  // ---------------------------------------------------------------------------
120
120
  // Validation
121
121
  // ---------------------------------------------------------------------------
122
- export { EMAIL_REGEX, USERNAME_REGEX, PASSWORD_REGEX, MAX_DISPLAY_NAME_LENGTH, isValidEmail, isValidUsername, isValidPassword, isValidDisplayName, DISPLAY_NAME_ALLOWED_SCRIPTS, DISPLAY_NAME_DISALLOWED_SOURCE, DISPLAY_NAME_ORPHANED_MARK_SOURCE, DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE, isRequiredString, isRequiredNumber, isRequiredBoolean, isValidArray, isValidObject, isValidUUID, isValidURL, isValidDate, isValidFileSize, isValidFileType, sanitizeString, sanitizeHTML, isValidObjectId, validateAndSanitizeUserInput, } from './utils/validationUtils.js';
122
+ export { EMAIL_REGEX, USERNAME_REGEX, PASSWORD_REGEX, MAX_DISPLAY_NAME_LENGTH, DISPLAY_NAME_INVALID_MESSAGE, isValidEmail, isValidUsername, isValidPassword, isValidDisplayName, DISPLAY_NAME_ALLOWED_SCRIPTS, DISPLAY_NAME_DISALLOWED_SOURCE, DISPLAY_NAME_ORPHANED_MARK_SOURCE, DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE, isRequiredString, isRequiredNumber, isRequiredBoolean, isValidArray, isValidObject, isValidUUID, isValidURL, isValidDate, isValidFileSize, isValidFileType, sanitizeString, sanitizeHTML, isValidObjectId, validateAndSanitizeUserInput, } from './utils/validationUtils.js';
123
123
  // ---------------------------------------------------------------------------
124
124
  // Text normalization
125
125
  // ---------------------------------------------------------------------------
@@ -12,6 +12,12 @@ import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/use
12
12
  * returned `expiresAt` is authoritative; this is only the client-proposed value.
13
13
  */
14
14
  const COMMONS_SIGN_IN_EXPIRY_MS = 5 * 60 * 1000;
15
+ /**
16
+ * Fallback access-token lifetime used only if the token endpoint ever omits the
17
+ * RFC 6749 `expires_in` member. Matches the server's current 15-minute access
18
+ * token; the server's value always wins when present.
19
+ */
20
+ const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
15
21
  /**
16
22
  * @internal Narrow an untrusted `requesterLabel` from the approve-info response.
17
23
  *
@@ -1127,39 +1133,43 @@ export function OxyServicesAuthMixin(Base) {
1127
1133
  * after sign-in at auth.oxy.so) for a device-first session.
1128
1134
  * Public first-party clients use PKCE (`codeVerifier`); the access token is
1129
1135
  * planted immediately on success.
1136
+ *
1137
+ * Speaks the standard RFC 6749 §4.1.3 token request — a form-urlencoded
1138
+ * body with snake_case parameters and `grant_type=authorization_code` — and
1139
+ * reads the flat §5.1 response. The camelCase JSON request and `{ data }`
1140
+ * response this method used before were an Oxy invention no OAuth library
1141
+ * could interoperate with; the endpoint no longer accepts them. The method's
1142
+ * OWN signature is unchanged, so callers are unaffected.
1130
1143
  */
1131
1144
  async exchangeOAuthCode(params) {
1132
1145
  try {
1133
- const res = await this.makeRequest('POST', '/auth/oauth/token', {
1146
+ const form = new URLSearchParams({
1147
+ grant_type: 'authorization_code',
1134
1148
  code: params.code,
1135
- clientId: params.clientId,
1136
- redirectUri: params.redirectUri,
1137
- codeVerifier: params.codeVerifier,
1138
- }, { cache: false, skipAuth: true });
1139
- const payload = res.data ??
1140
- res;
1141
- if (!payload || typeof payload !== 'object') {
1149
+ redirect_uri: params.redirectUri,
1150
+ client_id: params.clientId,
1151
+ code_verifier: params.codeVerifier,
1152
+ });
1153
+ const res = await this.makeRequest('POST', '/auth/oauth/token', form, { cache: false, skipAuth: true });
1154
+ if (!res || typeof res !== 'object') {
1142
1155
  throw new Error('auth/oauth/token returned an unexpected response shape');
1143
1156
  }
1144
- const record = payload;
1145
- const accessToken = (record.access_token ?? record.accessToken);
1146
- const sessionId = (record.session_id ?? record.sessionId);
1147
- const deviceId = (record.deviceId ?? record.device_id);
1148
- const deviceSecret = (record.deviceSecret ?? record.device_secret);
1157
+ // RFC 6749 §5.1: every member sits at the TOP LEVEL of the document.
1158
+ const record = res;
1159
+ const accessToken = typeof record.access_token === 'string' ? record.access_token : undefined;
1160
+ const sessionId = typeof record.session_id === 'string' ? record.session_id : undefined;
1161
+ const deviceId = typeof record.deviceId === 'string' ? record.deviceId : undefined;
1162
+ const deviceSecret = typeof record.deviceSecret === 'string' ? record.deviceSecret : undefined;
1149
1163
  const userRaw = record.user;
1150
1164
  if (!sessionId || !deviceId || !deviceSecret || !userRaw || typeof userRaw !== 'object') {
1151
1165
  throw new Error('auth/oauth/token returned an incomplete session payload');
1152
1166
  }
1153
1167
  const userObj = userRaw;
1154
- const userId = userObj.id;
1168
+ const userId = typeof userObj.id === 'string' ? userObj.id : undefined;
1155
1169
  if (!userId) {
1156
1170
  throw new Error('auth/oauth/token returned a session without user.id');
1157
1171
  }
1158
- const expiresInSec = typeof record.expires_in === 'number'
1159
- ? record.expires_in
1160
- : typeof record.expiresIn === 'number'
1161
- ? record.expiresIn
1162
- : 15 * 60;
1172
+ const expiresInSec = typeof record.expires_in === 'number' ? record.expires_in : DEFAULT_ACCESS_TOKEN_TTL_SECONDS;
1163
1173
  const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
1164
1174
  if (accessToken) {
1165
1175
  this.setTokens(accessToken);
@@ -1181,5 +1191,33 @@ export function OxyServicesAuthMixin(Base) {
1181
1191
  throw this.handleError(error);
1182
1192
  }
1183
1193
  }
1194
+ /**
1195
+ * Fetch OpenID Connect userinfo for the current bearer (`GET /auth/oauth/userinfo`).
1196
+ * The response is a flat JSON document — no `{ data }` wrapper.
1197
+ */
1198
+ async getOAuthUserInfo() {
1199
+ try {
1200
+ const res = await this.makeRequest('GET', '/auth/oauth/userinfo', undefined, { cache: false });
1201
+ if (!res || typeof res !== 'object') {
1202
+ throw new Error('auth/oauth/userinfo returned an unexpected response shape');
1203
+ }
1204
+ const record = res;
1205
+ const sub = typeof record.sub === 'string' ? record.sub : undefined;
1206
+ if (!sub) {
1207
+ throw new Error('auth/oauth/userinfo returned a response without sub');
1208
+ }
1209
+ return {
1210
+ sub,
1211
+ ...(typeof record.preferred_username === 'string'
1212
+ ? { preferred_username: record.preferred_username }
1213
+ : {}),
1214
+ ...(typeof record.name === 'string' ? { name: record.name } : {}),
1215
+ ...(typeof record.picture === 'string' ? { picture: record.picture } : {}),
1216
+ };
1217
+ }
1218
+ catch (error) {
1219
+ throw this.handleError(error);
1220
+ }
1221
+ }
1184
1222
  };
1185
1223
  }
@@ -25,6 +25,9 @@ export { createOxyCors } from './cors.js';
25
25
  export { buildOxyCspDirectives, buildOxyPagesHeaders, createOxySecurityHeaders, formatOxyCspPolicy, OXY_CSP_BASELINE, } from './securityHeaders.js';
26
26
  // Constant-time secret comparison.
27
27
  export { verifySecret } from './verifySecret.js';
28
+ // Cross-service user-invalidation signal: oxy-api publishes when identity
29
+ // changes, every consuming backend sweeps its caches instead of waiting out a TTL.
30
+ export { createOxyUserInvalidationHandler, evictOxyIdentityCache, publishOxyUserInvalidation, } from './userInvalidation.js';
28
31
  // Registrable-apex (eTLD+1) derivation via the Public Suffix List — the SINGLE
29
32
  // SOURCE OF TRUTH shared with the IdP worker and the client FAPI auto-detect.
30
33
  // Pure host handling (no browser deps), so it is safe on the server subpath and
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Oxy user-invalidation publish/consume helpers for Oxy backends.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * Every Oxy backend caches Oxy identity, and none of them find out when it
7
+ * changes. The `OxyServices` GET response cache holds `GET /users/:id` and
8
+ * `GET /profiles/username/:name` for five minutes; it is swept when THIS process
9
+ * writes the profile (see the `clearCacheEntry` calls in the user mixin) and
10
+ * never when somebody else does — which is the normal case, since profiles are
11
+ * edited in Oxy's own apps. So an avatar or display-name change is invisible to
12
+ * every consuming backend for up to five minutes, per process.
13
+ *
14
+ * oxy-api broadcasts {@link OXY_USER_INVALIDATION_CHANNEL} on the shared Valkey
15
+ * when a user's identity changes. This module is the consumer half: it parses
16
+ * and validates the event, sweeps the SDK's own cache, and hands the event to
17
+ * app-specific eviction. Wiring it is two lines and every backend that does so
18
+ * stops serving stale identity.
19
+ *
20
+ * WHY THE TRANSPORT IS THE CALLER'S JOB
21
+ * -------------------------------------
22
+ * This module deliberately does NOT take a Redis client. ioredis and node-redis
23
+ * disagree about how to subscribe — node-redis passes the listener to
24
+ * `subscribe(channel, listener)`, ioredis takes `subscribe(channel)` and then
25
+ * emits `'message'` on the client — and a helper that accepted "a client" would
26
+ * have to sniff which library it was handed. That kind of detection is exactly
27
+ * what breaks silently when a consumer upgrades a client library.
28
+ *
29
+ * So the split is: this module owns parsing, validation, dispatch and the
30
+ * never-throw guarantee (the parts that are easy to get wrong and identical
31
+ * everywhere), and the caller owns its own client's two-line subscribe idiom
32
+ * (trivial, but library-specific).
33
+ *
34
+ * // node-redis
35
+ * await subscriber.subscribe(
36
+ * OXY_USER_INVALIDATION_CHANNEL,
37
+ * createOxyUserInvalidationHandler({ oxy: oxyClient }),
38
+ * );
39
+ *
40
+ * // ioredis
41
+ * const handle = createOxyUserInvalidationHandler({ oxy: oxyClient });
42
+ * await subscriber.subscribe(OXY_USER_INVALIDATION_CHANNEL);
43
+ * subscriber.on('message', (_channel, raw) => handle(raw));
44
+ *
45
+ * Subscribe on EVERY task, not just an elected leader. The SDK cache this sweeps
46
+ * is per-process in-memory, so a leader-only subscriber would leave every other
47
+ * task stale — and leader-gating would add a failure mode (leader down means no
48
+ * invalidation anywhere) to a signal whose whole point is that losing it is
49
+ * merely slow, never wrong.
50
+ *
51
+ * Node-only; exported solely from `@oxyhq/core/server`.
52
+ */
53
+ import { OXY_USER_INVALIDATION_CHANNEL, isPublishedOxyUserChangeReason, oxyUserInvalidationEventSchema, } from '@oxyhq/contracts';
54
+ /**
55
+ * Broadcast that an Oxy user's record changed.
56
+ *
57
+ * Returns `true` when a message was put on the wire and `false` when the reason
58
+ * is not a broadcast one ({@link isPublishedOxyUserChangeReason}) — the latter is
59
+ * a deliberate no-op, not a failure. Suppressing at the publisher rather than
60
+ * letting every subscriber discard matters at bulk-follow scale, where a single
61
+ * call moves up to 200 edges.
62
+ *
63
+ * NEVER THROWS AND NEVER RETURNS A REJECTED PROMISE. This is called from inside
64
+ * cache invalidation, which itself runs after a successful database write on the
65
+ * request path: a publish failure must not turn a completed profile update into
66
+ * a 500. A dropped message costs a consumer its TTL and nothing more.
67
+ *
68
+ * @param publisher - A connected Redis client. Must NOT be a client currently in
69
+ * subscriber mode — Redis forbids `PUBLISH` on a subscribed
70
+ * connection, so pass the publisher half of a pub/sub pair.
71
+ * @param userId - The Oxy user whose record changed.
72
+ * @param reason - How the record changed. See {@link OxyUserChangeReason}.
73
+ * @param onError - Optional diagnostic sink for a failed publish.
74
+ */
75
+ export function publishOxyUserInvalidation(publisher, userId, reason, onError) {
76
+ if (!userId || !isPublishedOxyUserChangeReason(reason)) {
77
+ return false;
78
+ }
79
+ const event = { userId, reason, at: Date.now() };
80
+ try {
81
+ const result = publisher.publish(OXY_USER_INVALIDATION_CHANNEL, JSON.stringify(event));
82
+ // node-redis returns a promise; ioredis returns a promise too, but a mocked
83
+ // or synchronous client may return anything. Only attach a rejection handler
84
+ // when there is actually something thenable to reject.
85
+ if (isPromiseLike(result)) {
86
+ Promise.resolve(result).catch((error) => onError?.(error));
87
+ }
88
+ return true;
89
+ }
90
+ catch (error) {
91
+ onError?.(error);
92
+ return false;
93
+ }
94
+ }
95
+ function isPromiseLike(value) {
96
+ return (typeof value === 'object' &&
97
+ value !== null &&
98
+ typeof value.then === 'function');
99
+ }
100
+ /**
101
+ * Build the message handler for {@link OXY_USER_INVALIDATION_CHANNEL}.
102
+ *
103
+ * The returned function NEVER THROWS and never returns a rejected promise. It
104
+ * runs inside the Redis client's message dispatch, where an exception either
105
+ * takes down the subscriber connection or surfaces as an unhandled rejection —
106
+ * and losing the subscription is strictly worse than losing one message, because
107
+ * it is silent and permanent.
108
+ *
109
+ * A message that fails schema validation is dropped, not retried: the payload is
110
+ * produced by a contract both sides compile against, so a malformed one means a
111
+ * version skew or an unrelated publisher on the channel, neither of which a retry
112
+ * fixes.
113
+ */
114
+ export function createOxyUserInvalidationHandler(options = {}) {
115
+ const { oxy, onInvalidate, onError } = options;
116
+ return (raw) => {
117
+ let event;
118
+ try {
119
+ const parsed = oxyUserInvalidationEventSchema.safeParse(JSON.parse(raw));
120
+ if (!parsed.success) {
121
+ onError?.(parsed.error, raw);
122
+ return;
123
+ }
124
+ event = parsed.data;
125
+ }
126
+ catch (error) {
127
+ onError?.(error, raw);
128
+ return;
129
+ }
130
+ if (oxy) {
131
+ try {
132
+ evictOxyIdentityCache(oxy, event.userId);
133
+ }
134
+ catch (error) {
135
+ // A cache sweep must never cost us the app-specific eviction below.
136
+ onError?.(error, raw);
137
+ }
138
+ }
139
+ if (!onInvalidate)
140
+ return;
141
+ try {
142
+ const result = onInvalidate(event);
143
+ if (isPromiseLike(result)) {
144
+ Promise.resolve(result).catch((error) => onError?.(error, raw));
145
+ }
146
+ }
147
+ catch (error) {
148
+ onError?.(error, raw);
149
+ }
150
+ };
151
+ }
152
+ /**
153
+ * Sweep an `OxyServices` GET response cache of everything that could carry the
154
+ * given user's identity.
155
+ *
156
+ * The by-id entry is exact. The by-username and resolve entries are keyed by
157
+ * HANDLE, which cannot be derived from an id without the very lookup we are
158
+ * invalidating, so those are swept by prefix — the same imprecision the SDK
159
+ * already accepts when it sweeps its own cache after a local profile write, and
160
+ * bounded by the fact that over-eviction costs a refetch and can never serve
161
+ * wrong data.
162
+ */
163
+ export function evictOxyIdentityCache(oxy, userId) {
164
+ // Match the sweep the user mixin runs after a local profile write — session-
165
+ // bound and /users/me entries are keyed without the user id, so they must be
166
+ // prefix-swept on cross-service invalidation too.
167
+ oxy.clearCacheByPrefix('GET:/session/user/');
168
+ oxy.clearCacheByPrefix('GET:/users/me');
169
+ oxy.clearCacheByPrefix('GET:/auth/lookup/');
170
+ oxy.clearCacheEntry(`GET:/users/${userId}`);
171
+ oxy.clearCacheByPrefix('GET:/profiles/username/');
172
+ oxy.clearCacheByPrefix('GET:/profiles/resolve');
173
+ }
@@ -7,6 +7,8 @@ import { DISPLAY_NAME_ALLOWED_SCRIPTS_RANGES, DISPLAY_NAME_COMBINING_MARKS_RANGE
7
7
  * Shared by the API write path and client input surfaces.
8
8
  */
9
9
  export const MAX_DISPLAY_NAME_LENGTH = 80;
10
+ /** Shared 400 / inline-validation copy for native display-name policy rejections. */
11
+ export const DISPLAY_NAME_INVALID_MESSAGE = 'Name may only contain letters, spaces, apostrophes, and name separators (·, ־, ་, ・).';
10
12
  /**
11
13
  * Email validation regex
12
14
  */