@oxyhq/core 16.0.0 → 17.0.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 (47) 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 +6 -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 +172 -0
  11. package/dist/cjs/utils/displayNamePolicyRanges.generated.js +28 -3
  12. package/dist/cjs/utils/validationUtils.js +112 -21
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/HttpService.js +28 -6
  15. package/dist/esm/i18n/locales/en-US.json +5 -3
  16. package/dist/esm/i18n/locales/es-ES.json +5 -3
  17. package/dist/esm/i18n/locales/locales/en-US.json +5 -3
  18. package/dist/esm/i18n/locales/locales/es-ES.json +5 -3
  19. package/dist/esm/index.js +1 -1
  20. package/dist/esm/mixins/OxyServices.auth.js +57 -19
  21. package/dist/esm/server/index.js +3 -0
  22. package/dist/esm/server/userInvalidation.js +167 -0
  23. package/dist/esm/utils/displayNamePolicyRanges.generated.js +27 -2
  24. package/dist/esm/utils/validationUtils.js +112 -21
  25. package/dist/types/.tsbuildinfo +1 -1
  26. package/dist/types/HttpService.d.ts +13 -4
  27. package/dist/types/index.d.ts +2 -2
  28. package/dist/types/mixins/OxyServices.auth.d.ts +19 -0
  29. package/dist/types/server/index.d.ts +2 -0
  30. package/dist/types/server/userInvalidation.d.ts +133 -0
  31. package/dist/types/utils/displayNamePolicyRanges.generated.d.ts +27 -2
  32. package/dist/types/utils/validationUtils.d.ts +106 -20
  33. package/package.json +1 -1
  34. package/src/HttpService.ts +32 -7
  35. package/src/__tests__/httpServiceFormEncoded.test.ts +142 -0
  36. package/src/i18n/locales/en-US.json +5 -3
  37. package/src/i18n/locales/es-ES.json +5 -3
  38. package/src/index.ts +4 -1
  39. package/src/mixins/OxyServices.auth.ts +76 -20
  40. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +72 -11
  41. package/src/server/__tests__/userInvalidation.test.ts +208 -0
  42. package/src/server/index.ts +13 -0
  43. package/src/server/userInvalidation.ts +221 -0
  44. package/src/utils/__tests__/coldBoot.test.ts +9 -4
  45. package/src/utils/__tests__/validationUtils.test.ts +292 -1
  46. package/src/utils/displayNamePolicyRanges.generated.ts +31 -2
  47. package/src/utils/validationUtils.ts +117 -20
