# pages — Planning ## Phases | Phase | Content | Status | |---|---|---| | 0 | Security analysis + design (`plans/2026-07-12-pages-design.md`) | done | | 1 | v0 implementation on branch `pages-v0` (beans below) | done | | 2 | Deploy + manual QA against real Cloudflare account | deployed; manual QA (login/upload/delete) pending | | 3 | v1 candidates: per-page subdomains, page overwrite, Yew UI, OpenAPI | not started | ## v0 tickets (beans, serial) 1. scaffold — crate skeleton, wrangler.toml, hello-world worker builds 2. core — slug/mime/upload/header policy modules + unit tests 3. db — D1 schema + query layer 4. auth — GitHub OAuth, sessions, auth middleware 5. api — pages + users JSON API 6. serve — page content serving from R2 7. frontend — embedded management UI 8. infra — OpenTofu + deployment docs ## Work log - **2026-07-12** — Project initialized. Security analysis and v0 design written; beans created; implementation pipeline started (Sonnet implementation agents, Opus review agents, one commit per ticket). - **2026-07-13** — infra ticket (pages-ihlp) done: `infra/*.tf` (R2 bucket, D1 database, DNS placeholder record, rate limiting ruleset) mirroring `../quotesdb/infra` style, `infra/README.md` deployment guide, `tofu fmt` + `tofu validate` passing. - **2026-07-13** — v0 implementation complete: all 8 beans done on `pages-v0`, one reviewed commit per ticket (Sonnet implementation, Opus review). 142 unit tests + 17 doctests; both-target clippy clean. Next: phase 2 (real deploy + manual QA per `infra/README.md`). - **2026-07-13** — Deployed. tofu applied R2 + D1 + DNS (rate-limit ruleset deliberately NOT applied — quotesdb's live http_ratelimit ruleset owns the zone's single slot; merge required if wanted). Remote schema applied; GITHUB_CLIENT_SECRET secret set; route pages.elijah.run/* live. Smoke tests pass (/, /api/me, /auth/login 302, reserved + unknown slugs 404 with sandbox CSP). Pending manual QA: GitHub login as admin, upload, view, delete. - **2026-07-13** — pages-shqc: per-page/per-user "trusted" escape hatch from the CSP sandbox (`pages.trusted OR owner's users.trusted`, computed at serve time). `migrations/0002-trust.sql` added for the live DB (not yet applied remotely — manual step); schema.sql, db.rs, api.rs, serve.rs, core/headers.rs, and the admin UI all updated; 148 unit + 18 doctests, both-target clippy clean. - **2026-07-14** — pages-ytau: in-place page updates, retiring the design's "no page overwrite: delete + re-upload" scope cut. New `PUT /api/pages/:slug` (design §4.6b) replaces a page's content — owner-or-admin *and* `can_upload`, new objects written before stale keys are pruned, D1 stats refreshed while `owner_id`/`trusted`/`created_at` are preserved. No schema change. The UI's always-visible upload card became a modal driven by a "+ New Page" button and a per-row "Update" button (slug pre-filled, read-only). 152 unit + 19 doctests, both-target clippy clean. Verified end to end against `wrangler dev` with local D1/R2 (create → serve → replace → stale assets 404 → stats/trust/ownership preserved; 401/403/404/400/413 paths) and the UI driven in jsdom (37 assertions). Local-dev gotcha found while verifying: `wrangler dev` rewrites the request `Origin` to the `[[routes]]` host (`pages.elijah.run`), so a local `BASE_URL=http://localhost:8787` makes every mutating route 403 on the Origin check. Set `BASE_URL=http://pages.elijah.run` in `.dev.vars` when exercising the API locally. - **2026-07-21** — pages-9lsk: page **aliases** (design §4.1). A page keeps one canonical `slug` and gains up to 10 removable, long-lived alternate names in the same URL namespace. New `aliases` table (`migrations/0003-aliases.sql`, idempotent `CREATE TABLE IF NOT EXISTS`) + `idx_aliases_slug`; namespace uniqueness across `pages.slug`+`aliases.alias` is enforced in the handlers (SQLite can't express a cross-table rule). 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 `/` prefix — no redirect, no second copy, and a real page always wins over an alias of the same name. New `POST /api/pages/:slug/aliases` (can_upload + owner-or-admin) and `DELETE /api/pages/:slug/aliases/:alias` (owner-or-admin); `create_page` now rejects a name held by an alias; `delete_page` removes a page's aliases; `GET /api/pages` returns each page's `aliases[]`. Management UI shows alias chips (with × to remove) and an "Add alias" button. No R2/behavioral change to existing pages. Replaced the earlier rename+conditional-redirect idea (dropped — 301-cache fragility). 196 unit + 20 doctests, both-target clippy clean.