refactor(reserved-slugs): single JSON source for backend + frontend (#2148)

Reserved workspace slugs lived in two parallel files (`workspace_reserved_slugs.go`
and `packages/core/paths/reserved-slugs.ts`) with no parity check. Adding or
renaming a global route on one side without the other would slip through CI
and surface only when a real user hit the collision.

Collapse the two lists into one source: `server/internal/handler/reserved_slugs.json`.
Go embeds the JSON via `//go:embed` and parses it at package init; the TS file
is regenerated by `scripts/generate-reserved-slugs.mjs` (run via
`pnpm generate:reserved-slugs`). CI re-runs the generator and `git diff
--exit-code`s the TS output, so a stale TS file cannot land. The slug set is
unchanged (87 entries, byte-equivalent slug literals).

Update CLAUDE.md to describe the new "edit JSON, run generator" workflow.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-05-08 19:14:12 +08:00
committed by GitHub
parent d1a6881707
commit bda475cbba
7 changed files with 332 additions and 149 deletions

View File

@@ -29,6 +29,15 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Verify reserved-slugs.ts is up to date
# Re-runs the generator and fails on any drift from the
# checked-in TypeScript output. The Go side embeds the JSON
# source directly, so a passing diff here proves both sides
# share one source of truth.
run: |
pnpm generate:reserved-slugs
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
- name: Build, type check, lint, and test
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'

View File

@@ -151,6 +151,7 @@ make start-worktree # Start using .env.worktree
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Avoid broad refactors unless required by the task.
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
### API Response Compatibility

View File

@@ -13,7 +13,8 @@
"test": "turbo test",
"lint": "turbo lint",
"clean": "turbo clean && rm -rf node_modules",
"ui:add": "cd packages/ui && npx shadcn@latest add"
"ui:add": "cd packages/ui && npx shadcn@latest add",
"generate:reserved-slugs": "node scripts/generate-reserved-slugs.mjs"
},
"packageManager": "pnpm@10.28.2",
"pnpm": {

View File

@@ -1,16 +1,22 @@
// AUTO-GENERATED by scripts/generate-reserved-slugs.mjs.
// Do not edit by hand — edit server/internal/handler/reserved_slugs.json
// and run `pnpm generate:reserved-slugs`.
/**
* Slugs reserved because they collide with frontend top-level routes,
* platform features, or web standards.
*
* Keep in sync with server/internal/handler/workspace_reserved_slugs.go.
* Single source of truth: `server/internal/handler/reserved_slugs.json`.
* The Go backend embeds that JSON; this file is regenerated from it.
*
* Convention for new global routes (CLAUDE.md): use a single word
* (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Hyphenated
* root-level word groups (`/new-workspace`, `/create-team`) collide with
* common user workspace names — see PR for full discussion.
*/
export const RESERVED_SLUGS = new Set([
export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
// Auth flow
// `onboarding` is historical, kept reserved post-removal of the route.
"login",
"logout",
"signin",
@@ -24,17 +30,21 @@ export const RESERVED_SLUGS = new Set([
"verify",
"reset",
"password",
"onboarding", // historical, kept reserved post-removal
"onboarding",
// Platform / marketing routes (current + likely-future)
// `multica` is reserved as the brand name to block impersonation workspaces.
// `www`, `new`, `home`, `homepage`, `dashboard` are confusables or
// likely-future global landing/entry routes; `homepage` matches the existing
// `/homepage` landing variant in apps/web.
"api",
"admin",
"multica", // brand name — prevent impersonation workspaces
"www", // hostname confusable; never a legitimate workspace slug
"new", // ambiguous verb-as-slug; reserved for future global create routes
"home", // likely-future marketing/landing entry
"homepage", // existing /homepage landing variant in apps/web
"dashboard", // standard SaaS entry; likely-future global route
"multica",
"www",
"new",
"home",
"homepage",
"dashboard",
"help",
"about",
"pricing",
@@ -52,7 +62,7 @@ export const RESERVED_SLUGS = new Set([
"press",
"download",
// Account / billing (likely-future global routes in the avatar menu).
// Account / billing (likely-future global routes in the avatar menu)
"profile",
"account",
"billing",
@@ -60,9 +70,11 @@ export const RESERVED_SLUGS = new Set([
"search",
"members",
// Dashboard / workspace route segments. Reserving the segment name
// prevents `/{slug}/{view}` from being visually ambiguous (e.g. a
// workspace named "issues" makes `/issues/abc` mean two things).
// Dashboard / workspace route segments
// Reserving each segment name prevents `/{slug}/{view}` from being visually
// ambiguous (e.g. a workspace named `issues` would make `/issues/abc` mean two
// things). `workspaces` covers the global `/workspaces/new` workspace-creation
// page; `teams` is reserved for future team management.
"issues",
"projects",
"autopilots",
@@ -72,12 +84,13 @@ export const RESERVED_SLUGS = new Set([
"runtimes",
"skills",
"settings",
"workspaces", // global `/workspaces/new` workspace creation page
"teams", // reserved for future team management routes
"workspaces",
"teams",
// API / integration prefixes. `api` above already covers /api/*; these
// guard against future top-level API alias routes (e.g. /v1, /graphql)
// and against accidental workspace slugs that read like API identifiers.
// API / integration prefixes
// `api` above already covers `/api/*`; these guard against future top-level
// API alias routes (e.g. `/v1`, `/graphql`) and against accidental workspace
// slugs that read like API identifiers.
"v1",
"v2",
"graphql",
@@ -86,10 +99,10 @@ export const RESERVED_SLUGS = new Set([
"tokens",
"cli",
// Backend ops / observability. `/health`, `/readyz`, `/healthz`, and `/ws`
// exist on the backend
// host; reserving them on the workspace slug space prevents naming
// confusion if/when these paths are ever proxied through the web origin.
// Backend ops / observability
// `/health`, `/readyz`, `/healthz`, and `/ws` exist on the backend host;
// reserving them on the workspace slug space prevents naming confusion if/when
// these paths are ever proxied through the web origin.
"health",
"readyz",
"healthz",
@@ -97,16 +110,18 @@ export const RESERVED_SLUGS = new Set([
"metrics",
"ping",
// RFC 2142 — privileged email mailboxes. Allowing user workspaces with
// these slugs would let attackers spoof system messaging.
// RFC 2142 — privileged email mailboxes
// Allowing user workspaces with these slugs would let attackers spoof system
// messaging.
"postmaster",
"abuse",
"noreply",
"webmaster",
"hostmaster",
// Hostname / subdomain confusables. Even on path-based routing these
// names attract phishing and subdomain-takeover attempts.
// Hostname / subdomain confusables
// Even on path-based routing these names attract phishing and
// subdomain-takeover attempts.
"mail",
"ftp",
"static",
@@ -116,12 +131,12 @@ export const RESERVED_SLUGS = new Set([
"files",
"uploads",
// Next.js / web standards. These entries contain characters (dots,
// underscores) that today's slug regex `^[a-z0-9]+(?:-[a-z0-9]+)*$`
// already rejects at the format-validation step — so `isReservedSlug`
// never actually matches them. They are kept as defense-in-depth so
// that if the slug regex is ever relaxed (e.g. to support dotted
// corporate slugs like `acme.io`), these system paths stay protected.
// Next.js / web standards
// These entries contain characters (dots, underscores) that today's slug regex
// `^[a-z0-9]+(?:-[a-z0-9]+)*$` already rejects at the format-validation step —
// so `isReservedSlug` never actually matches them. They are kept as
// defense-in-depth so that if the slug regex is ever relaxed (e.g. to support
// dotted corporate slugs like `acme.io`), these system paths stay protected.
"_next",
"favicon.ico",
"robots.txt",

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env node
// Regenerates packages/core/paths/reserved-slugs.ts from
// server/internal/handler/reserved_slugs.json (the single source of truth).
//
// Run via `pnpm generate:reserved-slugs`. CI re-runs the generator and
// `git diff --exit-code`s the output, so the JSON and TS sides cannot drift.
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const jsonPath = resolve(repoRoot, "server/internal/handler/reserved_slugs.json");
const tsPath = resolve(repoRoot, "packages/core/paths/reserved-slugs.ts");
const raw = readFileSync(jsonPath, "utf8");
const data = JSON.parse(raw);
if (!Array.isArray(data.groups)) {
throw new Error(`reserved_slugs.json: expected top-level "groups" array`);
}
const lines = [];
lines.push("// AUTO-GENERATED by scripts/generate-reserved-slugs.mjs.");
lines.push("// Do not edit by hand — edit server/internal/handler/reserved_slugs.json");
lines.push("// and run `pnpm generate:reserved-slugs`.");
lines.push("");
lines.push("/**");
lines.push(" * Slugs reserved because they collide with frontend top-level routes,");
lines.push(" * platform features, or web standards.");
lines.push(" *");
lines.push(" * Single source of truth: `server/internal/handler/reserved_slugs.json`.");
lines.push(" * The Go backend embeds that JSON; this file is regenerated from it.");
lines.push(" *");
lines.push(" * Convention for new global routes (CLAUDE.md): use a single word");
lines.push(" * (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Hyphenated");
lines.push(" * root-level word groups (`/new-workspace`, `/create-team`) collide with");
lines.push(" * common user workspace names — see PR for full discussion.");
lines.push(" */");
lines.push("export const RESERVED_SLUGS: ReadonlySet<string> = new Set([");
const seen = new Set();
for (const [i, group] of data.groups.entries()) {
if (!group.label || !Array.isArray(group.slugs)) {
throw new Error(
`reserved_slugs.json: each group must have a "label" string and "slugs" array`,
);
}
if (i > 0) lines.push("");
lines.push(` // ${group.label}`);
if (typeof group.description === "string" && group.description.length > 0) {
for (const wrapped of wrapComment(group.description, 76)) {
lines.push(` // ${wrapped}`);
}
}
for (const slug of group.slugs) {
if (typeof slug !== "string" || slug.length === 0) {
throw new Error(`reserved_slugs.json: slug entries must be non-empty strings`);
}
if (seen.has(slug)) {
throw new Error(`reserved_slugs.json: duplicate slug ${JSON.stringify(slug)}`);
}
seen.add(slug);
lines.push(` ${JSON.stringify(slug)},`);
}
}
lines.push("]);");
lines.push("");
lines.push("export function isReservedSlug(slug: string): boolean {");
lines.push(" return RESERVED_SLUGS.has(slug);");
lines.push("}");
lines.push("");
writeFileSync(tsPath, lines.join("\n"));
// Wrap a single-line description into comment lines no wider than `width`,
// breaking on whitespace only. Tokens longer than `width` are kept intact.
function wrapComment(text, width) {
const tokens = text.split(/\s+/).filter(Boolean);
const out = [];
let current = "";
for (const tok of tokens) {
if (current.length === 0) {
current = tok;
} else if (current.length + 1 + tok.length <= width) {
current += ` ${tok}`;
} else {
out.push(current);
current = tok;
}
}
if (current.length > 0) out.push(current);
return out;
}

View File

@@ -0,0 +1,145 @@
{
"$comment": "Source of truth for reserved workspace slugs. Edit this file only. The Go side embeds this JSON directly; the TS side (packages/core/paths/reserved-slugs.ts) is regenerated from this file by `pnpm generate:reserved-slugs`. CI re-runs the generator and fails on any diff, so the two sides cannot drift. Convention for new global routes: single word (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Never add hyphenated root-level word groups (`/new-workspace`, `/create-team`) — they collide with common user workspace names.",
"groups": [
{
"label": "Auth flow",
"description": "`onboarding` is historical, kept reserved post-removal of the route.",
"slugs": [
"login",
"logout",
"signin",
"signout",
"signup",
"auth",
"oauth",
"callback",
"invite",
"invitations",
"verify",
"reset",
"password",
"onboarding"
]
},
{
"label": "Platform / marketing routes (current + likely-future)",
"description": "`multica` is reserved as the brand name to block impersonation workspaces. `www`, `new`, `home`, `homepage`, `dashboard` are confusables or likely-future global landing/entry routes; `homepage` matches the existing `/homepage` landing variant in apps/web.",
"slugs": [
"api",
"admin",
"multica",
"www",
"new",
"home",
"homepage",
"dashboard",
"help",
"about",
"pricing",
"changelog",
"docs",
"support",
"status",
"legal",
"privacy",
"terms",
"security",
"contact",
"blog",
"careers",
"press",
"download"
]
},
{
"label": "Account / billing (likely-future global routes in the avatar menu)",
"slugs": [
"profile",
"account",
"billing",
"notifications",
"search",
"members"
]
},
{
"label": "Dashboard / workspace route segments",
"description": "Reserving each segment name prevents `/{slug}/{view}` from being visually ambiguous (e.g. a workspace named `issues` would make `/issues/abc` mean two things). `workspaces` covers the global `/workspaces/new` workspace-creation page; `teams` is reserved for future team management.",
"slugs": [
"issues",
"projects",
"autopilots",
"agents",
"inbox",
"my-issues",
"runtimes",
"skills",
"settings",
"workspaces",
"teams"
]
},
{
"label": "API / integration prefixes",
"description": "`api` above already covers `/api/*`; these guard against future top-level API alias routes (e.g. `/v1`, `/graphql`) and against accidental workspace slugs that read like API identifiers.",
"slugs": [
"v1",
"v2",
"graphql",
"webhooks",
"sdk",
"tokens",
"cli"
]
},
{
"label": "Backend ops / observability",
"description": "`/health`, `/readyz`, `/healthz`, and `/ws` exist on the backend host; reserving them on the workspace slug space prevents naming confusion if/when these paths are ever proxied through the web origin.",
"slugs": [
"health",
"readyz",
"healthz",
"ws",
"metrics",
"ping"
]
},
{
"label": "RFC 2142 — privileged email mailboxes",
"description": "Allowing user workspaces with these slugs would let attackers spoof system messaging.",
"slugs": [
"postmaster",
"abuse",
"noreply",
"webmaster",
"hostmaster"
]
},
{
"label": "Hostname / subdomain confusables",
"description": "Even on path-based routing these names attract phishing and subdomain-takeover attempts.",
"slugs": [
"mail",
"ftp",
"static",
"cdn",
"assets",
"public",
"files",
"uploads"
]
},
{
"label": "Next.js / web standards",
"description": "These entries contain characters (dots, underscores) that today's slug regex `^[a-z0-9]+(?:-[a-z0-9]+)*$` already rejects at the format-validation step — so `isReservedSlug` never actually matches them. They are kept as defense-in-depth so that if the slug regex is ever relaxed (e.g. to support dotted corporate slugs like `acme.io`), these system paths stay protected.",
"slugs": [
"_next",
"favicon.ico",
"robots.txt",
"sitemap.xml",
"manifest.json",
".well-known"
]
}
]
}

View File

@@ -1,130 +1,47 @@
package handler
import (
_ "embed"
"encoding/json"
)
// reservedSlugs are workspace slugs that would collide with frontend top-level
// routes, platform features, or web standards. The frontend URL shape is
// /{workspaceSlug}/... so any slug that matches a top-level route or a
// system-significant name is rejected at workspace creation time.
//
// Keep this list in sync with packages/core/paths/reserved-slugs.ts.
//
// Convention for new global routes: use a single word (`/login`, `/inbox`)
// or `/{noun}/{verb}` (`/workspaces/new`). Hyphenated root-level word groups
// (`/new-workspace`, `/create-team`) collide with common user workspace names.
var reservedSlugs = map[string]bool{
// Auth flow
"login": true,
"logout": true,
"signin": true,
"signout": true,
"signup": true,
"auth": true,
"oauth": true,
"callback": true,
"invite": true,
"invitations": true,
"verify": true,
"reset": true,
"password": true,
"onboarding": true, // historical, kept reserved post-removal
// The list is loaded from reserved_slugs.json (embedded at build time), which
// is the single source of truth shared with the TypeScript side. Edit only
// the JSON; packages/core/paths/reserved-slugs.ts is regenerated from it by
// `pnpm generate:reserved-slugs` and CI fails on any drift.
// Platform / marketing routes (current + likely-future)
"api": true,
"admin": true,
"multica": true, // brand name — prevent impersonation workspaces
"www": true, // hostname confusable; never a legitimate workspace slug
"new": true, // ambiguous verb-as-slug; reserved for future global create routes
"home": true, // likely-future marketing/landing entry
"homepage": true, // existing /homepage landing variant in apps/web
"dashboard": true, // standard SaaS entry; likely-future global route
"help": true,
"about": true,
"pricing": true,
"changelog": true,
"docs": true,
"support": true,
"status": true,
"legal": true,
"privacy": true,
"terms": true,
"security": true,
"contact": true,
"blog": true,
"careers": true,
"press": true,
"download": true,
//go:embed reserved_slugs.json
var reservedSlugsJSON []byte
// Account / billing (likely-future global routes in the avatar menu)
"profile": true,
"account": true,
"billing": true,
"notifications": true,
"search": true,
"members": true,
var reservedSlugs = loadReservedSlugs()
// Dashboard / workspace route segments
"issues": true,
"projects": true,
"autopilots": true,
"agents": true,
"inbox": true,
"my-issues": true,
"runtimes": true,
"skills": true,
"settings": true,
"workspaces": true, // global /workspaces/new workspace creation page
"teams": true, // reserved for future team management routes
type reservedSlugsFile struct {
Groups []struct {
Slugs []string `json:"slugs"`
} `json:"groups"`
}
// API / integration prefixes. `api` above already covers /api/*; these
// guard against future top-level API alias routes (e.g. /v1, /graphql)
// and against accidental workspace slugs that read like API identifiers.
"v1": true,
"v2": true,
"graphql": true,
"webhooks": true,
"sdk": true,
"tokens": true,
"cli": true,
// Backend ops / observability. `/health`, `/readyz`, `/healthz`, and `/ws`
// exist on the backend
// host; reserving them on the workspace slug space prevents naming
// confusion if/when these paths are ever proxied through the web origin.
"health": true,
"readyz": true,
"healthz": true,
"ws": true,
"metrics": true,
"ping": true,
// RFC 2142 — privileged email mailboxes
"postmaster": true,
"abuse": true,
"noreply": true,
"webmaster": true,
"hostmaster": true,
// Hostname / subdomain confusables
"mail": true,
"ftp": true,
"static": true,
"cdn": true,
"assets": true,
"public": true,
"files": true,
"uploads": true,
// Next.js / web standards. These entries contain characters (dots,
// underscores) that today's slug regex `^[a-z0-9]+(?:-[a-z0-9]+)*$`
// already rejects at the format-validation step — so `isReservedSlug`
// never actually matches them. They are kept as defense-in-depth so
// that if the slug regex is ever relaxed (e.g. to support dotted
// corporate slugs like `acme.io`), these system paths stay protected.
"_next": true,
"favicon.ico": true,
"robots.txt": true,
"sitemap.xml": true,
"manifest.json": true,
".well-known": true,
func loadReservedSlugs() map[string]bool {
var data reservedSlugsFile
if err := json.Unmarshal(reservedSlugsJSON, &data); err != nil {
// reserved_slugs.json is checked into the repo and embedded into the
// binary; a parse failure is a programming error caught at the very
// first request that touches workspace creation, which is too late.
// Panic at init so the binary refuses to start instead.
panic("handler: parse reserved_slugs.json: " + err.Error())
}
out := make(map[string]bool)
for _, g := range data.Groups {
for _, slug := range g.Slugs {
out[slug] = true
}
}
return out
}
func isReservedSlug(slug string) bool {