namespace-guard 0.22.0 → 0.23.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.
Files changed (42) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +50 -40
  3. package/THIRD-PARTY-NOTICES.md +120 -0
  4. package/dist/adapters/drizzle.js +6 -5
  5. package/dist/adapters/drizzle.mjs +6 -5
  6. package/dist/adapters/knex.js +1 -1
  7. package/dist/adapters/knex.mjs +1 -1
  8. package/dist/adapters/kysely.js +5 -2
  9. package/dist/adapters/kysely.mjs +5 -2
  10. package/dist/adapters/mikro-orm.js +5 -2
  11. package/dist/adapters/mikro-orm.mjs +5 -2
  12. package/dist/adapters/mongoose.js +4 -6
  13. package/dist/adapters/mongoose.mjs +4 -6
  14. package/dist/adapters/prisma.js +2 -4
  15. package/dist/adapters/prisma.mjs +2 -4
  16. package/dist/adapters/raw.js +1 -2
  17. package/dist/adapters/raw.mjs +1 -2
  18. package/dist/adapters/sequelize.js +1 -1
  19. package/dist/adapters/sequelize.mjs +1 -1
  20. package/dist/adapters/typeorm.js +5 -5
  21. package/dist/adapters/typeorm.mjs +5 -5
  22. package/dist/cli.js +8721 -5620
  23. package/dist/cli.mjs +8721 -5620
  24. package/dist/composability-vectors.js +7660 -5106
  25. package/dist/composability-vectors.mjs +7660 -5106
  26. package/dist/confusable-weights.d.mts +7 -1
  27. package/dist/confusable-weights.d.ts +7 -1
  28. package/dist/confusable-weights.js +4563 -378
  29. package/dist/confusable-weights.mjs +4563 -378
  30. package/dist/font-specific-weights.d.mts +8 -2
  31. package/dist/font-specific-weights.d.ts +8 -2
  32. package/dist/font-specific-weights.js +34890 -2492
  33. package/dist/font-specific-weights.mjs +34890 -2492
  34. package/dist/index.d.mts +162 -81
  35. package/dist/index.d.ts +162 -81
  36. package/dist/index.js +10927 -5776
  37. package/dist/index.mjs +10924 -5776
  38. package/dist/profanity-en.d.mts +5 -3
  39. package/dist/profanity-en.d.ts +5 -3
  40. package/dist/profanity-en.js +10306 -5304
  41. package/dist/profanity-en.mjs +10305 -5304
  42. package/package.json +10 -3
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Paul Wood FRSA
3
+ Copyright (c) 2026 Paul Wood FRSA (@paultendo)
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,43 +1,51 @@
1
1
  # namespace-guard
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/namespace-guard.svg)](https://www.npmjs.com/package/namespace-guard)
4
+ [![npm downloads](https://img.shields.io/npm/dw/namespace-guard.svg)](https://www.npmjs.com/package/namespace-guard)
4
5
  [![bundle size](https://img.shields.io/bundlephobia/minzip/namespace-guard)](https://bundlephobia.com/package/namespace-guard)
5
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/)
6
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
8
 
8
- **The world's first library that detects confusable characters across non-Latin scripts.** Slug claimability, Unicode anti-spoofing, and LLM [Denial of Spend](https://paultendo.github.io/posts/confusable-vision-llm-attack-tests/) defence in one zero-dependency package.
9
+ **Lookalike detection built on the world's first font-by-font confusables dataset, measured in 322 fonts, including pairs between two non-Latin scripts that Unicode's confusables list leaves out.** Slug claimability, Unicode anti-spoofing, and LLM [Denial of Spend](https://paultendo.github.io/posts/confusable-vision-llm-attack-tests/) defence in one zero-dependency package.
10
+
11
+ The measured pairs come from [confusable-vision](https://github.com/paultendo/confusable-vision), which [addons.mozilla.org](https://addons.mozilla.org/) uses to check add-on names for lookalikes, [credited by name in Mozilla's source](https://github.com/mozilla/addons-server/blob/master/src/olympia/amo/confusables.py#L4-L6).
9
12
 
10
13
  - Live demo: https://paultendo.github.io/namespace-guard/
14
+ - Docs: https://paultendo.github.io/namespace-guard/docs/
11
15
  - Blog post: https://paultendo.github.io/posts/namespace-guard-launch/
12
16
 
17
+ ## Used by
18
+
19
+ - **[agent-sanitizer](https://github.com/AlexanderMattTurner/agent-sanitizer)** cleans untrusted text before an AI agent reads it, and uses namespace-guard as its default engine for folding lookalike characters in tool-call input (paths and commands) to ASCII. [Dependents on deps.dev](https://deps.dev/npm/namespace-guard/0.20.0/dependents).
20
+ - **[d0ma1n](https://d0ma1n.app)** finds the lookalikes of a domain that are already registered, scored with namespace-guard's measured weights.
21
+ - **[Mozilla addons.mozilla.org](https://addons.mozilla.org/)** checks add-on names for lookalikes with characters from **[confusable-vision](https://github.com/paultendo/confusable-vision#used-by)**, where these measured pairs come from ([`confusables.py`](https://github.com/mozilla/addons-server/blob/master/src/olympia/amo/confusables.py#L4-L6) in [addons-server](https://github.com/mozilla/addons-server)). [disarm](https://disarm.dev/) and [SilverSpeak](https://acmcmc.github.io/silverspeak/) use confusable-vision's data too.
22
+
13
23
  ## Cross-script confusable detection
14
24
 
15
- Existing confusable standards (TR39, IDNA) map non-Latin characters to Latin equivalents. They have zero coverage for confusable pairs *between* two non-Latin scripts.
25
+ Unicode's confusables list (TR39), which IDNA and most tools build on, maps each lookalike to one prototype, usually a Latin letter. Two non-Latin characters that look alike are only paired when they happen to share a prototype, so many pairs *between* two non-Latin scripts are missing.
16
26
 
17
- namespace-guard ships measured lookalike pairs from [confusable-vision](https://github.com/paultendo/confusable-vision) release 2026.09.24 (vector-outline raycasting, [RaySpace](https://paultendo.github.io/posts/rayspace-methodology/), at the size and baseline position glyphs have in running text, across every macOS system font and Roboto). They include pairs between two non-Latin scripts that no standard covers:
27
+ namespace-guard ships measured lookalike pairs from [confusable-vision](https://github.com/paultendo/confusable-vision) release 2026.09.26 (vector-outline raycasting, [RaySpace](https://paultendo.github.io/posts/rayspace-methodology/), at the size and baseline position glyphs have in running text, in 322 fonts: every macOS font, Roboto, Noto and DejaVu). They include pairs between two non-Latin scripts that no standard covers:
18
28
 
19
29
  ```typescript
20
30
  import { areConfusable, detectCrossScriptRisk } from "namespace-guard";
21
31
  import { CONFUSABLE_WEIGHTS } from "namespace-guard/confusable-weights";
22
32
 
23
- // Hangul ᅵ and Han 丨 are alike in every text font that draws both
24
- areConfusable("\u1175", "\u4E28", { weights: CONFUSABLE_WEIGHTS }); // true
25
-
26
- // Cyrillic ј and Greek ϳ are alike in 95% of text fonts
27
- areConfusable("\u0458", "\u03F3", { weights: CONFUSABLE_WEIGHTS }); // true
28
-
29
- // Cyrillic І and Greek Ι are identical outlines (62 fonts)
30
- areConfusable("\u0406", "\u0399", { weights: CONFUSABLE_WEIGHTS }); // true
33
+ // Hangul ㅣ and Han 丨 are alike in every text font that draws both. Unicode's list doesn't pair them,
34
+ // so only the measured weights catch it
35
+ areConfusable("\u3163", "\u4E28", { weights: CONFUSABLE_WEIGHTS }); // true
36
+ areConfusable("\u3163", "\u4E28"); // false
31
37
 
32
- // Without weights, only skeleton-based detection (TR39 coverage)
33
- areConfusable("\u1175", "\u4E28"); // false
38
+ // Cyrillic ј and Greek ϳ (alike in 92% of text fonts), and Cyrillic І and Greek Ι (identical outlines in 51 fonts),
39
+ // both map to a Latin letter in Unicode's list, so they match with or without weights
40
+ areConfusable("\u0458", "\u03F3"); // true
41
+ areConfusable("\u0406", "\u0399"); // true
34
42
 
35
43
  // Analyze an identifier for cross-script risk
36
- const risk = detectCrossScriptRisk("\u1175\u4E28", { weights: CONFUSABLE_WEIGHTS });
44
+ const risk = detectCrossScriptRisk("\u3163\u4E28", { weights: CONFUSABLE_WEIGHTS });
37
45
  // { riskLevel: "high", scripts: ["han", "hangul"], crossScriptPairs: [...] }
38
46
  ```
39
47
 
40
- 322 pairs ship, each found alike at running-text size within one font or across fonts. A pair's `danger` (0–1) is the share of text fonts (or font combinations) where it holds; filter at `danger > 0.7` (85 pairs) for the pairs alike almost everywhere. `namespace-guard/font-specific-weights` gives the same pairs per font (106 fonts), for saying which font makes a lookalike most convincing. Data licensed CC-BY-4.0.
48
+ 2,300 pairs ship, each found alike at running-text size within one font or across fonts, where one character is an ASCII letter or digit or the two are in different scripts. A pair's `danger` (0–1) is the share of text fonts (or font combinations) where it holds; filter at `danger > 0.7` (1,079 pairs) for the pairs alike almost everywhere. `namespace-guard/font-specific-weights` gives the pairs found within one font per font (1,417 pairs in 166 fonts), for saying which font makes a lookalike most convincing. Data licensed CC-BY-4.0.
41
49
 
42
50
  ## Installation
43
51
 
@@ -69,7 +77,7 @@ const guard = createNamespaceGuardWithProfile(
69
77
  await guard.assertClaimable("acme-corp");
70
78
  ```
71
79
 
72
- For race-safe writes, use `claim()`:
80
+ To check and write in one step, use `claim()`. With a unique index on the canonical column, a write that loses a race comes back as "taken" rather than as a database error. If users and organisations share one namespace, add a shared names table too; see [Names shared by several tables](https://paultendo.github.io/namespace-guard/docs/claiming/#names-shared-by-several-tables).
73
81
 
74
82
  ```typescript
75
83
  const result = await guard.claim(input.handle, async (canonical) => {
@@ -99,11 +107,15 @@ if (!result.claimed) {
99
107
 
100
108
  ## LLM Pipeline Preprocessing
101
109
 
102
- Confusable characters are pixel-identical to Latin letters but encode as multi-byte BPE tokens. A 95-line contract that costs 881 tokens in clean ASCII costs 4,567 tokens when flooded with confusables: **5.2x the API bill**. The model reads it correctly. The invoice does not care.
110
+ **Lookalike letters can't fool an LLM. They can make it cost up to 5.7x as much to read.**
103
111
 
104
- We tested this across 4 frontier models, 8 attack types, and 130+ API calls. Zero meaning flips. Every substituted clause was correctly interpreted. But the billing attack succeeds. We call it **Denial of Spend**: the confusable analogue of DDoS, where the attacker cannot degrade the service but can inflate the cost of running it.
112
+ I flooded a contract with characters that pass for Latin letters and gave it to seven GPT and Claude models: GPT-6 Astra, Sol and Luna, and Claude Fable 5.1, Opus 5.5, Sonnet 5 and Haiku 4.5. Every one still read every negation correctly. But the contract grew from about a thousand tokens to more than four thousand, and you pay for every one of them. I call this **Denial of Spend**: an attack that can't change what the service does, but can multiply what it costs to run.
105
113
 
106
- `canonicalise()` recovered every substituted term across all 12 attack variants, collapsing the 5.2x inflation to 1.0x. Processing a 10,000-character document takes under 1ms.
114
+ Reading is only part of the bill. The model's answer isn't flooded, and output tokens cost more, so on a short contract the bill for each question rose by up to 3.9x, depending on how much the model wrote back. For work that is all reading, such as embedding documents for search, the bill rises by the full 4x to 5.7x ([results](https://github.com/paultendo/confusable-vision/blob/main/data/output/denial-of-spend/RESULTS.md)).
115
+
116
+ The first test, in February 2026, covered 4 models, 8 attack types and 130+ API calls, with the same result: no meaning flips, and 5.2x the tokens for the flooded contract.
117
+
118
+ `canonicalise()` rewrites any word that shows a sign of tampering: one that mixes Latin with another script, has a Latin letter no modern language uses (ɦ, ꞡ), or has a non-ASCII capital inside a lowercase word. Every lookalike in such a word goes back to its Latin letter, in the case of the word around it; ordinary Turkish, Russian or Sámi words are left alone. With `strategy: "all"` it turns the flooded contract back into the clean one, byte for byte, so it takes as many tokens as the clean one; the default leaves 9 short words (1.02x to 1.03x the clean contract's tokens). The flooded contract takes under 2 ms.
107
119
 
108
120
  ```typescript
109
121
  import { canonicalise, scan, isClean } from "namespace-guard";
@@ -112,7 +124,10 @@ const raw = "The seller аssumes аll liаbility.";
112
124
 
113
125
  const report = scan(raw); // detailed findings + risk level
114
126
  const clean = canonicalise(raw); // "The seller assumes all liability."
115
- const ok = isClean(raw); // false (mixed-script confusable detected)
127
+ const ok = isClean(raw); // false: true only when canonicalise() would change nothing
128
+
129
+ canonicalise("shall поŧ be limited"); // "shall not be limited" (Cyrillic п, о; Latin t with stroke)
130
+ canonicalise("Tɦİs ƙİŧe İstanbul"); // "This kite İstanbul" (İstanbul shows no sign of tampering)
116
131
 
117
132
  // For known-Latin documents (e.g. English contracts), use strategy: "all"
118
133
  // to also catch words where every character was substituted:
@@ -136,7 +151,7 @@ areConfusable("paypal", "pa\u0443pal"); // true
136
151
  confusableDistance("paypal", "pa\u0443pal"); // graded similarity + chainDepth + explainable steps
137
152
  ```
138
153
 
139
- For measured visual scoring, pass the optional weights from confusable-vision release 2026.09.24 (322 pairs measured at running-text size by vector-outline raycasting). Each pair has a `danger` score (0–1), the share of text fonts where it holds; use `danger > 0.7` for the pairs alike almost everywhere. The `context` filter restricts to identifier-valid, domain-valid, or all pairs.
154
+ For measured visual scoring, pass the optional weights from confusable-vision release 2026.09.26 (2,300 pairs measured at running-text size by vector-outline raycasting). Each pair has a `danger` score (0–1), the share of text fonts where it holds; use `danger > 0.7` for the pairs alike almost everywhere. The `context` filter restricts to identifier-valid, domain-valid, or all pairs.
140
155
 
141
156
  ```typescript
142
157
  import { confusableDistance } from "namespace-guard";
@@ -159,7 +174,7 @@ import { CONFUSABLE_WEIGHTS } from "namespace-guard/confusable-weights";
159
174
 
160
175
  // Full-Cyrillic lookalike — registrable and deceptive
161
176
  isDomainSpoof("\u0440\u0430\u0443\u0440\u0430\u04CF", "paypal", { weights: CONFUSABLE_WEIGHTS });
162
- // { spoof: true, script: "cyrillic", danger: 0.91, substitutions: [...] }
177
+ // { spoof: true, script: "cyrillic", danger: 0.781, substitutions: [...] }
163
178
 
164
179
  // Mixed-script — not registrable, not flagged
165
180
  isDomainSpoof("\u0440aypal", "paypal", { weights: CONFUSABLE_WEIGHTS });
@@ -179,11 +194,11 @@ The `danger` score (0–1) is always returned when a script match is found, even
179
194
 
180
195
  Two research tracks feed the library:
181
196
 
182
- **Visual measurement.** Lookalike pairs measured by vector-outline raycasting ([RaySpace](https://paultendo.github.io/posts/rayspace-methodology/)) at the size and baseline position glyphs have in running text, across every macOS system font and Roboto, and calibrated against pairs with known answers. Release 2026.09.24 finds 857 pairs alike; the 322 that pass its thresholds ship here, including pairs between two non-Latin scripts (Cyrillic/Greek, Katakana/Hiragana, Han/Hangul) that no standard covers. The March 2026 measurements were blind to size and are superseded. Full dataset published as [confusable-vision](https://github.com/paultendo/confusable-vision) (CC-BY-4.0).
197
+ **Visual measurement.** Lookalike pairs measured by vector-outline raycasting ([RaySpace](https://paultendo.github.io/posts/rayspace-methodology/)) at the size and baseline position glyphs have in running text, in 322 fonts (every macOS font, Roboto, Noto and DejaVu), and calibrated against pairs with known answers. Release 2026.09.26 compares every letter and digit each font draws; the 2,300 pairs that pass its thresholds and involve an ASCII letter or two scripts ship here, including pairs between two non-Latin scripts (Cyrillic/Greek, Katakana/Hiragana, Han/Hangul) that no standard covers. Its lookalikes of ASCII letters are also checked in place, set between other letters in common fonts at text size; those that Unicode does not list are in `CONFUSABLE_MAP_FULL` and `MEASURED_CONFUSABLES`. Earlier releases compared only part of this and are superseded. Full dataset published as [confusable-vision](https://github.com/paultendo/confusable-vision) (CC-BY-4.0).
183
198
 
184
- **Normalisation composability.** 31 characters where Unicode's confusables.txt and NFKC normalisation disagree. Two production maps (`CONFUSABLE_MAP` for NFKC-first, `CONFUSABLE_MAP_FULL` for raw-input pipelines), a benchmark corpus, and composability vectors wired into CLI drift baselines. Submitted to [Unicode public review (PRI #540)](https://www.unicode.org/review/pri540/) and published in [accumulated feedback](https://www.unicode.org/review/pri540/feedback.html).
199
+ **Normalisation composability.** 34 characters where Unicode's confusables.txt (August 2026) and NFKC normalisation disagree, up from 31 with the previous file. Three of them are Unicode 16 characters, so a runtime whose NFKC predates Unicode 16 derives 31. Two production maps (`CONFUSABLE_MAP` for NFKC-first, `CONFUSABLE_MAP_FULL` for raw-input pipelines), a benchmark corpus, and composability vectors wired into CLI drift baselines. Submitted to [Unicode public review (PRI #540)](https://www.unicode.org/review/pri540/) and published in [accumulated feedback](https://www.unicode.org/review/pri540/feedback.html).
185
200
 
186
- - Technical reference: [docs/reference.md#how-the-anti-spoofing-pipeline-works](docs/reference.md#how-the-anti-spoofing-pipeline-works)
201
+ - Technical reference: [how a name is checked](https://paultendo.github.io/namespace-guard/docs/how-it-works/) and [Unicode names](https://paultendo.github.io/namespace-guard/docs/unicode-names/)
187
202
  - Launch write-up: https://paultendo.github.io/posts/namespace-guard-launch/
188
203
  - Denial of Spend: https://paultendo.github.io/posts/confusable-vision-llm-attack-tests/
189
204
 
@@ -223,6 +238,8 @@ const guard = createNamespaceGuard(
223
238
  );
224
239
  ```
225
240
 
241
+ The English list comes with an allowlist of names, places and common words that contain a listed word (Scunthorpe, Dickson, Kirkland). With its defaults it refuses 0.4% of dictionary words and 11 of the 38,970 first names given to babies in England and Wales since 1996, and it leaves out names and places that look like a slur. Add your own users' names and towns with `allowlist`, and run it over the names you already have before you switch it on: see [Moderation](https://paultendo.github.io/namespace-guard/docs/moderation/).
242
+
226
243
  ## CLI Workflow
227
244
 
228
245
  ```bash
@@ -251,29 +268,22 @@ npx namespace-guard drift --json
251
268
  - Mongoose
252
269
  - Raw SQL
253
270
 
254
- Adapter setup examples and migration guidance: [docs/reference.md#adapters](docs/reference.md#adapters)
271
+ Adapter setup examples and migration guidance: [Adapters](https://paultendo.github.io/namespace-guard/docs/adapters/)
255
272
 
256
273
  ## Production Recommendation: Canonical Uniqueness
257
274
 
258
275
  For full protection against Unicode/canonicalization edge cases, enforce uniqueness on canonical columns (for example `handleCanonical`, `slugCanonical`) and point `sources[*].column` there.
259
276
 
260
- Migration guides per adapter: [docs/reference.md#canonical-uniqueness-migration-per-adapter](docs/reference.md#canonical-uniqueness-migration-per-adapter)
277
+ Adding a canonical column, per adapter: [Claiming names](https://paultendo.github.io/namespace-guard/docs/claiming/#store-a-canonical-column) and [Adapters](https://paultendo.github.io/namespace-guard/docs/adapters/)
261
278
 
262
279
  ## Documentation Map
263
280
 
264
- - Full reference: [docs/reference.md](docs/reference.md)
265
- - Config reference: [docs/reference.md#configuration](docs/reference.md#configuration)
266
- - Validators (profanity, homoglyph, invisible): [docs/reference.md#async-validators](docs/reference.md#async-validators)
267
- - Canonical preflight audit (`audit-canonical`): [docs/reference.md#audit-canonical-command](docs/reference.md#audit-canonical-command)
268
- - Anti-spoofing pipeline and composability vectors: [docs/reference.md#how-the-anti-spoofing-pipeline-works](docs/reference.md#how-the-anti-spoofing-pipeline-works)
269
- - LLM preprocessing (`canonicalise`, `scan`, `isClean`): [docs/reference.md#llm-pipeline-preprocessing](docs/reference.md#llm-pipeline-preprocessing)
270
- - Benchmark corpus (`confusable-bench.v1`): [docs/reference.md#confusable-benchmark-corpus-artifact](docs/reference.md#confusable-benchmark-corpus-artifact)
271
- - Advanced primitives (`skeleton`, `areConfusable`, `confusableDistance`): [docs/reference.md#advanced-security-primitives](docs/reference.md#advanced-security-primitives)
272
- - Confusable weights (scored pairs, including cross-script): [docs/reference.md#confusable-weights-subpath](docs/reference.md#confusable-weights-subpath)
273
- - Cross-script detection: [docs/reference.md#cross-script-detection](docs/reference.md#cross-script-detection)
274
- - CLI reference: [docs/reference.md#cli](docs/reference.md#cli)
275
- - API reference: [docs/reference.md#api-reference](docs/reference.md#api-reference)
276
- - Framework integration (Next.js/Express/tRPC): [docs/reference.md#framework-integration](docs/reference.md#framework-integration)
281
+ The docs live at **[paultendo.github.io/namespace-guard/docs](https://paultendo.github.io/namespace-guard/docs/)**, with search and examples you can run in the page. The pages are Markdown in [`guide/`](guide/), and every example in them runs in the test suite.
282
+
283
+ - [Getting started](https://paultendo.github.io/namespace-guard/docs/getting-started/) and [how a name is checked](https://paultendo.github.io/namespace-guard/docs/how-it-works/)
284
+ - Guides: [protect names](https://paultendo.github.io/namespace-guard/docs/protect-names/), [Unicode names](https://paultendo.github.io/namespace-guard/docs/unicode-names/), [claiming names](https://paultendo.github.io/namespace-guard/docs/claiming/), [suggestions](https://paultendo.github.io/namespace-guard/docs/suggestions/), [moderation](https://paultendo.github.io/namespace-guard/docs/moderation/), [text for LLMs](https://paultendo.github.io/namespace-guard/docs/llm-text/), [domain names](https://paultendo.github.io/namespace-guard/docs/domains/), [comparing names](https://paultendo.github.io/namespace-guard/docs/comparing-names/), [the CLI](https://paultendo.github.io/namespace-guard/docs/cli/), [upgrading to 0.23](https://paultendo.github.io/namespace-guard/docs/upgrading/)
285
+ - Reference: [configuration](https://paultendo.github.io/namespace-guard/docs/configuration/), [the guard](https://paultendo.github.io/namespace-guard/docs/guard/), [functions](https://paultendo.github.io/namespace-guard/docs/functions/), [data and maps](https://paultendo.github.io/namespace-guard/docs/data/), [adapters](https://paultendo.github.io/namespace-guard/docs/adapters/), [frameworks](https://paultendo.github.io/namespace-guard/docs/frameworks/), [TypeScript](https://paultendo.github.io/namespace-guard/docs/typescript/)
286
+ - [Changelog](https://paultendo.github.io/namespace-guard/docs/changelog/)
277
287
 
278
288
  ## Support
279
289
 
@@ -0,0 +1,120 @@
1
+ # Third-party notices
2
+
3
+ namespace-guard includes data derived from third-party sources. The
4
+ namespace-guard source code is MIT-licensed (see `LICENSE`). The
5
+ embedded data retains its original licence as noted below.
6
+
7
+ ---
8
+
9
+ ## Unicode confusables.txt
10
+
11
+ **Used by:** `CONFUSABLE_MAP`, `CONFUSABLE_MAP_FULL`, `skeleton()`,
12
+ `areConfusable()` (in `src/index.ts`)
13
+
14
+ **Source:** https://unicode.org/Public/security/latest/confusables.txt
15
+
16
+ **Licence:** Unicode License v3
17
+ https://www.unicode.org/terms_of_use.html
18
+
19
+ > Copyright 1991-Present Unicode, Inc. All rights reserved.
20
+
21
+ Regenerate: `npx tsx scripts/generate-confusables.ts`
22
+
23
+ ---
24
+
25
+ ## Unicode Character Database (UnicodeData.txt, Scripts.txt, IdentifierType.txt)
26
+
27
+ **Used by:** `LATIN_FOLD` and `LATIN_NOT_IN_MODERN_USE` (in `src/latin-fold.ts`),
28
+ used by `canonicalise()`, `scan()` and `isClean()`
29
+
30
+ **Source:** https://www.unicode.org/Public/UCD/latest/ucd/ and
31
+ https://www.unicode.org/Public/security/latest/IdentifierType.txt (UCD 17.0.0; IdentifierType 16.0.0)
32
+
33
+ **Licence:** Unicode License v3
34
+ https://www.unicode.org/terms_of_use.html
35
+
36
+ > Copyright 1991-Present Unicode, Inc. All rights reserved.
37
+
38
+ Regenerate: `npm run build:latin-fold`
39
+
40
+ ---
41
+
42
+ ## confusable-vision visual similarity data
43
+
44
+ **Used by:** `CONFUSABLE_WEIGHTS` (in `src/confusable-weights.ts`),
45
+ `FONT_SPECIFIC_WEIGHTS` (in `src/font-specific-weights.ts`), the `visualScore`
46
+ and `novel` entries of `LLM_CONFUSABLE_MAP` (in `src/llm-confusable-map.ts`),
47
+ `MEASURED_CONFUSABLES` and the measured entries of `CONFUSABLE_MAP` and
48
+ `CONFUSABLE_MAP_FULL` (in `src/confusable-maps.ts`)
49
+
50
+ **Source:** https://github.com/paultendo/confusable-vision, release 2026.09.26
51
+
52
+ **Licence:** CC-BY-4.0
53
+ https://creativecommons.org/licenses/by/4.0/
54
+
55
+ **Attribution:** Paul Wood FRSA (@paultendo), confusable-vision.
56
+ Visual similarity weights for 2,300 character pairs (confusable-weights-v4.json),
57
+ 1,417 font-specific pairs in 166 fonts, and the in-place checks of lookalikes of
58
+ ASCII letters and digits, from release 2026.09.26, measured with RaySpace.
59
+
60
+ Regenerate: `node scripts/generate-confusable-weights.js`,
61
+ `node scripts/generate-font-specific-weights.js` and
62
+ `npx tsx scripts/generate-confusables.ts`
63
+
64
+ ---
65
+
66
+ ## profane-words (English profanity list)
67
+
68
+ **Used by:** `PROFANITY_WORDS_EN` (in `src/profanity-en.ts`)
69
+
70
+ **Source:** https://github.com/zautumnz/profane-words
71
+
72
+ **Licence:** WTFPL v2
73
+
74
+ See `docs/data/profanity-words.SOURCE.md` for curation notes.
75
+
76
+ Regenerate: `node scripts/generate-profanity-global.js`
77
+
78
+ ---
79
+
80
+ ## Names and places in the English allowlist
81
+
82
+ **Used by:** `PROFANITY_ALLOWLIST_EN` (in `src/profanity-en.ts`) and
83
+ `docs/data/profanity-allowlist.json`: names, places and common words that contain
84
+ a word on the English list, which `createEnglishProfanityValidator()` lets through.
85
+ The allowlist is a selection of words from these sources, screened for slurs and
86
+ crude words; see `docs/data/profanity-words.SOURCE.md`.
87
+
88
+ **GeoNames** (`GB.txt`, populated places and administrative areas; `cities1000.txt`)
89
+ https://download.geonames.org/export/dump/
90
+
91
+ **Licence:** CC BY 4.0
92
+ https://creativecommons.org/licenses/by/4.0/
93
+
94
+ **Attribution:** Place names from GeoNames (https://www.geonames.org/), licensed
95
+ under CC BY 4.0.
96
+
97
+ **ONS baby names** ("Baby names in England and Wales: from 1996", 1996 to 2025)
98
+ https://www.ons.gov.uk/peoplepopulationandcommunity/birthsdeathsandmarriages/livebirths/datasets/babynamesinenglandandwalesfrom1996
99
+
100
+ **Licence:** Open Government Licence v3.0
101
+ https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/
102
+
103
+ **Attribution:** Contains public sector information licensed under the Open
104
+ Government Licence v3.0.
105
+
106
+ **US Census Bureau 2010 surnames** (`Names_2010Census.csv`)
107
+ https://www2.census.gov/topics/genealogy/2010surnames/names.zip
108
+
109
+ **Licence:** public domain (a work of the US federal government).
110
+
111
+ **Webster's Second New International Dictionary (1934)**, as
112
+ `/usr/share/dict/words`: public domain.
113
+
114
+ No Apple data and no wordfreq data ship with namespace-guard. The core's
115
+ `PROFANITY_COMMON_INSIDE_WORDS` (in `src/index.ts`) holds only letter sequences
116
+ taken from the English list's own entries; they were chosen by measuring those
117
+ entries against `/usr/share/dict/words` and wordfreq's word frequencies.
118
+
119
+ Regenerate: `npm run build && node scripts/measure-profanity.mjs --write`, then
120
+ `npm run build:profanity-data`. The source files go in `.cache/open-names/`.
@@ -48,15 +48,16 @@ function createDrizzleAdapter(db, tables, eqOrOptions) {
48
48
  compareFn = ops.ilike;
49
49
  }
50
50
  return queryHandler.findFirst({
51
- where: compareFn(column, value),
52
- columns: {
53
- [idColumn]: true,
54
- ...source.scopeKey && source.scopeKey !== idColumn ? { [source.scopeKey]: true } : {}
55
- }
51
+ where: compareFn(column, findOptions?.caseInsensitive ? escapeLike(value) : value),
52
+ // The guard only needs the id: it compares the scope's value with it
53
+ columns: { [idColumn]: true }
56
54
  });
57
55
  }
58
56
  };
59
57
  }
58
+ function escapeLike(value) {
59
+ return value.replace(/[\\%_]/g, "\\$&");
60
+ }
60
61
  // Annotate the CommonJS export names for ESM import in node:
61
62
  0 && (module.exports = {
62
63
  createDrizzleAdapter
@@ -24,15 +24,16 @@ function createDrizzleAdapter(db, tables, eqOrOptions) {
24
24
  compareFn = ops.ilike;
25
25
  }
26
26
  return queryHandler.findFirst({
27
- where: compareFn(column, value),
28
- columns: {
29
- [idColumn]: true,
30
- ...source.scopeKey && source.scopeKey !== idColumn ? { [source.scopeKey]: true } : {}
31
- }
27
+ where: compareFn(column, findOptions?.caseInsensitive ? escapeLike(value) : value),
28
+ // The guard only needs the id: it compares the scope's value with it
29
+ columns: { [idColumn]: true }
32
30
  });
33
31
  }
34
32
  };
35
33
  }
34
+ function escapeLike(value) {
35
+ return value.replace(/[\\%_]/g, "\\$&");
36
+ }
36
37
  export {
37
38
  createDrizzleAdapter
38
39
  };
@@ -27,7 +27,7 @@ function createKnexAdapter(knex) {
27
27
  return {
28
28
  async findOne(source, value, options) {
29
29
  const idColumn = source.idColumn ?? "id";
30
- const columns = source.scopeKey && source.scopeKey !== idColumn ? [idColumn, source.scopeKey] : [idColumn];
30
+ const columns = [idColumn];
31
31
  let query = knex(source.name).select(columns);
32
32
  if (options?.caseInsensitive) {
33
33
  query = query.whereRaw(`LOWER(??) = LOWER(?)`, [source.column, value]);
@@ -3,7 +3,7 @@ function createKnexAdapter(knex) {
3
3
  return {
4
4
  async findOne(source, value, options) {
5
5
  const idColumn = source.idColumn ?? "id";
6
- const columns = source.scopeKey && source.scopeKey !== idColumn ? [idColumn, source.scopeKey] : [idColumn];
6
+ const columns = [idColumn];
7
7
  let query = knex(source.name).select(columns);
8
8
  if (options?.caseInsensitive) {
9
9
  query = query.whereRaw(`LOWER(??) = LOWER(?)`, [source.column, value]);
@@ -27,10 +27,10 @@ function createKyselyAdapter(db) {
27
27
  return {
28
28
  async findOne(source, value, options) {
29
29
  const idColumn = source.idColumn ?? "id";
30
- const columns = source.scopeKey && source.scopeKey !== idColumn ? [idColumn, source.scopeKey] : [idColumn];
30
+ const columns = [idColumn];
31
31
  let query = db.selectFrom(source.name).select(columns);
32
32
  if (options?.caseInsensitive) {
33
- query = query.where(source.column, "ilike", value);
33
+ query = query.where(source.column, "ilike", escapeLike(value));
34
34
  } else {
35
35
  query = query.where(source.column, "=", value);
36
36
  }
@@ -39,6 +39,9 @@ function createKyselyAdapter(db) {
39
39
  }
40
40
  };
41
41
  }
42
+ function escapeLike(value) {
43
+ return value.replace(/[\\%_]/g, "\\$&");
44
+ }
42
45
  // Annotate the CommonJS export names for ESM import in node:
43
46
  0 && (module.exports = {
44
47
  createKyselyAdapter
@@ -3,10 +3,10 @@ function createKyselyAdapter(db) {
3
3
  return {
4
4
  async findOne(source, value, options) {
5
5
  const idColumn = source.idColumn ?? "id";
6
- const columns = source.scopeKey && source.scopeKey !== idColumn ? [idColumn, source.scopeKey] : [idColumn];
6
+ const columns = [idColumn];
7
7
  let query = db.selectFrom(source.name).select(columns);
8
8
  if (options?.caseInsensitive) {
9
- query = query.where(source.column, "ilike", value);
9
+ query = query.where(source.column, "ilike", escapeLike(value));
10
10
  } else {
11
11
  query = query.where(source.column, "=", value);
12
12
  }
@@ -15,6 +15,9 @@ function createKyselyAdapter(db) {
15
15
  }
16
16
  };
17
17
  }
18
+ function escapeLike(value) {
19
+ return value.replace(/[\\%_]/g, "\\$&");
20
+ }
18
21
  export {
19
22
  createKyselyAdapter
20
23
  };
@@ -31,8 +31,8 @@ function createMikroORMAdapter(em, entities) {
31
31
  throw new Error(`MikroORM entity "${source.name}" not found in provided entities object`);
32
32
  }
33
33
  const idColumn = source.idColumn ?? "id";
34
- const fields = source.scopeKey && source.scopeKey !== idColumn ? [idColumn, source.scopeKey] : [idColumn];
35
- const whereValue = options?.caseInsensitive ? { $ilike: value } : value;
34
+ const fields = [idColumn];
35
+ const whereValue = options?.caseInsensitive ? { $ilike: escapeLike(value) } : value;
36
36
  return em.findOne(
37
37
  entity,
38
38
  { [source.column]: whereValue },
@@ -41,6 +41,9 @@ function createMikroORMAdapter(em, entities) {
41
41
  }
42
42
  };
43
43
  }
44
+ function escapeLike(value) {
45
+ return value.replace(/[\\%_]/g, "\\$&");
46
+ }
44
47
  // Annotate the CommonJS export names for ESM import in node:
45
48
  0 && (module.exports = {
46
49
  createMikroORMAdapter
@@ -7,8 +7,8 @@ function createMikroORMAdapter(em, entities) {
7
7
  throw new Error(`MikroORM entity "${source.name}" not found in provided entities object`);
8
8
  }
9
9
  const idColumn = source.idColumn ?? "id";
10
- const fields = source.scopeKey && source.scopeKey !== idColumn ? [idColumn, source.scopeKey] : [idColumn];
11
- const whereValue = options?.caseInsensitive ? { $ilike: value } : value;
10
+ const fields = [idColumn];
11
+ const whereValue = options?.caseInsensitive ? { $ilike: escapeLike(value) } : value;
12
12
  return em.findOne(
13
13
  entity,
14
14
  { [source.column]: whereValue },
@@ -17,6 +17,9 @@ function createMikroORMAdapter(em, entities) {
17
17
  }
18
18
  };
19
19
  }
20
+ function escapeLike(value) {
21
+ return value.replace(/[\\%_]/g, "\\$&");
22
+ }
20
23
  export {
21
24
  createMikroORMAdapter
22
25
  };
@@ -32,17 +32,15 @@ function createMongooseAdapter(models) {
32
32
  }
33
33
  const idColumn = source.idColumn ?? "_id";
34
34
  const projection = { [idColumn]: 1 };
35
- if (source.scopeKey && source.scopeKey !== idColumn) {
36
- projection[source.scopeKey] = 1;
37
- }
38
35
  const query = model.findOne(
39
36
  { [source.column]: value },
40
37
  projection
41
38
  );
42
- if (options?.caseInsensitive) {
43
- return query.collation({ locale: "en", strength: 2 }).lean();
39
+ const doc = options?.caseInsensitive ? await query.collation({ locale: "en", strength: 2 }).lean() : await query.lean();
40
+ if (doc && source.idColumn === void 0 && doc.id === void 0 && doc._id !== void 0) {
41
+ return { ...doc, id: doc._id };
44
42
  }
45
- return query.lean();
43
+ return doc;
46
44
  }
47
45
  };
48
46
  }
@@ -8,17 +8,15 @@ function createMongooseAdapter(models) {
8
8
  }
9
9
  const idColumn = source.idColumn ?? "_id";
10
10
  const projection = { [idColumn]: 1 };
11
- if (source.scopeKey && source.scopeKey !== idColumn) {
12
- projection[source.scopeKey] = 1;
13
- }
14
11
  const query = model.findOne(
15
12
  { [source.column]: value },
16
13
  projection
17
14
  );
18
- if (options?.caseInsensitive) {
19
- return query.collation({ locale: "en", strength: 2 }).lean();
15
+ const doc = options?.caseInsensitive ? await query.collation({ locale: "en", strength: 2 }).lean() : await query.lean();
16
+ if (doc && source.idColumn === void 0 && doc.id === void 0 && doc._id !== void 0) {
17
+ return { ...doc, id: doc._id };
20
18
  }
21
- return query.lean();
19
+ return doc;
22
20
  }
23
21
  };
24
22
  }
@@ -34,10 +34,8 @@ function createPrismaAdapter(prisma) {
34
34
  const whereValue = options?.caseInsensitive ? { equals: value, mode: "insensitive" } : value;
35
35
  return model.findFirst({
36
36
  where: { [source.column]: whereValue },
37
- select: {
38
- [idColumn]: true,
39
- ...source.scopeKey ? { [source.scopeKey]: true } : {}
40
- }
37
+ // The guard only needs the id: it compares the scope's value with it
38
+ select: { [idColumn]: true }
41
39
  });
42
40
  }
43
41
  };
@@ -10,10 +10,8 @@ function createPrismaAdapter(prisma) {
10
10
  const whereValue = options?.caseInsensitive ? { equals: value, mode: "insensitive" } : value;
11
11
  return model.findFirst({
12
12
  where: { [source.column]: whereValue },
13
- select: {
14
- [idColumn]: true,
15
- ...source.scopeKey ? { [source.scopeKey]: true } : {}
16
- }
13
+ // The guard only needs the id: it compares the scope's value with it
14
+ select: { [idColumn]: true }
17
15
  });
18
16
  }
19
17
  };
@@ -36,8 +36,7 @@ function createRawAdapter(execute) {
36
36
  assertSafeIdentifier(source.name, "table name");
37
37
  assertSafeIdentifier(source.column, "column name");
38
38
  assertSafeIdentifier(idColumn, "id column name");
39
- if (source.scopeKey) assertSafeIdentifier(source.scopeKey, "scope key");
40
- const columns = source.scopeKey && source.scopeKey !== idColumn ? `"${idColumn}", "${source.scopeKey}"` : `"${idColumn}"`;
39
+ const columns = `"${idColumn}"`;
41
40
  const whereClause = options?.caseInsensitive ? `LOWER("${source.column}") = LOWER($1)` : `"${source.column}" = $1`;
42
41
  const sql = `SELECT ${columns} FROM "${source.name}" WHERE ${whereClause} LIMIT 1`;
43
42
  const result = await execute(sql, [value]);
@@ -12,8 +12,7 @@ function createRawAdapter(execute) {
12
12
  assertSafeIdentifier(source.name, "table name");
13
13
  assertSafeIdentifier(source.column, "column name");
14
14
  assertSafeIdentifier(idColumn, "id column name");
15
- if (source.scopeKey) assertSafeIdentifier(source.scopeKey, "scope key");
16
- const columns = source.scopeKey && source.scopeKey !== idColumn ? `"${idColumn}", "${source.scopeKey}"` : `"${idColumn}"`;
15
+ const columns = `"${idColumn}"`;
17
16
  const whereClause = options?.caseInsensitive ? `LOWER("${source.column}") = LOWER($1)` : `"${source.column}" = $1`;
18
17
  const sql = `SELECT ${columns} FROM "${source.name}" WHERE ${whereClause} LIMIT 1`;
19
18
  const result = await execute(sql, [value]);
@@ -31,7 +31,7 @@ function createSequelizeAdapter(models, helpers) {
31
31
  throw new Error(`Sequelize model "${source.name}" not found in provided models object`);
32
32
  }
33
33
  const idColumn = source.idColumn ?? "id";
34
- const attributes = source.scopeKey && source.scopeKey !== idColumn ? [idColumn, source.scopeKey] : [idColumn];
34
+ const attributes = [idColumn];
35
35
  let where;
36
36
  if (options?.caseInsensitive) {
37
37
  if (!helpers) {
@@ -7,7 +7,7 @@ function createSequelizeAdapter(models, helpers) {
7
7
  throw new Error(`Sequelize model "${source.name}" not found in provided models object`);
8
8
  }
9
9
  const idColumn = source.idColumn ?? "id";
10
- const attributes = source.scopeKey && source.scopeKey !== idColumn ? [idColumn, source.scopeKey] : [idColumn];
10
+ const attributes = [idColumn];
11
11
  let where;
12
12
  if (options?.caseInsensitive) {
13
13
  if (!helpers) {