namespace-guarddocs

Reference

Configuration

createNamespaceGuard(config, adapter) and createNamespaceGuardWithProfile(profile, config, adapter) take the same config object. Only sources is required. This page lists every option, then the three profiles and how your settings combine with them.

#All options

OptionTypeDefaultWhat it does
sourcesNamespaceSource[]requiredthe tables a name is looked up in; it must be free in all of them
reservedstring[], Set<string> or Record<string, string[]>nonenames nobody can claim, optionally in categories with their own messages
patternRegExp/^[a-z0-9][a-z0-9-]{1,29}$/the format a name’s canonical form must match
allowPurelyNumericbooleantrueaccept names made only of digits, such as 123 or 12-34
normalizeUnicodebooleantrueapply NFKC when a name is normalised
caseInsensitivebooleanfalseask the adapter to compare without case
messagesobjectsee messagesthe messages for invalid, reserved, taken and numbers-only names
validatorsNamespaceValidator[]nonechecks of your own, run before the database
suggestobjectoffoffer free names when a name is taken
cacheobjectoffkeep adapter results in memory for a while
riskobjectsee riskhow names that pass for protected ones are scored

A profile sets pattern, allowPurelyNumeric, normalizeUnicode, the invalid message and risk for you; see Profiles.

#sources

A source is a table, collection or model that holds names. The guard asks the adapter about every source at once, and a name is taken if any of them has it.

FieldDefaultWhat it is
namerequiredthe table or model, as your adapter knows it (see Adapters)
columnrequiredthe column that holds the name; point it at a canonical column if you have one
idColumn"id"the primary key, compared with the scope to find the caller’s own row. The Mongoose adapter looks up _id when it’s unset
scopeKeynonethe key in the ownership scope that holds the caller’s ID for this source; see Let people keep their own name
TypeScript
const rows = { user: ["sarah"], organization: ["acme"] };
const adapter = { findOne: async (source, value) => (rows[source.name].includes(value) ? { id: "1" } : null) };

const guard = createNamespaceGuard({
  sources: [
    { name: "user", column: "handle_canonical", scopeKey: "id" },
    { name: "organization", column: "slug_canonical", scopeKey: "id" },
  ],
}, adapter);

await guard.check("acme");  // → { available: false, reason: "taken", source: "organization" }
await guard.check("sarah");  // → { available: false, reason: "taken", source: "user" }
await guard.check("new-team");  // → { available: true }

When more than one source has the name, source is the first of them in the order you listed. sources: [] is allowed: the guard then checks format, reserved names and validators, and never calls the adapter.

#reserved

Names held back from everyone: routes such as admin and settings, your brand, words you don’t want used. It takes three forms:

  • a list, ["admin", "api"]
  • a Set, new Set(["admin", "api"])
  • categories, { system: ["admin", "api"], brand: ["acme"] }, each of which can have its own message

A reserved name gets reason: "reserved" and a category. With a list or a Set, the category is "default".

TypeScript
const adapter = { findOne: async () => null };
const guard = createNamespaceGuard({
  reserved: {
    system: ["admin", "api", "settings"],
    brand: ["acme"],
    offensive: ["badword"],
  },
  sources: [{ name: "user", column: "handle" }],
  messages: {
    reserved: {
      system: "That's a page on our site.",
      brand: "That name belongs to Acme.",
    },
  },
}, adapter);

await guard.check("Admin");  // → { available: false, reason: "reserved", category: "system", message: "That's a page on our site." }
await guard.check("acme");  // → { available: false, reason: "reserved", category: "brand", message: "That name belongs to Acme." }
await guard.check("badword");  // → { available: false, reason: "reserved", category: "offensive", message: "That name is reserved. Try another one." }

A category without a message of its own gets the default. Set messages.reserved to a string to use one message for every category.

Reserved names are also protected against lookalikes, so аdmin with a Cyrillic а is refused by claim() and assertClaimable(). See Protect names.

#pattern

The format a name must have, tested against its canonical form: trimmed, NFKC-normalised, lowercase, without a leading @. The default is /^[a-z0-9][a-z0-9-]{1,29}$/: 2 to 30 lowercase letters, digits and hyphens, not starting with a hyphen.

The canonical form is always lowercase, so a pattern that needs capitals never matches. When you change the pattern, change messages.invalid to describe it, since the default message describes the default pattern:

TypeScript
const adapter = { findOne: async () => null };
const guard = createNamespaceGuard({
  sources: [{ name: "user", column: "handle" }],
  pattern: /^[a-z][a-z0-9_]{2,19}$/,
  messages: { invalid: "Use 3 to 20 letters, numbers or underscores, starting with a letter." },
}, adapter);

await guard.check("sarah_k");  // → { available: true }
await guard.check("Sarah_K");  // → { available: true } (checked as sarah_k)
await guard.check("sarah-k");  // → { available: false, reason: "invalid", message: "Use 3 to 20 letters, numbers or underscores, starting with a letter." }
await guard.check("1sarah");  // → { available: false, reason: "invalid" }

