feat(pages): add removable page aliases (serve-direct)
A page keeps one canonical slug and can have up to 10 long-lived, removable aliases in the same URL namespace. Serving is serve-direct: routes::serve falls back to Db::get_page_by_alias on a canonical miss and serves the canonical page's R2 content under its <slug>/ prefix — no redirect, no second copy, and a real page always wins over an alias of the same name. - migrations: new `aliases` table + idx_aliases_slug (schema.sql + idempotent 0003-aliases.sql). - db.rs: get_page_by_alias, insert_alias (NameTaken on collision), delete_alias (page-scoped), list/count/delete-for-page, alias_exists. - api.rs: POST /api/pages/:slug/aliases (can_upload + owner-or-admin) and DELETE /api/pages/:slug/aliases/:alias (owner-or-admin); create_page rejects a name held by an alias; delete_page removes a page's aliases; GET /api/pages returns each page's aliases[]. - Namespace uniqueness across pages.slug + aliases.alias is enforced in the handlers (SQLite can't express the cross-table rule). - Frontend: alias chips (× to remove) + an "Add alias" button. Replaces the earlier rename+conditional-redirect idea (dropped for 301-cache fragility). No R2 or behavioral change to existing pages. 196 unit + 20 doctests, both-target clippy clean. Bean: pages-9lsk. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>main
parent
36d13cf266
commit
46e7a3dae5
@ -0,0 +1,61 @@
|
||||
---
|
||||
# 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)
|
||||
@ -0,0 +1,27 @@
|
||||
-- pages.elijah.run — incremental migration: page aliases
|
||||
--
|
||||
-- pages-9lsk: adds removable, long-lived **aliases** — extra names a page can
|
||||
-- be reached at, in the same URL namespace as `pages.slug`. Serving is
|
||||
-- serve-direct (an alias request serves the canonical page's R2 content with
|
||||
-- no redirect); namespace uniqueness across `pages.slug` and `aliases.alias`
|
||||
-- is enforced in the route handlers (src/routes/api.rs). See design §4.1.
|
||||
--
|
||||
-- Unlike 0002-trust.sql (an in-place `ALTER TABLE`), this adds a brand-new
|
||||
-- table, so every statement is `IF NOT EXISTS` and the file is fully
|
||||
-- idempotent — re-running it against a database that already has the table is
|
||||
-- a safe no-op. `migrations/schema.sql` already contains the same definitions
|
||||
-- for fresh installs; this file is the isolated change for the *existing*
|
||||
-- production D1 database.
|
||||
--
|
||||
-- Apply with (a separate, human-triggered step):
|
||||
-- wrangler d1 execute <DB_NAME> --file=migrations/0003-aliases.sql (remote)
|
||||
-- wrangler d1 execute <DB_NAME> --local --file=migrations/0003-aliases.sql (local dev)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS aliases (
|
||||
alias TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL REFERENCES pages(slug),
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_aliases_slug ON aliases(slug);
|
||||
Loading…
Reference in New Issue