namespace-guard 0.6.0 → 0.7.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/LICENSE +21 -0
- package/README.md +70 -5
- package/dist/cli.js +25 -21
- package/dist/cli.mjs +25 -21
- package/dist/index.d.mts +58 -3
- package/dist/index.d.ts +58 -3
- package/dist/index.js +89 -21
- package/dist/index.mjs +87 -21
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Wood FRSA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.typescriptlang.org/)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
-
**[Live Demo](https://paultendo.github.io/namespace-guard/)** — try it in your browser
|
|
8
|
+
**[Live Demo](https://paultendo.github.io/namespace-guard/)** — try it in your browser | **[Blog Post](https://paultendo.github.io/posts/namespace-guard-launch/)** — why this exists
|
|
9
9
|
|
|
10
10
|
**Check slug/handle uniqueness across multiple database tables with reserved name protection.**
|
|
11
11
|
|
|
@@ -60,7 +60,7 @@ if (result.available) {
|
|
|
60
60
|
// Create the org
|
|
61
61
|
} else {
|
|
62
62
|
// Show error: result.message
|
|
63
|
-
// e.g., "That name is reserved." or "That name is already in use."
|
|
63
|
+
// e.g., "That name is reserved. Try another one." or "That name is already in use."
|
|
64
64
|
}
|
|
65
65
|
```
|
|
66
66
|
|
|
@@ -98,6 +98,7 @@ import { createDrizzleAdapter } from "namespace-guard/adapters/drizzle";
|
|
|
98
98
|
import { db } from "./db";
|
|
99
99
|
import { users, organizations } from "./schema";
|
|
100
100
|
|
|
101
|
+
// Pass eq directly, or use { eq, ilike } for case-insensitive support
|
|
101
102
|
const adapter = createDrizzleAdapter(db, { users, organizations }, eq);
|
|
102
103
|
```
|
|
103
104
|
|
|
@@ -296,6 +297,68 @@ const guard = createNamespaceGuard({
|
|
|
296
297
|
|
|
297
298
|
No words are bundled — use any word list you like (e.g., the `bad-words` npm package, your own list, or an external API wrapped in a custom validator).
|
|
298
299
|
|
|
300
|
+
### Built-in Homoglyph Validator
|
|
301
|
+
|
|
302
|
+
Prevent spoofing attacks where Cyrillic or Greek characters are substituted for visually identical Latin letters (e.g., Cyrillic "а" for Latin "a" in "admin"):
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
import { createNamespaceGuard, createHomoglyphValidator } from "namespace-guard";
|
|
306
|
+
|
|
307
|
+
const guard = createNamespaceGuard({
|
|
308
|
+
sources: [/* ... */],
|
|
309
|
+
validators: [
|
|
310
|
+
createHomoglyphValidator(),
|
|
311
|
+
],
|
|
312
|
+
}, adapter);
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Options:
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
createHomoglyphValidator({
|
|
319
|
+
message: "Custom rejection message.", // optional
|
|
320
|
+
additionalMappings: { "\u0261": "g" }, // extend the built-in map
|
|
321
|
+
rejectMixedScript: true, // also reject Latin + Cyrillic/Greek mixing
|
|
322
|
+
})
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
The built-in `CONFUSABLE_MAP` covers ~30 Cyrillic-to-Latin and Greek-to-Latin pairs — the most common spoofing vectors. It's exported for inspection or extension.
|
|
326
|
+
|
|
327
|
+
## Unicode Normalization
|
|
328
|
+
|
|
329
|
+
By default, `normalize()` applies [NFKC normalization](https://unicode.org/reports/tr15/) before lowercasing. This collapses full-width characters, ligatures, superscripts, and other Unicode compatibility forms to their canonical equivalents:
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
normalize("hello"); // "hello" (full-width → ASCII)
|
|
333
|
+
normalize("\ufb01nance"); // "finance" (fi ligature → fi)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
NFKC is a no-op for ASCII input and matches what ENS, GitHub, and Unicode IDNA standards mandate. To opt out:
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
const guard = createNamespaceGuard({
|
|
340
|
+
sources: [/* ... */],
|
|
341
|
+
normalizeUnicode: false,
|
|
342
|
+
}, adapter);
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Rejecting Purely Numeric Identifiers
|
|
346
|
+
|
|
347
|
+
Twitter/X blocks purely numeric handles. Enable this with `allowPurelyNumeric: false`:
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
const guard = createNamespaceGuard({
|
|
351
|
+
sources: [/* ... */],
|
|
352
|
+
allowPurelyNumeric: false,
|
|
353
|
+
messages: {
|
|
354
|
+
purelyNumeric: "Handles cannot be all numbers.", // optional custom message
|
|
355
|
+
},
|
|
356
|
+
}, adapter);
|
|
357
|
+
|
|
358
|
+
await guard.check("123456"); // { available: false, reason: "invalid", message: "Handles cannot be all numbers." }
|
|
359
|
+
await guard.check("abc123"); // available (has letters)
|
|
360
|
+
```
|
|
361
|
+
|
|
299
362
|
## Conflict Suggestions
|
|
300
363
|
|
|
301
364
|
When a slug is taken, automatically suggest available alternatives using pluggable strategies:
|
|
@@ -358,7 +421,7 @@ suggest: {
|
|
|
358
421
|
}
|
|
359
422
|
```
|
|
360
423
|
|
|
361
|
-
Suggestions are verified against format, reserved names, validators, and database collisions using
|
|
424
|
+
Suggestions are verified against format, reserved names, validators, and database collisions using a progressive batched pipeline. Only available suggestions are returned.
|
|
362
425
|
|
|
363
426
|
## Batch Checking
|
|
364
427
|
|
|
@@ -512,9 +575,9 @@ Validate format only (no database queries).
|
|
|
512
575
|
|
|
513
576
|
---
|
|
514
577
|
|
|
515
|
-
### `normalize(identifier)`
|
|
578
|
+
### `normalize(identifier, options?)`
|
|
516
579
|
|
|
517
|
-
Utility function to normalize identifiers. Trims whitespace, lowercases, and strips leading `@` symbols.
|
|
580
|
+
Utility function to normalize identifiers. Trims whitespace, applies NFKC Unicode normalization (by default), lowercases, and strips leading `@` symbols. Pass `{ unicode: false }` to skip NFKC.
|
|
518
581
|
|
|
519
582
|
```typescript
|
|
520
583
|
import { normalize } from "namespace-guard";
|
|
@@ -664,6 +727,8 @@ Full TypeScript support with exported types:
|
|
|
664
727
|
import {
|
|
665
728
|
createNamespaceGuard,
|
|
666
729
|
createProfanityValidator,
|
|
730
|
+
createHomoglyphValidator,
|
|
731
|
+
CONFUSABLE_MAP,
|
|
667
732
|
normalize,
|
|
668
733
|
type NamespaceConfig,
|
|
669
734
|
type NamespaceSource,
|
package/dist/cli.js
CHANGED
|
@@ -266,8 +266,10 @@ function resolveGenerator(suggest, pattern) {
|
|
|
266
266
|
return result;
|
|
267
267
|
};
|
|
268
268
|
}
|
|
269
|
-
function normalize(raw) {
|
|
270
|
-
|
|
269
|
+
function normalize(raw, options) {
|
|
270
|
+
const trimmed = raw.trim();
|
|
271
|
+
const nfkc = options?.unicode ?? true ? trimmed.normalize("NFKC") : trimmed;
|
|
272
|
+
return nfkc.toLowerCase().replace(/^@+/, "");
|
|
271
273
|
}
|
|
272
274
|
function buildReservedMap(reserved) {
|
|
273
275
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -291,6 +293,9 @@ function createNamespaceGuard(config, adapter) {
|
|
|
291
293
|
const invalidMsg = configMessages.invalid ?? DEFAULT_MESSAGES.invalid;
|
|
292
294
|
const takenMsg = configMessages.taken ?? DEFAULT_MESSAGES.taken;
|
|
293
295
|
const validators = config.validators ?? [];
|
|
296
|
+
const normalizeOpts = { unicode: config.normalizeUnicode ?? true };
|
|
297
|
+
const allowPurelyNumeric = config.allowPurelyNumeric ?? true;
|
|
298
|
+
const purelyNumericMsg = configMessages.purelyNumeric ?? "Identifiers cannot be purely numeric.";
|
|
294
299
|
const cacheEnabled = !!config.cache;
|
|
295
300
|
const cacheTtl = config.cache?.ttl ?? 5e3;
|
|
296
301
|
const cacheMaxSize = 1e3;
|
|
@@ -324,38 +329,44 @@ function createNamespaceGuard(config, adapter) {
|
|
|
324
329
|
return defaultReservedMsg;
|
|
325
330
|
}
|
|
326
331
|
function validateFormat(identifier) {
|
|
327
|
-
const normalized = normalize(identifier);
|
|
332
|
+
const normalized = normalize(identifier, normalizeOpts);
|
|
328
333
|
if (!pattern.test(normalized)) {
|
|
329
334
|
return invalidMsg;
|
|
330
335
|
}
|
|
336
|
+
if (!allowPurelyNumeric && /^\d+(-\d+)*$/.test(normalized)) {
|
|
337
|
+
return purelyNumericMsg;
|
|
338
|
+
}
|
|
331
339
|
if (reservedMap.has(normalized)) {
|
|
332
340
|
return getReservedMessage(reservedMap.get(normalized));
|
|
333
341
|
}
|
|
334
342
|
return null;
|
|
335
343
|
}
|
|
344
|
+
function isOwnedByScope(existing, source, scope) {
|
|
345
|
+
if (!source.scopeKey) return false;
|
|
346
|
+
const scopeValue = scope[source.scopeKey];
|
|
347
|
+
const idColumn = source.idColumn ?? "id";
|
|
348
|
+
const existingId = existing[idColumn];
|
|
349
|
+
return !!(scopeValue && existingId && scopeValue === String(existingId));
|
|
350
|
+
}
|
|
336
351
|
async function checkDbOnly(value, scope) {
|
|
337
352
|
const findOptions = config.caseInsensitive ? { caseInsensitive: true } : void 0;
|
|
338
353
|
const checks = config.sources.map(async (source) => {
|
|
339
354
|
const existing = await cachedFindOne(source, value, findOptions);
|
|
340
355
|
if (!existing) return null;
|
|
341
|
-
if (source
|
|
342
|
-
const scopeValue = scope[source.scopeKey];
|
|
343
|
-
const idColumn = source.idColumn ?? "id";
|
|
344
|
-
const existingId = existing[idColumn];
|
|
345
|
-
if (scopeValue && existingId && scopeValue === String(existingId)) {
|
|
346
|
-
return null;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
356
|
+
if (isOwnedByScope(existing, source, scope)) return null;
|
|
349
357
|
return source.name;
|
|
350
358
|
});
|
|
351
359
|
const results = await Promise.all(checks);
|
|
352
360
|
return !results.some((r) => r !== null);
|
|
353
361
|
}
|
|
354
362
|
async function check(identifier, scope = {}, options) {
|
|
355
|
-
const normalized = normalize(identifier);
|
|
363
|
+
const normalized = normalize(identifier, normalizeOpts);
|
|
356
364
|
if (!pattern.test(normalized)) {
|
|
357
365
|
return { available: false, reason: "invalid", message: invalidMsg };
|
|
358
366
|
}
|
|
367
|
+
if (!allowPurelyNumeric && /^\d+(-\d+)*$/.test(normalized)) {
|
|
368
|
+
return { available: false, reason: "invalid", message: purelyNumericMsg };
|
|
369
|
+
}
|
|
359
370
|
const reservedCategory = reservedMap.get(normalized);
|
|
360
371
|
if (reservedCategory) {
|
|
361
372
|
return {
|
|
@@ -380,14 +391,7 @@ function createNamespaceGuard(config, adapter) {
|
|
|
380
391
|
const checks = config.sources.map(async (source) => {
|
|
381
392
|
const existing = await cachedFindOne(source, normalized, findOptions);
|
|
382
393
|
if (!existing) return null;
|
|
383
|
-
if (source
|
|
384
|
-
const scopeValue = scope[source.scopeKey];
|
|
385
|
-
const idColumn = source.idColumn ?? "id";
|
|
386
|
-
const existingId = existing[idColumn];
|
|
387
|
-
if (scopeValue && existingId && scopeValue === String(existingId)) {
|
|
388
|
-
return null;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
394
|
+
if (isOwnedByScope(existing, source, scope)) return null;
|
|
391
395
|
return source.name;
|
|
392
396
|
});
|
|
393
397
|
const results = await Promise.all(checks);
|
|
@@ -405,7 +409,7 @@ function createNamespaceGuard(config, adapter) {
|
|
|
405
409
|
const candidates = generate(normalized);
|
|
406
410
|
const suggestions = [];
|
|
407
411
|
const passedSync = candidates.filter(
|
|
408
|
-
(c) => pattern.test(c) && !reservedMap.has(c)
|
|
412
|
+
(c) => pattern.test(c) && !reservedMap.has(c) && (allowPurelyNumeric || !/^\d+(-\d+)*$/.test(c))
|
|
409
413
|
);
|
|
410
414
|
for (let i = 0; i < passedSync.length && suggestions.length < max; i += max) {
|
|
411
415
|
const batch = passedSync.slice(i, i + max);
|
package/dist/cli.mjs
CHANGED
|
@@ -243,8 +243,10 @@ function resolveGenerator(suggest, pattern) {
|
|
|
243
243
|
return result;
|
|
244
244
|
};
|
|
245
245
|
}
|
|
246
|
-
function normalize(raw) {
|
|
247
|
-
|
|
246
|
+
function normalize(raw, options) {
|
|
247
|
+
const trimmed = raw.trim();
|
|
248
|
+
const nfkc = options?.unicode ?? true ? trimmed.normalize("NFKC") : trimmed;
|
|
249
|
+
return nfkc.toLowerCase().replace(/^@+/, "");
|
|
248
250
|
}
|
|
249
251
|
function buildReservedMap(reserved) {
|
|
250
252
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -268,6 +270,9 @@ function createNamespaceGuard(config, adapter) {
|
|
|
268
270
|
const invalidMsg = configMessages.invalid ?? DEFAULT_MESSAGES.invalid;
|
|
269
271
|
const takenMsg = configMessages.taken ?? DEFAULT_MESSAGES.taken;
|
|
270
272
|
const validators = config.validators ?? [];
|
|
273
|
+
const normalizeOpts = { unicode: config.normalizeUnicode ?? true };
|
|
274
|
+
const allowPurelyNumeric = config.allowPurelyNumeric ?? true;
|
|
275
|
+
const purelyNumericMsg = configMessages.purelyNumeric ?? "Identifiers cannot be purely numeric.";
|
|
271
276
|
const cacheEnabled = !!config.cache;
|
|
272
277
|
const cacheTtl = config.cache?.ttl ?? 5e3;
|
|
273
278
|
const cacheMaxSize = 1e3;
|
|
@@ -301,38 +306,44 @@ function createNamespaceGuard(config, adapter) {
|
|
|
301
306
|
return defaultReservedMsg;
|
|
302
307
|
}
|
|
303
308
|
function validateFormat(identifier) {
|
|
304
|
-
const normalized = normalize(identifier);
|
|
309
|
+
const normalized = normalize(identifier, normalizeOpts);
|
|
305
310
|
if (!pattern.test(normalized)) {
|
|
306
311
|
return invalidMsg;
|
|
307
312
|
}
|
|
313
|
+
if (!allowPurelyNumeric && /^\d+(-\d+)*$/.test(normalized)) {
|
|
314
|
+
return purelyNumericMsg;
|
|
315
|
+
}
|
|
308
316
|
if (reservedMap.has(normalized)) {
|
|
309
317
|
return getReservedMessage(reservedMap.get(normalized));
|
|
310
318
|
}
|
|
311
319
|
return null;
|
|
312
320
|
}
|
|
321
|
+
function isOwnedByScope(existing, source, scope) {
|
|
322
|
+
if (!source.scopeKey) return false;
|
|
323
|
+
const scopeValue = scope[source.scopeKey];
|
|
324
|
+
const idColumn = source.idColumn ?? "id";
|
|
325
|
+
const existingId = existing[idColumn];
|
|
326
|
+
return !!(scopeValue && existingId && scopeValue === String(existingId));
|
|
327
|
+
}
|
|
313
328
|
async function checkDbOnly(value, scope) {
|
|
314
329
|
const findOptions = config.caseInsensitive ? { caseInsensitive: true } : void 0;
|
|
315
330
|
const checks = config.sources.map(async (source) => {
|
|
316
331
|
const existing = await cachedFindOne(source, value, findOptions);
|
|
317
332
|
if (!existing) return null;
|
|
318
|
-
if (source
|
|
319
|
-
const scopeValue = scope[source.scopeKey];
|
|
320
|
-
const idColumn = source.idColumn ?? "id";
|
|
321
|
-
const existingId = existing[idColumn];
|
|
322
|
-
if (scopeValue && existingId && scopeValue === String(existingId)) {
|
|
323
|
-
return null;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
333
|
+
if (isOwnedByScope(existing, source, scope)) return null;
|
|
326
334
|
return source.name;
|
|
327
335
|
});
|
|
328
336
|
const results = await Promise.all(checks);
|
|
329
337
|
return !results.some((r) => r !== null);
|
|
330
338
|
}
|
|
331
339
|
async function check(identifier, scope = {}, options) {
|
|
332
|
-
const normalized = normalize(identifier);
|
|
340
|
+
const normalized = normalize(identifier, normalizeOpts);
|
|
333
341
|
if (!pattern.test(normalized)) {
|
|
334
342
|
return { available: false, reason: "invalid", message: invalidMsg };
|
|
335
343
|
}
|
|
344
|
+
if (!allowPurelyNumeric && /^\d+(-\d+)*$/.test(normalized)) {
|
|
345
|
+
return { available: false, reason: "invalid", message: purelyNumericMsg };
|
|
346
|
+
}
|
|
336
347
|
const reservedCategory = reservedMap.get(normalized);
|
|
337
348
|
if (reservedCategory) {
|
|
338
349
|
return {
|
|
@@ -357,14 +368,7 @@ function createNamespaceGuard(config, adapter) {
|
|
|
357
368
|
const checks = config.sources.map(async (source) => {
|
|
358
369
|
const existing = await cachedFindOne(source, normalized, findOptions);
|
|
359
370
|
if (!existing) return null;
|
|
360
|
-
if (source
|
|
361
|
-
const scopeValue = scope[source.scopeKey];
|
|
362
|
-
const idColumn = source.idColumn ?? "id";
|
|
363
|
-
const existingId = existing[idColumn];
|
|
364
|
-
if (scopeValue && existingId && scopeValue === String(existingId)) {
|
|
365
|
-
return null;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
371
|
+
if (isOwnedByScope(existing, source, scope)) return null;
|
|
368
372
|
return source.name;
|
|
369
373
|
});
|
|
370
374
|
const results = await Promise.all(checks);
|
|
@@ -382,7 +386,7 @@ function createNamespaceGuard(config, adapter) {
|
|
|
382
386
|
const candidates = generate(normalized);
|
|
383
387
|
const suggestions = [];
|
|
384
388
|
const passedSync = candidates.filter(
|
|
385
|
-
(c) => pattern.test(c) && !reservedMap.has(c)
|
|
389
|
+
(c) => pattern.test(c) && !reservedMap.has(c) && (allowPurelyNumeric || !/^\d+(-\d+)*$/.test(c))
|
|
386
390
|
);
|
|
387
391
|
for (let i = 0; i < passedSync.length && suggestions.length < max; i += max) {
|
|
388
392
|
const batch = passedSync.slice(i, i + max);
|
package/dist/index.d.mts
CHANGED
|
@@ -21,11 +21,19 @@ type NamespaceConfig = {
|
|
|
21
21
|
pattern?: RegExp;
|
|
22
22
|
/** Use case-insensitive matching in database queries (default: false) */
|
|
23
23
|
caseInsensitive?: boolean;
|
|
24
|
+
/** Apply NFKC Unicode normalization during normalize() (default: true).
|
|
25
|
+
* Collapses full-width characters, ligatures, and compatibility forms to their canonical equivalents. */
|
|
26
|
+
normalizeUnicode?: boolean;
|
|
27
|
+
/** Allow purely numeric identifiers like "123" or "12-34" (default: true).
|
|
28
|
+
* Set to false to reject them, matching Twitter/X handle rules. */
|
|
29
|
+
allowPurelyNumeric?: boolean;
|
|
24
30
|
/** Custom error messages */
|
|
25
31
|
messages?: {
|
|
26
32
|
invalid?: string;
|
|
27
33
|
reserved?: string | Record<string, string>;
|
|
28
34
|
taken?: (sourceName: string) => string;
|
|
35
|
+
/** Message shown when a purely numeric identifier is rejected (default: "Identifiers cannot be purely numeric.") */
|
|
36
|
+
purelyNumeric?: string;
|
|
29
37
|
};
|
|
30
38
|
/** Async validation hooks — run after format/reserved checks, before DB */
|
|
31
39
|
validators?: Array<(value: string) => Promise<{
|
|
@@ -73,18 +81,28 @@ type CheckResult = {
|
|
|
73
81
|
suggestions?: string[];
|
|
74
82
|
};
|
|
75
83
|
/**
|
|
76
|
-
* Normalize a raw identifier: trims whitespace,
|
|
84
|
+
* Normalize a raw identifier: trims whitespace, applies NFKC Unicode normalization,
|
|
85
|
+
* lowercases, and strips leading `@` symbols.
|
|
86
|
+
*
|
|
87
|
+
* NFKC normalization collapses full-width characters, ligatures, superscripts,
|
|
88
|
+
* and other compatibility forms to their canonical equivalents. This is a no-op
|
|
89
|
+
* for ASCII-only input.
|
|
77
90
|
*
|
|
78
91
|
* @param raw - The raw user input
|
|
92
|
+
* @param options - Optional settings
|
|
93
|
+
* @param options.unicode - Apply NFKC Unicode normalization (default: true)
|
|
79
94
|
* @returns The normalized identifier
|
|
80
95
|
*
|
|
81
96
|
* @example
|
|
82
97
|
* ```ts
|
|
83
98
|
* normalize(" @Sarah "); // "sarah"
|
|
84
99
|
* normalize("ACME-Corp"); // "acme-corp"
|
|
100
|
+
* normalize("\uff48\uff45\uff4c\uff4c\uff4f"); // "hello" (full-width → ASCII)
|
|
85
101
|
* ```
|
|
86
102
|
*/
|
|
87
|
-
declare function normalize(raw: string
|
|
103
|
+
declare function normalize(raw: string, options?: {
|
|
104
|
+
unicode?: boolean;
|
|
105
|
+
}): string;
|
|
88
106
|
/**
|
|
89
107
|
* Create a validator that rejects identifiers containing profanity or offensive words.
|
|
90
108
|
*
|
|
@@ -117,6 +135,43 @@ declare function createProfanityValidator(words: string[], options?: {
|
|
|
117
135
|
available: false;
|
|
118
136
|
message: string;
|
|
119
137
|
} | null>;
|
|
138
|
+
/**
|
|
139
|
+
* Default mapping of visually confusable Unicode characters to their Latin equivalents.
|
|
140
|
+
* Covers Cyrillic-to-Latin and Greek-to-Latin lookalikes — the most common spoofing vectors.
|
|
141
|
+
* Exported for advanced users who need to inspect or extend the mapping.
|
|
142
|
+
*/
|
|
143
|
+
declare const CONFUSABLE_MAP: Record<string, string>;
|
|
144
|
+
/**
|
|
145
|
+
* Create a validator that rejects identifiers containing homoglyph/confusable characters.
|
|
146
|
+
*
|
|
147
|
+
* Catches spoofing attacks where Cyrillic or Greek characters are substituted for
|
|
148
|
+
* visually identical Latin characters (e.g., Cyrillic "а" for Latin "a" in "admin").
|
|
149
|
+
* Uses a curated mapping of ~30 character pairs that covers 95%+ of real impersonation attempts.
|
|
150
|
+
*
|
|
151
|
+
* @param options - Optional settings
|
|
152
|
+
* @param options.message - Custom rejection message (default: "That name contains characters that could be confused with other letters.")
|
|
153
|
+
* @param options.additionalMappings - Extra confusable pairs to merge with the built-in map
|
|
154
|
+
* @param options.rejectMixedScript - Also reject identifiers that mix Latin with Cyrillic/Greek characters (default: false)
|
|
155
|
+
* @returns An async validator function for use in `config.validators`
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```ts
|
|
159
|
+
* const guard = createNamespaceGuard({
|
|
160
|
+
* sources: [{ name: "user", column: "handle" }],
|
|
161
|
+
* validators: [
|
|
162
|
+
* createHomoglyphValidator(),
|
|
163
|
+
* ],
|
|
164
|
+
* }, adapter);
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
declare function createHomoglyphValidator(options?: {
|
|
168
|
+
message?: string;
|
|
169
|
+
additionalMappings?: Record<string, string>;
|
|
170
|
+
rejectMixedScript?: boolean;
|
|
171
|
+
}): (value: string) => Promise<{
|
|
172
|
+
available: false;
|
|
173
|
+
message: string;
|
|
174
|
+
} | null>;
|
|
120
175
|
/**
|
|
121
176
|
* Create a namespace guard instance for checking slug/handle uniqueness
|
|
122
177
|
* across multiple database tables with reserved name protection.
|
|
@@ -165,4 +220,4 @@ declare function createNamespaceGuard(config: NamespaceConfig, adapter: Namespac
|
|
|
165
220
|
/** The guard instance returned by `createNamespaceGuard`. */
|
|
166
221
|
type NamespaceGuard = ReturnType<typeof createNamespaceGuard>;
|
|
167
222
|
|
|
168
|
-
export { type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SuggestStrategyName, createNamespaceGuard, createProfanityValidator, normalize };
|
|
223
|
+
export { CONFUSABLE_MAP, type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SuggestStrategyName, createHomoglyphValidator, createNamespaceGuard, createProfanityValidator, normalize };
|
package/dist/index.d.ts
CHANGED
|
@@ -21,11 +21,19 @@ type NamespaceConfig = {
|
|
|
21
21
|
pattern?: RegExp;
|
|
22
22
|
/** Use case-insensitive matching in database queries (default: false) */
|
|
23
23
|
caseInsensitive?: boolean;
|
|
24
|
+
/** Apply NFKC Unicode normalization during normalize() (default: true).
|
|
25
|
+
* Collapses full-width characters, ligatures, and compatibility forms to their canonical equivalents. */
|
|
26
|
+
normalizeUnicode?: boolean;
|
|
27
|
+
/** Allow purely numeric identifiers like "123" or "12-34" (default: true).
|
|
28
|
+
* Set to false to reject them, matching Twitter/X handle rules. */
|
|
29
|
+
allowPurelyNumeric?: boolean;
|
|
24
30
|
/** Custom error messages */
|
|
25
31
|
messages?: {
|
|
26
32
|
invalid?: string;
|
|
27
33
|
reserved?: string | Record<string, string>;
|
|
28
34
|
taken?: (sourceName: string) => string;
|
|
35
|
+
/** Message shown when a purely numeric identifier is rejected (default: "Identifiers cannot be purely numeric.") */
|
|
36
|
+
purelyNumeric?: string;
|
|
29
37
|
};
|
|
30
38
|
/** Async validation hooks — run after format/reserved checks, before DB */
|
|
31
39
|
validators?: Array<(value: string) => Promise<{
|
|
@@ -73,18 +81,28 @@ type CheckResult = {
|
|
|
73
81
|
suggestions?: string[];
|
|
74
82
|
};
|
|
75
83
|
/**
|
|
76
|
-
* Normalize a raw identifier: trims whitespace,
|
|
84
|
+
* Normalize a raw identifier: trims whitespace, applies NFKC Unicode normalization,
|
|
85
|
+
* lowercases, and strips leading `@` symbols.
|
|
86
|
+
*
|
|
87
|
+
* NFKC normalization collapses full-width characters, ligatures, superscripts,
|
|
88
|
+
* and other compatibility forms to their canonical equivalents. This is a no-op
|
|
89
|
+
* for ASCII-only input.
|
|
77
90
|
*
|
|
78
91
|
* @param raw - The raw user input
|
|
92
|
+
* @param options - Optional settings
|
|
93
|
+
* @param options.unicode - Apply NFKC Unicode normalization (default: true)
|
|
79
94
|
* @returns The normalized identifier
|
|
80
95
|
*
|
|
81
96
|
* @example
|
|
82
97
|
* ```ts
|
|
83
98
|
* normalize(" @Sarah "); // "sarah"
|
|
84
99
|
* normalize("ACME-Corp"); // "acme-corp"
|
|
100
|
+
* normalize("\uff48\uff45\uff4c\uff4c\uff4f"); // "hello" (full-width → ASCII)
|
|
85
101
|
* ```
|
|
86
102
|
*/
|
|
87
|
-
declare function normalize(raw: string
|
|
103
|
+
declare function normalize(raw: string, options?: {
|
|
104
|
+
unicode?: boolean;
|
|
105
|
+
}): string;
|
|
88
106
|
/**
|
|
89
107
|
* Create a validator that rejects identifiers containing profanity or offensive words.
|
|
90
108
|
*
|
|
@@ -117,6 +135,43 @@ declare function createProfanityValidator(words: string[], options?: {
|
|
|
117
135
|
available: false;
|
|
118
136
|
message: string;
|
|
119
137
|
} | null>;
|
|
138
|
+
/**
|
|
139
|
+
* Default mapping of visually confusable Unicode characters to their Latin equivalents.
|
|
140
|
+
* Covers Cyrillic-to-Latin and Greek-to-Latin lookalikes — the most common spoofing vectors.
|
|
141
|
+
* Exported for advanced users who need to inspect or extend the mapping.
|
|
142
|
+
*/
|
|
143
|
+
declare const CONFUSABLE_MAP: Record<string, string>;
|
|
144
|
+
/**
|
|
145
|
+
* Create a validator that rejects identifiers containing homoglyph/confusable characters.
|
|
146
|
+
*
|
|
147
|
+
* Catches spoofing attacks where Cyrillic or Greek characters are substituted for
|
|
148
|
+
* visually identical Latin characters (e.g., Cyrillic "а" for Latin "a" in "admin").
|
|
149
|
+
* Uses a curated mapping of ~30 character pairs that covers 95%+ of real impersonation attempts.
|
|
150
|
+
*
|
|
151
|
+
* @param options - Optional settings
|
|
152
|
+
* @param options.message - Custom rejection message (default: "That name contains characters that could be confused with other letters.")
|
|
153
|
+
* @param options.additionalMappings - Extra confusable pairs to merge with the built-in map
|
|
154
|
+
* @param options.rejectMixedScript - Also reject identifiers that mix Latin with Cyrillic/Greek characters (default: false)
|
|
155
|
+
* @returns An async validator function for use in `config.validators`
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```ts
|
|
159
|
+
* const guard = createNamespaceGuard({
|
|
160
|
+
* sources: [{ name: "user", column: "handle" }],
|
|
161
|
+
* validators: [
|
|
162
|
+
* createHomoglyphValidator(),
|
|
163
|
+
* ],
|
|
164
|
+
* }, adapter);
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
declare function createHomoglyphValidator(options?: {
|
|
168
|
+
message?: string;
|
|
169
|
+
additionalMappings?: Record<string, string>;
|
|
170
|
+
rejectMixedScript?: boolean;
|
|
171
|
+
}): (value: string) => Promise<{
|
|
172
|
+
available: false;
|
|
173
|
+
message: string;
|
|
174
|
+
} | null>;
|
|
120
175
|
/**
|
|
121
176
|
* Create a namespace guard instance for checking slug/handle uniqueness
|
|
122
177
|
* across multiple database tables with reserved name protection.
|
|
@@ -165,4 +220,4 @@ declare function createNamespaceGuard(config: NamespaceConfig, adapter: Namespac
|
|
|
165
220
|
/** The guard instance returned by `createNamespaceGuard`. */
|
|
166
221
|
type NamespaceGuard = ReturnType<typeof createNamespaceGuard>;
|
|
167
222
|
|
|
168
|
-
export { type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SuggestStrategyName, createNamespaceGuard, createProfanityValidator, normalize };
|
|
223
|
+
export { CONFUSABLE_MAP, type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SuggestStrategyName, createHomoglyphValidator, createNamespaceGuard, createProfanityValidator, normalize };
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
CONFUSABLE_MAP: () => CONFUSABLE_MAP,
|
|
24
|
+
createHomoglyphValidator: () => createHomoglyphValidator,
|
|
23
25
|
createNamespaceGuard: () => createNamespaceGuard,
|
|
24
26
|
createProfanityValidator: () => createProfanityValidator,
|
|
25
27
|
normalize: () => normalize
|
|
@@ -263,8 +265,10 @@ function resolveGenerator(suggest, pattern) {
|
|
|
263
265
|
return result;
|
|
264
266
|
};
|
|
265
267
|
}
|
|
266
|
-
function normalize(raw) {
|
|
267
|
-
|
|
268
|
+
function normalize(raw, options) {
|
|
269
|
+
const trimmed = raw.trim();
|
|
270
|
+
const nfkc = options?.unicode ?? true ? trimmed.normalize("NFKC") : trimmed;
|
|
271
|
+
return nfkc.toLowerCase().replace(/^@+/, "");
|
|
268
272
|
}
|
|
269
273
|
function createProfanityValidator(words, options) {
|
|
270
274
|
const message = options?.message ?? "That name is not allowed.";
|
|
@@ -284,6 +288,66 @@ function createProfanityValidator(words, options) {
|
|
|
284
288
|
return null;
|
|
285
289
|
};
|
|
286
290
|
}
|
|
291
|
+
var CONFUSABLE_MAP = {
|
|
292
|
+
// Cyrillic lowercase → Latin
|
|
293
|
+
"\u0430": "a",
|
|
294
|
+
"\u0441": "c",
|
|
295
|
+
"\u0435": "e",
|
|
296
|
+
"\u043E": "o",
|
|
297
|
+
"\u0440": "p",
|
|
298
|
+
"\u0445": "x",
|
|
299
|
+
"\u0443": "y",
|
|
300
|
+
"\u0456": "i",
|
|
301
|
+
"\u0455": "s",
|
|
302
|
+
"\u0458": "j",
|
|
303
|
+
"\u04BB": "h",
|
|
304
|
+
"\u051D": "w",
|
|
305
|
+
// Cyrillic uppercase → Latin
|
|
306
|
+
"\u0410": "A",
|
|
307
|
+
"\u0412": "B",
|
|
308
|
+
"\u0421": "C",
|
|
309
|
+
"\u0415": "E",
|
|
310
|
+
"\u041D": "H",
|
|
311
|
+
"\u041A": "K",
|
|
312
|
+
"\u041C": "M",
|
|
313
|
+
"\u041E": "O",
|
|
314
|
+
"\u0420": "P",
|
|
315
|
+
"\u0422": "T",
|
|
316
|
+
"\u0425": "X",
|
|
317
|
+
// Greek lowercase → Latin
|
|
318
|
+
"\u03B1": "a",
|
|
319
|
+
"\u03BF": "o",
|
|
320
|
+
"\u03C1": "p",
|
|
321
|
+
"\u03BD": "v",
|
|
322
|
+
"\u03C4": "t",
|
|
323
|
+
"\u03B9": "i",
|
|
324
|
+
"\u03BA": "k"
|
|
325
|
+
};
|
|
326
|
+
function createHomoglyphValidator(options) {
|
|
327
|
+
const message = options?.message ?? "That name contains characters that could be confused with other letters.";
|
|
328
|
+
const rejectMixedScript = options?.rejectMixedScript ?? false;
|
|
329
|
+
const map = { ...CONFUSABLE_MAP };
|
|
330
|
+
if (options?.additionalMappings) {
|
|
331
|
+
Object.assign(map, options.additionalMappings);
|
|
332
|
+
}
|
|
333
|
+
const confusableChars = Object.keys(map);
|
|
334
|
+
const confusableRegex = confusableChars.length > 0 ? new RegExp("[" + confusableChars.join("") + "]") : null;
|
|
335
|
+
const cyrillicOrGreekRegex = /[\u0370-\u03FF\u0400-\u04FF\u0500-\u052F]/;
|
|
336
|
+
const latinRegex = /[a-zA-Z]/;
|
|
337
|
+
return async (value) => {
|
|
338
|
+
if (confusableRegex && confusableRegex.test(value)) {
|
|
339
|
+
return { available: false, message };
|
|
340
|
+
}
|
|
341
|
+
if (rejectMixedScript) {
|
|
342
|
+
const hasLatin = latinRegex.test(value);
|
|
343
|
+
const hasCyrillicOrGreek = cyrillicOrGreekRegex.test(value);
|
|
344
|
+
if (hasLatin && hasCyrillicOrGreek) {
|
|
345
|
+
return { available: false, message };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
};
|
|
350
|
+
}
|
|
287
351
|
function buildReservedMap(reserved) {
|
|
288
352
|
const map = /* @__PURE__ */ new Map();
|
|
289
353
|
if (!reserved) return map;
|
|
@@ -306,6 +370,9 @@ function createNamespaceGuard(config, adapter) {
|
|
|
306
370
|
const invalidMsg = configMessages.invalid ?? DEFAULT_MESSAGES.invalid;
|
|
307
371
|
const takenMsg = configMessages.taken ?? DEFAULT_MESSAGES.taken;
|
|
308
372
|
const validators = config.validators ?? [];
|
|
373
|
+
const normalizeOpts = { unicode: config.normalizeUnicode ?? true };
|
|
374
|
+
const allowPurelyNumeric = config.allowPurelyNumeric ?? true;
|
|
375
|
+
const purelyNumericMsg = configMessages.purelyNumeric ?? "Identifiers cannot be purely numeric.";
|
|
309
376
|
const cacheEnabled = !!config.cache;
|
|
310
377
|
const cacheTtl = config.cache?.ttl ?? 5e3;
|
|
311
378
|
const cacheMaxSize = 1e3;
|
|
@@ -339,38 +406,44 @@ function createNamespaceGuard(config, adapter) {
|
|
|
339
406
|
return defaultReservedMsg;
|
|
340
407
|
}
|
|
341
408
|
function validateFormat(identifier) {
|
|
342
|
-
const normalized = normalize(identifier);
|
|
409
|
+
const normalized = normalize(identifier, normalizeOpts);
|
|
343
410
|
if (!pattern.test(normalized)) {
|
|
344
411
|
return invalidMsg;
|
|
345
412
|
}
|
|
413
|
+
if (!allowPurelyNumeric && /^\d+(-\d+)*$/.test(normalized)) {
|
|
414
|
+
return purelyNumericMsg;
|
|
415
|
+
}
|
|
346
416
|
if (reservedMap.has(normalized)) {
|
|
347
417
|
return getReservedMessage(reservedMap.get(normalized));
|
|
348
418
|
}
|
|
349
419
|
return null;
|
|
350
420
|
}
|
|
421
|
+
function isOwnedByScope(existing, source, scope) {
|
|
422
|
+
if (!source.scopeKey) return false;
|
|
423
|
+
const scopeValue = scope[source.scopeKey];
|
|
424
|
+
const idColumn = source.idColumn ?? "id";
|
|
425
|
+
const existingId = existing[idColumn];
|
|
426
|
+
return !!(scopeValue && existingId && scopeValue === String(existingId));
|
|
427
|
+
}
|
|
351
428
|
async function checkDbOnly(value, scope) {
|
|
352
429
|
const findOptions = config.caseInsensitive ? { caseInsensitive: true } : void 0;
|
|
353
430
|
const checks = config.sources.map(async (source) => {
|
|
354
431
|
const existing = await cachedFindOne(source, value, findOptions);
|
|
355
432
|
if (!existing) return null;
|
|
356
|
-
if (source
|
|
357
|
-
const scopeValue = scope[source.scopeKey];
|
|
358
|
-
const idColumn = source.idColumn ?? "id";
|
|
359
|
-
const existingId = existing[idColumn];
|
|
360
|
-
if (scopeValue && existingId && scopeValue === String(existingId)) {
|
|
361
|
-
return null;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
433
|
+
if (isOwnedByScope(existing, source, scope)) return null;
|
|
364
434
|
return source.name;
|
|
365
435
|
});
|
|
366
436
|
const results = await Promise.all(checks);
|
|
367
437
|
return !results.some((r) => r !== null);
|
|
368
438
|
}
|
|
369
439
|
async function check(identifier, scope = {}, options) {
|
|
370
|
-
const normalized = normalize(identifier);
|
|
440
|
+
const normalized = normalize(identifier, normalizeOpts);
|
|
371
441
|
if (!pattern.test(normalized)) {
|
|
372
442
|
return { available: false, reason: "invalid", message: invalidMsg };
|
|
373
443
|
}
|
|
444
|
+
if (!allowPurelyNumeric && /^\d+(-\d+)*$/.test(normalized)) {
|
|
445
|
+
return { available: false, reason: "invalid", message: purelyNumericMsg };
|
|
446
|
+
}
|
|
374
447
|
const reservedCategory = reservedMap.get(normalized);
|
|
375
448
|
if (reservedCategory) {
|
|
376
449
|
return {
|
|
@@ -395,14 +468,7 @@ function createNamespaceGuard(config, adapter) {
|
|
|
395
468
|
const checks = config.sources.map(async (source) => {
|
|
396
469
|
const existing = await cachedFindOne(source, normalized, findOptions);
|
|
397
470
|
if (!existing) return null;
|
|
398
|
-
if (source
|
|
399
|
-
const scopeValue = scope[source.scopeKey];
|
|
400
|
-
const idColumn = source.idColumn ?? "id";
|
|
401
|
-
const existingId = existing[idColumn];
|
|
402
|
-
if (scopeValue && existingId && scopeValue === String(existingId)) {
|
|
403
|
-
return null;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
471
|
+
if (isOwnedByScope(existing, source, scope)) return null;
|
|
406
472
|
return source.name;
|
|
407
473
|
});
|
|
408
474
|
const results = await Promise.all(checks);
|
|
@@ -420,7 +486,7 @@ function createNamespaceGuard(config, adapter) {
|
|
|
420
486
|
const candidates = generate(normalized);
|
|
421
487
|
const suggestions = [];
|
|
422
488
|
const passedSync = candidates.filter(
|
|
423
|
-
(c) => pattern.test(c) && !reservedMap.has(c)
|
|
489
|
+
(c) => pattern.test(c) && !reservedMap.has(c) && (allowPurelyNumeric || !/^\d+(-\d+)*$/.test(c))
|
|
424
490
|
);
|
|
425
491
|
for (let i = 0; i < passedSync.length && suggestions.length < max; i += max) {
|
|
426
492
|
const batch = passedSync.slice(i, i + max);
|
|
@@ -499,6 +565,8 @@ function createNamespaceGuard(config, adapter) {
|
|
|
499
565
|
}
|
|
500
566
|
// Annotate the CommonJS export names for ESM import in node:
|
|
501
567
|
0 && (module.exports = {
|
|
568
|
+
CONFUSABLE_MAP,
|
|
569
|
+
createHomoglyphValidator,
|
|
502
570
|
createNamespaceGuard,
|
|
503
571
|
createProfanityValidator,
|
|
504
572
|
normalize
|
package/dist/index.mjs
CHANGED
|
@@ -237,8 +237,10 @@ function resolveGenerator(suggest, pattern) {
|
|
|
237
237
|
return result;
|
|
238
238
|
};
|
|
239
239
|
}
|
|
240
|
-
function normalize(raw) {
|
|
241
|
-
|
|
240
|
+
function normalize(raw, options) {
|
|
241
|
+
const trimmed = raw.trim();
|
|
242
|
+
const nfkc = options?.unicode ?? true ? trimmed.normalize("NFKC") : trimmed;
|
|
243
|
+
return nfkc.toLowerCase().replace(/^@+/, "");
|
|
242
244
|
}
|
|
243
245
|
function createProfanityValidator(words, options) {
|
|
244
246
|
const message = options?.message ?? "That name is not allowed.";
|
|
@@ -258,6 +260,66 @@ function createProfanityValidator(words, options) {
|
|
|
258
260
|
return null;
|
|
259
261
|
};
|
|
260
262
|
}
|
|
263
|
+
var CONFUSABLE_MAP = {
|
|
264
|
+
// Cyrillic lowercase → Latin
|
|
265
|
+
"\u0430": "a",
|
|
266
|
+
"\u0441": "c",
|
|
267
|
+
"\u0435": "e",
|
|
268
|
+
"\u043E": "o",
|
|
269
|
+
"\u0440": "p",
|
|
270
|
+
"\u0445": "x",
|
|
271
|
+
"\u0443": "y",
|
|
272
|
+
"\u0456": "i",
|
|
273
|
+
"\u0455": "s",
|
|
274
|
+
"\u0458": "j",
|
|
275
|
+
"\u04BB": "h",
|
|
276
|
+
"\u051D": "w",
|
|
277
|
+
// Cyrillic uppercase → Latin
|
|
278
|
+
"\u0410": "A",
|
|
279
|
+
"\u0412": "B",
|
|
280
|
+
"\u0421": "C",
|
|
281
|
+
"\u0415": "E",
|
|
282
|
+
"\u041D": "H",
|
|
283
|
+
"\u041A": "K",
|
|
284
|
+
"\u041C": "M",
|
|
285
|
+
"\u041E": "O",
|
|
286
|
+
"\u0420": "P",
|
|
287
|
+
"\u0422": "T",
|
|
288
|
+
"\u0425": "X",
|
|
289
|
+
// Greek lowercase → Latin
|
|
290
|
+
"\u03B1": "a",
|
|
291
|
+
"\u03BF": "o",
|
|
292
|
+
"\u03C1": "p",
|
|
293
|
+
"\u03BD": "v",
|
|
294
|
+
"\u03C4": "t",
|
|
295
|
+
"\u03B9": "i",
|
|
296
|
+
"\u03BA": "k"
|
|
297
|
+
};
|
|
298
|
+
function createHomoglyphValidator(options) {
|
|
299
|
+
const message = options?.message ?? "That name contains characters that could be confused with other letters.";
|
|
300
|
+
const rejectMixedScript = options?.rejectMixedScript ?? false;
|
|
301
|
+
const map = { ...CONFUSABLE_MAP };
|
|
302
|
+
if (options?.additionalMappings) {
|
|
303
|
+
Object.assign(map, options.additionalMappings);
|
|
304
|
+
}
|
|
305
|
+
const confusableChars = Object.keys(map);
|
|
306
|
+
const confusableRegex = confusableChars.length > 0 ? new RegExp("[" + confusableChars.join("") + "]") : null;
|
|
307
|
+
const cyrillicOrGreekRegex = /[\u0370-\u03FF\u0400-\u04FF\u0500-\u052F]/;
|
|
308
|
+
const latinRegex = /[a-zA-Z]/;
|
|
309
|
+
return async (value) => {
|
|
310
|
+
if (confusableRegex && confusableRegex.test(value)) {
|
|
311
|
+
return { available: false, message };
|
|
312
|
+
}
|
|
313
|
+
if (rejectMixedScript) {
|
|
314
|
+
const hasLatin = latinRegex.test(value);
|
|
315
|
+
const hasCyrillicOrGreek = cyrillicOrGreekRegex.test(value);
|
|
316
|
+
if (hasLatin && hasCyrillicOrGreek) {
|
|
317
|
+
return { available: false, message };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
};
|
|
322
|
+
}
|
|
261
323
|
function buildReservedMap(reserved) {
|
|
262
324
|
const map = /* @__PURE__ */ new Map();
|
|
263
325
|
if (!reserved) return map;
|
|
@@ -280,6 +342,9 @@ function createNamespaceGuard(config, adapter) {
|
|
|
280
342
|
const invalidMsg = configMessages.invalid ?? DEFAULT_MESSAGES.invalid;
|
|
281
343
|
const takenMsg = configMessages.taken ?? DEFAULT_MESSAGES.taken;
|
|
282
344
|
const validators = config.validators ?? [];
|
|
345
|
+
const normalizeOpts = { unicode: config.normalizeUnicode ?? true };
|
|
346
|
+
const allowPurelyNumeric = config.allowPurelyNumeric ?? true;
|
|
347
|
+
const purelyNumericMsg = configMessages.purelyNumeric ?? "Identifiers cannot be purely numeric.";
|
|
283
348
|
const cacheEnabled = !!config.cache;
|
|
284
349
|
const cacheTtl = config.cache?.ttl ?? 5e3;
|
|
285
350
|
const cacheMaxSize = 1e3;
|
|
@@ -313,38 +378,44 @@ function createNamespaceGuard(config, adapter) {
|
|
|
313
378
|
return defaultReservedMsg;
|
|
314
379
|
}
|
|
315
380
|
function validateFormat(identifier) {
|
|
316
|
-
const normalized = normalize(identifier);
|
|
381
|
+
const normalized = normalize(identifier, normalizeOpts);
|
|
317
382
|
if (!pattern.test(normalized)) {
|
|
318
383
|
return invalidMsg;
|
|
319
384
|
}
|
|
385
|
+
if (!allowPurelyNumeric && /^\d+(-\d+)*$/.test(normalized)) {
|
|
386
|
+
return purelyNumericMsg;
|
|
387
|
+
}
|
|
320
388
|
if (reservedMap.has(normalized)) {
|
|
321
389
|
return getReservedMessage(reservedMap.get(normalized));
|
|
322
390
|
}
|
|
323
391
|
return null;
|
|
324
392
|
}
|
|
393
|
+
function isOwnedByScope(existing, source, scope) {
|
|
394
|
+
if (!source.scopeKey) return false;
|
|
395
|
+
const scopeValue = scope[source.scopeKey];
|
|
396
|
+
const idColumn = source.idColumn ?? "id";
|
|
397
|
+
const existingId = existing[idColumn];
|
|
398
|
+
return !!(scopeValue && existingId && scopeValue === String(existingId));
|
|
399
|
+
}
|
|
325
400
|
async function checkDbOnly(value, scope) {
|
|
326
401
|
const findOptions = config.caseInsensitive ? { caseInsensitive: true } : void 0;
|
|
327
402
|
const checks = config.sources.map(async (source) => {
|
|
328
403
|
const existing = await cachedFindOne(source, value, findOptions);
|
|
329
404
|
if (!existing) return null;
|
|
330
|
-
if (source
|
|
331
|
-
const scopeValue = scope[source.scopeKey];
|
|
332
|
-
const idColumn = source.idColumn ?? "id";
|
|
333
|
-
const existingId = existing[idColumn];
|
|
334
|
-
if (scopeValue && existingId && scopeValue === String(existingId)) {
|
|
335
|
-
return null;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
405
|
+
if (isOwnedByScope(existing, source, scope)) return null;
|
|
338
406
|
return source.name;
|
|
339
407
|
});
|
|
340
408
|
const results = await Promise.all(checks);
|
|
341
409
|
return !results.some((r) => r !== null);
|
|
342
410
|
}
|
|
343
411
|
async function check(identifier, scope = {}, options) {
|
|
344
|
-
const normalized = normalize(identifier);
|
|
412
|
+
const normalized = normalize(identifier, normalizeOpts);
|
|
345
413
|
if (!pattern.test(normalized)) {
|
|
346
414
|
return { available: false, reason: "invalid", message: invalidMsg };
|
|
347
415
|
}
|
|
416
|
+
if (!allowPurelyNumeric && /^\d+(-\d+)*$/.test(normalized)) {
|
|
417
|
+
return { available: false, reason: "invalid", message: purelyNumericMsg };
|
|
418
|
+
}
|
|
348
419
|
const reservedCategory = reservedMap.get(normalized);
|
|
349
420
|
if (reservedCategory) {
|
|
350
421
|
return {
|
|
@@ -369,14 +440,7 @@ function createNamespaceGuard(config, adapter) {
|
|
|
369
440
|
const checks = config.sources.map(async (source) => {
|
|
370
441
|
const existing = await cachedFindOne(source, normalized, findOptions);
|
|
371
442
|
if (!existing) return null;
|
|
372
|
-
if (source
|
|
373
|
-
const scopeValue = scope[source.scopeKey];
|
|
374
|
-
const idColumn = source.idColumn ?? "id";
|
|
375
|
-
const existingId = existing[idColumn];
|
|
376
|
-
if (scopeValue && existingId && scopeValue === String(existingId)) {
|
|
377
|
-
return null;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
443
|
+
if (isOwnedByScope(existing, source, scope)) return null;
|
|
380
444
|
return source.name;
|
|
381
445
|
});
|
|
382
446
|
const results = await Promise.all(checks);
|
|
@@ -394,7 +458,7 @@ function createNamespaceGuard(config, adapter) {
|
|
|
394
458
|
const candidates = generate(normalized);
|
|
395
459
|
const suggestions = [];
|
|
396
460
|
const passedSync = candidates.filter(
|
|
397
|
-
(c) => pattern.test(c) && !reservedMap.has(c)
|
|
461
|
+
(c) => pattern.test(c) && !reservedMap.has(c) && (allowPurelyNumeric || !/^\d+(-\d+)*$/.test(c))
|
|
398
462
|
);
|
|
399
463
|
for (let i = 0; i < passedSync.length && suggestions.length < max; i += max) {
|
|
400
464
|
const batch = passedSync.slice(i, i + max);
|
|
@@ -472,6 +536,8 @@ function createNamespaceGuard(config, adapter) {
|
|
|
472
536
|
};
|
|
473
537
|
}
|
|
474
538
|
export {
|
|
539
|
+
CONFUSABLE_MAP,
|
|
540
|
+
createHomoglyphValidator,
|
|
475
541
|
createNamespaceGuard,
|
|
476
542
|
createProfanityValidator,
|
|
477
543
|
normalize
|
package/package.json
CHANGED