@oxyhq/core 12.2.1 → 12.4.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 (67) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/aead.js +79 -0
  3. package/dist/cjs/crypto/ecdh.js +53 -0
  4. package/dist/cjs/crypto/kdf.js +39 -0
  5. package/dist/cjs/crypto/keyManager.js +7 -0
  6. package/dist/cjs/crypto/recoveryPhrase.js +89 -1
  7. package/dist/cjs/i18n/locales/en-US.json +5 -0
  8. package/dist/cjs/i18n/locales/es-ES.json +5 -0
  9. package/dist/cjs/i18n/locales/locales/en-US.json +5 -0
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +5 -0
  11. package/dist/cjs/index.js +17 -4
  12. package/dist/cjs/mixins/OxyServices.identity.js +166 -0
  13. package/dist/cjs/mixins/OxyServices.identityBackup.js +161 -0
  14. package/dist/cjs/mixins/OxyServices.user.js +43 -0
  15. package/dist/cjs/mixins/index.js +4 -0
  16. package/dist/cjs/utils/validationUtils.js +58 -21
  17. package/dist/esm/.tsbuildinfo +1 -1
  18. package/dist/esm/crypto/aead.js +74 -0
  19. package/dist/esm/crypto/ecdh.js +51 -0
  20. package/dist/esm/crypto/kdf.js +36 -0
  21. package/dist/esm/crypto/keyManager.js +7 -0
  22. package/dist/esm/crypto/recoveryPhrase.js +88 -0
  23. package/dist/esm/i18n/locales/en-US.json +5 -0
  24. package/dist/esm/i18n/locales/es-ES.json +5 -0
  25. package/dist/esm/i18n/locales/locales/en-US.json +5 -0
  26. package/dist/esm/i18n/locales/locales/es-ES.json +5 -0
  27. package/dist/esm/index.js +5 -1
  28. package/dist/esm/mixins/OxyServices.identity.js +166 -0
  29. package/dist/esm/mixins/OxyServices.identityBackup.js +158 -0
  30. package/dist/esm/mixins/OxyServices.user.js +43 -0
  31. package/dist/esm/mixins/index.js +4 -0
  32. package/dist/esm/utils/validationUtils.js +60 -23
  33. package/dist/types/.tsbuildinfo +1 -1
  34. package/dist/types/crypto/aead.d.ts +56 -0
  35. package/dist/types/crypto/ecdh.d.ts +29 -0
  36. package/dist/types/crypto/kdf.d.ts +25 -0
  37. package/dist/types/crypto/keyManager.d.ts +5 -0
  38. package/dist/types/crypto/recoveryPhrase.d.ts +85 -0
  39. package/dist/types/index.d.ts +8 -4
  40. package/dist/types/mixins/OxyServices.identity.d.ts +95 -0
  41. package/dist/types/mixins/OxyServices.identityBackup.d.ts +129 -0
  42. package/dist/types/mixins/OxyServices.user.d.ts +38 -8
  43. package/dist/types/mixins/index.d.ts +2 -1
  44. package/dist/types/utils/validationUtils.d.ts +65 -0
  45. package/package.json +4 -2
  46. package/src/crypto/__tests__/backupMaterial.test.ts +86 -0
  47. package/src/crypto/__tests__/cryptoPrimitives.test.ts +225 -0
  48. package/src/crypto/__tests__/keyManager.atomicity.test.ts +33 -0
  49. package/src/crypto/__tests__/recoveryPhrase.test.ts +61 -0
  50. package/src/crypto/aead.ts +97 -0
  51. package/src/crypto/ecdh.ts +60 -0
  52. package/src/crypto/kdf.ts +43 -0
  53. package/src/crypto/keyManager.ts +8 -0
  54. package/src/crypto/recoveryPhrase.ts +133 -0
  55. package/src/i18n/locales/en-US.json +5 -0
  56. package/src/i18n/locales/es-ES.json +5 -0
  57. package/src/index.ts +19 -1
  58. package/src/mixins/OxyServices.identity.ts +250 -0
  59. package/src/mixins/OxyServices.identityBackup.ts +237 -0
  60. package/src/mixins/OxyServices.user.ts +82 -4
  61. package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +277 -0
  62. package/src/mixins/__tests__/getFollowStatuses.test.ts +95 -0
  63. package/src/mixins/__tests__/identityBackup.test.ts +258 -0
  64. package/src/mixins/index.ts +5 -0
  65. package/src/types/elliptic.d.ts +10 -2
  66. package/src/utils/__tests__/validationUtils.test.ts +27 -0
  67. package/src/utils/validationUtils.ts +61 -20