Suggestions read the longest name your pattern allows and don’t offer anything longer. To allow names in other scripts, see When to allow Unicode names.

#allowPurelyNumeric

Set it to false to refuse names made only of digits, alone or in groups joined by hyphens: 123, 2024, 12-34. A name with any letter in it passes. It’s true for createNamespaceGuard(), and the consumer-handle and org-slug profiles set it to false.

TypeScript
const adapter = { findOne: async () => null };
const guard = createNamespaceGuard({
  sources: [{ name: "user", column: "handle" }],
  allowPurelyNumeric: false,
  messages: { purelyNumeric: "Add at least one letter." },
}, adapter);

await guard.check("2024");  // → { available: false, reason: "invalid", message: "Add at least one letter." }
await guard.check("12-34");  // → { available: false, reason: "invalid" }
await guard.check("2024a");  // → { available: true }

#normalizeUnicode

With true, the default, a name goes through NFKC before anything else, so fullwidth hello becomes hello. With false, the guard only trims, lowercases and drops a leading @, and hello fails the default pattern:

TypeScript
const adapter = { findOne: async () => null };
const guard = createNamespaceGuard({
  sources: [{ name: "user", column: "handle" }],
  normalizeUnicode: false,
}, adapter);

await guard.check("hello");  // → { available: false, reason: "invalid" }
normalize("hello", { unicode: false });  // → "hello"

Leave it on unless you have stored names that depend on the difference. If you turn it off, write your canonical column with normalize(name, { unicode: false }), or with the value claim() passes to your write. Normalisation explains what NFKC folds.

#caseInsensitive

The guard lowercases a name before it looks it up, so an exact match only finds rows stored in lowercase. If your column holds names as people typed them, set caseInsensitive: true and the guard passes { caseInsensitive: true } to every findOne call, so each adapter compares without case:

TypeScript
const seen = [];
const adapter = { findOne: async (source, value, options) => { seen.push(options); return null; } };
const guard = createNamespaceGuard({
  sources: [{ name: "user", column: "handle" }],
  caseInsensitive: true,
}, adapter);

await guard.check("Sarah");
seen[0];  // → { caseInsensitive: true }

A canonical column is the better fix, since an exact match can use its unique index. See Case-insensitive matching for what each adapter sends, and Store a canonical column.

#messages

MessageDefaultUsed for
invalid“Use 2-30 lowercase letters, numbers, or hyphens.”a name that doesn’t match pattern
purelyNumeric“Identifiers cannot be purely numeric.”a name of digits, when allowPurelyNumeric is false
reserved“That name is reserved. Try another one.”a reserved name; a string, or one message per category
taken“That name is already in use.”a name a source already has; a function that receives the source’s name

taken is a function, so the message can depend on where the name was found:

TypeScript
const rows = { user: ["sarah"], organization: ["acme"] };
const adapter = { findOne: async (source, value) => (rows[source.name].includes(value) ? { id: "1" } : null) };
const guard = createNamespaceGuard({
  sources: [
    { name: "user", column: "handle" },
    { name: "organization", column: "slug" },
  ],
  messages: {
    taken: (source) => (source === "organization" ? "An organisation already uses that name." : "Someone already has that name."),
  },
}, adapter);

(await guard.check("acme")).message;  // → "An organisation already uses that name."
(await guard.check("sarah")).message;  // → "Someone already has that name."

Other messages are set elsewhere:

  • a validator returns its own message;
  • a name refused as a lookalike of a protected name gets enforceRisk()’s message, which you change with the messages option of enforceRisk(), assertClaimable() or claim() (see The guard);
  • a write that loses the race in claim() gets its takenMessage option (see Claiming names).

#validators

Functions that check a name in ways the pattern can’t: lookalike characters, invisible characters, offensive words, or a rule of your own. They run in the order you list them, after the format and reserved checks and before the database, so a refused name costs no query.

A validator receives the canonical form, and a second argument with the name as typed, { identifier }. It resolves to null to let the name through, or { available: false, message } to refuse it. If it throws, the name is refused with the error’s message.

TypeScript
const noDoubleHyphens = async (name) =>
  name.includes("--") ? { available: false, message: "Use one hyphen at a time." } : null;

const adapter = { findOne: async () => null };
const guard = createNamespaceGuard({
  sources: [{ name: "user", column: "handle" }],
  validators: [createInvisibleCharacterValidator(), noDoubleHyphens],
}, adapter);

await guard.check("sarah--k");  // → { available: false, reason: "invalid", message: "Use one hyphen at a time." }
await guard.check("sarah-k");  // → { available: true }

Validators also run on every suggestion, and one that throws drops that suggestion rather than failing the check. The built-in validators are covered in Unicode names and Moderation, and listed in Functions.

#suggest

When a name is taken, check() can offer free alternatives. Suggestions are off until you set suggest; suggest: {} turns them on with the defaults.

