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

62 lines
3.6 KiB
Markdown

---
# 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 `<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
- [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)