JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 22
  • Score
    100M100P100Q60509F
  • License MIT

Check slug/handle uniqueness across multiple database tables with reserved name protection

Package Exports

  • namespace-guard
  • namespace-guard/adapters/drizzle
  • namespace-guard/adapters/knex
  • namespace-guard/adapters/kysely
  • namespace-guard/adapters/mikro-orm
  • namespace-guard/adapters/mongoose
  • namespace-guard/adapters/prisma
  • namespace-guard/adapters/raw
  • namespace-guard/adapters/sequelize
  • namespace-guard/adapters/typeorm

Readme

namespace-guard

npm version bundle size TypeScript License: MIT

Live Demo — try it in your browser | Blog Post — why this exists

Check slug/handle uniqueness across multiple database tables with reserved name protection.

Perfect for multi-tenant apps where usernames, organization slugs, and reserved routes all share one URL namespace - like Twitter (@username), GitHub (github.com/username), or any SaaS with vanity URLs.

The Problem

You have a URL structure like yourapp.com/:slug that could be:

  • A user profile (/sarah)
  • An organization (/acme-corp)
  • A reserved route (/settings, /admin, /api)

When someone signs up or creates an org, you need to check that their chosen slug:

  1. Isn't already taken by another user
  2. Isn't already taken by an organization
  3. Isn't a reserved system route
  4. Follows your naming rules

This library handles all of that in one call.

Installation

npm install namespace-guard

Quick Start

import { createNamespaceGuard } from "namespace-guard";
import { createPrismaAdapter } from "namespace-guard/adapters/prisma";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

// Define your namespace rules once
const guard = createNamespaceGuard(
  {
    reserved: ["admin", "api", "settings", "dashboard", "login", "signup"],
    sources: [
      { name: "user", column: "handle", scopeKey: "id" },
      { name: "organization", column: "slug", scopeKey: "id" },
    ],
  },
  createPrismaAdapter(prisma)
);

// Check if a slug is available
const result = await guard.check("acme-corp");

if (result.available) {
  // Create the org
} else {
  // Show error: result.message
  // e.g., "That name is reserved. Try another one." or "That name is already in use."
}

Why namespace-guard?

Feature namespace-guard DIY Solution
Multi-table uniqueness One call Multiple queries
Reserved name blocking Built-in with categories Manual list checking
Ownership scoping No false positives on self-update Easy to forget
Format validation Configurable regex Scattered validation
Conflict suggestions Auto-suggest alternatives Not built
Async validators Custom hooks (profanity, etc.) Manual wiring
Batch checking checkMany() Loop it yourself
ORM agnostic Prisma, Drizzle, Kysely, Knex, TypeORM, MikroORM, Sequelize, Mongoose, raw SQL Tied to your ORM
CLI npx namespace-guard check None

Adapters

Prisma

import { PrismaClient } from "@prisma/client";
import { createPrismaAdapter } from "namespace-guard/adapters/prisma";

const prisma = new PrismaClient();
const adapter = createPrismaAdapter(prisma);

Drizzle

import { eq } from "drizzle-orm";
import { createDrizzleAdapter } from "namespace-guard/adapters/drizzle";
import { db } from "./db";
import { users, organizations } from "./schema";

// Pass eq directly, or use { eq, ilike } for case-insensitive support
const adapter = createDrizzleAdapter(db, { users, organizations }, eq);

Kysely

import { Kysely, PostgresDialect } from "kysely";
import { createKyselyAdapter } from "namespace-guard/adapters/kysely";

const db = new Kysely<Database>({ dialect: new PostgresDialect({ pool }) });
const adapter = createKyselyAdapter(db);

Knex

import Knex from "knex";
import { createKnexAdapter } from "namespace-guard/adapters/knex";

const knex = Knex({ client: "pg", connection: process.env.DATABASE_URL });
const adapter = createKnexAdapter(knex);

TypeORM

import { DataSource } from "typeorm";
import { createTypeORMAdapter } from "namespace-guard/adapters/typeorm";
import { User, Organization } from "./entities";