@@ -0,0 +1,221 @@
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
+
54
+ import {
55
+ OXY_USER_INVALIDATION_CHANNEL,
56
+ isPublishedOxyUserChangeReason,
57
+ oxyUserInvalidationEventSchema,
58
+ type OxyUserChangeReason,
59
+ type OxyUserInvalidationEvent,
60
+ } from '@oxyhq/contracts';
61
+
62
+ /**
63
+ * The publish surface of a Redis client. Both `ioredis` and `node-redis`
64
+ * satisfy this structurally, so neither library is a dependency here.
65
+ */
66
+ export interface OxyInvalidationPublisher {
67
+ publish(channel: string, message: string): unknown;
68
+ }
69
+
70
+ /**
71
+ * The cache-eviction surface of an {@link OxyServices} instance. Declared
72
+ * structurally so this Node-only module does not pull in the client.
73
+ */
74
+ export interface OxyIdentityCacheEvictor {
75
+ clearCacheEntry(key: string): void;
76
+ clearCacheByPrefix(prefix: string): number;
77
+ }
78
+
79
+ /**
80
+ * Broadcast that an Oxy user's record changed.
81
+ *
82
+ * Returns `true` when a message was put on the wire and `false` when the reason
83
+ * is not a broadcast one ({@link isPublishedOxyUserChangeReason}) — the latter is
84
+ * a deliberate no-op, not a failure. Suppressing at the publisher rather than
85
+ * letting every subscriber discard matters at bulk-follow scale, where a single
86
+ * call moves up to 200 edges.
87
+ *
88
+ * NEVER THROWS AND NEVER RETURNS A REJECTED PROMISE. This is called from inside
89
+ * cache invalidation, which itself runs after a successful database write on the
90
+ * request path: a publish failure must not turn a completed profile update into
91
+ * a 500. A dropped message costs a consumer its TTL and nothing more.
92
+ *
93
+ * @param publisher - A connected Redis client. Must NOT be a client currently in
94
+ * subscriber mode — Redis forbids `PUBLISH` on a subscribed
95
+ * connection, so pass the publisher half of a pub/sub pair.
96
+ * @param userId - The Oxy user whose record changed.
97
+ * @param reason - How the record changed. See {@link OxyUserChangeReason}.
98
+ * @param onError - Optional diagnostic sink for a failed publish.
99
+ */
100
+ export function publishOxyUserInvalidation(
101
+ publisher: OxyInvalidationPublisher,
102
+ userId: string,
103
+ reason: OxyUserChangeReason,
104
+ onError?: (error: unknown) => void,
105
+ ): boolean {
106
+ if (!userId || !isPublishedOxyUserChangeReason(reason)) {
107
+ return false;
108
+ }
109
+
110
+ const event: OxyUserInvalidationEvent = { userId, reason, at: Date.now() };
111
+
112
+ try {
113
+ const result = publisher.publish(OXY_USER_INVALIDATION_CHANNEL, JSON.stringify(event));
114
+ // node-redis returns a promise; ioredis returns a promise too, but a mocked
115
+ // or synchronous client may return anything. Only attach a rejection handler
116
+ // when there is actually something thenable to reject.
117
+ if (isPromiseLike(result)) {
118
+ Promise.resolve(result).catch((error: unknown) => onError?.(error));
119
+ }
120
+ return true;
121
+ } catch (error) {
122
+ onError?.(error);
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
128
+ return (
129
+ typeof value === 'object' &&
130
+ value !== null &&
131
+ typeof (value as { then?: unknown }).then === 'function'
132
+ );
133
+ }
134
+
135
+ /** Options for {@link createOxyUserInvalidationHandler}. */
136
+ export interface OxyUserInvalidationHandlerOptions {
137
+ /**
138
+ * The backend's `OxyServices` instance. When supplied, its GET response cache
139
+ * is swept for the invalidated user — this is the whole reason a backend that
140
+ * has no cache of its own still benefits from subscribing.
141
+ */
142
+ oxy?: OxyIdentityCacheEvictor;
143
+ /**
144
+ * App-specific eviction (e.g. a Redis identity cache the app maintains itself).
145
+ * May be async; a rejection is routed to `onError` and never escapes.
146
+ */
147
+ onInvalidate?: (event: OxyUserInvalidationEvent) => void | Promise<void>;
148
+ /** Diagnostic sink for an unparseable message or a failing `onInvalidate`. */
149
+ onError?: (error: unknown, raw: string) => void;
150
+ }
151
+
152
+ /**
153
+ * Build the message handler for {@link OXY_USER_INVALIDATION_CHANNEL}.
154
+ *
155
+ * The returned function NEVER THROWS and never returns a rejected promise. It
156
+ * runs inside the Redis client's message dispatch, where an exception either
157
+ * takes down the subscriber connection or surfaces as an unhandled rejection —
158
+ * and losing the subscription is strictly worse than losing one message, because
159
+ * it is silent and permanent.
160
+ *
161
+ * A message that fails schema validation is dropped, not retried: the payload is
162
+ * produced by a contract both sides compile against, so a malformed one means a
163
+ * version skew or an unrelated publisher on the channel, neither of which a retry
164
+ * fixes.
165
+ */
166
+ export function createOxyUserInvalidationHandler(
167
+ options: OxyUserInvalidationHandlerOptions = {},
168
+ ): (raw: string) => void {
169
+ const { oxy, onInvalidate, onError } = options;
170
+
171
+ return (raw: string): void => {
172
+ let event: OxyUserInvalidationEvent;
173
+ try {
174
+ const parsed = oxyUserInvalidationEventSchema.safeParse(JSON.parse(raw));
175
+ if (!parsed.success) {
176
+ onError?.(parsed.error, raw);
177
+ return;
178
+ }
179
+ event = parsed.data;
180
+ } catch (error) {
181
+ onError?.(error, raw);
182
+ return;
183
+ }
184
+
185
+ if (oxy) {
186
+ try {
187
+ evictOxyIdentityCache(oxy, event.userId);
188
+ } catch (error) {
189
+ // A cache sweep must never cost us the app-specific eviction below.
190
+ onError?.(error, raw);
191
+ }
192
+ }
193
+
194
+ if (!onInvalidate) return;
195
+ try {
196
+ const result = onInvalidate(event);
197
+ if (isPromiseLike(result)) {
198
+ Promise.resolve(result).catch((error: unknown) => onError?.(error, raw));
199
+ }
200
+ } catch (error) {
201
+ onError?.(error, raw);
202
+ }
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Sweep an `OxyServices` GET response cache of everything that could carry the
208
+ * given user's identity.
209
+ *
210
+ * The by-id entry is exact. The by-username and resolve entries are keyed by
211
+ * HANDLE, which cannot be derived from an id without the very lookup we are
212
+ * invalidating, so those are swept by prefix — the same imprecision the SDK
213
+ * already accepts when it sweeps its own cache after a local profile write, and
214
+ * bounded by the fact that over-eviction costs a refetch and can never serve
215
+ * wrong data.
216
+ */
217
+ export function evictOxyIdentityCache(oxy: OxyIdentityCacheEvictor, userId: string): void {
218
+ oxy.clearCacheEntry(`GET:/users/${userId}`);
219
+ oxy.clearCacheByPrefix('GET:/profiles/username/');
220
+ oxy.clearCacheByPrefix('GET:/profiles/resolve');
221
+ }
@@ -241,13 +241,17 @@ describe('runColdBoot', () => {
241
241
  it('hangs forever when a step never settles and no deadline is set', async () => {
242
242
  const terminalRan = jest.fn();
243
243
  let settled = false;
244
+ let releaseHang: (() => void) | undefined;
244
245
 
245
246
  const outcomePromise = runColdBoot<TestSession>({
246
247
  steps: [
247
248
  {
248
249
  id: 'never-settles',
249
- // Never resolves or rejects — models a hung async call.
250
- run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
250
+ // Never resolves or rejects until the test tears down — models a hung async call.
251
+ run: () =>
252
+ new Promise<ColdBootStepResult<TestSession>>((resolve) => {
253
+ releaseHang = () => resolve({ kind: 'skip' });
254
+ }),
251
255
  },
252
256
  {
253
257
  id: 'terminal',
@@ -268,8 +272,9 @@ describe('runColdBoot', () => {
268
272
  expect(settled).toBe(false);
269
273
  expect(terminalRan).not.toHaveBeenCalled();
270
274
 
271
- // Avoid a dangling unhandled promise in the test runner.
272
- void outcomePromise;
275
+ // Tear down the intentional hang so Jest workers can exit cleanly.
276
+ releaseHang?.();
277
+ await outcomePromise;
273
278
  });
274
279
 
275
280
  /**
@@ -13,6 +13,7 @@ import {
13
13
  DISPLAY_NAME_ALLOWED_SCRIPTS,
14
14
  DISPLAY_NAME_DISALLOWED_SOURCE,
15
15
  DISPLAY_NAME_ORPHANED_MARK_SOURCE,
16
+ DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE,
16
17
  isValidUUID,
17
18
  isValidDate,
18
19
  isValidFileSize,
@@ -21,6 +22,16 @@ import {
21
22
  sanitizeHTML,
22
23
  validateAndSanitizeUserInput
23
24
  } from '../validationUtils';
25
+ // Imported from the generated module rather than re-exported through
26
+ // `validationUtils`: the broad letters class is an internal operand of the
27
+ // orphaned-mark lookbehind, and the denylist is a generation-time operand
28
+ // already subtracted from the allowlist — neither is part of the policy's
29
+ // public runtime surface.
30
+ import {
31
+ DISPLAY_NAME_DENIED_SYMBOL_LETTERS_RANGES,
32
+ DISPLAY_NAME_LETTERS_RANGES,
33
+ DISPLAY_NAME_NAME_SEPARATORS_RANGES,
34
+ } from '../displayNamePolicyRanges.generated';
24
35
 
25
36
  describe('Validation Utils', () => {
26
37
  describe('isRequiredString', () => {
@@ -214,6 +225,7 @@ describe('Validation Utils', () => {
214
225
  expect(PROPERTY_ESCAPE.test(DISPLAY_NAME_ALLOWED_SCRIPTS)).toBe(false);
215
226
  expect(PROPERTY_ESCAPE.test(DISPLAY_NAME_DISALLOWED_SOURCE)).toBe(false);
216
227
  expect(PROPERTY_ESCAPE.test(DISPLAY_NAME_ORPHANED_MARK_SOURCE)).toBe(false);
228
+ expect(PROPERTY_ESCAPE.test(DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE)).toBe(false);
217
229
  });
218
230
 
219
231
  it('only uses code-point escapes (\\x / \\u) in the class bodies', () => {
@@ -221,7 +233,11 @@ describe('Validation Utils', () => {
221
233
  // code-point escapes. Every backslash-escape in the runtime sources must
222
234
  // be one of those forms — never a property escape.
223
235
  const escapeLeads = new Set<string>();
224
- for (const src of [DISPLAY_NAME_DISALLOWED_SOURCE, DISPLAY_NAME_ORPHANED_MARK_SOURCE]) {
236
+ for (const src of [
237
+ DISPLAY_NAME_DISALLOWED_SOURCE,
238
+ DISPLAY_NAME_ORPHANED_MARK_SOURCE,
239
+ DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE,
240
+ ]) {
225
241
  for (const [, lead] of src.matchAll(/\\(.)/g)) {
226
242
  escapeLeads.add(lead);
227
243
  }
@@ -299,6 +315,281 @@ describe('Validation Utils', () => {
299
315
  });
300
316
  });
301
317
 
318
+ // A Unicode script is not only its letters: `scx=X` also carries script X's
319
+ // own digits, punctuation and symbols. The allowlist is therefore generated as
320
+ // `scripts ∩ General_Category L`. Before that intersection existed the class
321
+ // admitted 1831 non-letter code points, so the documented policy ("digits,
322
+ // hyphens, dots, symbols are removed") held for ASCII input only — a federated
323
+ // actor could keep Arabic-Indic digits, a Bengali currency sign, or the U+061C
324
+ // bidi control in their display name. This block is the regression guard.
325
+ describe('display-name allowlist admits ONLY letters', () => {
326
+ const allowed = new RegExp(`[${DISPLAY_NAME_ALLOWED_SCRIPTS}]`, 'u');
327
+ // Built from the same generated ranges the policy ships, so this assertion
328
+ // stays property-escape-free and cannot drift from the runtime regex.
329
+ const letter = new RegExp(`[${DISPLAY_NAME_LETTERS_RANGES}]`, 'u');
330
+
331
+ // Scanning the FULL code-point space, not a sample: one leaked invisible
332
+ // control is a spoofing vector, and a sample cannot prove its absence.
333
+ const leaks: number[] = [];
334
+ let admitted = 0;
335
+ for (let cp = 0; cp <= 0x10ffff; cp++) {
336
+ if (cp >= 0xd800 && cp <= 0xdfff) continue; // lone surrogates
337
+ const ch = String.fromCodePoint(cp);
338
+ if (!allowed.test(ch)) continue;
339
+ admitted++;
340
+ if (!letter.test(ch)) leaks.push(cp);
341
+ }
342
+
343
+ it('admits no non-letter code point anywhere in Unicode', () => {
344
+ const named = leaks
345
+ .slice(0, 16)
346
+ .map((cp) => `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`);
347
+ expect({ count: leaks.length, sample: named }).toEqual({ count: 0, sample: [] });
348
+ });
349
+
350
+ // Vacuity floor: an allowlist that collapsed to (near) nothing would satisfy
351
+ // the assertion above while silently rejecting every real name.
352
+ it('still admits the expected bulk of letters', () => {
353
+ expect(admitted).toBeGreaterThan(100_000);
354
+ });
355
+
356
+ it.each([
357
+ ['٥', 'Arabic-Indic digit (scx=Arabic, GC=Nd)'],
358
+ ['०', 'Devanagari digit (scx=Devanagari, GC=Nd)'],
359
+ ['۞', 'Arabic ornament (scx=Arabic, GC=So)'],
360
+ ['৳', 'Bengali rupee sign (scx=Bengali, GC=Sc)'],
361
+ ['।', 'Devanagari danda (scx=Devanagari, GC=Po)'],
362
+ ['،', 'Arabic comma (scx=Arabic, GC=Po)'],
363
+ ['؜', 'ARABIC LETTER MARK - invisible bidi control (scx=Arabic, GC=Cf)'],
364
+ ['᠎', 'MONGOLIAN VOWEL SEPARATOR - invisible (scx=Mongolian, GC=Cf)'],
365
+ ])('rejects %p, a non-letter from an allowlisted script (%s)', (ch) => {
366
+ expect(isValidDisplayName(ch)).toBe(false);
367
+ });
368
+
369
+ // The intersection narrows the allowlist, so every previously-accepted real
370
+ // name must still pass — these are the ones whose scripts own the code
371
+ // points removed above.
372
+ it.each([
373
+ ['مُحَمَد', 'Arabic letters + harakat survive'],
374
+ ['नमस्ते', 'Devanagari'],
375
+ ['ᠰᠣᠩᠭᠣᠯ', 'Mongolian'],
376
+ ['বাংলা', 'Bengali'],
377
+ ])('still accepts %p (%s)', (name) => {
378
+ expect(isValidDisplayName(name)).toBe(true);
379
+ });
380
+ });
381
+
382
+ // A character policy classifies FORM, never MEANING. `卐` U+5350 and `卍`
383
+ // U+534D are CJK Unified Ideographs (GC=Lo, scx=Han) — to Unicode they are the
384
+ // same kind of thing as `山` in `山田太郎`, so no script- or category-level rule
385
+ // can reject them without also rejecting every real Chinese, Japanese and
386
+ // Korean name. They are therefore subtracted from the allowlist by an explicit
387
+ // code-point denylist at generation time. This block is the regression guard,
388
+ // and asserts the division of labour: the denylist covers ONLY what the
389
+ // intersection cannot.
390
+ describe('display-name denylist — letters that function as symbols', () => {
391
+ // The glyphs below are visually confusable with each other and with ordinary
392
+ // ideographs, so pin them to their code points before relying on them.
393
+ it('the fixtures below really are U+5350 and U+534D', () => {
394
+ expect('卐'.codePointAt(0)).toBe(0x5350);
395
+ expect('卍'.codePointAt(0)).toBe(0x534d);
396
+ });
397
+
398
+ it.each([
399
+ ['卐', 'U+5350 right-facing swastika (GC=Lo, scx=Han)'],
400
+ ['卍', 'U+534D left-facing swastika (GC=Lo, scx=Han)'],
401
+ ['卐 Glowniggers 卐', 'the production display name that motivated the denylist'],
402
+ ['卍 卐', 'both, alone'],
403
+ ['山田卍太郎', 'embedded mid-name, between real Han letters'],
404
+ ])('rejects %p (%s)', (name) => {
405
+ expect(isValidDisplayName(name)).toBe(false);
406
+ });
407
+
408
+ // The whole point of a per-code-point denylist instead of a script rule.
409
+ it.each([
410
+ ['山田太郎', 'Han (Japanese)'],
411
+ ['김철수', 'Hangul (Korean)'],
412
+ ['王小明', 'Han (Chinese)'],
413
+ ['田中\u{20000}', 'Han incl. astral CJK Extension B'],
414
+ // The four immediate neighbours of the denied pair: the subtraction must
415
+ // punch out exactly two code points, not a range around them.
416
+ ['卌', 'U+534C, immediately below U+534D'],
417
+ ['华', 'U+534E, immediately above U+534D (as in 中华)'],
418
+ ['协', 'U+534F, immediately below U+5350'],
419
+ ['卑', 'U+5351, immediately above U+5350'],
420
+ ])('still accepts %p (%s)', (name) => {
421
+ expect(isValidDisplayName(name)).toBe(true);
422
+ });
423
+
424
+ // Enumerate what is ACTUALLY denied from the generated class rather than
425
+ // trusting the literals above, so a future entry that is added to the list
426
+ // but not enforced by the emitted allowlist fails here.
427
+ const denied = new RegExp(`[${DISPLAY_NAME_DENIED_SYMBOL_LETTERS_RANGES}]`, 'u');
428
+ const deniedCodePoints: number[] = [];
429
+ for (let cp = 0; cp <= 0x10ffff; cp++) {
430
+ if (cp >= 0xd800 && cp <= 0xdfff) continue; // lone surrogates
431
+ if (denied.test(String.fromCodePoint(cp))) deniedCodePoints.push(cp);
432
+ }
433
+
434
+ it('denies exactly the two swastika ideographs', () => {
435
+ expect(deniedCodePoints).toEqual([0x534d, 0x5350]);
436
+ });
437
+
438
+ it('rejects every code point on the denylist', () => {
439
+ const accepted = deniedCodePoints.filter((cp) =>
440
+ isValidDisplayName(String.fromCodePoint(cp))
441
+ );
442
+ expect(
443
+ accepted.map((cp) => `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`)
444
+ ).toEqual([]);
445
+ // Vacuity floor: an empty denylist would satisfy the assertion above while
446
+ // enforcing nothing.
447
+ expect(deniedCodePoints.length).toBeGreaterThanOrEqual(2);
448
+ });
449
+
450
+ // Hermes safety for the new class (see the property-escape block above).
451
+ it('the denylist ranges contain NO Unicode property escape', () => {
452
+ expect(/\\[pP]\{/.test(DISPLAY_NAME_DENIED_SYMBOL_LETTERS_RANGES)).toBe(false);
453
+ expect(() => new RegExp(`[${DISPLAY_NAME_DENIED_SYMBOL_LETTERS_RANGES}]`, 'u')).not.toThrow();
454
+ });
455
+
456
+ // Division of labour: the Tibetan svasti signs look like the entries above
457
+ // but are GC=So, so the `scripts ∩ General_Category L` intersection already
458
+ // excludes them. They are rejected — and deliberately NOT on the denylist,
459
+ // where they would be dead weight that reads as load-bearing. The generator
460
+ // fails the build if such a redundant entry is added.
461
+ it.each([
462
+ ['࿕', 'U+0FD5 RIGHT-FACING SVASTI SIGN'],
463
+ ['࿖', 'U+0FD6 LEFT-FACING SVASTI SIGN'],
464
+ ['࿗', 'U+0FD7 RIGHT-FACING SVASTI SIGN WITH DOTS'],
465
+ ['࿘', 'U+0FD8 LEFT-FACING SVASTI SIGN WITH DOTS'],
466
+ ])('rejects %p (%s) via the intersection, not the denylist', (ch) => {
467
+ expect(isValidDisplayName(ch)).toBe(false);
468
+ expect(denied.test(ch)).toBe(false);
469
+ });
470
+ });
471
+
472
+ // Four punctuation code points JOIN two letters inside one real name. All are
473
+ // General_Category P, so `scripts ∩ L` strips them by default and stripping
474
+ // SPLITS the name (`Codeur·euses` → `Codeur euses`). They are re-admitted, but
475
+ // ONLY between two letters — the same characters are also used as ornament
476
+ // (a trailing `Roberto ·`), which must keep being trimmed. The conditional is
477
+ // the whole rule, so it is tested from both sides.
478
+ describe('display-name name separators — allowed only between letters', () => {
479
+ // Visually confusable with each other and with ASCII punctuation.
480
+ it('the fixtures below really are U+00B7 U+05BE U+0F0B U+30FB', () => {
481
+ expect('·'.codePointAt(0)).toBe(0x00b7);
482
+ expect('־'.codePointAt(0)).toBe(0x05be);
483
+ expect('་'.codePointAt(0)).toBe(0x0f0b);
484
+ expect('・'.codePointAt(0)).toBe(0x30fb);
485
+ });
486
+
487
+ it.each([
488
+ ['Codeur·euses en Liberté', 'U+00B7 French inclusive writing (production value)'],
489
+ ['Codeur·euses', 'U+00B7 bare'],
490
+ ['Pouet·te', 'U+00B7 short form'],
491
+ ['お坐・エガード', 'U+30FB Japanese name separator (production value)'],
492
+ ['אייר אברמסקי־קרוננברג', 'U+05BE Hebrew maqaf compound surname (production value)'],
493
+ ['འོད་ཟེར', 'U+0F0B Tibetan intersyllabic tsheg (production value)'],
494
+ ])('accepts letter-flanked %p (%s)', (name) => {
495
+ expect(isValidDisplayName(name)).toBe(true);
496
+ });
497
+
498
+ // A base letter can carry a combining mark, and that mark sits between the
499
+ // letter and the separator. If the flanking test only looked for a letter,
500
+ // these would be misread as unflanked and the separator stripped. Both
501
+ // fixtures use marks that do NOT recompose under NFC, so the mark really
502
+ // reaches the flanking test instead of being folded into a precomposed
503
+ // letter — the Tibetan one is the ordinary shape of Tibetan text.
504
+ it.each([
505
+ ['ཀི་ཁ', 'Tibetan letter + vowel sign U+0F72 + tsheg + letter'],
506
+ ['مُ·م', 'Arabic letter + damma U+064F + middle dot + letter'],
507
+ ])('accepts %p where a combining mark precedes the separator (%s)', (name) => {
508
+ expect(isValidDisplayName(name)).toBe(true);
509
+ });
510
+
511
+ it.each([
512
+ ['Roberto ·', 'trailing ornament (production value)'],
513
+ ['Michał rysiek Woźniak ·', 'trailing ornament (production value)'],
514
+ ['·Roberto', 'leading'],
515
+ ['a··b', 'doubled — neither is letter-flanked on both sides'],
516
+ ['·', 'alone'],
517
+ ['お坐・', 'trailing katakana middle dot'],
518
+ ['・エガード', 'leading katakana middle dot'],
519
+ ['a ·b', 'space on the left'],
520
+ ['a· b', 'space on the right'],
521
+ ])('rejects unflanked %p (%s)', (name) => {
522
+ expect(isValidDisplayName(name)).toBe(false);
523
+ });
524
+
525
+ // Enumerate what is ACTUALLY admitted from the generated class rather than
526
+ // trusting the literals above, and exercise both sides of the rule for each.
527
+ const separator = new RegExp(`[${DISPLAY_NAME_NAME_SEPARATORS_RANGES}]`, 'u');
528
+ const separatorCodePoints: number[] = [];
529
+ for (let cp = 0; cp <= 0x10ffff; cp++) {
530
+ if (cp >= 0xd800 && cp <= 0xdfff) continue; // lone surrogates
531
+ if (separator.test(String.fromCodePoint(cp))) separatorCodePoints.push(cp);
532
+ }
533
+
534
+ it('admits exactly the four name separators', () => {
535
+ expect(separatorCodePoints).toEqual([0x00b7, 0x05be, 0x0f0b, 0x30fb]);
536
+ });
537
+
538
+ it('every separator is valid between letters and invalid unflanked', () => {
539
+ const wrongWhenFlanked: string[] = [];
540
+ const wrongWhenUnflanked: string[] = [];
541
+ for (const cp of separatorCodePoints) {
542
+ const ch = String.fromCodePoint(cp);
543
+ const label = `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`;
544
+ if (!isValidDisplayName(`a${ch}b`)) wrongWhenFlanked.push(label);
545
+ if (isValidDisplayName(`a ${ch}`)) wrongWhenUnflanked.push(label);
546
+ }
547
+ expect({ wrongWhenFlanked, wrongWhenUnflanked }).toEqual({
548
+ wrongWhenFlanked: [],
549
+ wrongWhenUnflanked: [],
550
+ });
551
+ // Vacuity floor: an empty separator class would satisfy both loops above.
552
+ expect(separatorCodePoints.length).toBe(4);
553
+ });
554
+
555
+ // The ASCII hyphen decision is untouched: U+05BE is General_Category Pd like
556
+ // U+002D, but admitting one says nothing about the other.
557
+ it('does not admit the ASCII hyphen', () => {
558
+ expect(separator.test('-')).toBe(false);
559
+ expect(isValidDisplayName('Jean-Luc')).toBe(false);
560
+ expect(isValidDisplayName('a-b')).toBe(false);
561
+ });
562
+
563
+ // Hermes safety for the new pattern (see the property-escape block above).
564
+ it('the unflanked-separator source contains NO Unicode property escape', () => {
565
+ expect(/\\[pP]\{/.test(DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE)).toBe(false);
566
+ expect(/\\[pP]\{/.test(DISPLAY_NAME_NAME_SEPARATORS_RANGES)).toBe(false);
567
+ expect(() => new RegExp(DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE, 'u')).not.toThrow();
568
+ // Global variant is what @oxyhq/api compiles for the strip path.
569
+ expect(() => new RegExp(DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE, 'gu')).not.toThrow();
570
+ });
571
+
572
+ // Nothing the policy already rejected may become valid.
573
+ it.each([
574
+ ['卐', 'denied symbol letter'],
575
+ ['卍', 'denied symbol letter'],
576
+ ['Agent007', 'digit'],
577
+ ['J.R.', 'dot'],
578
+ ['ᯅ', 'non-allowlisted script'],
579
+ ['nixCraft \u{1f427}', 'emoji'],
580
+ ])('still rejects %p (%s)', (name) => {
581
+ expect(isValidDisplayName(name)).toBe(false);
582
+ });
583
+
584
+ it.each([
585
+ ['山田太郎', 'Han'],
586
+ ['김철수', 'Hangul'],
587
+ ["Renée O'Brien", 'Latin with accent + apostrophe'],
588
+ ])('still accepts %p (%s)', (name) => {
589
+ expect(isValidDisplayName(name)).toBe(true);
590
+ });
591
+ });
592
+
302
593
  describe('isValidUUID', () => {
303
594
  it('should return true for valid UUIDs', () => {
304
595
  expect(isValidUUID('123e4567-e89b-12d3-a456-426614174000')).toBe(true);