namespace-guard 0.8.2 → 0.11.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/README.md +125 -15
- package/dist/cli.js +14 -2
- package/dist/cli.mjs +14 -2
- package/dist/index.d.mts +79 -8
- package/dist/index.d.ts +79 -8
- package/dist/index.js +1798 -4
- package/dist/index.mjs +1794 -3
- package/package.json +1 -1
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/)**
|
|
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
|
|
|
@@ -92,6 +92,8 @@ const adapter = createPrismaAdapter(prisma);
|
|
|
92
92
|
|
|
93
93
|
### Drizzle
|
|
94
94
|
|
|
95
|
+
> **Note:** The Drizzle adapter uses `db.query` (the relational query API). Make sure your Drizzle client is set up with `drizzle(client, { schema })` so that `db.query.<tableName>` is available.
|
|
96
|
+
|
|
95
97
|
```typescript
|
|
96
98
|
import { eq } from "drizzle-orm";
|
|
97
99
|
import { createDrizzleAdapter } from "namespace-guard/adapters/drizzle";
|
|
@@ -163,7 +165,9 @@ import { User, Organization } from "./models";
|
|
|
163
165
|
const adapter = createMongooseAdapter({ user: User, organization: Organization });
|
|
164
166
|
```
|
|
165
167
|
|
|
166
|
-
### Raw SQL (pg, mysql2, etc.)
|
|
168
|
+
### Raw SQL (pg, mysql2, better-sqlite3, etc.)
|
|
169
|
+
|
|
170
|
+
The raw adapter generates PostgreSQL-style SQL (`$1` placeholders, double-quoted identifiers). For pg this works directly. For MySQL or SQLite, translate the parameter syntax in your executor wrapper.
|
|
167
171
|
|
|
168
172
|
```typescript
|
|
169
173
|
import { Pool } from "pg";
|
|
@@ -173,6 +177,34 @@ const pool = new Pool();
|
|
|
173
177
|
const adapter = createRawAdapter((sql, params) => pool.query(sql, params));
|
|
174
178
|
```
|
|
175
179
|
|
|
180
|
+
**MySQL2 wrapper** (translates `$1` to `?` and `"col"` to `` `col` ``):
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import mysql from "mysql2/promise";
|
|
184
|
+
import { createRawAdapter } from "namespace-guard/adapters/raw";
|
|
185
|
+
|
|
186
|
+
const pool = mysql.createPool({ uri: process.env.DATABASE_URL });
|
|
187
|
+
const adapter = createRawAdapter(async (sql, params) => {
|
|
188
|
+
const mysqlSql = sql.replace(/\$\d+/g, "?").replace(/"/g, "`");
|
|
189
|
+
const [rows] = await pool.execute(mysqlSql, params);
|
|
190
|
+
return { rows: rows as Record<string, unknown>[] };
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**better-sqlite3 wrapper** (translates `$1` to `?` and strips identifier quotes):
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
import Database from "better-sqlite3";
|
|
198
|
+
import { createRawAdapter } from "namespace-guard/adapters/raw";
|
|
199
|
+
|
|
200
|
+
const db = new Database("app.db");
|
|
201
|
+
const adapter = createRawAdapter(async (sql, params) => {
|
|
202
|
+
const sqliteSql = sql.replace(/\$\d+/g, "?").replace(/"/g, "");
|
|
203
|
+
const rows = db.prepare(sqliteSql).all(...params);
|
|
204
|
+
return { rows: rows as Record<string, unknown>[] };
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
176
208
|
## Configuration
|
|
177
209
|
|
|
178
210
|
```typescript
|
|
@@ -249,7 +281,7 @@ const result = await guard.check("admin");
|
|
|
249
281
|
// { available: false, reason: "reserved", category: "system", message: "That's a system route." }
|
|
250
282
|
```
|
|
251
283
|
|
|
252
|
-
You can also use a single string message for all categories, or mix
|
|
284
|
+
You can also use a single string message for all categories, or mix - categories without a specific message fall back to the default.
|
|
253
285
|
|
|
254
286
|
## Async Validators
|
|
255
287
|
|
|
@@ -279,7 +311,7 @@ Validators run sequentially and stop at the first rejection. They receive the no
|
|
|
279
311
|
|
|
280
312
|
### Built-in Profanity Validator
|
|
281
313
|
|
|
282
|
-
Use `createProfanityValidator` for a turnkey profanity filter
|
|
314
|
+
Use `createProfanityValidator` for a turnkey profanity filter - supply your own word list:
|
|
283
315
|
|
|
284
316
|
```typescript
|
|
285
317
|
import { createNamespaceGuard, createProfanityValidator } from "namespace-guard";
|
|
@@ -295,7 +327,7 @@ const guard = createNamespaceGuard({
|
|
|
295
327
|
}, adapter);
|
|
296
328
|
```
|
|
297
329
|
|
|
298
|
-
No words are bundled
|
|
330
|
+
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).
|
|
299
331
|
|
|
300
332
|
### Built-in Homoglyph Validator
|
|
301
333
|
|
|
@@ -324,6 +356,44 @@ createHomoglyphValidator({
|
|
|
324
356
|
|
|
325
357
|
The built-in `CONFUSABLE_MAP` contains 613 character pairs generated from [Unicode TR39 confusables.txt](https://unicode.org/reports/tr39/) plus supplemental Latin small capitals. It covers Cyrillic, Greek, Armenian, Cherokee, IPA, Coptic, Lisu, Canadian Syllabics, Georgian, and 20+ other scripts. The map is exported for inspection or extension, and is regenerable for new Unicode versions with `npx tsx scripts/generate-confusables.ts`.
|
|
326
358
|
|
|
359
|
+
#### CONFUSABLE_MAP_FULL
|
|
360
|
+
|
|
361
|
+
For standalone use without NFKC normalization, `CONFUSABLE_MAP_FULL` (~1,400 entries) includes every single-character-to-Latin mapping from TR39 with no NFKC filtering. This is the right map when your pipeline does not run NFKC before confusable detection, which is the case for most real-world systems: TR39's skeleton algorithm uses NFD, Chromium's IDN spoof checker uses NFD, Rust's `confusable_idents` lint runs on NFC, and django-registration applies the confusable map to raw input with no normalization at all.
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
import { CONFUSABLE_MAP_FULL } from "namespace-guard";
|
|
365
|
+
|
|
366
|
+
// Contains everything in CONFUSABLE_MAP, plus:
|
|
367
|
+
// - ~766 entries where NFKC agrees with TR39 (mathematical alphanumerics, fullwidth forms)
|
|
368
|
+
// - 31 entries where TR39 and NFKC disagree on the target letter
|
|
369
|
+
CONFUSABLE_MAP_FULL["\u017f"]; // "f" (Long S: TR39 visual mapping)
|
|
370
|
+
CONFUSABLE_MAP_FULL["\u{1D41A}"]; // "a" (Mathematical Bold Small A)
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
#### `skeleton()` and `areConfusable()`
|
|
374
|
+
|
|
375
|
+
The TR39 Section 4 skeleton algorithm computes a normalized form of a string for confusable comparison. Two strings that look alike will produce the same skeleton. This is the same algorithm used by ICU's SpoofChecker, Chromium's IDN spoof checker, and the Rust compiler's `confusable_idents` lint.
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
import { skeleton, areConfusable, CONFUSABLE_MAP } from "namespace-guard";
|
|
379
|
+
|
|
380
|
+
// Compute skeletons for comparison
|
|
381
|
+
skeleton("paypal"); // "paypal"
|
|
382
|
+
skeleton("\u0440\u0430ypal"); // "paypal" (Cyrillic р and а)
|
|
383
|
+
skeleton("pay\u200Bpal"); // "paypal" (zero-width space stripped)
|
|
384
|
+
skeleton("\u017f"); // "f" (Long S via TR39 visual mapping)
|
|
385
|
+
|
|
386
|
+
// Compare two strings directly
|
|
387
|
+
areConfusable("paypal", "\u0440\u0430ypal"); // true
|
|
388
|
+
areConfusable("google", "g\u043e\u043egle"); // true (Cyrillic о)
|
|
389
|
+
areConfusable("hello", "world"); // false
|
|
390
|
+
|
|
391
|
+
// Use CONFUSABLE_MAP for NFKC-first pipelines
|
|
392
|
+
skeleton("\u017f", { map: CONFUSABLE_MAP }); // "\u017f" (Long S not in filtered map)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
By default, `skeleton()` uses `CONFUSABLE_MAP_FULL` (the complete TR39 map), which matches the NFD-based pipeline specified by TR39. Pass `{ map: CONFUSABLE_MAP }` if your pipeline runs NFKC normalization before calling `skeleton()`.
|
|
396
|
+
|
|
327
397
|
### How the anti-spoofing pipeline works
|
|
328
398
|
|
|
329
399
|
Most confusable-detection libraries apply a character map in isolation. namespace-guard uses a three-stage pipeline where each stage is aware of the others:
|
|
@@ -335,13 +405,13 @@ Input → NFKC normalize → Confusable map → Mixed-script reject
|
|
|
335
405
|
|
|
336
406
|
**Stage 1: NFKC normalization** collapses full-width characters (`I` → `I`), ligatures (`fi` → `fi`), superscripts, and other Unicode compatibility forms to their canonical equivalents. This runs first, before any confusable check.
|
|
337
407
|
|
|
338
|
-
**Stage 2: Confusable map** catches characters that survive NFKC but visually mimic Latin letters
|
|
408
|
+
**Stage 2: Confusable map** catches characters that survive NFKC but visually mimic Latin letters - Cyrillic `а` for `a`, Greek `ο` for `o`, Cherokee `Ꭺ` for `A`, and 600+ others from the Unicode Consortium's [confusables.txt](https://unicode.org/Public/security/latest/confusables.txt).
|
|
339
409
|
|
|
340
410
|
**Stage 3: Mixed-script rejection** (`rejectMixedScript: true`) blocks identifiers that mix Latin with non-Latin scripts (Hebrew, Arabic, Devanagari, Thai, Georgian, Ethiopic, etc.) even if the specific characters aren't in the confusable map. This catches novel homoglyphs that the map doesn't cover.
|
|
341
411
|
|
|
342
412
|
#### Why NFKC-aware filtering matters
|
|
343
413
|
|
|
344
|
-
The key insight: TR39's confusables.txt and NFKC normalization sometimes disagree. For example, Unicode says capital `I` (U+0049) is confusable with lowercase `l`
|
|
414
|
+
The key insight: TR39's confusables.txt and NFKC normalization sometimes disagree. For example, Unicode says capital `I` (U+0049) is confusable with lowercase `l` - visually true in many fonts. But NFKC maps Mathematical Bold `𝐈` (U+1D408) to `I`, not `l`. If you naively ship the TR39 mapping (`𝐈` → `l`), the confusable check will never see that character - NFKC already converted it to `I` in stage 1.
|
|
345
415
|
|
|
346
416
|
We found 31 entries where this happens:
|
|
347
417
|
|
|
@@ -354,7 +424,7 @@ We found 31 entries where this happens:
|
|
|
354
424
|
| 11 Mathematical I variants | `l` | `i` | NFKC |
|
|
355
425
|
| 12 Mathematical 0/1 variants | `o`/`l` | `0`/`1` | NFKC |
|
|
356
426
|
|
|
357
|
-
These entries are dead code in any pipeline that runs NFKC first
|
|
427
|
+
These entries are dead code in any pipeline that runs NFKC first - and worse, they encode the *wrong* mapping. The generate script (`scripts/generate-confusables.ts`) automatically detects and excludes them.
|
|
358
428
|
|
|
359
429
|
## Unicode Normalization
|
|
360
430
|
|
|
@@ -429,7 +499,7 @@ const result = await guard.check("acme-corp");
|
|
|
429
499
|
|
|
430
500
|
### Composing Strategies
|
|
431
501
|
|
|
432
|
-
Combine multiple strategies
|
|
502
|
+
Combine multiple strategies - candidates are interleaved round-robin:
|
|
433
503
|
|
|
434
504
|
```typescript
|
|
435
505
|
suggest: {
|
|
@@ -503,10 +573,10 @@ npx namespace-guard check acme-corp
|
|
|
503
573
|
# ✓ acme-corp is available
|
|
504
574
|
|
|
505
575
|
npx namespace-guard check admin
|
|
506
|
-
# ✗ admin
|
|
576
|
+
# ✗ admin - That name is reserved. Try another one.
|
|
507
577
|
|
|
508
578
|
npx namespace-guard check "a"
|
|
509
|
-
# ✗ a
|
|
579
|
+
# ✗ a - Use 2-30 lowercase letters, numbers, or hyphens.
|
|
510
580
|
```
|
|
511
581
|
|
|
512
582
|
### With a config file
|
|
@@ -585,9 +655,16 @@ Check if an identifier is available.
|
|
|
585
655
|
|
|
586
656
|
---
|
|
587
657
|
|
|
588
|
-
### `guard.checkMany(identifiers, scope?)`
|
|
658
|
+
### `guard.checkMany(identifiers, scope?, options?)`
|
|
659
|
+
|
|
660
|
+
Check multiple identifiers in parallel. Suggestions are skipped by default for performance.
|
|
589
661
|
|
|
590
|
-
|
|
662
|
+
**Parameters:**
|
|
663
|
+
- `identifiers` - Array of slugs/handles to check
|
|
664
|
+
- `scope` - Optional ownership scope applied to all checks
|
|
665
|
+
- `options` - Optional `{ skipSuggestions?: boolean }` (default: `true`)
|
|
666
|
+
|
|
667
|
+
Pass `{ skipSuggestions: false }` to include suggestions for taken identifiers.
|
|
591
668
|
|
|
592
669
|
**Returns:** `Record<string, CheckResult>`
|
|
593
670
|
|
|
@@ -601,9 +678,37 @@ Same as `check()`, but throws an `Error` if not available.
|
|
|
601
678
|
|
|
602
679
|
### `guard.validateFormat(identifier)`
|
|
603
680
|
|
|
604
|
-
Validate format
|
|
681
|
+
Validate format, purely-numeric restriction, and reserved name status without querying the database.
|
|
682
|
+
|
|
683
|
+
**Returns:** Error message string if invalid or reserved, `null` if OK.
|
|
684
|
+
|
|
685
|
+
---
|
|
686
|
+
|
|
687
|
+
### `guard.validateFormatOnly(identifier)`
|
|
688
|
+
|
|
689
|
+
Validate only the identifier's format and purely-numeric restriction. Does not check reserved names or query the database. Useful for instant client-side feedback on input shape.
|
|
690
|
+
|
|
691
|
+
**Returns:** Error message string if the format is invalid, `null` if OK.
|
|
692
|
+
|
|
693
|
+
---
|
|
694
|
+
|
|
695
|
+
### `guard.normalize(identifier)`
|
|
696
|
+
|
|
697
|
+
Convenience re-export of the standalone `normalize()` function. Note: always applies NFKC normalization regardless of the guard's `normalizeUnicode` setting. Use `normalize(id, { unicode: false })` directly if you need to skip NFKC.
|
|
698
|
+
|
|
699
|
+
---
|
|
700
|
+
|
|
701
|
+
### `guard.clearCache()`
|
|
702
|
+
|
|
703
|
+
Clear the in-memory cache and reset hit/miss counters. No-op if caching is not enabled.
|
|
704
|
+
|
|
705
|
+
---
|
|
706
|
+
|
|
707
|
+
### `guard.cacheStats()`
|
|
708
|
+
|
|
709
|
+
Get cache performance statistics.
|
|
605
710
|
|
|
606
|
-
**Returns:**
|
|
711
|
+
**Returns:** `{ size: number; hits: number; misses: number }`
|
|
607
712
|
|
|
608
713
|
---
|
|
609
714
|
|
|
@@ -761,7 +866,10 @@ import {
|
|
|
761
866
|
createNamespaceGuard,
|
|
762
867
|
createProfanityValidator,
|
|
763
868
|
createHomoglyphValidator,
|
|
869
|
+
skeleton,
|
|
870
|
+
areConfusable,
|
|
764
871
|
CONFUSABLE_MAP,
|
|
872
|
+
CONFUSABLE_MAP_FULL,
|
|
765
873
|
normalize,
|
|
766
874
|
type NamespaceConfig,
|
|
767
875
|
type NamespaceSource,
|
|
@@ -771,6 +879,8 @@ import {
|
|
|
771
879
|
type FindOneOptions,
|
|
772
880
|
type OwnershipScope,
|
|
773
881
|
type SuggestStrategyName,
|
|
882
|
+
type SkeletonOptions,
|
|
883
|
+
type CheckManyOptions,
|
|
774
884
|
} from "namespace-guard";
|
|
775
885
|
```
|
|
776
886
|
|
package/dist/cli.js
CHANGED
|
@@ -339,6 +339,16 @@ function createNamespaceGuard(config, adapter) {
|
|
|
339
339
|
}
|
|
340
340
|
return null;
|
|
341
341
|
}
|
|
342
|
+
function validateFormatOnly(identifier) {
|
|
343
|
+
const normalized = normalize(identifier, normalizeOpts);
|
|
344
|
+
if (!pattern.test(normalized)) {
|
|
345
|
+
return invalidMsg;
|
|
346
|
+
}
|
|
347
|
+
if (!allowPurelyNumeric && /^\d+(-\d+)*$/.test(normalized)) {
|
|
348
|
+
return purelyNumericMsg;
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
342
352
|
function isOwnedByScope(existing, source, scope) {
|
|
343
353
|
if (!source.scopeKey) return false;
|
|
344
354
|
const scopeValue = scope[source.scopeKey];
|
|
@@ -457,10 +467,11 @@ function createNamespaceGuard(config, adapter) {
|
|
|
457
467
|
throw new Error(result.message);
|
|
458
468
|
}
|
|
459
469
|
}
|
|
460
|
-
async function checkMany(identifiers, scope = {}) {
|
|
470
|
+
async function checkMany(identifiers, scope = {}, options) {
|
|
471
|
+
const skip = options?.skipSuggestions ?? true;
|
|
461
472
|
const entries = await Promise.all(
|
|
462
473
|
identifiers.map(async (id) => {
|
|
463
|
-
const result = await check(id, scope, { skipSuggestions:
|
|
474
|
+
const result = await check(id, scope, { skipSuggestions: skip });
|
|
464
475
|
return [id, result];
|
|
465
476
|
})
|
|
466
477
|
);
|
|
@@ -477,6 +488,7 @@ function createNamespaceGuard(config, adapter) {
|
|
|
477
488
|
return {
|
|
478
489
|
normalize,
|
|
479
490
|
validateFormat,
|
|
491
|
+
validateFormatOnly,
|
|
480
492
|
check,
|
|
481
493
|
assertAvailable,
|
|
482
494
|
checkMany,
|
package/dist/cli.mjs
CHANGED
|
@@ -316,6 +316,16 @@ function createNamespaceGuard(config, adapter) {
|
|
|
316
316
|
}
|
|
317
317
|
return null;
|
|
318
318
|
}
|
|
319
|
+
function validateFormatOnly(identifier) {
|
|
320
|
+
const normalized = normalize(identifier, normalizeOpts);
|
|
321
|
+
if (!pattern.test(normalized)) {
|
|
322
|
+
return invalidMsg;
|
|
323
|
+
}
|
|
324
|
+
if (!allowPurelyNumeric && /^\d+(-\d+)*$/.test(normalized)) {
|
|
325
|
+
return purelyNumericMsg;
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
319
329
|
function isOwnedByScope(existing, source, scope) {
|
|
320
330
|
if (!source.scopeKey) return false;
|
|
321
331
|
const scopeValue = scope[source.scopeKey];
|
|
@@ -434,10 +444,11 @@ function createNamespaceGuard(config, adapter) {
|
|
|
434
444
|
throw new Error(result.message);
|
|
435
445
|
}
|
|
436
446
|
}
|
|
437
|
-
async function checkMany(identifiers, scope = {}) {
|
|
447
|
+
async function checkMany(identifiers, scope = {}, options) {
|
|
448
|
+
const skip = options?.skipSuggestions ?? true;
|
|
438
449
|
const entries = await Promise.all(
|
|
439
450
|
identifiers.map(async (id) => {
|
|
440
|
-
const result = await check(id, scope, { skipSuggestions:
|
|
451
|
+
const result = await check(id, scope, { skipSuggestions: skip });
|
|
441
452
|
return [id, result];
|
|
442
453
|
})
|
|
443
454
|
);
|
|
@@ -454,6 +465,7 @@ function createNamespaceGuard(config, adapter) {
|
|
|
454
465
|
return {
|
|
455
466
|
normalize,
|
|
456
467
|
validateFormat,
|
|
468
|
+
validateFormatOnly,
|
|
457
469
|
check,
|
|
458
470
|
assertAvailable,
|
|
459
471
|
checkMany,
|
package/dist/index.d.mts
CHANGED
|
@@ -6,14 +6,14 @@ type NamespaceSource = {
|
|
|
6
6
|
column: string;
|
|
7
7
|
/** Column name for the primary key (default: "id", or "_id" for Mongoose) */
|
|
8
8
|
idColumn?: string;
|
|
9
|
-
/** Scope key for ownership checks
|
|
9
|
+
/** Scope key for ownership checks - allows users to update their own slug without a false collision */
|
|
10
10
|
scopeKey?: string;
|
|
11
11
|
};
|
|
12
12
|
/** Built-in suggestion strategy names. */
|
|
13
13
|
type SuggestStrategyName = "sequential" | "random-digits" | "suffix-words" | "short-random" | "scramble" | "similar";
|
|
14
14
|
/** Configuration for a namespace guard instance. */
|
|
15
15
|
type NamespaceConfig = {
|
|
16
|
-
/** Reserved names
|
|
16
|
+
/** Reserved names - flat list, Set, or categorized record */
|
|
17
17
|
reserved?: Set<string> | string[] | Record<string, string[]>;
|
|
18
18
|
/** Data sources to check for collisions */
|
|
19
19
|
sources: NamespaceSource[];
|
|
@@ -35,7 +35,7 @@ type NamespaceConfig = {
|
|
|
35
35
|
/** Message shown when a purely numeric identifier is rejected (default: "Identifiers cannot be purely numeric.") */
|
|
36
36
|
purelyNumeric?: string;
|
|
37
37
|
};
|
|
38
|
-
/** Async validation hooks
|
|
38
|
+
/** Async validation hooks - run after format/reserved checks, before DB */
|
|
39
39
|
validators?: Array<(value: string) => Promise<{
|
|
40
40
|
available: false;
|
|
41
41
|
message: string;
|
|
@@ -62,7 +62,7 @@ type FindOneOptions = {
|
|
|
62
62
|
/** Use case-insensitive matching */
|
|
63
63
|
caseInsensitive?: boolean;
|
|
64
64
|
};
|
|
65
|
-
/** Database adapter interface
|
|
65
|
+
/** Database adapter interface - implement this for your ORM or query builder. */
|
|
66
66
|
type NamespaceAdapter = {
|
|
67
67
|
findOne: (source: NamespaceSource, value: string, options?: FindOneOptions) => Promise<Record<string, unknown> | null>;
|
|
68
68
|
};
|
|
@@ -82,6 +82,18 @@ type CheckResult = {
|
|
|
82
82
|
category?: string;
|
|
83
83
|
suggestions?: string[];
|
|
84
84
|
};
|
|
85
|
+
/** Options for `checkMany()`. */
|
|
86
|
+
type CheckManyOptions = {
|
|
87
|
+
/** Skip suggestion generation for taken identifiers (default: `true`). */
|
|
88
|
+
skipSuggestions?: boolean;
|
|
89
|
+
};
|
|
90
|
+
/** Options for the `skeleton()` and `areConfusable()` functions. */
|
|
91
|
+
type SkeletonOptions = {
|
|
92
|
+
/** Confusable character map to use.
|
|
93
|
+
* Default: `CONFUSABLE_MAP_FULL` (complete TR39 map, no NFKC filtering).
|
|
94
|
+
* Pass `CONFUSABLE_MAP` if your pipeline runs NFKC before calling skeleton(). */
|
|
95
|
+
map?: Record<string, string>;
|
|
96
|
+
};
|
|
85
97
|
/**
|
|
86
98
|
* Normalize a raw identifier: trims whitespace, applies NFKC Unicode normalization,
|
|
87
99
|
* lowercases, and strips leading `@` symbols.
|
|
@@ -108,7 +120,7 @@ declare function normalize(raw: string, options?: {
|
|
|
108
120
|
/**
|
|
109
121
|
* Create a validator that rejects identifiers containing profanity or offensive words.
|
|
110
122
|
*
|
|
111
|
-
* Supply your own word list
|
|
123
|
+
* Supply your own word list - no words are bundled with the library.
|
|
112
124
|
* The returned function is compatible with `config.validators`.
|
|
113
125
|
*
|
|
114
126
|
* @param words - Array of words to block
|
|
@@ -146,6 +158,22 @@ declare function createProfanityValidator(words: string[], options?: {
|
|
|
146
158
|
* Regenerate: `npx tsx scripts/generate-confusables.ts`
|
|
147
159
|
*/
|
|
148
160
|
declare const CONFUSABLE_MAP: Record<string, string>;
|
|
161
|
+
/**
|
|
162
|
+
* Complete TR39 confusable mapping: every single-character mapping to a
|
|
163
|
+
* lowercase Latin letter or digit from confusables.txt, with no NFKC filtering.
|
|
164
|
+
*
|
|
165
|
+
* Use this when your pipeline does NOT run NFKC normalization before confusable
|
|
166
|
+
* detection (which is most real-world systems: TR39 skeleton uses NFD, Chromium
|
|
167
|
+
* uses NFD, Rust uses NFC, django-registration uses no normalization at all).
|
|
168
|
+
*
|
|
169
|
+
* Includes ~1,400 entries vs CONFUSABLE_MAP's ~613 NFKC-deduped entries.
|
|
170
|
+
* The additional entries cover characters that NFKC normalization would handle
|
|
171
|
+
* (mathematical alphanumerics, fullwidth forms, etc.) plus the 31 entries where
|
|
172
|
+
* TR39 and NFKC disagree on the target letter.
|
|
173
|
+
*
|
|
174
|
+
* Regenerate: `npx tsx scripts/generate-confusables.ts`
|
|
175
|
+
*/
|
|
176
|
+
declare const CONFUSABLE_MAP_FULL: Record<string, string>;
|
|
149
177
|
/**
|
|
150
178
|
* Create a validator that rejects identifiers containing homoglyph/confusable characters.
|
|
151
179
|
*
|
|
@@ -179,13 +207,55 @@ declare function createHomoglyphValidator(options?: {
|
|
|
179
207
|
available: false;
|
|
180
208
|
message: string;
|
|
181
209
|
} | null>;
|
|
210
|
+
/**
|
|
211
|
+
* Compute the TR39 Section 4 skeleton of a string for confusable comparison.
|
|
212
|
+
*
|
|
213
|
+
* Implements `internalSkeleton`:
|
|
214
|
+
* 1. NFD normalize
|
|
215
|
+
* 2. Remove Default_Ignorable_Code_Point characters
|
|
216
|
+
* 3. Replace each character via the confusable map
|
|
217
|
+
* 4. Reapply NFD
|
|
218
|
+
* 5. Lowercase
|
|
219
|
+
*
|
|
220
|
+
* The default map is `CONFUSABLE_MAP_FULL` (the complete TR39 mapping without
|
|
221
|
+
* NFKC filtering), which matches the NFD-based pipeline used by ICU, Chromium,
|
|
222
|
+
* and the TR39 spec itself. Pass `{ map: CONFUSABLE_MAP }` if your pipeline
|
|
223
|
+
* runs NFKC normalization before calling skeleton().
|
|
224
|
+
*
|
|
225
|
+
* @param input - The string to skeletonize
|
|
226
|
+
* @param options - Optional settings (custom confusable map)
|
|
227
|
+
* @returns The skeleton string for comparison
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```ts
|
|
231
|
+
* skeleton("paypal") === skeleton("\u0440\u0430ypal") // true (Cyrillic р/а)
|
|
232
|
+
* skeleton("pay\u200Bpal") === skeleton("paypal") // true (zero-width stripped)
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
declare function skeleton(input: string, options?: SkeletonOptions): string;
|
|
236
|
+
/**
|
|
237
|
+
* Check whether two strings are visually confusable by comparing their TR39 skeletons.
|
|
238
|
+
*
|
|
239
|
+
* @param a - First string
|
|
240
|
+
* @param b - Second string
|
|
241
|
+
* @param options - Optional settings (custom confusable map)
|
|
242
|
+
* @returns `true` if the strings produce the same skeleton
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* ```ts
|
|
246
|
+
* areConfusable("paypal", "\u0440\u0430ypal") // true
|
|
247
|
+
* areConfusable("google", "g\u043e\u043egle") // true
|
|
248
|
+
* areConfusable("hello", "world") // false
|
|
249
|
+
* ```
|
|
250
|
+
*/
|
|
251
|
+
declare function areConfusable(a: string, b: string, options?: SkeletonOptions): boolean;
|
|
182
252
|
/**
|
|
183
253
|
* Create a namespace guard instance for checking slug/handle uniqueness
|
|
184
254
|
* across multiple database tables with reserved name protection.
|
|
185
255
|
*
|
|
186
256
|
* @param config - Reserved names, data sources, validation pattern, and optional features
|
|
187
257
|
* @param adapter - Database adapter implementing the `findOne` lookup (use a built-in adapter or write your own)
|
|
188
|
-
* @returns A guard with `check`, `checkMany`, `assertAvailable`, `validateFormat`, `clearCache`, and `cacheStats` methods
|
|
258
|
+
* @returns A guard with `check`, `checkMany`, `assertAvailable`, `validateFormat`, `validateFormatOnly`, `clearCache`, and `cacheStats` methods
|
|
189
259
|
*
|
|
190
260
|
* @example
|
|
191
261
|
* ```ts
|
|
@@ -212,11 +282,12 @@ declare function createHomoglyphValidator(options?: {
|
|
|
212
282
|
declare function createNamespaceGuard(config: NamespaceConfig, adapter: NamespaceAdapter): {
|
|
213
283
|
normalize: typeof normalize;
|
|
214
284
|
validateFormat: (identifier: string) => string | null;
|
|
285
|
+
validateFormatOnly: (identifier: string) => string | null;
|
|
215
286
|
check: (identifier: string, scope?: OwnershipScope, options?: {
|
|
216
287
|
skipSuggestions?: boolean;
|
|
217
288
|
}) => Promise<CheckResult>;
|
|
218
289
|
assertAvailable: (identifier: string, scope?: OwnershipScope) => Promise<void>;
|
|
219
|
-
checkMany: (identifiers: string[], scope?: OwnershipScope) => Promise<Record<string, CheckResult>>;
|
|
290
|
+
checkMany: (identifiers: string[], scope?: OwnershipScope, options?: CheckManyOptions) => Promise<Record<string, CheckResult>>;
|
|
220
291
|
clearCache: () => void;
|
|
221
292
|
cacheStats: () => {
|
|
222
293
|
size: number;
|
|
@@ -227,4 +298,4 @@ declare function createNamespaceGuard(config: NamespaceConfig, adapter: Namespac
|
|
|
227
298
|
/** The guard instance returned by `createNamespaceGuard`. */
|
|
228
299
|
type NamespaceGuard = ReturnType<typeof createNamespaceGuard>;
|
|
229
300
|
|
|
230
|
-
export { CONFUSABLE_MAP, type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SuggestStrategyName, createHomoglyphValidator, createNamespaceGuard, createProfanityValidator, normalize };
|
|
301
|
+
export { CONFUSABLE_MAP, CONFUSABLE_MAP_FULL, type CheckManyOptions, type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SkeletonOptions, type SuggestStrategyName, areConfusable, createHomoglyphValidator, createNamespaceGuard, createProfanityValidator, normalize, skeleton };
|
package/dist/index.d.ts
CHANGED
|
@@ -6,14 +6,14 @@ type NamespaceSource = {
|
|
|
6
6
|
column: string;
|
|
7
7
|
/** Column name for the primary key (default: "id", or "_id" for Mongoose) */
|
|
8
8
|
idColumn?: string;
|
|
9
|
-
/** Scope key for ownership checks
|
|
9
|
+
/** Scope key for ownership checks - allows users to update their own slug without a false collision */
|
|
10
10
|
scopeKey?: string;
|
|
11
11
|
};
|
|
12
12
|
/** Built-in suggestion strategy names. */
|
|
13
13
|
type SuggestStrategyName = "sequential" | "random-digits" | "suffix-words" | "short-random" | "scramble" | "similar";
|
|
14
14
|
/** Configuration for a namespace guard instance. */
|
|
15
15
|
type NamespaceConfig = {
|
|
16
|
-
/** Reserved names
|
|
16
|
+
/** Reserved names - flat list, Set, or categorized record */
|
|
17
17
|
reserved?: Set<string> | string[] | Record<string, string[]>;
|
|
18
18
|
/** Data sources to check for collisions */
|
|
19
19
|
sources: NamespaceSource[];
|
|
@@ -35,7 +35,7 @@ type NamespaceConfig = {
|
|
|
35
35
|
/** Message shown when a purely numeric identifier is rejected (default: "Identifiers cannot be purely numeric.") */
|
|
36
36
|
purelyNumeric?: string;
|
|
37
37
|
};
|
|
38
|
-
/** Async validation hooks
|
|
38
|
+
/** Async validation hooks - run after format/reserved checks, before DB */
|
|
39
39
|
validators?: Array<(value: string) => Promise<{
|
|
40
40
|
available: false;
|
|
41
41
|
message: string;
|
|
@@ -62,7 +62,7 @@ type FindOneOptions = {
|
|
|
62
62
|
/** Use case-insensitive matching */
|
|
63
63
|
caseInsensitive?: boolean;
|
|
64
64
|
};
|
|
65
|
-
/** Database adapter interface
|
|
65
|
+
/** Database adapter interface - implement this for your ORM or query builder. */
|
|
66
66
|
type NamespaceAdapter = {
|
|
67
67
|
findOne: (source: NamespaceSource, value: string, options?: FindOneOptions) => Promise<Record<string, unknown> | null>;
|
|
68
68
|
};
|
|
@@ -82,6 +82,18 @@ type CheckResult = {
|
|
|
82
82
|
category?: string;
|
|
83
83
|
suggestions?: string[];
|
|
84
84
|
};
|
|
85
|
+
/** Options for `checkMany()`. */
|
|
86
|
+
type CheckManyOptions = {
|
|
87
|
+
/** Skip suggestion generation for taken identifiers (default: `true`). */
|
|
88
|
+
skipSuggestions?: boolean;
|
|
89
|
+
};
|
|
90
|
+
/** Options for the `skeleton()` and `areConfusable()` functions. */
|
|
91
|
+
type SkeletonOptions = {
|
|
92
|
+
/** Confusable character map to use.
|
|
93
|
+
* Default: `CONFUSABLE_MAP_FULL` (complete TR39 map, no NFKC filtering).
|
|
94
|
+
* Pass `CONFUSABLE_MAP` if your pipeline runs NFKC before calling skeleton(). */
|
|
95
|
+
map?: Record<string, string>;
|
|
96
|
+
};
|
|
85
97
|
/**
|
|
86
98
|
* Normalize a raw identifier: trims whitespace, applies NFKC Unicode normalization,
|
|
87
99
|
* lowercases, and strips leading `@` symbols.
|
|
@@ -108,7 +120,7 @@ declare function normalize(raw: string, options?: {
|
|
|
108
120
|
/**
|
|
109
121
|
* Create a validator that rejects identifiers containing profanity or offensive words.
|
|
110
122
|
*
|
|
111
|
-
* Supply your own word list
|
|
123
|
+
* Supply your own word list - no words are bundled with the library.
|
|
112
124
|
* The returned function is compatible with `config.validators`.
|
|
113
125
|
*
|
|
114
126
|
* @param words - Array of words to block
|
|
@@ -146,6 +158,22 @@ declare function createProfanityValidator(words: string[], options?: {
|
|
|
146
158
|
* Regenerate: `npx tsx scripts/generate-confusables.ts`
|
|
147
159
|
*/
|
|
148
160
|
declare const CONFUSABLE_MAP: Record<string, string>;
|
|
161
|
+
/**
|
|
162
|
+
* Complete TR39 confusable mapping: every single-character mapping to a
|
|
163
|
+
* lowercase Latin letter or digit from confusables.txt, with no NFKC filtering.
|
|
164
|
+
*
|
|
165
|
+
* Use this when your pipeline does NOT run NFKC normalization before confusable
|
|
166
|
+
* detection (which is most real-world systems: TR39 skeleton uses NFD, Chromium
|
|
167
|
+
* uses NFD, Rust uses NFC, django-registration uses no normalization at all).
|
|
168
|
+
*
|
|
169
|
+
* Includes ~1,400 entries vs CONFUSABLE_MAP's ~613 NFKC-deduped entries.
|
|
170
|
+
* The additional entries cover characters that NFKC normalization would handle
|
|
171
|
+
* (mathematical alphanumerics, fullwidth forms, etc.) plus the 31 entries where
|
|
172
|
+
* TR39 and NFKC disagree on the target letter.
|
|
173
|
+
*
|
|
174
|
+
* Regenerate: `npx tsx scripts/generate-confusables.ts`
|
|
175
|
+
*/
|
|
176
|
+
declare const CONFUSABLE_MAP_FULL: Record<string, string>;
|
|
149
177
|
/**
|
|
150
178
|
* Create a validator that rejects identifiers containing homoglyph/confusable characters.
|
|
151
179
|
*
|
|
@@ -179,13 +207,55 @@ declare function createHomoglyphValidator(options?: {
|
|
|
179
207
|
available: false;
|
|
180
208
|
message: string;
|
|
181
209
|
} | null>;
|
|
210
|
+
/**
|
|
211
|
+
* Compute the TR39 Section 4 skeleton of a string for confusable comparison.
|
|
212
|
+
*
|
|
213
|
+
* Implements `internalSkeleton`:
|
|
214
|
+
* 1. NFD normalize
|
|
215
|
+
* 2. Remove Default_Ignorable_Code_Point characters
|
|
216
|
+
* 3. Replace each character via the confusable map
|
|
217
|
+
* 4. Reapply NFD
|
|
218
|
+
* 5. Lowercase
|
|
219
|
+
*
|
|
220
|
+
* The default map is `CONFUSABLE_MAP_FULL` (the complete TR39 mapping without
|
|
221
|
+
* NFKC filtering), which matches the NFD-based pipeline used by ICU, Chromium,
|
|
222
|
+
* and the TR39 spec itself. Pass `{ map: CONFUSABLE_MAP }` if your pipeline
|
|
223
|
+
* runs NFKC normalization before calling skeleton().
|
|
224
|
+
*
|
|
225
|
+
* @param input - The string to skeletonize
|
|
226
|
+
* @param options - Optional settings (custom confusable map)
|
|
227
|
+
* @returns The skeleton string for comparison
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```ts
|
|
231
|
+
* skeleton("paypal") === skeleton("\u0440\u0430ypal") // true (Cyrillic р/а)
|
|
232
|
+
* skeleton("pay\u200Bpal") === skeleton("paypal") // true (zero-width stripped)
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
declare function skeleton(input: string, options?: SkeletonOptions): string;
|
|
236
|
+
/**
|
|
237
|
+
* Check whether two strings are visually confusable by comparing their TR39 skeletons.
|
|
238
|
+
*
|
|
239
|
+
* @param a - First string
|
|
240
|
+
* @param b - Second string
|
|
241
|
+
* @param options - Optional settings (custom confusable map)
|
|
242
|
+
* @returns `true` if the strings produce the same skeleton
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* ```ts
|
|
246
|
+
* areConfusable("paypal", "\u0440\u0430ypal") // true
|
|
247
|
+
* areConfusable("google", "g\u043e\u043egle") // true
|
|
248
|
+
* areConfusable("hello", "world") // false
|
|
249
|
+
* ```
|
|
250
|
+
*/
|
|
251
|
+
declare function areConfusable(a: string, b: string, options?: SkeletonOptions): boolean;
|
|
182
252
|
/**
|
|
183
253
|
* Create a namespace guard instance for checking slug/handle uniqueness
|
|
184
254
|
* across multiple database tables with reserved name protection.
|
|
185
255
|
*
|
|
186
256
|
* @param config - Reserved names, data sources, validation pattern, and optional features
|
|
187
257
|
* @param adapter - Database adapter implementing the `findOne` lookup (use a built-in adapter or write your own)
|
|
188
|
-
* @returns A guard with `check`, `checkMany`, `assertAvailable`, `validateFormat`, `clearCache`, and `cacheStats` methods
|
|
258
|
+
* @returns A guard with `check`, `checkMany`, `assertAvailable`, `validateFormat`, `validateFormatOnly`, `clearCache`, and `cacheStats` methods
|
|
189
259
|
*
|
|
190
260
|
* @example
|
|
191
261
|
* ```ts
|
|
@@ -212,11 +282,12 @@ declare function createHomoglyphValidator(options?: {
|
|
|
212
282
|
declare function createNamespaceGuard(config: NamespaceConfig, adapter: NamespaceAdapter): {
|
|
213
283
|
normalize: typeof normalize;
|
|
214
284
|
validateFormat: (identifier: string) => string | null;
|
|
285
|
+
validateFormatOnly: (identifier: string) => string | null;
|
|
215
286
|
check: (identifier: string, scope?: OwnershipScope, options?: {
|
|
216
287
|
skipSuggestions?: boolean;
|
|
217
288
|
}) => Promise<CheckResult>;
|
|
218
289
|
assertAvailable: (identifier: string, scope?: OwnershipScope) => Promise<void>;
|
|
219
|
-
checkMany: (identifiers: string[], scope?: OwnershipScope) => Promise<Record<string, CheckResult>>;
|
|
290
|
+
checkMany: (identifiers: string[], scope?: OwnershipScope, options?: CheckManyOptions) => Promise<Record<string, CheckResult>>;
|
|
220
291
|
clearCache: () => void;
|
|
221
292
|
cacheStats: () => {
|
|
222
293
|
size: number;
|
|
@@ -227,4 +298,4 @@ declare function createNamespaceGuard(config: NamespaceConfig, adapter: Namespac
|
|
|
227
298
|
/** The guard instance returned by `createNamespaceGuard`. */
|
|
228
299
|
type NamespaceGuard = ReturnType<typeof createNamespaceGuard>;
|
|
229
300
|
|
|
230
|
-
export { CONFUSABLE_MAP, type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SuggestStrategyName, createHomoglyphValidator, createNamespaceGuard, createProfanityValidator, normalize };
|
|
301
|
+
export { CONFUSABLE_MAP, CONFUSABLE_MAP_FULL, type CheckManyOptions, type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SkeletonOptions, type SuggestStrategyName, areConfusable, createHomoglyphValidator, createNamespaceGuard, createProfanityValidator, normalize, skeleton };
|