@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/aead.js +79 -0
- package/dist/cjs/crypto/ecdh.js +53 -0
- package/dist/cjs/crypto/kdf.js +39 -0
- package/dist/cjs/crypto/keyManager.js +7 -0
- package/dist/cjs/crypto/recoveryPhrase.js +89 -1
- package/dist/cjs/i18n/locales/en-US.json +5 -0
- package/dist/cjs/i18n/locales/es-ES.json +5 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +5 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +5 -0
- package/dist/cjs/index.js +17 -4
- package/dist/cjs/mixins/OxyServices.identity.js +166 -0
- package/dist/cjs/mixins/OxyServices.identityBackup.js +161 -0
- package/dist/cjs/mixins/OxyServices.user.js +43 -0
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/utils/validationUtils.js +58 -21
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/aead.js +74 -0
- package/dist/esm/crypto/ecdh.js +51 -0
- package/dist/esm/crypto/kdf.js +36 -0
- package/dist/esm/crypto/keyManager.js +7 -0
- package/dist/esm/crypto/recoveryPhrase.js +88 -0
- package/dist/esm/i18n/locales/en-US.json +5 -0
- package/dist/esm/i18n/locales/es-ES.json +5 -0
- package/dist/esm/i18n/locales/locales/en-US.json +5 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +5 -0
- package/dist/esm/index.js +5 -1
- package/dist/esm/mixins/OxyServices.identity.js +166 -0
- package/dist/esm/mixins/OxyServices.identityBackup.js +158 -0
- package/dist/esm/mixins/OxyServices.user.js +43 -0
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/utils/validationUtils.js +60 -23
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/aead.d.ts +56 -0
- package/dist/types/crypto/ecdh.d.ts +29 -0
- package/dist/types/crypto/kdf.d.ts +25 -0
- package/dist/types/crypto/keyManager.d.ts +5 -0
- package/dist/types/crypto/recoveryPhrase.d.ts +85 -0
- package/dist/types/index.d.ts +8 -4
- package/dist/types/mixins/OxyServices.identity.d.ts +95 -0
- package/dist/types/mixins/OxyServices.identityBackup.d.ts +129 -0
- package/dist/types/mixins/OxyServices.user.d.ts +38 -8
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/utils/validationUtils.d.ts +65 -0
- package/package.json +4 -2
- package/src/crypto/__tests__/backupMaterial.test.ts +86 -0
- package/src/crypto/__tests__/cryptoPrimitives.test.ts +225 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +33 -0
- package/src/crypto/__tests__/recoveryPhrase.test.ts +61 -0
- package/src/crypto/aead.ts +97 -0
- package/src/crypto/ecdh.ts +60 -0
- package/src/crypto/kdf.ts +43 -0
- package/src/crypto/keyManager.ts +8 -0
- package/src/crypto/recoveryPhrase.ts +133 -0
- package/src/i18n/locales/en-US.json +5 -0
- package/src/i18n/locales/es-ES.json +5 -0
- package/src/index.ts +19 -1
- package/src/mixins/OxyServices.identity.ts +250 -0
- package/src/mixins/OxyServices.identityBackup.ts +237 -0
- package/src/mixins/OxyServices.user.ts +82 -4
- package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +277 -0
- package/src/mixins/__tests__/getFollowStatuses.test.ts +95 -0
- package/src/mixins/__tests__/identityBackup.test.ts +258 -0
- package/src/mixins/index.ts +5 -0
- package/src/types/elliptic.d.ts +10 -2
- package/src/utils/__tests__/validationUtils.test.ts +27 -0
- 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
|
*/
|
package/dist/esm/mixins/index.js
CHANGED
|
@@ -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
|
|
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),
|
|
49
|
-
* punctuation. The allowed set
|
|
50
|
-
* `&`,
|
|
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
|
-
*
|
|
54
|
-
* (
|
|
55
|
-
*
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|