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 +109 -814
- package/dist/cli.js +4406 -14
- package/dist/cli.mjs +4406 -14
- package/dist/composability-vectors.d.mts +1 -0
- package/dist/composability-vectors.d.ts +1 -0
- package/dist/composability-vectors.js +1853 -0
- package/dist/composability-vectors.mjs +1824 -0
- package/dist/index.d.mts +317 -17
- package/dist/index.d.ts +317 -17
- package/dist/index.js +874 -8
- package/dist/index.mjs +862 -8
- package/dist/profanity-en.d.mts +21 -0
- package/dist/profanity-en.d.ts +21 -0
- package/dist/profanity-en.js +4698 -0
- package/dist/profanity-en.mjs +4667 -0
- package/package.json +29 -2
package/README.md
CHANGED
|
@@ -5,26 +5,10 @@
|
|
|
5
5
|
[](https://www.typescriptlang.org/)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**Claim safe slugs in one line**: availability, reserved names, spoofing protection, and moderation hooks.
|
|
9
9
|
|
|
10
|
-
|
|
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 {
|
|
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
|
-
|
|
45
|
-
|
|
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: "
|
|
50
|
-
{ name: "organization", column: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
+
## Built-in Profiles
|
|
688
86
|
|
|
689
|
-
|
|
87
|
+
Use `createNamespaceGuardWithProfile(profile, overrides, adapter)`:
|
|
690
88
|
|
|
691
|
-
|
|
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
|
-
|
|
95
|
+
## Zero-Dependency Moderation Integration
|
|
696
96
|
|
|
697
|
-
|
|
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 {
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
123
|
+
```bash
|
|
124
|
+
# 1) Generate realistic attack variants
|
|
125
|
+
npx namespace-guard attack-gen paypal --json
|
|
751
126
|
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
762
|
-
guard.
|
|
130
|
+
# 3) Preflight canonical collisions before adding DB unique constraints
|
|
131
|
+
npx namespace-guard audit-canonical ./users-export.json --json
|
|
763
132
|
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
##
|
|
137
|
+
## Advanced Security Primitives (Optional)
|
|
770
138
|
|
|
771
|
-
|
|
139
|
+
Use these when you need custom scoring, explainability, or pairwise checks outside the default claim flow:
|
|
772
140
|
|
|
773
141
|
```typescript
|
|
774
|
-
|
|
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
|
-
|
|
802
|
-
|
|
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
|
-
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
151
|
+
- Prisma
|
|
152
|
+
- Drizzle
|
|
153
|
+
- Kysely
|
|
154
|
+
- Knex
|
|
155
|
+
- TypeORM
|
|
156
|
+
- MikroORM
|
|
157
|
+
- Sequelize
|
|
158
|
+
- Mongoose
|
|
159
|
+
- Raw SQL
|
|
820
160
|
|
|
821
|
-
|
|
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
|
-
|
|
163
|
+
## Production Recommendation: Canonical Uniqueness
|
|
835
164
|
|
|
836
|
-
|
|
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
|
-
|
|
167
|
+
Migration guides per adapter: [docs/reference.md#canonical-uniqueness-migration-per-adapter](docs/reference.md#canonical-uniqueness-migration-per-adapter)
|
|
861
168
|
|
|
862
|
-
|
|
169
|
+
## Documentation Map
|
|
863
170
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
|
184
|
+
If `namespace-guard` helped you, please star the repo. It helps the project a lot.
|
|
890
185
|
|
|
891
|
-
-
|
|
892
|
-
-
|
|
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
|
|
191
|
+
Contributions welcome. Please open an issue first to discuss larger changes.
|
|
897
192
|
|
|
898
193
|
## License
|
|
899
194
|
|