diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59dcb5f42..53f03c978 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/CLAUDE.md b/CLAUDE.md index 69fbee192..c15a76a37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/package.json b/package.json index 9d021ab01..a82e2962a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/core/paths/reserved-slugs.ts b/packages/core/paths/reserved-slugs.ts index 15c823542..4377e28a9 100644 --- a/packages/core/paths/reserved-slugs.ts +++ b/packages/core/paths/reserved-slugs.ts @@ -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 = 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", diff --git a/scripts/generate-reserved-slugs.mjs b/scripts/generate-reserved-slugs.mjs new file mode 100644 index 000000000..220ee4ec4 --- /dev/null +++ b/scripts/generate-reserved-slugs.mjs @@ -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 = 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; +} diff --git a/server/internal/handler/reserved_slugs.json b/server/internal/handler/reserved_slugs.json new file mode 100644 index 000000000..2ace5547e --- /dev/null +++ b/server/internal/handler/reserved_slugs.json @@ -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" + ] + } + ] +} diff --git a/server/internal/handler/workspace_reserved_slugs.go b/server/internal/handler/workspace_reserved_slugs.go index c45aa8a95..0f16fdf70 100644 --- a/server/internal/handler/workspace_reserved_slugs.go +++ b/server/internal/handler/workspace_reserved_slugs.go @@ -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 {