namespace-guard 0.11.0 → 0.15.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,26 +5,10 @@
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
+ **Claim safe slugs in one line**: availability, reserved names, spoofing protection, and moderation hooks.
9
9
 
10
- **Check slug/handle uniqueness across multiple database tables with reserved name protection.**
11
-
12
- Perfect for multi-tenant apps where usernames, organization slugs, and reserved routes all share one URL namespace - like Twitter (`@username`), GitHub (`github.com/username`), or any SaaS with vanity URLs.
13
-
14
- ## The Problem
15
-
16
- You have a URL structure like `yourapp.com/:slug` that could be:
17
- - A user profile (`/sarah`)
18
- - An organization (`/acme-corp`)
19
- - A reserved route (`/settings`, `/admin`, `/api`)
20
-
21
- When someone signs up or creates an org, you need to check that their chosen slug:
22
- 1. Isn't already taken by another user
23
- 2. Isn't already taken by an organization
24
- 3. Isn't a reserved system route
25
- 4. Follows your naming rules
26
-
27
- This library handles all of that in one call.
10
+ - Live demo: https://paultendo.github.io/namespace-guard/
11
+ - Blog post: https://paultendo.github.io/posts/namespace-guard-launch/
28
12
 
29
13
  ## Installation
30
14
 
@@ -32,868 +16,179 @@ This library handles all of that in one call.
32
16
  npm install namespace-guard
33
17
  ```
34
18
 
35
- ## Quick Start
19
+ ## Quick Start (60 seconds)
36
20
 
37
21
  ```typescript
38
- import { createNamespaceGuard } from "namespace-guard";
22
+ import { createNamespaceGuardWithProfile } from "namespace-guard";
39
23
  import { createPrismaAdapter } from "namespace-guard/adapters/prisma";
40
24
  import { PrismaClient } from "@prisma/client";
41
25
 
42
26
  const prisma = new PrismaClient();
43
27
 
