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 +54 -11
- package/dist/adapters/raw.d.mts +0 -26
- package/dist/adapters/raw.d.ts +0 -26
- package/dist/adapters/raw.js +10 -0
- package/dist/adapters/raw.mjs +10 -0
- package/dist/cli.js +21 -9
- package/dist/cli.mjs +21 -9
- package/dist/index.d.mts +73 -6
- package/dist/index.d.ts +73 -6
- package/dist/index.js +1817 -35
- package/dist/index.mjs +1813 -34
- 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
|
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
544
|
+
# ✗ admin - That name is reserved. Try another one.
|
|
507
545
|
|
|
508
546
|
npx namespace-guard check "a"
|
|
509
|
-
# ✗ a
|
|
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,
|
|
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
|
|
package/dist/adapters/raw.d.mts
CHANGED
|
@@ -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 };
|
package/dist/adapters/raw.d.ts
CHANGED
|
@@ -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 };
|
package/dist/adapters/raw.js
CHANGED
|
@@ -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`;
|
package/dist/adapters/raw.mjs
CHANGED
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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;
|
|
@@ -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
|
|
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
|
|
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 };
|