const dataSource = new DataSource({ /* ... */ });
const adapter = createTypeORMAdapter(dataSource, { user: User, organization: Organization });

MikroORM

import { MikroORM } from "@mikro-orm/core";
import { createMikroORMAdapter } from "namespace-guard/adapters/mikro-orm";
import { User, Organization } from "./entities";

const orm = await MikroORM.init(config);
const adapter = createMikroORMAdapter(orm.em, { user: User, organization: Organization });

Sequelize

import { createSequelizeAdapter } from "namespace-guard/adapters/sequelize";
import { User, Organization } from "./models";

const adapter = createSequelizeAdapter({ user: User, organization: Organization });

Mongoose

import { createMongooseAdapter } from "namespace-guard/adapters/mongoose";
import { User, Organization } from "./models";

// Note: Mongoose sources typically use idColumn: "_id"
const adapter = createMongooseAdapter({ user: User, organization: Organization });

Raw SQL (pg, mysql2, etc.)

import { Pool } from "pg";
import { createRawAdapter } from "namespace-guard/adapters/raw";

const pool = new Pool();
const adapter = createRawAdapter((sql, params) => pool.query(sql, params));

Configuration

const guard = createNamespaceGuard({
  // Reserved names - flat list, Set, or categorized
  reserved: new Set([
    "admin",
    "api",
    "settings",
    "dashboard",
    "login",
    "signup",
    "help",
    "support",
    "billing",
  ]),

  // Data sources to check for collisions
  // Queries run in parallel for speed
  sources: [
    {
      name: "user",           // Prisma model / Drizzle table / SQL table name
      column: "handle",       // Column containing the slug/handle
      idColumn: "id",         // Primary key column (default: "id")
      scopeKey: "id",         // Key for ownership checks (see below)
    },
    {
      name: "organization",
      column: "slug",
      scopeKey: "id",
    },
    {
      name: "team",
      column: "slug",
      scopeKey: "id",
    },
  ],

  // Validation pattern (default: /^[a-z0-9][a-z0-9-]{1,29}$/)
  // This default requires: 2-30 chars, lowercase alphanumeric + hyphens, can't start with hyphen
  pattern: /^[a-z0-9][a-z0-9-]{2,39}$/,

  // Custom error messages
  messages: {
    invalid: "Use 3-40 lowercase letters, numbers, or hyphens.",
    reserved: "That name is reserved. Please choose another.",
    taken: (sourceName) => `That name is already taken.`,
  },
}, adapter);

Reserved Name Categories

Group reserved names by category with different error messages:

const guard = createNamespaceGuard({
  reserved: {
    system: ["admin", "api", "settings", "dashboard"],
    brand: ["oncor", "bandcamp"],
    offensive: ["..."],
  },
  sources: [/* ... */],
  messages: {
    reserved: {
      system: "That's a system route.",
      brand: "That's a protected brand name.",
      offensive: "That name is not allowed.",
    },
  },
}, adapter);

const result = await guard.check("admin");
// { available: false, reason: "reserved", category: "system", message: "That's a system route." }

You can also use a single string message for all categories, or mix — categories without a specific message fall back to the default.

Async Validators

Add custom async checks that run after format/reserved validation but before database queries:

const guard = createNamespaceGuard({
  sources: [/* ... */],
  validators: [
    async (identifier) => {
      if (await isProfane(identifier)) {
        return { available: false, message: "That name is not allowed." };
      }
      return null; // pass
    },
    async (identifier) => {
      if (await isTrademarkViolation(identifier)) {
        return { available: false, message: "That name is trademarked." };
      }
      return null;
    },
  ],
}, adapter);

Validators run sequentially and stop at the first rejection. They receive the normalized identifier.

Built-in Profanity Validator

Use createProfanityValidator for a turnkey profanity filter — supply your own word list:

import { createNamespaceGuard, createProfanityValidator } from "namespace-guard";