44
- // Define your namespace rules once
45
- const guard = createNamespaceGuard(
28
+ const guard = createNamespaceGuardWithProfile(
29
+ "consumer-handle",
46
30
  {
47
31
  reserved: ["admin", "api", "settings", "dashboard", "login", "signup"],
48
32
  sources: [
49
- { name: "user", column: "handle", scopeKey: "id" },
50
- { name: "organization", column: "slug", scopeKey: "id" },
33
+ { name: "user", column: "handleCanonical", scopeKey: "id" },
34
+ { name: "organization", column: "slugCanonical", scopeKey: "id" },
51
35
  ],
52
36
  },
53
37
  createPrismaAdapter(prisma)
54
38
  );
55
39
 
56
- // Check if a slug is available
57
- const result = await guard.check("acme-corp");
58
-
59
- if (result.available) {
60
- // Create the org
61
- } else {
62
- // Show error: result.message
63
- // e.g., "That name is reserved. Try another one." or "That name is already in use."
64
- }
40
+ await guard.assertClaimable("acme-corp");
65
41
  ```
66
42
 
67
- ## Why namespace-guard?
68
-
69
- | Feature | namespace-guard | DIY Solution |
70
- |---------|-----------------|--------------|
71
- | Multi-table uniqueness | One call | Multiple queries |
72
- | Reserved name blocking | Built-in with categories | Manual list checking |
73
- | Ownership scoping | No false positives on self-update | Easy to forget |
74
- | Format validation | Configurable regex | Scattered validation |
75
- | Conflict suggestions | Auto-suggest alternatives | Not built |
76
- | Async validators | Custom hooks (profanity, etc.) | Manual wiring |
77
- | Batch checking | `checkMany()` | Loop it yourself |
78
- | ORM agnostic | Prisma, Drizzle, Kysely, Knex, TypeORM, MikroORM, Sequelize, Mongoose, raw SQL | Tied to your ORM |
79
- | CLI | `npx namespace-guard check` | None |
80
-
81
- ## Adapters
82
-
83
- ### Prisma
43
+ For race-safe writes, use `claim()`:
84
44
 
85
45
  ```typescript
86
- import { PrismaClient } from "@prisma/client";
87
- import { createPrismaAdapter } from "namespace-guard/adapters/prisma";
88
-
89
- const prisma = new PrismaClient();
90
- const adapter = createPrismaAdapter(prisma);
91
- ```
92
-
93
- ### Drizzle
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
-
97
- ```typescript
98
- import { eq } from "drizzle-orm";
99
- import { createDrizzleAdapter } from "namespace-guard/adapters/drizzle";
100
- import { db } from "./db";
101
- import { users, organizations } from "./schema";
102
-
103
- // Pass eq directly, or use { eq, ilike } for case-insensitive support
104
- const adapter = createDrizzleAdapter(db, { users, organizations }, eq);
105
- ```
106
-
107
- ### Kysely
108
-
109
- ```typescript
110
- import { Kysely, PostgresDialect } from "kysely";
111
- import { createKyselyAdapter } from "namespace-guard/adapters/kysely";
112
-
113
- const db = new Kysely<Database>({ dialect: new PostgresDialect({ pool }) });
114
- const adapter = createKyselyAdapter(db);
115
- ```
116
-
117
- ### Knex
118
-
119
- ```typescript
120
- import Knex from "knex";
121
- import { createKnexAdapter } from "namespace-guard/adapters/knex";
122
-
123
- const knex = Knex({ client: "pg", connection: process.env.DATABASE_URL });
124
- const adapter = createKnexAdapter(knex);
125
- ```
126
-
127
- ### TypeORM
128
-
129
- ```typescript
130
- import { DataSource } from "typeorm";
131
- import { createTypeORMAdapter } from "namespace-guard/adapters/typeorm";
132
- import { User, Organization } from "./entities";
133
-
134
- const dataSource = new DataSource({ /* ... */ });
135
- const adapter = createTypeORMAdapter(dataSource, { user: User, organization: Organization });
136
- ```
137
-
138
- ### MikroORM
139
-
140
- ```typescript
141
- import { MikroORM } from "@mikro-orm/core";
142
- import { createMikroORMAdapter } from "namespace-guard/adapters/mikro-orm";
143
- import { User, Organization } from "./entities";
144
-
145
- const orm = await MikroORM.init(config);
146
- const adapter = createMikroORMAdapter(orm.em, { user: User, organization: Organization });
147
- ```
148
-
149
- ### Sequelize
150
-
151
- ```typescript
152
- import { createSequelizeAdapter } from "namespace-guard/adapters/sequelize";
153
- import { User, Organization } from "./models";
154
-
155
- const adapter = createSequelizeAdapter({ user: User, organization: Organization });
156
- ```
157
-
158
- ### Mongoose
159
-
160
- ```typescript
161
- import { createMongooseAdapter } from "namespace-guard/adapters/mongoose";
162
- import { User, Organization } from "./models";
163
-
164
- // Note: Mongoose sources typically use idColumn: "_id"
165
- const adapter = createMongooseAdapter({ user: User, organization: Organization });
166
- ```
167
-
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.
171
-
172
- ```typescript
173
- import { Pool } from "pg";
174
- import { createRawAdapter } from "namespace-guard/adapters/raw";
175
-
176
- const pool = new Pool();
177
- const adapter = createRawAdapter((sql, params) => pool.query(sql, params));
178
- ```
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
-
208
- ## Configuration
209
-
210
- ```typescript
211
- const guard = createNamespaceGuard({
212
- // Reserved names - flat list, Set, or categorized
213
- reserved: new Set([
214
- "admin",
215
- "api",
216
- "settings",
217
- "dashboard",
218
- "login",
219
- "signup",
220
- "help",
221
- "support",
222
- "billing",
223
- ]),
224
-
225
- // Data sources to check for collisions
226
- // Queries run in parallel for speed
227
- sources: [
228
- {
229
- name: "user", // Prisma model / Drizzle table / SQL table name
230
- column: "handle", // Column containing the slug/handle
231
- idColumn: "id", // Primary key column (default: "id")
232
- scopeKey: "id", // Key for ownership checks (see below)
233
- },
234
- {
235
- name: "organization",
236
- column: "slug",
237
- scopeKey: "id",
238
- },
239
- {
240
- name: "team",
241
- column: "slug",
242
- scopeKey: "id",
46
+ const result = await guard.claim(input.handle, async (canonical) => {
47
+ return prisma.user.create({
48
+ data: {
49
+ handle: input.handle,
50
+ handleCanonical: canonical,
243
51
  },
244
- ],
245
-
246
- // Validation pattern (default: /^[a-z0-9][a-z0-9-]{1,29}$/)
247
- // This default requires: 2-30 chars, lowercase alphanumeric + hyphens, can't start with hyphen
248
- pattern: /^[a-z0-9][a-z0-9-]{2,39}$/,
249
-
250
- // Custom error messages
251
- messages: {
252
- invalid: "Use 3-40 lowercase letters, numbers, or hyphens.",
253
- reserved: "That name is reserved. Please choose another.",
254
- taken: (sourceName) => `That name is already taken.`,
255
- },
256
- }, adapter);
257
- ```
258
-
259
- ## Reserved Name Categories
260
-
261
- Group reserved names by category with different error messages:
262
-
263
- ```typescript
264
- const guard = createNamespaceGuard({
265
- reserved: {
266
- system: ["admin", "api", "settings", "dashboard"],
267
- brand: ["oncor", "bandcamp"],
268
- offensive: ["..."],
269
- },
270
- sources: [/* ... */],
271
- messages: {
272
- reserved: {
273
- system: "That's a system route.",
274
- brand: "That's a protected brand name.",
275
- offensive: "That name is not allowed.",
276
- },
277
- },
278
- }, adapter);
279
-
280
- const result = await guard.check("admin");
281
- // { available: false, reason: "reserved", category: "system", message: "That's a system route." }
282
- ```
283
-
284
- You can also use a single string message for all categories, or mix - categories without a specific message fall back to the default.
285
-
286
- ## Async Validators
287
-
288
- Add custom async checks that run after format/reserved validation but before database queries:
289
-
290
- ```typescript
291
- const guard = createNamespaceGuard({
292
- sources: [/* ... */],
293
- validators: [
294
- async (identifier) => {
295
- if (await isProfane(identifier)) {
296
- return { available: false, message: "That name is not allowed." };
297
- }
298
- return null; // pass
299
- },
300
- async (identifier) => {
301
- if (await isTrademarkViolation(identifier)) {
302
- return { available: false, message: "That name is trademarked." };
303
- }
304
- return null;
305
- },
306
- ],
307
- }, adapter);
308
- ```
309
-
310
- Validators run sequentially and stop at the first rejection. They receive the normalized identifier.
311
-
312
- ### Built-in Profanity Validator
313
-
314
- Use `createProfanityValidator` for a turnkey profanity filter - supply your own word list:
315
-
316
- ```typescript
317
- import { createNamespaceGuard, createProfanityValidator } from "namespace-guard";
318
-
319
- const guard = createNamespaceGuard({
320
- sources: [/* ... */],
321
- validators: [
322
- createProfanityValidator(["badword", "offensive", "slur"], {
323
- message: "Please choose an appropriate name.", // optional custom message
324
- checkSubstrings: true, // default: true
325
- }),
326
- ],
327
- }, adapter);
328
- ```
329
-
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).
331
-
332
- ### Built-in Homoglyph Validator
333
-
334
- Prevent spoofing attacks where visually similar characters from any Unicode script are substituted for Latin letters (e.g., Cyrillic "а" for Latin "a" in "admin"):
335
-
336
- ```typescript
337
- import { createNamespaceGuard, createHomoglyphValidator } from "namespace-guard";
338
-
339
- const guard = createNamespaceGuard({
340
- sources: [/* ... */],
341
- validators: [
342
- createHomoglyphValidator(),
343
- ],
344
- }, adapter);
345
- ```
346
-
347
- Options:
348
-
349
- ```typescript
350
- createHomoglyphValidator({
351
- message: "Custom rejection message.", // optional
352
- additionalMappings: { "\u0261": "g" }, // extend the built-in map
353
- rejectMixedScript: true, // also reject Latin + non-Latin script mixing
354
- })
355
- ```
356
-
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`.
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
-
397
- ### How the anti-spoofing pipeline works
398
-
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:
400
-
401
- ```
402
- Input → NFKC normalize → Confusable map → Mixed-script reject
403
- (stage 1) (stage 2) (stage 3)
404
- ```
405
-
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.
407
-
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).
409
-
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.
411
-
412
- #### Why NFKC-aware filtering matters
413
-
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.
415
-
416
- We found 31 entries where this happens:
417
-
418
- | Character | TR39 says | NFKC says | Winner |
419
- |-----------|-----------|-----------|--------|
420
- | `ſ` Long S (U+017F) | `f` | `s` | NFKC (`s` is correct) |
421
- | `Ⅰ` Roman Numeral I (U+2160) | `l` | `i` | NFKC (`i` is correct) |
422
- | `I` Fullwidth I (U+FF29) | `l` | `i` | NFKC (`i` is correct) |
423
- | `𝟎` Math Bold 0 (U+1D7CE) | `o` | `0` | NFKC (`0` is correct) |
424
- | 11 Mathematical I variants | `l` | `i` | NFKC |
425
- | 12 Mathematical 0/1 variants | `o`/`l` | `0`/`1` | NFKC |
426
-
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.
428
-
429
- ## Unicode Normalization
430
-
431
- By default, `normalize()` applies [NFKC normalization](https://unicode.org/reports/tr15/) before lowercasing. This collapses full-width characters, ligatures, superscripts, and other Unicode compatibility forms to their canonical equivalents:
432
-
433
- ```typescript
434
- normalize("hello"); // "hello" (full-width → ASCII)
435
- normalize("\ufb01nance"); // "finance" (fi ligature → fi)
436
- ```
437
-
438
- NFKC is a no-op for ASCII input and matches what ENS, GitHub, and Unicode IDNA standards mandate. To opt out:
439
-
440
- ```typescript
441
- const guard = createNamespaceGuard({
442
- sources: [/* ... */],
443
- normalizeUnicode: false,
444
- }, adapter);
445
- ```
446
-
447
- ## Rejecting Purely Numeric Identifiers
448
-
449
- Twitter/X blocks purely numeric handles. Enable this with `allowPurelyNumeric: false`:
450
-
451
- ```typescript
452
- const guard = createNamespaceGuard({
453
- sources: [/* ... */],
454
- allowPurelyNumeric: false,
455
- messages: {
456
- purelyNumeric: "Handles cannot be all numbers.", // optional custom message
457
- },
458
- }, adapter);
459
-
460
- await guard.check("123456"); // { available: false, reason: "invalid", message: "Handles cannot be all numbers." }
461
- await guard.check("abc123"); // available (has letters)
462
- ```
463
-
464
- ## Conflict Suggestions
465
-
466
- When a slug is taken, automatically suggest available alternatives using pluggable strategies:
467
-
468
- ```typescript
469
- const guard = createNamespaceGuard({
470
- sources: [/* ... */],
471
- suggest: {
472
- // Named strategy (default: ["sequential", "random-digits"])
473
- strategy: "suffix-words",
474
- // Max suggestions to return (default: 3)
475
- max: 3,
476
- },
477
- }, adapter);
478
-
479
- const result = await guard.check("acme-corp");
480
- // {
481
- // available: false,
482
- // reason: "taken",
483
- // message: "That name is already in use.",
484
- // source: "organization",
485
- // suggestions: ["acme-corp-dev", "acme-corp-io", "acme-corp-app"]
486
- // }
487
- ```
488
-
489
- ### Built-in Strategies
490
-
491
- | Strategy | Example Output | Description |
492
- |----------|---------------|-------------|
493
- | `"sequential"` | `sarah-1`, `sarah1`, `sarah-2` | Hyphenated and compact numeric suffixes |
494
- | `"random-digits"` | `sarah-4821`, `sarah-1037` | Random 3-4 digit suffixes |
495
- | `"suffix-words"` | `sarah-dev`, `sarah-hq`, `sarah-app` | Common word suffixes |
496
- | `"short-random"` | `sarah-x7k`, `sarah-m2p` | Short 3-char alphanumeric suffixes |
497
- | `"scramble"` | `asrah`, `sarha` | Adjacent character transpositions |
498
- | `"similar"` | `sara`, `darah`, `thesarah` | Edit-distance-1 mutations (deletions, keyboard-adjacent substitutions, prefix/suffix) |
499
-
500
- ### Composing Strategies
501
-
502
- Combine multiple strategies - candidates are interleaved round-robin:
503
-
504
- ```typescript
505
- suggest: {
506
- strategy: ["random-digits", "suffix-words"],
507
- max: 4,
508
- }
509
- // → ["sarah-4821", "sarah-dev", "sarah-1037", "sarah-io"]
510
- ```
511
-
512
- ### Custom Strategy Function
513
-
514
- Pass a function that returns candidate slugs:
515
-
516
- ```typescript
517
- suggest: {
518
- strategy: (identifier) => [
519
- `${identifier}-io`,
520
- `${identifier}-app`,
521
- `the-real-${identifier}`,
522
- ],
523
- }
524
- ```
525
-
526
- Suggestions are verified against format, reserved names, validators, and database collisions using a progressive batched pipeline. Only available suggestions are returned.
527
-
528
- ## Batch Checking
529
-
530
- Check multiple identifiers at once:
531
-
532
- ```typescript
533
- const results = await guard.checkMany(["sarah", "admin", "acme-corp"]);
534
- // {
535
- // sarah: { available: true },
536
- // admin: { available: false, reason: "reserved", ... },
537
- // "acme-corp": { available: false, reason: "taken", ... }
538
- // }
539
- ```
540
-
541
- All checks run in parallel. Accepts an optional scope parameter.
542
-
543
- ## Ownership Scoping
544
-
545
- When users update their own slug, you don't want a false "already taken" error:
546
-
547
- ```typescript
548
- // User with ID "user_123" wants to change handle from "sarah" to "sarah-dev"
549
- // Without scoping, this would error because "sarah-dev" != their current handle
550
-
551
- // Pass their ID to exclude their own record from collision detection
552
- const result = await guard.check("sarah-dev", { id: "user_123" });
553
- // Available (unless another user/org has it)
554
- ```
555
-
556
- The scope object keys map to `scopeKey` in your source config. This lets you check multiple ownership types:
557
-
558
- ```typescript
559
- // Check if a user OR their org owns this slug
560
- const result = await guard.check("acme", {
561
- userId: currentUser.id,
562
- orgId: currentOrg.id,
52
+ });
563
53
  });
564
- ```
565
-
566
- ## CLI
567
-
568
- Validate slugs from the command line:
569
-
570
- ```bash
571
- # Format + reserved name checking (no database needed)
572
- npx namespace-guard check acme-corp
573
- # ✓ acme-corp is available
574
-
575
- npx namespace-guard check admin
576
- # ✗ admin - That name is reserved. Try another one.
577
-
578
- npx namespace-guard check "a"
579
- # ✗ a - Use 2-30 lowercase letters, numbers, or hyphens.
580
- ```
581
-
582
- ### With a config file
583
-
584
- Create `namespace-guard.config.json`:
585
-
586
- ```json
587
- {
588
- "reserved": ["admin", "api", "settings", "dashboard"],
589
- "pattern": "^[a-z0-9][a-z0-9-]{2,39}$",
590
- "sources": [
591
- { "name": "users", "column": "handle" },
592
- { "name": "organizations", "column": "slug" }
593
- ]
594
- }
595
- ```
596
-
597
- Or with categorized reserved names:
598
-
599
- ```json
600
- {
601
- "reserved": {
602
- "system": ["admin", "api", "settings"],
603
- "brand": ["oncor"]
604
- }
605
- }
606
- ```
607
-
608
- ```bash
609
- npx namespace-guard check sarah --config ./my-config.json
610
- ```
611
-
612
- ### With database checking
613
-
614
- ```bash
615
- npx namespace-guard check sarah --database-url postgres://localhost/mydb
616
- ```
617
-
618
- Requires `pg` to be installed (`npm install pg`).
619
54
 
620
- Exit code 0 = available, 1 = unavailable.
621
-
622
- ## API Reference
623
-
624
- ### `createNamespaceGuard(config, adapter)`
625
-
626
- Creates a guard instance with your configuration and database adapter.
627
-
628
- **Returns:** `NamespaceGuard` instance
629
-
630
- ---
631
-
632
- ### `guard.check(identifier, scope?)`
633
-
634
- Check if an identifier is available.
635
-
636
- **Parameters:**
637
- - `identifier` - The slug/handle to check
638
- - `scope` - Optional ownership scope to exclude own records
639
-
640
- **Returns:**
641
- ```typescript
642
- // Available
643
- { available: true }
644
-
645
- // Not available
646
- {
647
- available: false,
648
- reason: "invalid" | "reserved" | "taken",
649
- message: string,
650
- source?: string, // Which table caused the collision (reason: "taken")
651
- category?: string, // Reserved name category (reason: "reserved")
652
- suggestions?: string[] // Available alternatives (reason: "taken", requires suggest config)
55
+ if (!result.claimed) {
56
+ return { error: result.message };
653
57
  }
654
58
  ```
655
59
 
656
- ---
657
-
658
- ### `guard.checkMany(identifiers, scope?, options?)`
659
-
660
- Check multiple identifiers in parallel. Suggestions are skipped by default for performance.
661
-
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.
668
-
669
- **Returns:** `Record<string, CheckResult>`
670
-
671
- ---
672
-
673
- ### `guard.assertAvailable(identifier, scope?)`
674
-
675
- Same as `check()`, but throws an `Error` if not available.
60
+ ## Research-Backed Differentiation
676
61
 
677
- ---
62
+ We started by auditing how major Unicode-confusable implementations compose normalization and mapping in practice (including ICU, Chromium, Rust, and django-registration), then converted that gap into a reproducible library design.
678
63
 
679
- ### `guard.validateFormat(identifier)`
64
+ - Documented a 31-entry NFKC vs TR39 divergence set and shipped it as a named regression suite: `nfkc-tr39-divergence-v1`.
65
+ - Ship two maps for two real pipelines:
66
+ `CONFUSABLE_MAP` (NFKC-first) and `CONFUSABLE_MAP_FULL` (TR39/NFD/raw-input pipelines).
67
+ - Export the vectors as JSON (`docs/data/composability-vectors.json`) and wire them into CLI drift baselines.
68
+ - Publish a labeled benchmark corpus (`docs/data/confusable-bench.v1.json`) for cross-tool evaluation and CI regressions.
69
+ - Submitted the findings for Unicode public review (PRI #540): https://www.unicode.org/review/pri540/
680
70
 
681
- Validate format, purely-numeric restriction, and reserved name status without querying the database.
71
+ Details:
72
+ - Technical reference: [docs/reference.md#how-the-anti-spoofing-pipeline-works](docs/reference.md#how-the-anti-spoofing-pipeline-works)
73
+ - Launch write-up: https://paultendo.github.io/posts/namespace-guard-launch/
682
74
 
683
- **Returns:** Error message string if invalid or reserved, `null` if OK.
75
+ ## What You Get
684
76
 
685
- ---
77
+ - Cross-table collision checks (users, orgs, teams, etc.)
78
+ - Reserved-name blocking with category-aware messages
79
+ - Unicode anti-spoofing (NFKC + confusable detection + mixed-script/risk controls)
80
+ - Invisible character detection (default-ignorable + bidi controls, optional combining-mark blocking)
81
+ - Optional profanity/evasion validation
82
+ - Suggestion strategies for taken names
83
+ - CLI for red-team generation, calibration, drift, and CI gates
686
84
 
687
- ### `guard.validateFormatOnly(identifier)`
85
+ ## Built-in Profiles
688
86
 
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.
87
+ Use `createNamespaceGuardWithProfile(profile, overrides, adapter)`:
690
88
 
691
- **Returns:** Error message string if the format is invalid, `null` if OK.
89
+ - `consumer-handle`: strict defaults for public handles
90
+ - `org-slug`: workspace/org slugs
91
+ - `developer-id`: technical IDs with looser numeric rules
692
92
 
693
- ---
93
+ Profiles are defaults, not lock-in. Override only what you need.
694
94
 
695
- ### `guard.normalize(identifier)`
95
+ ## Zero-Dependency Moderation Integration
696
96
 
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.
710
-
711
- **Returns:** `{ size: number; hits: number; misses: number }`
712
-
713
- ---
714
-
715
- ### `normalize(identifier, options?)`
716
-
717
- Utility function to normalize identifiers. Trims whitespace, applies NFKC Unicode normalization (by default), lowercases, and strips leading `@` symbols. Pass `{ unicode: false }` to skip NFKC.
97
+ Core stays zero-dependency. You can use built-ins or plug in any external library.
718
98
 
719
99
  ```typescript
720
- import { normalize } from "namespace-guard";
721
-
722
- normalize(" @Sarah "); // "sarah"
723
- normalize("ACME-Corp"); // "acme-corp"
724
- ```
725
-
726
- ## Case-Insensitive Matching
727
-
728
- By default, slug lookups are case-sensitive. Enable case-insensitive matching to catch collisions regardless of stored casing:
100
+ import {
101
+ createNamespaceGuard,
102
+ createPredicateValidator,
103
+ } from "namespace-guard";
104
+ import { createEnglishProfanityValidator } from "namespace-guard/profanity-en";
729
105
 
730
- ```typescript
731
- const guard = createNamespaceGuard({
732
- sources: [/* ... */],
733
- caseInsensitive: true,
734
- }, adapter);
106
+ const guard = createNamespaceGuard(
107
+ {
108
+ sources: [
109
+ { name: "user", column: "handleCanonical", scopeKey: "id" },
110
+ { name: "organization", column: "slugCanonical", scopeKey: "id" },
111
+ ],
112
+ validators: [
113
+ createEnglishProfanityValidator({ mode: "evasion" }),
114
+ createPredicateValidator((identifier) => thirdPartyFilter.has(identifier)),
115
+ ],
116
+ },
117
+ adapter
118
+ );
735
119
  ```
736
120
 
737
- Each adapter handles this differently:
738
- - **Prisma**: Uses `mode: "insensitive"` on the where clause
739
- - **Drizzle**: Uses `ilike` instead of `eq` (pass `ilike` to the adapter: `createDrizzleAdapter(db, tables, { eq, ilike })`)
740
- - **Kysely**: Uses `ilike` operator
741
- - **Knex**: Uses `LOWER()` in a raw where clause
742
- - **TypeORM**: Uses `ILike` (pass it to the adapter: `createTypeORMAdapter(dataSource, entities, ILike)`)
743
- - **MikroORM**: Uses `$ilike` operator
744
- - **Sequelize**: Uses `LOWER()` via Sequelize helpers (pass `{ where: Sequelize.where, fn: Sequelize.fn, col: Sequelize.col }`)
745
- - **Mongoose**: Uses collation `{ locale: "en", strength: 2 }`
746
- - **Raw SQL**: Wraps both sides in `LOWER()`
121
+ ## CLI Workflow
747
122
 
748
- ## Caching
749
-
750
- Enable in-memory caching to reduce database calls during rapid checks (e.g., live form validation, suggestion generation):
123
+ ```bash
124
+ # 1) Generate realistic attack variants
125
+ npx namespace-guard attack-gen paypal --json
751
126
 
752
- ```typescript
753
- const guard = createNamespaceGuard({
754
- sources: [/* ... */],
755
- cache: {
756
- ttl: 5000, // milliseconds (default: 5000)
757
- maxSize: 1000, // max cached entries before LRU eviction (default: 1000)
758
- },
759
- }, adapter);
127
+ # 2) Calibrate thresholds and CI gate suggestions from your dataset
128
+ npx namespace-guard recommend ./risk-dataset.json
760
129
 
761
- // Manually clear the cache after writes
762
- guard.clearCache();
130
+ # 3) Preflight canonical collisions before adding DB unique constraints
131
+ npx namespace-guard audit-canonical ./users-export.json --json
763
132
 
764
- // Monitor cache performance
765
- const stats = guard.cacheStats();
766
- // { size: 12, hits: 48, misses: 12 }
133
+ # 4) Compare TR39-full vs NFKC-filtered behavior
134
+ npx namespace-guard drift --json
767
135
  ```
768
136
 
769
- ## Framework Integration
137
+ ## Advanced Security Primitives (Optional)
770
138
 
771
- ### Next.js (Server Actions)
139
+ Use these when you need custom scoring, explainability, or pairwise checks outside the default claim flow:
772
140
 
773
141
  ```typescript
774
- // lib/guard.ts
775
- import { createNamespaceGuard } from "namespace-guard";
776
- import { createPrismaAdapter } from "namespace-guard/adapters/prisma";
777
- import { prisma } from "./db";
778
-
779
- export const guard = createNamespaceGuard({
780
- reserved: ["admin", "api", "settings"],
781
- sources: [
782
- { name: "user", column: "handle", scopeKey: "id" },
783
- { name: "organization", column: "slug", scopeKey: "id" },
784
- ],
785
- suggest: {},
786
- }, createPrismaAdapter(prisma));
787
-
788
- // app/signup/actions.ts
789
- "use server";
790
-
791
- import { guard } from "@/lib/guard";
792
-
793
- export async function checkHandle(handle: string) {
794
- return guard.check(handle);
795
- }
796
-
797
- export async function createUser(handle: string, email: string) {
798
- const result = await guard.check(handle);
799
- if (!result.available) return { error: result.message };
142
+ import { skeleton, areConfusable, confusableDistance } from "namespace-guard";
800
143
 
801
- const user = await prisma.user.create({
802
- data: { handle: guard.normalize(handle), email },
803
- });
804
- return { user };
805
- }
144
+ skeleton("pa\u0443pal"); // "paypal" skeleton form
145
+ areConfusable("paypal", "pa\u0443pal"); // true
146
+ confusableDistance("paypal", "pa\u0443pal"); // graded similarity + chainDepth + explainable steps
806
147
  ```
807
148
 
808
- ### Express Middleware
809
-
810
- ```typescript
811
- import express from "express";
812
- import { guard } from "./lib/guard";
813
-
814
- const app = express();
149
+ ## Adapter Support
815
150
 
816
- // Reusable middleware
817
- function validateSlug(req, res, next) {
818
- const slug = req.body.handle || req.body.slug;
819
- if (!slug) return res.status(400).json({ error: "Slug is required" });
151
+ - Prisma
152
+ - Drizzle
153
+ - Kysely
154
+ - Knex
155
+ - TypeORM
156
+ - MikroORM
157
+ - Sequelize
158
+ - Mongoose
159
+ - Raw SQL
820
160
 
821
- guard.check(slug, { id: req.user?.id }).then((result) => {
822
- if (!result.available) return res.status(409).json(result);
823
- req.normalizedSlug = guard.normalize(slug);
824
- next();
825
- });
826
- }
827
-
828
- app.post("/api/users", validateSlug, async (req, res) => {
829
- const user = await db.user.create({ handle: req.normalizedSlug, ... });
830
- res.json({ user });
831
- });
832
- ```
161
+ Adapter setup examples and migration guidance: [docs/reference.md#adapters](docs/reference.md#adapters)
833
162
 
834
- ### tRPC
163
+ ## Production Recommendation: Canonical Uniqueness
835
164
 
836
- ```typescript
837
- import { z } from "zod";
838
- import { router, protectedProcedure } from "./trpc";
839
- import { guard } from "./lib/guard";
840
-
841
- export const namespaceRouter = router({
842
- check: protectedProcedure
843
- .input(z.object({ slug: z.string() }))
844
- .query(async ({ input, ctx }) => {
845
- return guard.check(input.slug, { id: ctx.user.id });
846
- }),
847
-
848
- claim: protectedProcedure
849
- .input(z.object({ slug: z.string() }))
850
- .mutation(async ({ input, ctx }) => {
851
- await guard.assertAvailable(input.slug, { id: ctx.user.id });
852
- return ctx.db.user.update({
853
- where: { id: ctx.user.id },
854
- data: { handle: guard.normalize(input.slug) },
855
- });
856
- }),
857
- });
858
- ```
165
+ For full protection against Unicode/canonicalization edge cases, enforce uniqueness on canonical columns (for example `handleCanonical`, `slugCanonical`) and point `sources[*].column` there.
859
166
 
860
- ## TypeScript
167
+ Migration guides per adapter: [docs/reference.md#canonical-uniqueness-migration-per-adapter](docs/reference.md#canonical-uniqueness-migration-per-adapter)
861
168
 
862
- Full TypeScript support with exported types:
169
+ ## Documentation Map
863
170
 
864
- ```typescript
865
- import {
866
- createNamespaceGuard,
867
- createProfanityValidator,
868
- createHomoglyphValidator,
869
- skeleton,
870
- areConfusable,
871
- CONFUSABLE_MAP,
872
- CONFUSABLE_MAP_FULL,
873
- normalize,
874
- type NamespaceConfig,
875
- type NamespaceSource,
876
- type NamespaceAdapter,
877
- type NamespaceGuard,
878
- type CheckResult,
879
- type FindOneOptions,
880
- type OwnershipScope,
881
- type SuggestStrategyName,
882
- type SkeletonOptions,
883
- type CheckManyOptions,
884
- } from "namespace-guard";
885
- ```
171
+ - Full reference: [docs/reference.md](docs/reference.md)
172
+ - Config reference: [docs/reference.md#configuration](docs/reference.md#configuration)
173
+ - Validators (profanity, homoglyph, invisible): [docs/reference.md#async-validators](docs/reference.md#async-validators)
174
+ - Canonical preflight audit (`audit-canonical`): [docs/reference.md#audit-canonical-command](docs/reference.md#audit-canonical-command)
175
+ - Anti-spoofing pipeline and composability vectors: [docs/reference.md#how-the-anti-spoofing-pipeline-works](docs/reference.md#how-the-anti-spoofing-pipeline-works)
176
+ - Benchmark corpus (`confusable-bench.v1`): [docs/reference.md#confusable-benchmark-corpus-artifact](docs/reference.md#confusable-benchmark-corpus-artifact)
177
+ - Advanced primitives (`skeleton`, `areConfusable`, `confusableDistance`): [docs/reference.md#advanced-security-primitives](docs/reference.md#advanced-security-primitives)
178
+ - CLI reference: [docs/reference.md#cli](docs/reference.md#cli)
179
+ - API reference: [docs/reference.md#api-reference](docs/reference.md#api-reference)
180
+ - Framework integration (Next.js/Express/tRPC): [docs/reference.md#framework-integration](docs/reference.md#framework-integration)
886
181
 
887
182
  ## Support
888
183
 
889
- If you find this useful, consider supporting the project:
184
+ If `namespace-guard` helped you, please star the repo. It helps the project a lot.
890
185
 
891
- - [GitHub Sponsors](https://github.com/sponsors/paultendo)
892
- - [Buy me a coffee](https://buymeacoffee.com/paultendo)
186
+ - GitHub Sponsors: https://github.com/sponsors/paultendo
187
+ - Buy me a coffee: https://buymeacoffee.com/paultendo
893
188
 
894
189
  ## Contributing
895
190
 
896
- Contributions welcome! Please open an issue first to discuss what you'd like to change.
191
+ Contributions welcome. Please open an issue first to discuss larger changes.
897
192
 
898
193
  ## License
899
194