3.6 KiB
| title | status | type | priority | created_at | updated_at |
|---|---|---|---|---|---|
| Page aliases: multiple long-lived names per page (serve-direct) | in-progress | feature | normal | 2026-07-21T17:32:02Z | 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:
- Serve-direct — the alias URL shows the content with the URL unchanged; content is stored once under the canonical
<slug>/R2 prefix; alias resolution just picks the canonical slug as the R2 prefix. No redirect, so no 301-cache pitfalls. - Aliases replace rename entirely. No canonical-rename feature.
- 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
- D1 migration: aliases table + index in migrations/schema.sql (+ 0003-aliases.sql)
- 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
- serve.rs: alias fallback in serve_page (serve under canonical slug prefix)
- api.rs: add_alias + remove_alias handlers; namespace check in create_page; alias cleanup in delete_page; aliases[] in list_pages
- router: POST /api/pages/{slug}/aliases, DELETE /api/pages/{slug}/aliases/{alias}
- frontend: alias chips (with x to remove) + Add alias button
- docs: design doc (§4.1/§4.2/§4.3) + PLANNING.md
- validation (all six) pass: 196 unit + 20 doctests, both-target clippy clean
- migrate prod D1 + deploy (with user go-ahead)