You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vibed/pages/.beans/pages-9lsk--page-aliases-mu...

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:

  1. 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.
  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

  • 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)