const guard = createNamespaceGuard({
  sources: [/* ... */],
  validators: [
    createProfanityValidator(["badword", "offensive", "slur"], {
      message: "Please choose an appropriate name.", // optional custom message
      checkSubstrings: true,                         // default: true
    }),
  ],
}, adapter);

No words are bundled — use any word list you like (e.g., the bad-words npm package, your own list, or an external API wrapped in a custom validator).

Built-in Homoglyph Validator

Prevent spoofing attacks where Cyrillic or Greek characters are substituted for visually identical Latin letters (e.g., Cyrillic "а" for Latin "a" in "admin"):

import { createNamespaceGuard, createHomoglyphValidator } from "namespace-guard";

const guard = createNamespaceGuard({
  sources: [/* ... */],
  validators: [
    createHomoglyphValidator(),
  ],
}, adapter);

Options:

createHomoglyphValidator({
  message: "Custom rejection message.",       // optional
  additionalMappings: { "\u0261": "g" },      // extend the built-in map
  rejectMixedScript: true,                    // also reject Latin + Cyrillic/Greek mixing
})

The built-in CONFUSABLE_MAP covers ~30 Cyrillic-to-Latin and Greek-to-Latin pairs — the most common spoofing vectors. It's exported for inspection or extension.

Unicode Normalization

By default, normalize() applies NFKC normalization before lowercasing. This collapses full-width characters, ligatures, superscripts, and other Unicode compatibility forms to their canonical equivalents:

normalize("hello");  // "hello" (full-width → ASCII)
normalize("\ufb01nance"); // "finance" (fi ligature → fi)

NFKC is a no-op for ASCII input and matches what ENS, GitHub, and Unicode IDNA standards mandate. To opt out:

const guard = createNamespaceGuard({
  sources: [/* ... */],
  normalizeUnicode: false,
}, adapter);

Rejecting Purely Numeric Identifiers

Twitter/X blocks purely numeric handles. Enable this with allowPurelyNumeric: false:

const guard = createNamespaceGuard({
  sources: [/* ... */],
  allowPurelyNumeric: false,
  messages: {
    purelyNumeric: "Handles cannot be all numbers.", // optional custom message
  },
}, adapter);

await guard.check("123456"); // { available: false, reason: "invalid", message: "Handles cannot be all numbers." }
await guard.check("abc123"); // available (has letters)

Conflict Suggestions

When a slug is taken, automatically suggest available alternatives using pluggable strategies:

const guard = createNamespaceGuard({
  sources: [/* ... */],
  suggest: {
    // Named strategy (default: ["sequential", "random-digits"])
    strategy: "suffix-words",
    // Max suggestions to return (default: 3)
    max: 3,
  },
}, adapter);

const result = await guard.check("acme-corp");
// {
//   available: false,
//   reason: "taken",
//   message: "That name is already in use.",
//   source: "organization",
//   suggestions: ["acme-corp-dev", "acme-corp-io", "acme-corp-app"]
// }

Built-in Strategies

Strategy Example Output Description
"sequential" sarah-1, sarah1, sarah-2 Hyphenated and compact numeric suffixes
"random-digits" sarah-4821, sarah-1037 Random 3-4 digit suffixes
"suffix-words" sarah-dev, sarah-hq, sarah-app Common word suffixes
"short-random" sarah-x7k, sarah-m2p Short 3-char alphanumeric suffixes
"scramble" asrah, sarha Adjacent character transpositions
"similar" sara, darah, thesarah Edit-distance-1 mutations (deletions, keyboard-adjacent substitutions, prefix/suffix)

Composing Strategies

Combine multiple strategies — candidates are interleaved round-robin:

suggest: {
  strategy: ["random-digits", "suffix-words"],
  max: 4,
}
// → ["sarah-4821", "sarah-dev", "sarah-1037", "sarah-io"]

Custom Strategy Function

Pass a function that returns candidate slugs:

suggest: {
  strategy: (identifier) => [
    `${identifier}-io`,
    `${identifier}-app`,
    `the-real-${identifier}`,
  ],
}

