--- # pages-9lsk title: 'Page aliases: multiple long-lived names per page (serve-direct)' status: in-progress type: feature priority: normal created_at: 2026-07-21T17:32:02Z updated_at: 2026-07-21T17:41:39Z --- Add removable, long-lived **aliases** to pages: a page keeps one canonical `slug` and can have N extra names in the same URL namespace that serve the page's content directly (no redirect). Replaces the earlier rename/redirect idea (no rename feature). Confirmed decisions: 1. Serve-direct — the alias URL shows the content with the URL unchanged; content is stored once under the canonical `/` R2 prefix; alias resolution just picks the canonical slug as the R2 prefix. No redirect, so no 301-cache pitfalls. 2. Aliases replace rename entirely. No canonical-rename feature. 3. Adding an alias requires `can_upload` (plus owner-or-admin + origin check), same authz shape as replace/delete. ## Namespace invariant (critical) Aliases and page slugs share ONE URL namespace; a name is unique across BOTH `pages.slug` and `aliases.alias`. - Creating a page named X → 409 if X is any existing slug OR alias. - Adding alias Y → 409 if Y is any existing slug OR alias; also reject Y == the page's own slug. - Alias must pass the same `validate_slug` (grammar + reserved names) as a real slug. - A real page always wins over an alias (aliases are only consulted on a page-miss), so no shadowing. ## Data model (D1) CREATE TABLE aliases ( alias TEXT PRIMARY KEY, slug TEXT NOT NULL REFERENCES pages(slug), owner_id INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_aliases_slug ON aliases(slug); schema.sql is idempotent (IF NOT EXISTS) — migration is additive/safe. ## Serve resolution (serve.rs) In serve_page: if get_page(slug) misses, fall back to get_page_by_alias(slug) (JOIN aliases->pages->users, returns the canonical PageRow + effective_trust in one query). Serve using page.slug as the R2 prefix (NOT the requested alias) via resolve_candidates(&page.slug, rest). redirect_to_index (/{name} -> /{name}/) is unchanged and already works for alias names. ## Lifecycle - Delete page → delete its aliases (explicit delete_aliases_for_page in delete_page handler; don't rely on FK cascade). - Replace content → aliases untouched. - Cap: max 10 aliases per page (anti-squatting). ## API - POST /api/pages/{slug}/aliases {"alias":"bar"} -> 201 (session, can_upload, origin, page exists, owner-or-admin, validate_slug, namespace check, cap) - DELETE /api/pages/{slug}/aliases/{alias} -> 200 - Include aliases[] per page in GET /api/pages (one extra list_all_aliases query, grouped by slug). - create_page: add alias-namespace check (reject name that is an existing alias). ## Frontend Per-page in the management UI: list aliases with remove (x) buttons + an "Add alias" input. Wire to the two endpoints. ## Tasks - [x] D1 migration: aliases table + index in migrations/schema.sql (+ 0003-aliases.sql) - [x] db.rs: get_page_by_alias, insert_alias, delete_alias, list_aliases_for_page, list_all_aliases, count_aliases_for_page, delete_aliases_for_page, alias_exists - [x] serve.rs: alias fallback in serve_page (serve under canonical slug prefix) - [x] api.rs: add_alias + remove_alias handlers; namespace check in create_page; alias cleanup in delete_page; aliases[] in list_pages - [x] router: POST /api/pages/{slug}/aliases, DELETE /api/pages/{slug}/aliases/{alias} - [x] frontend: alias chips (with x to remove) + Add alias button - [x] docs: design doc (§4.1/§4.2/§4.3) + PLANNING.md - [x] validation (all six) pass: 196 unit + 20 doctests, both-target clippy clean - [ ] migrate prod D1 + deploy (with user go-ahead)