FieldDefaultWhat it does
strategy["sequential", "random-digits"]a strategy’s name, a list of names taken in turn, or a function that returns candidates
max3the most suggestions returned, and how many candidates are checked at a time
generatenonethe older way to pass a function; if you set both, generate wins
TypeScript
const adapter = { findOne: async (source, value) => (value === "sarah" ? { id: "u1" } : null) };
const guard = createNamespaceGuard({
  sources: [{ name: "user", column: "handle" }],
  suggest: { strategy: "suffix-words", max: 2 },
}, adapter);

(await guard.check("sarah")).suggestions;  // → ["sarah-dev", "sarah-io"]

Suggestions covers the six strategies, combining them and writing your own.

#cache

Keeps each adapter result in memory, so the lookups repeated while someone types don’t reach the database each time. Off until you set it; cache: {} turns it on with the defaults.

FieldDefaultWhat it does
ttl5000how long a result is kept, in milliseconds
maxSize1000how many results are kept; past this, the least recently used goes first

The cache lives in one process’s memory. Cache lookups covers what to know before turning it on, and The guard covers clearCache() and cacheStats().

#risk

The defaults for scoring names against the names you protect. checkRisk(), enforceRisk(), assertClaimable() and claim() use them, and options passed to a call override them for that call.

FieldDefaultWhat it does
protectnonethe names to protect
includeReservedtrueprotect your reserved names too, against lookalikes only
leetspeakfalsecount digits and symbols standing for letters (4 for a) as lookalikes
warnThreshold45the score at which the action is warn
blockThreshold70the score at which the action is block
maxMatches3how many close protected names a result lists

Thresholds are rounded and kept between 0 and 100, and a blockThreshold at or below warnThreshold is raised to one above it.

Protect names explains the scores, what counts as a lookalike, and how to tune the thresholds.

#Profiles

createNamespaceGuardWithProfile(profile, config, adapter) starts from one of three sets of defaults. They’re exported as NAMESPACE_PROFILES:

Settingconsumer-handleorg-slugdeveloper-id
Forpublic usernamesteam and workspace URLspackage names, internal IDs
pattern/^[a-z0-9][a-z0-9-]{1,29}$//^[a-z0-9][a-z0-9-]{1,39}$//^[a-z0-9][a-z0-9-]{1,49}$/
Length2 to 302 to 402 to 50
invalid message“Use 2-30 lowercase letters, numbers, or hyphens.”“Use 2-40 lowercase letters, numbers, or hyphens.”“Use 2-50 lowercase letters, numbers, or hyphens.”
allowPurelyNumericfalsefalsetrue
normalizeUnicodetruetruetrue
risk.warnThreshold454035
risk.blockThreshold706560
risk.maxMatches355
risk.includeReservedtruetruetrue
risk.leetspeakfalsefalsefalse
risk.protect[][][]
TypeScript
NAMESPACE_PROFILES["org-slug"].risk.blockThreshold;  // → 65
NAMESPACE_PROFILES["developer-id"].allowPurelyNumeric;  // → true
Object.keys(NAMESPACE_PROFILES);  // → ["consumer-handle", "org-slug", "developer-id"]

The lower thresholds of org-slug and developer-id warn and block on names a little further from a protected one. A close spelling scores at most one under the block threshold, so under org-slug it scores at most 64.

#How your settings combine with a profile

Your config goes on top of the profile:

  • pattern, allowPurelyNumeric and normalizeUnicode: yours if you set them, otherwise the profile’s.
  • messages.invalid: the profile’s message describes the profile’s pattern, so it’s used only when you set neither pattern nor messages.invalid. If you set a pattern of your own, set its message too, or you get the default “Use 2-30 lowercase letters, numbers, or hyphens.”.
  • risk: merged field by field. risk: { protect: ["acme"] } adds your protected names and keeps the profile’s thresholds.
  • Everything else (sources, reserved, caseInsensitive, the other messages, validators, suggest, cache) comes from your config alone, since profiles don’t set it.
TypeScript
const adapter = { findOne: async () => null };
const guard = createNamespaceGuardWithProfile("org-slug", {
  sources: [{ name: "organization", column: "slug" }],
  risk: { protect: ["github"] },
}, adapter);

await guard.check("a");  // → { available: false, reason: "invalid", message: "Use 2-40 lowercase letters, numbers, or hyphens." }
await guard.check("2024");  // → { available: false, reason: "invalid", message: "Identifiers cannot be purely numeric." }
guard.checkRisk("githuh").score;  // → 64 (org-slug's block threshold is 65)
guard.checkRisk("paypa1").action;  // → "allow" (paypal isn't protected here)

const own = createNamespaceGuardWithProfile("consumer-handle", {
  sources: [{ name: "user", column: "handle" }],
  pattern: /^[a-z0-9_]{3,15}$/,
  messages: { invalid: "Use 3 to 15 letters, numbers or underscores." },
  allowPurelyNumeric: true,
  risk: { protect: ["github"], blockThreshold: 80 },
}, adapter);

await own.check("ab");  // → { available: false, reason: "invalid", message: "Use 3 to 15 letters, numbers or underscores." }
await own.check("2024");  // → { available: true }
own.checkRisk("githuh").score;  // → 79 (a close spelling stays one under your block threshold)