Suggestions are verified against format, reserved names, validators, and database collisions using a progressive batched pipeline. Only available suggestions are returned.

Batch Checking

Check multiple identifiers at once:

const results = await guard.checkMany(["sarah", "admin", "acme-corp"]);
// {
//   sarah: { available: true },
//   admin: { available: false, reason: "reserved", ... },
//   "acme-corp": { available: false, reason: "taken", ... }
// }

All checks run in parallel. Accepts an optional scope parameter.

Ownership Scoping

When users update their own slug, you don't want a false "already taken" error:

// User with ID "user_123" wants to change handle from "sarah" to "sarah-dev"
// Without scoping, this would error because "sarah-dev" != their current handle

// Pass their ID to exclude their own record from collision detection
const result = await guard.check("sarah-dev", { id: "user_123" });
// Available (unless another user/org has it)

The scope object keys map to scopeKey in your source config. This lets you check multiple ownership types:

// Check if a user OR their org owns this slug
const result = await guard.check("acme", {
  userId: currentUser.id,
  orgId: currentOrg.id,
});

CLI

Validate slugs from the command line:

# Format + reserved name checking (no database needed)
npx namespace-guard check acme-corp
# ✓ acme-corp is available

npx namespace-guard check admin
# ✗ admin — That name is reserved. Try another one.

npx namespace-guard check "a"
# ✗ a — Use 2-30 lowercase letters, numbers, or hyphens.

With a config file

Create namespace-guard.config.json:

{
  "reserved": ["admin", "api", "settings", "dashboard"],
  "pattern": "^[a-z0-9][a-z0-9-]{2,39}$",
  "sources": [
    { "name": "users", "column": "handle" },
    { "name": "organizations", "column": "slug" }
  ]
}

Or with categorized reserved names:

{
  "reserved": {
    "system": ["admin", "api", "settings"],
    "brand": ["oncor"]
  }
}
npx namespace-guard check sarah --config ./my-config.json

With database checking

npx namespace-guard check sarah --database-url postgres://localhost/mydb

Requires pg to be installed (npm install pg).

Exit code 0 = available, 1 = unavailable.

API Reference

createNamespaceGuard(config, adapter)

Creates a guard instance with your configuration and database adapter.

Returns: NamespaceGuard instance


guard.check(identifier, scope?)

Check if an identifier is available.

Parameters:

  • identifier - The slug/handle to check
  • scope - Optional ownership scope to exclude own records

Returns:

// Available
{ available: true }

// Not available
{
  available: false,
  reason: "invalid" | "reserved" | "taken",
  message: string,
  source?: string,       // Which table caused the collision (reason: "taken")
  category?: string,     // Reserved name category (reason: "reserved")
  suggestions?: string[] // Available alternatives (reason: "taken", requires suggest config)
}

guard.checkMany(identifiers, scope?)

Check multiple identifiers in parallel.

Returns: Record<string, CheckResult>


guard.assertAvailable(identifier, scope?)

Same as check(), but throws an Error if not available.


guard.validateFormat(identifier)

Validate format only (no database queries).

Returns: Error message string if invalid, null if valid.


normalize(identifier, options?)

Utility function to normalize identifiers. Trims whitespace, applies NFKC Unicode normalization (by default), lowercases, and strips leading @ symbols. Pass { unicode: false } to skip NFKC.

import { normalize } from "namespace-guard";

normalize("  @Sarah  "); // "sarah"
normalize("ACME-Corp"); // "acme-corp"

Case-Insensitive Matching

By default, slug lookups are case-sensitive. Enable case-insensitive matching to catch collisions regardless of stored casing:

const guard = createNamespaceGuard({
  sources: [/* ... */],
  caseInsensitive: true,
}, adapter);

