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.
- package/LICENSE +1 -1
- package/README.md +50 -40
- package/THIRD-PARTY-NOTICES.md +120 -0
- package/dist/adapters/drizzle.js +6 -5
- package/dist/adapters/drizzle.mjs +6 -5
- package/dist/adapters/knex.js +1 -1
- package/dist/adapters/knex.mjs +1 -1
- package/dist/adapters/kysely.js +5 -2
- package/dist/adapters/kysely.mjs +5 -2
- package/dist/adapters/mikro-orm.js +5 -2
- package/dist/adapters/mikro-orm.mjs +5 -2
- package/dist/adapters/mongoose.js +4 -6
- package/dist/adapters/mongoose.mjs +4 -6
- package/dist/adapters/prisma.js +2 -4
- package/dist/adapters/prisma.mjs +2 -4
- package/dist/adapters/raw.js +1 -2
- package/dist/adapters/raw.mjs +1 -2
- package/dist/adapters/sequelize.js +1 -1
- package/dist/adapters/sequelize.mjs +1 -1
- package/dist/adapters/typeorm.js +5 -5
- package/dist/adapters/typeorm.mjs +5 -5
- package/dist/cli.js +8721 -5620
- package/dist/cli.mjs +8721 -5620
- package/dist/composability-vectors.js +7660 -5106
- package/dist/composability-vectors.mjs +7660 -5106
- package/dist/confusable-weights.d.mts +7 -1
- package/dist/confusable-weights.d.ts +7 -1
- package/dist/confusable-weights.js +4563 -378
- package/dist/confusable-weights.mjs +4563 -378
- package/dist/font-specific-weights.d.mts +8 -2
- package/dist/font-specific-weights.d.ts +8 -2
- package/dist/font-specific-weights.js +34890 -2492
- package/dist/font-specific-weights.mjs +34890 -2492
- package/dist/index.d.mts +162 -81
- package/dist/index.d.ts +162 -81
- package/dist/index.js +10927 -5776
- package/dist/index.mjs +10924 -5776
- package/dist/profanity-en.d.mts +5 -3
- package/dist/profanity-en.d.ts +5 -3
- package/dist/profanity-en.js +10306 -5304
- package/dist/profanity-en.mjs +10305 -5304
- package/package.json +10 -3
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,43 +1,51 @@
|
|
|
1
1
|
# namespace-guard
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/namespace-guard)
|
|
4
|
+
[](https://www.npmjs.com/package/namespace-guard)
|
|
4
5
|
[](https://bundlephobia.com/package/namespace-guard)
|
|
5
6
|
[](https://www.typescriptlang.org/)
|
|
6
7
|
[](https://opensource.org/licenses/MIT)
|
|
7
8
|
|
|
8
|
-
**
|
|
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
|
-
|
|
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.
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
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
|
-
//
|
|
33
|
-
|
|
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("\
|
|
44
|
+
const risk = detectCrossScriptRisk("\u3163\u4E28", { weights: CONFUSABLE_WEIGHTS });
|
|
37
45
|
// { riskLevel: "high", scripts: ["han", "hangul"], crossScriptPairs: [...] }
|
|
38
46
|
```
|
|
39
47
|
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
**Lookalike letters can't fool an LLM. They can make it cost up to 5.7x as much to read.**
|
|
103
111
|
|
|
104
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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,
|
|
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.**
|
|
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/
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
-
|
|
267
|
-
-
|
|
268
|
-
-
|
|
269
|
-
-
|
|
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/`.
|
package/dist/adapters/drizzle.js
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
};
|
package/dist/adapters/knex.js
CHANGED
|
@@ -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 =
|
|
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]);
|
package/dist/adapters/knex.mjs
CHANGED
|
@@ -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 =
|
|
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]);
|
package/dist/adapters/kysely.js
CHANGED
|
@@ -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 =
|
|
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
|
package/dist/adapters/kysely.mjs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
19
|
+
return doc;
|
|
22
20
|
}
|
|
23
21
|
};
|
|
24
22
|
}
|
package/dist/adapters/prisma.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
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
|
};
|
package/dist/adapters/prisma.mjs
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
};
|
package/dist/adapters/raw.js
CHANGED
|
@@ -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
|
-
|
|
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]);
|
package/dist/adapters/raw.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
10
|
+
const attributes = [idColumn];
|
|
11
11
|
let where;
|
|
12
12
|
if (options?.caseInsensitive) {
|
|
13
13
|
if (!helpers) {
|