mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
95
scripts/generate-reserved-slugs.mjs
Normal file
95
scripts/generate-reserved-slugs.mjs
Normal 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;
|
||||
}
|
||||
145
server/internal/handler/reserved_slugs.json
Normal file
145
server/internal/handler/reserved_slugs.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user