Each adapter handles this differently:

  • Prisma: Uses mode: "insensitive" on the where clause
  • Drizzle: Uses ilike instead of eq (pass ilike to the adapter: createDrizzleAdapter(db, tables, { eq, ilike }))
  • Kysely: Uses ilike operator
  • Knex: Uses LOWER() in a raw where clause
  • TypeORM: Uses ILike (pass it to the adapter: createTypeORMAdapter(dataSource, entities, ILike))
  • MikroORM: Uses $ilike operator
  • Sequelize: Uses LOWER() via Sequelize helpers (pass { where: Sequelize.where, fn: Sequelize.fn, col: Sequelize.col })
  • Mongoose: Uses collation { locale: "en", strength: 2 }
  • Raw SQL: Wraps both sides in LOWER()

Caching

Enable in-memory caching to reduce database calls during rapid checks (e.g., live form validation, suggestion generation):

const guard = createNamespaceGuard({
  sources: [/* ... */],
  cache: {
    ttl: 5000, // milliseconds (default: 5000)
  },
}, adapter);

// Manually clear the cache after writes
guard.clearCache();

// Monitor cache performance
const stats = guard.cacheStats();
// { size: 12, hits: 48, misses: 12 }

Framework Integration

Next.js (Server Actions)

// lib/guard.ts
import { createNamespaceGuard } from "namespace-guard";
import { createPrismaAdapter } from "namespace-guard/adapters/prisma";
import { prisma } from "./db";

export const guard = createNamespaceGuard({
  reserved: ["admin", "api", "settings"],
  sources: [
    { name: "user", column: "handle", scopeKey: "id" },
    { name: "organization", column: "slug", scopeKey: "id" },
  ],
  suggest: {},
}, createPrismaAdapter(prisma));

// app/signup/actions.ts
"use server";

import { guard } from "@/lib/guard";

export async function checkHandle(handle: string) {
  return guard.check(handle);
}

export async function createUser(handle: string, email: string) {
  const result = await guard.check(handle);
  if (!result.available) return { error: result.message };

  const user = await prisma.user.create({
    data: { handle: guard.normalize(handle), email },
  });
  return { user };
}

Express Middleware

import express from "express";
import { guard } from "./lib/guard";

const app = express();

// Reusable middleware
function validateSlug(req, res, next) {
  const slug = req.body.handle || req.body.slug;
  if (!slug) return res.status(400).json({ error: "Slug is required" });

  guard.check(slug, { id: req.user?.id }).then((result) => {
    if (!result.available) return res.status(409).json(result);
    req.normalizedSlug = guard.normalize(slug);
    next();
  });
}

app.post("/api/users", validateSlug, async (req, res) => {
  const user = await db.user.create({ handle: req.normalizedSlug, ... });
  res.json({ user });
});

tRPC

import { z } from "zod";
import { router, protectedProcedure } from "./trpc";
import { guard } from "./lib/guard";

export const namespaceRouter = router({
  check: protectedProcedure
    .input(z.object({ slug: z.string() }))
    .query(async ({ input, ctx }) => {
      return guard.check(input.slug, { id: ctx.user.id });
    }),

  claim: protectedProcedure
    .input(z.object({ slug: z.string() }))
    .mutation(async ({ input, ctx }) => {
      await guard.assertAvailable(input.slug, { id: ctx.user.id });
      return ctx.db.user.update({
        where: { id: ctx.user.id },
        data: { handle: guard.normalize(input.slug) },
      });
    }),
});

TypeScript

Full TypeScript support with exported types:

import {
  createNamespaceGuard,
  createProfanityValidator,
  createHomoglyphValidator,
  CONFUSABLE_MAP,
  normalize,
  type NamespaceConfig,
  type NamespaceSource,
  type NamespaceAdapter,
  type NamespaceGuard,
  type CheckResult,
  type FindOneOptions,
  type OwnershipScope,
  type SuggestStrategyName,
} from "namespace-guard";

Support

If you find this useful, consider supporting the project:

Contributing

Contributions welcome! Please open an issue first to discuss what you'd like to change.

License

MIT © Paul Wood FRSA (@paultendo)