5 Commits (46e7a3dae5d8adcdd2410cfeff59810ed817add8)

Author SHA1 Message Date
Elijah Voigt 46e7a3dae5 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>
1 day ago
Elijah Voigt 36d13cf266 feat(pages): raise per-file upload limit to 32 MiB for large wasm
An optimized 22 MiB bin_bg.wasm exceeded the 16 MiB per-file cap. Bump
max_file 16 -> 32 MiB (still < the 48 MiB total). max_uncompressed_total
(48 MiB) and max_compressed / MAX_UPLOAD_BYTES (24 MiB) are left
unchanged, so worst-case Worker memory (buffered body + decompressed
manifest) stays well under the ~128 MiB isolate ceiling.

Note: prefer zstd/gzip over xz for large archives — lzma-rs is slow in
wasm and can trip Cloudflare's per-request CPU limit (503 exceededCpu).

Bean: pages-yhxp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 day ago
Elijah Voigt f7fa11071d feat(pages): raise upload limits for large wasm (per-file 16 MiB, total 48 MiB)
The 5 MiB per-file cap rejected wasm binaries larger than that
("exceeds the maximum per-file size of 5242880 bytes"). Raise the
coherent limit set to fit large wasm while staying well under the
Worker's ~128 MiB memory ceiling (the body is buffered and decompressed
in memory):

- max_file:               5  -> 16 MiB
- max_uncompressed_total: 25 -> 48 MiB
- max_compressed / MAX_UPLOAD_BYTES / axum body cap: 10 -> 24 MiB
- max_entries / max_path_len / max_depth: unchanged

Updates UploadLimits::default, routes::api::MAX_UPLOAD_BYTES (the
DefaultBodyLimit layer follows it), the frontend client-side pre-check
and message, and the design doc §2.2 numbers.

Bean: pages-ufk2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 day ago
Elijah Voigt bc4c952533 feat(pages): update a page in place via re-upload [pages-ytau]
Updating a page meant deleting it and re-creating it under the same name.
Add PUT /api/pages/:slug, which replaces an existing page's content while
keeping its URL, owner, trust flag, and created_at.

Authorized to owner-or-admin (core::origin::can_replace, same rule as
delete) *and* can_upload, so a revoked uploader can't keep pushing content
to pages they already own.

The replace can't roll back the way create does — R2 has no multi-object
transaction and the previous bytes aren't retained — so the ordering bounds
the damage instead: new objects are written before any old key is deleted (a
viewer mid-update sees old-or-new per file, never a blank page), stale keys
are pruned last and best-effort (a failure only orphans an object), and the
D1 stats are refreshed only once R2 succeeds. Design §4.6b covers the
reasoning; §5's "no page overwrite" scope cut is retired.

In the UI, the always-visible upload card becomes a modal driven by two
entry points: "+ New Page" (create) and a per-row "Update" (replace, with
the slug pre-filled and read-only).

Verified end to end against wrangler dev with local D1/R2 — create, serve,
replace, stale assets pruned, stats/trust/ownership preserved, plus the
401/403/404/400/413 paths — and the UI driven in jsdom (37 assertions).
152 unit + 19 doctests, both-target clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 week ago
Elijah Voigt d9c4830cf9 feat(pages): static-site host at pages.elijah.run (v0)
Micro static-site host running entirely on Cloudflare: a Rust->wasm
Worker (workers-rs 0.7 + axum 0.8) serving user-uploaded .html/.zip
content from R2 at pages.elijah.run/<slug>/, with D1 for users/
sessions/page metadata and GitHub OAuth for login. Admins approve
uploaders, delete pages, and can mark pages/users trusted.

Security design (docs/plans/2026-07-12-pages-design.md): hosted
content is served with a CSP sandbox (opaque origin — no cookies or
storage, cannot make credentialed /api calls) unless admin-trusted;
strict slug grammar + reserved names; streaming zip validation
(traversal, bombs, size/entry/depth limits); SHA-256-hashed session
tokens in __Host- cookies; Origin checks on all mutations; upload
quotas and size caps.

Includes OpenTofu infra (R2, D1, DNS; wrangler owns the Worker
deploy), an embedded single-file management UI, deployment guide,
and 148 native unit tests + 18 doctests (clippy -D warnings clean on
native and wasm32). Deployed and verified in production.

Squash of 15 commits from pages-v0: each ticket implemented by a
Claude Sonnet agent, reviewed by a Claude Opus agent, tracked with
beans (pages-lxjz..pages-shqc).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 week ago