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>
Extracted two shared helpers: `manifest_from_request` (size caps + Content-Type + `build_manifest`, shared with `create_page`) and `list_page_keys` (cursor-paginated prefix listing, shared with `delete_page`).
- `routes/mod.rs`: PUT wired with the 10 MiB `DefaultBodyLimit` merged onto the PUT method only, so PATCH/DELETE keep axum's 2 MiB default.
**Not transactional, by necessity:** R2 has no multi-object transaction and the old bytes aren't retained, so a replace can't roll back like a create can. Ordering bounds the damage: new content is written *before* any old key is deleted (a viewer mid-update sees old-or-new per file, never a blank page), stale-key deletion is last and best-effort (a failure only orphans an object), and D1 stats are updated only after R2 succeeds. A mid-way `put` failure returns 500 with a retry message and leaves D1 untouched.
**Frontend** — the always-visible "Upload a page" card is gone. One modal serves both flows: "+ New Page" (create) and a per-row "Update" button (replace: slug pre-filled + read-only, filename no longer rewrites the slug). Esc / backdrop / Cancel / × all dismiss; focus returns to the opening button; success closes the modal and banners on the pages card.
- Full suite green: 152 unit + 19 doctests, both-target clippy clean.
- End-to-end against `wrangler dev` (local D1 + R2), session seeded directly in local D1: create 3-file zip → serves v1 → PUT single .html → serves v2, `old.js`/`style.css` now 404 (stale keys pruned), `file_count` 3→1, `created_at`/`owner_login` unchanged. Admin-set `trusted` survived an owner re-upload. Negative paths: 401 (no session), 403 (bad/missing Origin, non-owner, `can_upload=0`), 404 (unknown slug), 400 (bad content-type, zip without index.html), 413 (11 MiB body).
- UI driven in jsdom with a stubbed fetch: 37/37 assertions (modal modes, correct endpoint+method per mode, slug not rewritten in replace mode, dismissal paths, no state leaking between opens).
## Notes / follow-ups
- **Local-dev gotcha**: `wrangler dev` rewrites the request `Origin` to the `[[routes]]` host, so `BASE_URL=http://localhost:8787` in `.dev.vars` makes every mutating route 403. Use `BASE_URL=http://pages.elijah.run` locally. Recorded in PLANNING.md.
- `wrangler dev` also drops a miniflare cache in `node_modules/.mf`; added to `.gitignore`.
- Possible follow-ups (not done): an `updated_at` column + "Updated" column in the list (needs a migration); shorter `Cache-Control` or cache-busting so a replaced page isn't stale for up to 5 minutes.
@ -158,6 +158,7 @@ so it should be granted even more sparingly than a single-page grant.
| GET | `/api/me` | session | Current user (login, can_upload, is_admin) |
| GET | `/api/me` | session | Current user (login, can_upload, is_admin) |
| GET | `/api/pages` | – | List pages (slug, owner login, created_at, size) |
| GET | `/api/pages` | – | List pages (slug, owner login, created_at, size) |
| POST | `/api/pages?name=<slug>` | can_upload | Raw body upload (html or zip). 201 / 400 / 401 / 403 / 409 / 413 |
| POST | `/api/pages?name=<slug>` | can_upload | Raw body upload (html or zip). 201 / 400 / 401 / 403 / 409 / 413 |
| PUT | `/api/pages/:slug` | can_upload **and** (owner or admin) | Replace an existing page's content in place — same raw body shape as POST. 200 / 400 / 401 / 403 / 404 / 413 (pages-ytau, §4.6b) |
| PATCH | `/api/users/:id` | admin | Set `can_upload` and/or `trusted` (JSON body `{"can_upload"?: bool, "trusted"?: bool}`, at least one required — pages-shqc) |
| PATCH | `/api/users/:id` | admin | Set `can_upload` and/or `trusted` (JSON body `{"can_upload"?: bool, "trusted"?: bool}`, at least one required — pages-shqc) |
@ -255,6 +256,38 @@ infra/*.tf
7. `put` each file to R2 under `<slug>/…`; on failure, best-effort cleanup
7. `put` each file to R2 under `<slug>/…`; on failure, best-effort cleanup
of written objects and the D1 row → 500.
of written objects and the D1 row → 500.
### 4.6b Replace pipeline (pages-ytau, added after v0)
`PUT /api/pages/:slug` replaces an existing page's content, retiring the §5
scope cut "no page overwrite/update: delete + re-upload".