namespace-guard 0.8.1 → 0.10.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 CHANGED
@@ -5,7 +5,7 @@
5
5
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
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
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
 
@@ -249,7 +249,7 @@ const result = await guard.check("admin");
249
249
  // { available: false, reason: "reserved", category: "system", message: "That's a system route." }
250
250
  ```
251
251
 
252
- You can also use a single string message for all categories, or mix categories without a specific message fall back to the default.
252
+ You can also use a single string message for all categories, or mix - categories without a specific message fall back to the default.
253
253
 
254
254
  ## Async Validators
255
255
 
@@ -279,7 +279,7 @@ Validators run sequentially and stop at the first rejection. They receive the no
279
279
 
280
280
  ### Built-in Profanity Validator
281
281
 
282
- Use `createProfanityValidator` for a turnkey profanity filter supply your own word list:
282
+ Use `createProfanityValidator` for a turnkey profanity filter - supply your own word list:
283
283
 
284
284
  ```typescript
285
285
  import { createNamespaceGuard, createProfanityValidator } from "namespace-guard";
@@ -295,7 +295,7 @@ const guard = createNamespaceGuard({
295
295
  }, adapter);
296
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
+ 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
299
 
300
300
  ### Built-in Homoglyph Validator
301
301
 
@@ -324,6 +324,44 @@ createHomoglyphValidator({
324
324
 
325
325
  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
326
 
327
+ #### CONFUSABLE_MAP_FULL
328
+
329
+ 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.
330
+
331
+ ```typescript
332
+ import { CONFUSABLE_MAP_FULL } from "namespace-guard";
333
+
334
+ // Contains everything in CONFUSABLE_MAP, plus:
335
+ // - ~766 entries where NFKC agrees with TR39 (mathematical alphanumerics, fullwidth forms)
336
+ // - 31 entries where TR39 and NFKC disagree on the target letter
337
+ CONFUSABLE_MAP_FULL["\u017f"]; // "f" (Long S: TR39 visual mapping)
338
+ CONFUSABLE_MAP_FULL["\u{1D41A}"]; // "a" (Mathematical Bold Small A)
339
+ ```
340
+
341
+ #### `skeleton()` and `areConfusable()`
342
+
343
+ 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.
344
+
345
+ ```typescript
346
+ import { skeleton, areConfusable, CONFUSABLE_MAP } from "namespace-guard";
347
+
348
+ // Compute skeletons for comparison
349
+ skeleton("paypal"); // "paypal"
350
+ skeleton("\u0440\u0430ypal"); // "paypal" (Cyrillic р and а)
351
+ skeleton("pay\u200Bpal"); // "paypal" (zero-width space stripped)
352
+ skeleton("\u017f"); // "f" (Long S via TR39 visual mapping)
353
+
354
+ // Compare two strings directly
355
+ areConfusable("paypal", "\u0440\u0430ypal"); // true
356
+ areConfusable("google", "g\u043e\u043egle"); // true (Cyrillic о)
357
+ areConfusable("hello", "world"); // false
358
+
359
+ // Use CONFUSABLE_MAP for NFKC-first pipelines
360
+ skeleton("\u017f", { map: CONFUSABLE_MAP }); // "\u017f" (Long S not in filtered map)
361
+ ```
362
+
363
+ 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()`.
364
+
327
365
  ### How the anti-spoofing pipeline works
328
366
 
329
367
  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 +373,13 @@ Input → NFKC normalize → Confusable map → Mixed-script reject
335
373
 
336
374
  **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
375
 
338
- **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).
376
+ **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
377
 
340
378
  **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
379
 
342
380
  #### Why NFKC-aware filtering matters
343
381
 
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` 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.
382
+ 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
383
 
346
384
  We found 31 entries where this happens:
347
385
 
@@ -354,7 +392,7 @@ We found 31 entries where this happens:
354
392
  | 11 Mathematical I variants | `l` | `i` | NFKC |
355
393
  | 12 Mathematical 0/1 variants | `o`/`l` | `0`/`1` | NFKC |
356
394
 
357
- 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.
395
+ 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
396
 
359
397
  ## Unicode Normalization
360
398
 
@@ -429,7 +467,7 @@ const result = await guard.check("acme-corp");
429
467
 
430
468
  ### Composing Strategies
431
469
 
432
- Combine multiple strategies candidates are interleaved round-robin:
470
+ Combine multiple strategies - candidates are interleaved round-robin:
433
471
 
434
472
  ```typescript
435
473
  suggest: {
@@ -503,10 +541,10 @@ npx namespace-guard check acme-corp
503
541
  # ✓ acme-corp is available
504
542
 
505
543
  npx namespace-guard check admin
506
- # ✗ admin That name is reserved. Try another one.
544
+ # ✗ admin - That name is reserved. Try another one.
507
545
 
508
546
  npx namespace-guard check "a"
509
- # ✗ a Use 2-30 lowercase letters, numbers, or hyphens.
547
+ # ✗ a - Use 2-30 lowercase letters, numbers, or hyphens.
510
548
  ```
511
549
 
512
550
  ### With a config file
@@ -648,7 +686,8 @@ Enable in-memory caching to reduce database calls during rapid checks (e.g., liv
648
686
  const guard = createNamespaceGuard({
649
687
  sources: [/* ... */],
650
688
  cache: {
651
- ttl: 5000, // milliseconds (default: 5000)
689
+ ttl: 5000, // milliseconds (default: 5000)
690
+ maxSize: 1000, // max cached entries before LRU eviction (default: 1000)
652
691
  },
653
692
  }, adapter);
654
693
 
@@ -760,7 +799,10 @@ import {
760
799
  createNamespaceGuard,
761
800
  createProfanityValidator,
762
801
  createHomoglyphValidator,
802
+ skeleton,
803
+ areConfusable,
763
804
  CONFUSABLE_MAP,
805
+ CONFUSABLE_MAP_FULL,
764
806
  normalize,
765
807
  type NamespaceConfig,
766
808
  type NamespaceSource,
@@ -770,6 +812,7 @@ import {
770
812
  type FindOneOptions,
771
813
  type OwnershipScope,
772
814
  type SuggestStrategyName,
815
+ type SkeletonOptions,
773
816
  } from "namespace-guard";
774
817
  ```
775
818
 
@@ -3,32 +3,6 @@ import { NamespaceAdapter } from '../index.mjs';
3
3
  type QueryExecutor = (sql: string, params: unknown[]) => Promise<{
4
4
  rows: Record<string, unknown>[];
5
5
  }>;
6
- /**
7
- * Create a namespace adapter for raw SQL queries
8
- *
9
- * Works with PostgreSQL-compatible clients that use $1-style parameter placeholders (pg).
10
- * For MySQL or SQLite, wrap the executor to translate parameter syntax.
11
- *
12
- * @example
13
- * ```ts
14
- * import { Pool } from "pg";
15
- * import { createNamespaceGuard } from "namespace-guard";
16
- * import { createRawAdapter } from "namespace-guard/adapters/raw";
17
- *
18
- * const pool = new Pool();
19
- *
20
- * const guard = createNamespaceGuard(
21
- * {
22
- * reserved: ["admin", "api", "settings"],
23
- * sources: [
24
- * { name: "users", column: "handle", scopeKey: "id" },
25
- * { name: "organizations", column: "slug", scopeKey: "id" },
26
- * ],
27
- * },
28
- * createRawAdapter((sql, params) => pool.query(sql, params))
29
- * );
30
- * ```
31
- */
32
6
  declare function createRawAdapter(execute: QueryExecutor): NamespaceAdapter;
33
7
 
34
8
  export { createRawAdapter };
@@ -3,32 +3,6 @@ import { NamespaceAdapter } from '../index.js';
3
3
  type QueryExecutor = (sql: string, params: unknown[]) => Promise<{
4
4
  rows: Record<string, unknown>[];
5
5
  }>;
6
- /**
7
- * Create a namespace adapter for raw SQL queries
8
- *
9
- * Works with PostgreSQL-compatible clients that use $1-style parameter placeholders (pg).
10
- * For MySQL or SQLite, wrap the executor to translate parameter syntax.
11
- *
12
- * @example
13
- * ```ts
14
- * import { Pool } from "pg";
15
- * import { createNamespaceGuard } from "namespace-guard";
16
- * import { createRawAdapter } from "namespace-guard/adapters/raw";
17
- *
18
- * const pool = new Pool();
19
- *
20
- * const guard = createNamespaceGuard(
21
- * {
22
- * reserved: ["admin", "api", "settings"],
23
- * sources: [
24
- * { name: "users", column: "handle", scopeKey: "id" },
25
- * { name: "organizations", column: "slug", scopeKey: "id" },
26
- * ],
27
- * },
28
- * createRawAdapter((sql, params) => pool.query(sql, params))
29
- * );
30
- * ```
31
- */
32
6
  declare function createRawAdapter(execute: QueryExecutor): NamespaceAdapter;
33
7
 
34
8
  export { createRawAdapter };
@@ -23,10 +23,20 @@ __export(raw_exports, {
23
23
  createRawAdapter: () => createRawAdapter
24
24
  });
25
25
  module.exports = __toCommonJS(raw_exports);
26
+ var SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
27
+ function assertSafeIdentifier(name, label) {
28
+ if (!SAFE_IDENTIFIER.test(name)) {
29
+ throw new Error(`Unsafe ${label}: ${JSON.stringify(name)}. Use only letters, digits, and underscores.`);
30
+ }
31
+ }
26
32
  function createRawAdapter(execute) {
27
33
  return {
28
34
  async findOne(source, value, options) {
29
35
  const idColumn = source.idColumn ?? "id";
36
+ assertSafeIdentifier(source.name, "table name");
37
+ assertSafeIdentifier(source.column, "column name");
38
+ assertSafeIdentifier(idColumn, "id column name");
39
+ if (source.scopeKey) assertSafeIdentifier(source.scopeKey, "scope key");
30
40
  const columns = source.scopeKey && source.scopeKey !== idColumn ? `"${idColumn}", "${source.scopeKey}"` : `"${idColumn}"`;
31
41
  const whereClause = options?.caseInsensitive ? `LOWER("${source.column}") = LOWER($1)` : `"${source.column}" = $1`;
32
42
  const sql = `SELECT ${columns} FROM "${source.name}" WHERE ${whereClause} LIMIT 1`;
@@ -1,8 +1,18 @@
1
1
  // src/adapters/raw.ts
2
+ var SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
3
+ function assertSafeIdentifier(name, label) {
4
+ if (!SAFE_IDENTIFIER.test(name)) {
5
+ throw new Error(`Unsafe ${label}: ${JSON.stringify(name)}. Use only letters, digits, and underscores.`);
6
+ }
7
+ }
2
8
  function createRawAdapter(execute) {
3
9
  return {
4
10
  async findOne(source, value, options) {
5
11
  const idColumn = source.idColumn ?? "id";
12
+ assertSafeIdentifier(source.name, "table name");
13
+ assertSafeIdentifier(source.column, "column name");
14
+ assertSafeIdentifier(idColumn, "id column name");
15
+ if (source.scopeKey) assertSafeIdentifier(source.scopeKey, "scope key");
6
16
  const columns = source.scopeKey && source.scopeKey !== idColumn ? `"${idColumn}", "${source.scopeKey}"` : `"${idColumn}"`;
7
17
  const whereClause = options?.caseInsensitive ? `LOWER("${source.column}") = LOWER($1)` : `"${source.column}" = $1`;
8
18
  const sql = `SELECT ${columns} FROM "${source.name}" WHERE ${whereClause} LIMIT 1`;
package/dist/cli.js CHANGED
@@ -144,14 +144,12 @@ function createScrambleStrategy(_pattern) {
144
144
  const candidates = [];
145
145
  const chars = identifier.split("");
146
146
  for (let i = 0; i < chars.length - 1; i++) {
147
- if (chars[i] !== chars[i + 1]) {
148
- const swapped = [...chars];
149
- [swapped[i], swapped[i + 1]] = [swapped[i + 1], swapped[i]];
150
- const candidate = swapped.join("");
151
- if (candidate !== identifier && !seen.has(candidate)) {
152
- seen.add(candidate);
153
- candidates.push(candidate);
154
- }
147
+ const swapped = [...chars];
148
+ [swapped[i], swapped[i + 1]] = [swapped[i + 1], swapped[i]];
149
+ const candidate = swapped.join("");
150
+ if (candidate !== identifier && !seen.has(candidate)) {
151
+ seen.add(candidate);
152
+ candidates.push(candidate);
155
153
  }
156
154
  }
157
155
  return candidates;
@@ -298,7 +296,7 @@ function createNamespaceGuard(config, adapter) {
298
296
  const purelyNumericMsg = configMessages.purelyNumeric ?? "Identifiers cannot be purely numeric.";
299
297
  const cacheEnabled = !!config.cache;
300
298
  const cacheTtl = config.cache?.ttl ?? 5e3;
301
- const cacheMaxSize = 1e3;
299
+ const cacheMaxSize = config.cache?.maxSize ?? 1e3;
302
300
  const cacheMap = /* @__PURE__ */ new Map();
303
301
  let cacheHits = 0;
304
302
  let cacheMisses = 0;
@@ -488,10 +486,20 @@ function createNamespaceGuard(config, adapter) {
488
486
  }
489
487
 
490
488
  // src/adapters/raw.ts
489
+ var SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
490
+ function assertSafeIdentifier(name, label) {
491
+ if (!SAFE_IDENTIFIER.test(name)) {
492
+ throw new Error(`Unsafe ${label}: ${JSON.stringify(name)}. Use only letters, digits, and underscores.`);
493
+ }
494
+ }
491
495
  function createRawAdapter(execute) {
492
496
  return {
493
497
  async findOne(source, value, options) {
494
498
  const idColumn = source.idColumn ?? "id";
499
+ assertSafeIdentifier(source.name, "table name");
500
+ assertSafeIdentifier(source.column, "column name");
501
+ assertSafeIdentifier(idColumn, "id column name");
502
+ if (source.scopeKey) assertSafeIdentifier(source.scopeKey, "scope key");
495
503
  const columns = source.scopeKey && source.scopeKey !== idColumn ? `"${idColumn}", "${source.scopeKey}"` : `"${idColumn}"`;
496
504
  const whereClause = options?.caseInsensitive ? `LOWER("${source.column}") = LOWER($1)` : `"${source.column}" = $1`;
497
505
  const sql = `SELECT ${columns} FROM "${source.name}" WHERE ${whereClause} LIMIT 1`;
@@ -574,6 +582,10 @@ async function createDatabaseAdapter(url) {
574
582
  process.exit(1);
575
583
  }
576
584
  const Pool = pg.default?.Pool ?? pg.Pool;
585
+ if (!Pool) {
586
+ console.error("Could not find Pool export from pg package. Check your pg version.");
587
+ process.exit(1);
588
+ }
577
589
  const pool = new Pool({ connectionString: url });
578
590
  return {
579
591
  adapter: createRawAdapter((sql, params) => pool.query(sql, params)),
package/dist/cli.mjs CHANGED
@@ -121,14 +121,12 @@ function createScrambleStrategy(_pattern) {
121
121
  const candidates = [];
122
122
  const chars = identifier.split("");
123
123
  for (let i = 0; i < chars.length - 1; i++) {
124
- if (chars[i] !== chars[i + 1]) {
125
- const swapped = [...chars];
126
- [swapped[i], swapped[i + 1]] = [swapped[i + 1], swapped[i]];
127
- const candidate = swapped.join("");
128
- if (candidate !== identifier && !seen.has(candidate)) {
129
- seen.add(candidate);
130
- candidates.push(candidate);
131
- }
124
+ const swapped = [...chars];
125
+ [swapped[i], swapped[i + 1]] = [swapped[i + 1], swapped[i]];
126
+ const candidate = swapped.join("");
127
+ if (candidate !== identifier && !seen.has(candidate)) {
128
+ seen.add(candidate);
129
+ candidates.push(candidate);
132
130
  }
133
131
  }
134
132
  return candidates;
@@ -275,7 +273,7 @@ function createNamespaceGuard(config, adapter) {
275
273
  const purelyNumericMsg = configMessages.purelyNumeric ?? "Identifiers cannot be purely numeric.";
276
274
  const cacheEnabled = !!config.cache;
277
275
  const cacheTtl = config.cache?.ttl ?? 5e3;
278
- const cacheMaxSize = 1e3;
276
+ const cacheMaxSize = config.cache?.maxSize ?? 1e3;
279
277
  const cacheMap = /* @__PURE__ */ new Map();
280
278
  let cacheHits = 0;
281
279
  let cacheMisses = 0;
@@ -465,10 +463,20 @@ function createNamespaceGuard(config, adapter) {
465
463
  }
466
464
 
467
465
  // src/adapters/raw.ts
466
+ var SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
467
+ function assertSafeIdentifier(name, label) {
468
+ if (!SAFE_IDENTIFIER.test(name)) {
469
+ throw new Error(`Unsafe ${label}: ${JSON.stringify(name)}. Use only letters, digits, and underscores.`);
470
+ }
471
+ }
468
472
  function createRawAdapter(execute) {
469
473
  return {
470
474
  async findOne(source, value, options) {
471
475
  const idColumn = source.idColumn ?? "id";
476
+ assertSafeIdentifier(source.name, "table name");
477
+ assertSafeIdentifier(source.column, "column name");
478
+ assertSafeIdentifier(idColumn, "id column name");
479
+ if (source.scopeKey) assertSafeIdentifier(source.scopeKey, "scope key");
472
480
  const columns = source.scopeKey && source.scopeKey !== idColumn ? `"${idColumn}", "${source.scopeKey}"` : `"${idColumn}"`;
473
481
  const whereClause = options?.caseInsensitive ? `LOWER("${source.column}") = LOWER($1)` : `"${source.column}" = $1`;
474
482
  const sql = `SELECT ${columns} FROM "${source.name}" WHERE ${whereClause} LIMIT 1`;
@@ -551,6 +559,10 @@ async function createDatabaseAdapter(url) {
551
559
  process.exit(1);
552
560
  }
553
561
  const Pool = pg.default?.Pool ?? pg.Pool;
562
+ if (!Pool) {
563
+ console.error("Could not find Pool export from pg package. Check your pg version.");
564
+ process.exit(1);
565
+ }
554
566
  const pool = new Pool({ connectionString: url });
555
567
  return {
556
568
  adapter: createRawAdapter((sql, params) => pool.query(sql, params)),
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 allows users to update their own slug without a false collision */
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 flat list, Set, or categorized record */
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 run after format/reserved checks, before DB */
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;
@@ -53,6 +53,8 @@ type NamespaceConfig = {
53
53
  cache?: {
54
54
  /** Time-to-live in milliseconds (default: 5000) */
55
55
  ttl?: number;
56
+ /** Maximum number of cached entries before LRU eviction (default: 1000) */
57
+ maxSize?: number;
56
58
  };
57
59
  };
58
60
  /** Options passed to adapter `findOne` calls. */
@@ -60,7 +62,7 @@ type FindOneOptions = {
60
62
  /** Use case-insensitive matching */
61
63
  caseInsensitive?: boolean;
62
64
  };
63
- /** Database adapter interface implement this for your ORM or query builder. */
65
+ /** Database adapter interface - implement this for your ORM or query builder. */
64
66
  type NamespaceAdapter = {
65
67
  findOne: (source: NamespaceSource, value: string, options?: FindOneOptions) => Promise<Record<string, unknown> | null>;
66
68
  };
@@ -80,6 +82,13 @@ type CheckResult = {
80
82
  category?: string;
81
83
  suggestions?: string[];
82
84
  };
85
+ /** Options for the `skeleton()` and `areConfusable()` functions. */
86
+ type SkeletonOptions = {
87
+ /** Confusable character map to use.
88
+ * Default: `CONFUSABLE_MAP_FULL` (complete TR39 map, no NFKC filtering).
89
+ * Pass `CONFUSABLE_MAP` if your pipeline runs NFKC before calling skeleton(). */
90
+ map?: Record<string, string>;
91
+ };
83
92
  /**
84
93
  * Normalize a raw identifier: trims whitespace, applies NFKC Unicode normalization,
85
94
  * lowercases, and strips leading `@` symbols.
@@ -106,7 +115,7 @@ declare function normalize(raw: string, options?: {
106
115
  /**
107
116
  * Create a validator that rejects identifiers containing profanity or offensive words.
108
117
  *
109
- * Supply your own word list no words are bundled with the library.
118
+ * Supply your own word list - no words are bundled with the library.
110
119
  * The returned function is compatible with `config.validators`.
111
120
  *
112
121
  * @param words - Array of words to block
@@ -144,6 +153,22 @@ declare function createProfanityValidator(words: string[], options?: {
144
153
  * Regenerate: `npx tsx scripts/generate-confusables.ts`
145
154
  */
146
155
  declare const CONFUSABLE_MAP: Record<string, string>;
156
+ /**
157
+ * Complete TR39 confusable mapping: every single-character mapping to a
158
+ * lowercase Latin letter or digit from confusables.txt, with no NFKC filtering.
159
+ *
160
+ * Use this when your pipeline does NOT run NFKC normalization before confusable
161
+ * detection (which is most real-world systems: TR39 skeleton uses NFD, Chromium
162
+ * uses NFD, Rust uses NFC, django-registration uses no normalization at all).
163
+ *
164
+ * Includes ~1,400 entries vs CONFUSABLE_MAP's ~613 NFKC-deduped entries.
165
+ * The additional entries cover characters that NFKC normalization would handle
166
+ * (mathematical alphanumerics, fullwidth forms, etc.) plus the 31 entries where
167
+ * TR39 and NFKC disagree on the target letter.
168
+ *
169
+ * Regenerate: `npx tsx scripts/generate-confusables.ts`
170
+ */
171
+ declare const CONFUSABLE_MAP_FULL: Record<string, string>;
147
172
  /**
148
173
  * Create a validator that rejects identifiers containing homoglyph/confusable characters.
149
174
  *
@@ -177,6 +202,48 @@ declare function createHomoglyphValidator(options?: {
177
202
  available: false;
178
203
  message: string;
179
204
  } | null>;
205
+ /**
206
+ * Compute the TR39 Section 4 skeleton of a string for confusable comparison.
207
+ *
208
+ * Implements `internalSkeleton`:
209
+ * 1. NFD normalize
210
+ * 2. Remove Default_Ignorable_Code_Point characters
211
+ * 3. Replace each character via the confusable map
212
+ * 4. Reapply NFD
213
+ * 5. Lowercase
214
+ *
215
+ * The default map is `CONFUSABLE_MAP_FULL` (the complete TR39 mapping without
216
+ * NFKC filtering), which matches the NFD-based pipeline used by ICU, Chromium,
217
+ * and the TR39 spec itself. Pass `{ map: CONFUSABLE_MAP }` if your pipeline
218
+ * runs NFKC normalization before calling skeleton().
219
+ *
220
+ * @param input - The string to skeletonize
221
+ * @param options - Optional settings (custom confusable map)
222
+ * @returns The skeleton string for comparison
223
+ *
224
+ * @example
225
+ * ```ts
226
+ * skeleton("paypal") === skeleton("\u0440\u0430ypal") // true (Cyrillic р/а)
227
+ * skeleton("pay\u200Bpal") === skeleton("paypal") // true (zero-width stripped)
228
+ * ```
229
+ */
230
+ declare function skeleton(input: string, options?: SkeletonOptions): string;
231
+ /**
232
+ * Check whether two strings are visually confusable by comparing their TR39 skeletons.
233
+ *
234
+ * @param a - First string
235
+ * @param b - Second string
236
+ * @param options - Optional settings (custom confusable map)
237
+ * @returns `true` if the strings produce the same skeleton
238
+ *
239
+ * @example
240
+ * ```ts
241
+ * areConfusable("paypal", "\u0440\u0430ypal") // true
242
+ * areConfusable("google", "g\u043e\u043egle") // true
243
+ * areConfusable("hello", "world") // false
244
+ * ```
245
+ */
246
+ declare function areConfusable(a: string, b: string, options?: SkeletonOptions): boolean;
180
247
  /**
181
248
  * Create a namespace guard instance for checking slug/handle uniqueness
182
249
  * across multiple database tables with reserved name protection.
@@ -225,4 +292,4 @@ declare function createNamespaceGuard(config: NamespaceConfig, adapter: Namespac
225
292
  /** The guard instance returned by `createNamespaceGuard`. */
226
293
  type NamespaceGuard = ReturnType<typeof createNamespaceGuard>;
227
294
 
228
- export { CONFUSABLE_MAP, type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SuggestStrategyName, createHomoglyphValidator, createNamespaceGuard, createProfanityValidator, normalize };
295
+ export { CONFUSABLE_MAP, CONFUSABLE_MAP_FULL, type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SkeletonOptions, type SuggestStrategyName, areConfusable, createHomoglyphValidator, createNamespaceGuard, createProfanityValidator, normalize, skeleton };