@@ -0,0 +1,158 @@
1
+ import { KeyManager, IdentityAlreadyExistsError } from '../crypto/keyManager.js';
2
+ import { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from '../crypto/recoveryPhrase.js';
3
+ import { encryptAead, decryptAead } from '../crypto/aead.js';
4
+ /** Envelope/KDF version — bump only on a breaking scheme change. */
5
+ const BACKUP_ENVELOPE_VERSION = 1;
6
+ /** The AEAD the backup is sealed with. Pinned so a mismatched decryptor fails loudly. */
7
+ const BACKUP_ALGORITHM = 'xchacha20poly1305';
8
+ /**
9
+ * Length (hex chars) of the public-key HINT stored/echoed with a backup — enough
10
+ * to let the owner recognise WHICH identity a backup belongs to, but only a
11
+ * prefix (the full key is public anyway; a prefix keeps the record minimal).
12
+ */
13
+ const PUBLIC_KEY_HINT_LENGTH = 16;
14
+ /** Encode bytes as lowercase hex (cross-platform, no Buffer dependency). */
15
+ function toHex(bytes) {
16
+ let out = '';
17
+ for (let i = 0; i < bytes.length; i += 1) {
18
+ out += bytes[i].toString(16).padStart(2, '0');
19
+ }
20
+ return out;
21
+ }
22
+ /** Decode a lowercase/uppercase hex string to bytes. Throws on malformed input. */
23
+ function fromHex(hex) {
24
+ if (hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) {
25
+ throw new Error('Malformed hex in encrypted backup envelope.');
26
+ }
27
+ const out = new Uint8Array(hex.length / 2);
28
+ for (let i = 0; i < out.length; i += 1) {
29
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * The AEAD associated data binds the ciphertext to its `{version, publicKeyHint}`
35
+ * context: the exact bytes must be reproduced at decrypt time, so a mismatched
36
+ * version or hint (e.g. an envelope re-stamped by a tamperer) fails the
37
+ * Poly1305 check. Deterministic: both sides build the SAME object literal, so
38
+ * `JSON.stringify` yields identical bytes.
39
+ */
40
+ function buildBackupAad(version, publicKeyHint) {
41
+ return new TextEncoder().encode(JSON.stringify({ version, publicKeyHint }));
42
+ }
43
+ export function OxyServicesIdentityBackupMixin(Base) {
44
+ return class extends Base {
45
+ constructor(...args) {
46
+ super(...args);
47
+ }
48
+ /**
49
+ * Derive the backup key material from the recovery phrase, encrypt the
50
+ * identity's `{privateKey, publicKey, createdAt}` with it, and upload the
51
+ * ciphertext + raw `lookupId` (`POST /identity/backup`, bearer). The server
52
+ * stores only `sha256(lookupId)` + the ciphertext. Idempotent per user: a
53
+ * re-upload REPLACES the prior backup (upsert by user id).
54
+ *
55
+ * The identity is derived from the PHRASE (not read from SecureStore), so
56
+ * this works cross-platform and does not require an on-device key.
57
+ *
58
+ * @param phrase - The identity's BIP-39 recovery phrase.
59
+ * @returns The post-write backup status (`{ exists: true, publicKeyHint, createdAt }`).
60
+ */
61
+ async createEncryptedBackup(phrase) {
62
+ try {
63
+ const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
64
+ const privateKey = await RecoveryPhraseService.derivePrivateKeyFromPhrase(phrase);
65
+ const publicKey = KeyManager.derivePublicKey(privateKey);
66
+ const createdAt = new Date().toISOString();
67
+ const publicKeyHint = publicKey.slice(0, PUBLIC_KEY_HINT_LENGTH);
68
+ const payload = { privateKey, publicKey, createdAt };
69
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
70
+ const aad = buildBackupAad(BACKUP_ENVELOPE_VERSION, publicKeyHint);
71
+ const { nonce, ciphertext } = encryptAead(backupKey, plaintext, aad);
72
+ const body = {
73
+ version: BACKUP_ENVELOPE_VERSION,
74
+ algorithm: BACKUP_ALGORITHM,
75
+ kdfInfo: BACKUP_KDF_ENCRYPTION_INFO,
76
+ nonce: toHex(nonce),
77
+ ciphertext: toHex(ciphertext),
78
+ publicKeyHint,
79
+ createdAt,
80
+ lookupId,
81
+ };
82
+ return await this.makeRequest('POST', '/identity/backup', body, { cache: false });
83
+ }
84
+ catch (error) {
85
+ throw this.handleError(error);
86
+ }
87
+ }
88
+ /**
89
+ * Whether the authenticated user has a stored encrypted backup, plus the
90
+ * non-sensitive hint + timestamp when one exists (`GET /identity/backup/status`,
91
+ * bearer). Returns no ciphertext and no locator.
92
+ */
93
+ async getBackupStatus() {
94
+ try {
95
+ return await this.makeRequest('GET', '/identity/backup/status', undefined, { cache: false });
96
+ }
97
+ catch (error) {
98
+ throw this.handleError(error);
99
+ }
100
+ }
101
+ /**
102
+ * Delete the authenticated user's stored backup (`DELETE /identity/backup`,
103
+ * bearer). Idempotent — deleting a non-existent backup still succeeds.
104
+ */
105
+ async deleteBackup() {
106
+ try {
107
+ return await this.makeRequest('DELETE', '/identity/backup', undefined, { cache: false });
108
+ }
109
+ catch (error) {
110
+ throw this.handleError(error);
111
+ }
112
+ }
113
+ /**
114
+ * Restore an identity from its encrypted off-device backup using ONLY the
115
+ * recovery phrase: re-derive `{backupKey, lookupId}`, fetch the envelope by
116
+ * `lookupId` (`GET /identity/backup/:lookupId`, PUBLIC — the 256-bit locator
117
+ * is the protection), decrypt + authenticate locally, then persist the key.
118
+ *
119
+ * NATIVE-ONLY persistence: `KeyManager.importKeyPair` throws on web. It also
120
+ * refuses to clobber a DIFFERENT existing on-device identity unless
121
+ * `overwrite: true` — the {@link import('../crypto/keyManager').IdentityAlreadyExistsError}
122
+ * propagates to the caller (never swallowed) so the UI can confirm before
123
+ * overwriting.
124
+ *
125
+ * @param phrase - The identity's BIP-39 recovery phrase.
126
+ * @param options.overwrite - Replace a different existing on-device identity.
127
+ * @returns The restored identity's public key.
128
+ * @throws if the phrase is invalid, no backup exists (404), the ciphertext
129
+ * fails authentication (tamper), or an existing identity blocks the import.
130
+ */
131
+ async restoreFromEncryptedBackup(phrase, options) {
132
+ try {
133
+ const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
134
+ const envelope = await this.makeRequest('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
135
+ if (envelope.algorithm !== BACKUP_ALGORITHM) {
136
+ throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
137
+ }
138
+ const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
139
+ const plaintext = decryptAead(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
140
+ const payload = JSON.parse(new TextDecoder().decode(plaintext));
141
+ // Persist the recovered key. Native-only; refuses to clobber a different
142
+ // identity unless overwrite — the IdentityAlreadyExistsError propagates.
143
+ return await KeyManager.importKeyPair(payload.privateKey, {
144
+ overwrite: options?.overwrite === true,
145
+ });
146
+ }
147
+ catch (error) {
148
+ // Preserve the typed "an identity already exists" signal so the caller
149
+ // can prompt for overwrite. `handleError` would flatten it to a generic
150
+ // Error and lose that discrimination.
151
+ if (error instanceof IdentityAlreadyExistsError) {
152
+ throw error;
153
+ }
154
+ throw this.handleError(error);
155
+ }
156
+ }
157
+ };
158
+ }
@@ -10,6 +10,12 @@ import { extractErrorStatus } from '../utils/errorUtils.js';
10
10
  * server-side batch cap; larger inputs are split into multiple chunked calls.
11
11
  */
12
12
  const USERS_BY_IDS_CHUNK_SIZE = 100;
13
+ /**
14
+ * Maximum number of ids sent per `POST /users/follow-status/bulk` request.
15
+ * Matches the server-side `MAX_BULK_FOLLOW` cap; larger inputs are split into
16
+ * multiple chunked calls whose result maps are merged.
17
+ */
18
+ const FOLLOW_STATUS_CHUNK_SIZE = 200;
13
19
  export function OxyServicesUserMixin(Base) {
14
20
  return class extends Base {
15
21
  constructor(...args) {
@@ -601,6 +607,43 @@ export function OxyServicesUserMixin(Base) {
601
607
  throw this.handleError(error);
602
608
  }
603
609
  }
610
+ /**
611
+ * Resolve the viewer's follow status for MANY users in one round-trip per
612
+ * chunk. Built for list UIs (a page of `FollowButton`s) that would otherwise
613
+ * fire one `getFollowStatus` per button (the classic N+1).
614
+ *
615
+ * Ids are deduplicated and validated (empty/blank ids dropped), split into
616
+ * chunks of {@link FOLLOW_STATUS_CHUNK_SIZE} (the server's bulk cap), and
617
+ * POSTed to `/users/follow-status/bulk` as `{ userIds }`. The per-chunk
618
+ * `{ statuses }` maps are merged into one `Record<string, boolean>` covering
619
+ * every requested id — ids the viewer does not follow come back `false`.
620
+ *
621
+ * Uncached (`{ cache: false }`): the UI store owns follow-status freshness
622
+ * and writes optimistically on every mutation, so an SDK cache here would
623
+ * serve a stale status right after a follow/unfollow. An empty/whitespace-
624
+ * only input resolves immediately with `{}` and performs no network call.
625
+ */
626
+ async getFollowStatuses(userIds) {
627
+ const uniqueIds = Array.from(new Set(userIds.filter((id) => typeof id === 'string' && id.trim().length > 0)));
628
+ if (uniqueIds.length === 0) {
629
+ return {};
630
+ }
631
+ const chunks = [];
632
+ for (let i = 0; i < uniqueIds.length; i += FOLLOW_STATUS_CHUNK_SIZE) {
633
+ chunks.push(uniqueIds.slice(i, i + FOLLOW_STATUS_CHUNK_SIZE));
634
+ }
635
+ try {
636
+ const responses = await Promise.all(chunks.map((chunk) => this.makeRequest('POST', '/users/follow-status/bulk', { userIds: chunk }, { cache: false })));
637
+ const merged = {};
638
+ for (const response of responses) {
639
+ Object.assign(merged, response?.statuses ?? {});
640
+ }
641
+ return merged;
642
+ }
643
+ catch (error) {
644
+ throw this.handleError(error);
645
+ }
646
+ }
604
647
  /**
605
648
  * Get user followers
606
649
  */
@@ -8,6 +8,7 @@ import { OxyServicesBase } from '../OxyServices.base.js';
8
8
  import { OxyServicesAuthMixin } from './OxyServices.auth.js';
9
9
  import { OxyServicesUserMixin } from './OxyServices.user.js';
10
10
  import { OxyServicesIdentityMixin } from './OxyServices.identity.js';
11
+ import { OxyServicesIdentityBackupMixin } from './OxyServices.identityBackup.js';
11
12
  import { OxyServicesPrivacyMixin } from './OxyServices.privacy.js';
12
13
  import { OxyServicesLanguageMixin } from './OxyServices.language.js';
13
14
  import { OxyServicesPaymentMixin } from './OxyServices.payment.js';
@@ -46,6 +47,9 @@ const MIXIN_PIPELINE = [
46
47
  OxyServicesUserMixin,
47
48
  // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping)
48
49
  OxyServicesIdentityMixin,
50
+ // Encrypted off-device identity backup (b3 Feature 1): store/restore an
51
+ // encrypted copy of the self-custody key, keyed off the recovery phrase.
52
+ OxyServicesIdentityBackupMixin,
49
53
  OxyServicesPrivacyMixin,
50
54
  // Feature mixins
51
55
  OxyServicesLanguageMixin,
@@ -36,7 +36,11 @@ export function isValidPassword(password) {
36
36
  * Display-name character policy.
37
37
  *
38
38
  * A clean display name is composed ONLY of:
39
- * - letters of any script (`\p{L}`),
39
+ * - letters from a curated ALLOWLIST of scripts that real names use
40
+ * ({@link DISPLAY_NAME_ALLOWED_SCRIPTS}) — NOT `\p{L}` (letters of ANY
41
+ * script), which admits decorative / historic / limited-use scripts whose
42
+ * characters are `\p{L}` yet never appear in a real name (e.g. `ᯅ` U+1BC5
43
+ * Batak, Runic, Deseret, dingbat letters),
40
44
  * - combining marks / accents (`\p{M}`, e.g. the acute accent in a decomposed
41
45
  * "é"),
42
46
  * - Unicode space separators (`\p{Zs}`: the ASCII space, NBSP, ideographic
@@ -45,32 +49,65 @@ export function isValidPassword(password) {
45
49
  * - the straight apostrophe (`'`, e.g. "O'Brien").
46
50
  *
47
51
  * Everything else is rejected: emoji (🐧), symbols (⁂ ⏚), `:emoji:` shortcodes,
48
- * digits, hyphens, dots, control whitespace (tab/newline/CR), and any other
49
- * punctuation. The allowed set `\p{L}\p{M}\p{Zs}'` explicitly EXCLUDES `<`, `>`,
50
- * `&`, and `"`, so a value that passes this predicate can never contain an
51
- * HTML/XSS vector.
52
+ * digits, hyphens, dots, control whitespace (tab/newline/CR), letters from
53
+ * non-allowlisted scripts, and any other punctuation. The allowed set never
54
+ * includes `<`, `>`, `&`, or `"`, so a value that passes this predicate can
55
+ * never contain an HTML/XSS vector.
52
56
  *
53
- * This is the SINGLE definition of the policy, shared between the API 400-gate
54
- * (`@oxyhq/api` `displayNameSanitize.ts`) and client-side inline validation
55
- * (the RN profile editor) so the two can never drift. It is platform-agnostic
57
+ * The allowlist is expressed with Unicode Script_Extensions (`\p{scx=…}`)
58
+ * escapes so a letter shared by several scripts (e.g. a Han ideograph used in
59
+ * both Chinese and Japanese) still matches. It is the set of scripts Unicode
60
+ * UTS #39 marks "Recommended" for general interchange / identifiers, plus
61
+ * Cherokee and Mongolian (both in real modern name use). "Common" script is
62
+ * deliberately EXCLUDED — that is where ASCII digits and general punctuation
63
+ * live, and this policy excludes those; the space separators, combining marks,
64
+ * and apostrophe a name needs are added back explicitly. Limited-use / excluded
65
+ * / historic scripts (Batak, Runic, Deseret, Adlam, …) are simply absent.
66
+ *
67
+ * This is the SINGLE definition of the policy: the character-class sources below
68
+ * are the ONE source of truth, shared between the API strip/gate
69
+ * (`@oxyhq/api` `displayNameSanitize.ts` builds its global-flag patterns from
70
+ * them) and client-side inline validation (the RN profile editor via
71
+ * {@link isValidDisplayName}) so the two can never drift. It is platform-agnostic
56
72
  * (no react/react-native/expo).
57
73
  */
58
74
  /**
59
- * Single test for the presence of a disallowed character (non-global). The
60
- * whitespace class is `\p{Zs}` (space separators only), NOT `\s` the latter
61
- * would admit tab/newline/carriage return, which break layout and enable
62
- * multi-line spoofing.
63
- */
64
- const DISALLOWED_PROBE = /[^\p{L}\p{M}\p{Zs}']/u;
65
- /**
66
- * Single test for the presence of an orphaned combining mark (non-global) — a
67
- * `\p{M}` not attached to a base letter (string start, whitespace, the
68
- * apostrophe, or a position vacated by a stripped character). A mark preceded by
69
- * `\p{L}` (a base letter, e.g. the decomposed accent in "Renée") or by another
70
- * `\p{M}` (a multi-mark cluster) is NOT matched because the negative lookbehind
71
- * fails at its position.
72
- */
73
- const ORPHANED_MARK_PROBE = /(?<![\p{L}\p{M}])\p{M}/u;
75
+ * The curated allowlist of Unicode scripts permitted in a display name, as a
76
+ * character-class body of Script_Extensions (`scx`) property escapes. Ordered by
77
+ * rough script family for readability; order has no semantic effect.
78
+ */
79
+ export const DISPLAY_NAME_ALLOWED_SCRIPTS = '\\p{scx=Latin}\\p{scx=Greek}\\p{scx=Cyrillic}\\p{scx=Armenian}' +
80
+ '\\p{scx=Hebrew}\\p{scx=Arabic}\\p{scx=Thaana}\\p{scx=Devanagari}' +
81
+ '\\p{scx=Bengali}\\p{scx=Gurmukhi}\\p{scx=Gujarati}\\p{scx=Oriya}' +
82
+ '\\p{scx=Tamil}\\p{scx=Telugu}\\p{scx=Kannada}\\p{scx=Malayalam}' +
83
+ '\\p{scx=Sinhala}\\p{scx=Thai}\\p{scx=Lao}\\p{scx=Tibetan}' +
84
+ '\\p{scx=Myanmar}\\p{scx=Georgian}\\p{scx=Hangul}\\p{scx=Ethiopic}' +
85
+ '\\p{scx=Cherokee}\\p{scx=Khmer}\\p{scx=Mongolian}\\p{scx=Hiragana}' +
86
+ '\\p{scx=Katakana}\\p{scx=Bopomofo}\\p{scx=Han}';
87
+ /**
88
+ * Source of the disallowed-character pattern: the negation of the full allowed
89
+ * set (allowlisted scripts + combining marks `\p{M}` + space separators `\p{Zs}`
90
+ * + the straight apostrophe). Consumers compile this with the `u` flag (and `g`
91
+ * for a global strip). The whitespace class is `\p{Zs}` (space separators only),
92
+ * NOT `\s` — the latter would admit tab/newline/carriage return, which break
93
+ * layout and enable multi-line spoofing.
94
+ */
95
+ export const DISPLAY_NAME_DISALLOWED_SOURCE = `[^${DISPLAY_NAME_ALLOWED_SCRIPTS}\\p{M}\\p{Zs}']`;
96
+ /**
97
+ * Source of the orphaned combining-mark pattern: a run of `\p{M}` NOT attached
98
+ * to a base letter (preceded by string start, whitespace, the apostrophe, or a
99
+ * position vacated by a stripped character). A mark preceded by `\p{L}` (a base
100
+ * letter, e.g. the decomposed accent in "Renée") or by another `\p{M}` (a
101
+ * multi-mark cluster) is NOT matched because the negative lookbehind fails at its
102
+ * position. Used both as a non-global probe (`.test`) and, with the `g` flag, to
103
+ * strip whole orphaned runs. The lookbehind intentionally still uses the broad
104
+ * `\p{L}` so that a mark riding on an allowlisted base letter is preserved.
105
+ */
106
+ export const DISPLAY_NAME_ORPHANED_MARK_SOURCE = '(?<![\\p{L}\\p{M}])\\p{M}+';
107
+ /** Non-global probe for the presence of a disallowed character. */
108
+ const DISALLOWED_PROBE = new RegExp(DISPLAY_NAME_DISALLOWED_SOURCE, 'u');
109
+ /** Non-global probe for the presence of an orphaned combining mark. */
110
+ const ORPHANED_MARK_PROBE = new RegExp(DISPLAY_NAME_ORPHANED_MARK_SOURCE, 'u');
74
111
  /**
75
112
  * Whether `raw` already satisfies the display-name policy, i.e. it contains no
76
113
  * disallowed characters AND no orphaned combining marks. Used to REJECT native