A 6-lens multi-agent review (103 agents; every finding adversarially verified by three
refute-by-default skeptics) confirmed the epoch redesign is sound — the core invariant
holds — and found 13 real defects around it. All are fixed here.
The redesign's invariant was re-verified and stands: an accepted upload is always in the
keepsake, and a downloadable keepsake always reflects the most recent release.
But a WEAKER adjacent invariant did NOT hold — "a downloadable keepsake reflects the
current content" — and that is the theme of the biggest fixes.
CONTENT REMOVAL NOW ALWAYS REACHES THE KEEPSAKE
Regeneration was wired only to host deletes. Ban, unban, guest self-delete and guest
comment-delete did not trigger it — yet the export query filters `is_banned = FALSE`,
so the pipeline already agreed that content must not be there. Ban someone for abusive
content after release and every guest kept downloading an archive containing it.
All five removal paths now invalidate and rebuild. `query_comments` also gained the
banned/hidden filter it was missing entirely (the ZIP had it; the viewer did not).
Each removal is now ATOMIC with its invalidation (one tx, models made executor-generic).
Previously the delete committed in its own tx and the regeneration in a second: a dropped
handler future between them left the taken-down photo permanently downloadable, and
nothing could notice — the keepsake still looked complete and the host could no longer
even find the upload to retry.
NO MORE TAKEDOWN STORM
Every removal spawned two uncancellable full exports. A superseded worker was INERT, not
STOPPED: it still ran every ffmpeg spawn and image resize and wrote a whole archive before
discovering it had lost. Five deletes left ten workers alive, each holding a
full-gallery-sized temp, in a 1 GB container.
Now: a debounce before `claim_job` (a superseded worker fails its claim and does ZERO
work, collapsing a burst into one export), plus `update_progress` — which already ran
exactly the right liveness predicate and threw the answer away — returning it so both file
loops bail the instant they are retired. Moderating a comment no longer rebuilds the
multi-GB ZIP either: the ZIP is media-only, so it is carried forward, with prune taught to
protect any file a live row still references.
AN ESCAPE HATCH
A failed export was terminal at runtime: release_gallery refuses an already-released event,
recovery only runs at boot, and there was no retry route — the only outs were restarting the
container or reopening uploads to every guest. Added POST /host/export/rebuild. Also widened
mark_failed's guard to `status IN ('running','pending')`: when claim_job itself ERRORED, the
row was still `pending`, so the failure was never recorded and the job sat at 0% forever.
SILENT INVALIDATION
Retiring the epoch 404s the download instantly, but nothing told the clients — guests kept
seeing an enabled download button through the whole rebuild. Now broadcasts an invalidation.
And the download was a top-level <a href>: a 404 (or a 429 from the per-IP export limit that
everyone on the venue WiFi shares) NAVIGATED THE USER OUT OF THE PWA onto raw JSON. It now
targets a hidden iframe, so an error response is harmless.
ALSO
- The HTML export still had the exists()-then-open TOCTOU the ZIP path was fixed to remove,
at three sites, two with a hard `?` that failed the ENTIRE viewer. It is reachable: the
compression worker hard-deletes an original when a transcode fails and can still be running
when the gallery is released.
- Crash-orphaned .tmp files were immortal (prune deliberately skips .tmp). Swept at boot,
where it is unambiguously safe.
- export_status read the epoch in one statement and used it in the next; now one query.
- DiskCache::snapshot IGNORED its path argument on a cache hit, returning whatever filesystem
was measured last. Latent only because both callers pass media_path — it would have silently
misreported the moment anyone measured the exports volume. Now keyed by path.
- Corrected two comments that asserted safety properties the code does not have (claim_job
does NOT prevent a retired-epoch claim — retirement is enforced at READ time; and a
multi-replica reap is NOT contained, it can corrupt the keepsake). Added a rollback runbook
to migration 014, the repo's first destructive migration.
TESTS
The regression guard for the headline FOR SHARE fix was probabilistic — it could pass on
broken code if the interleaving fell the wrong way. It is now STRUCTURAL: the upload is held
open mid-body with its pre-flight check already passed, so the release provably lands inside
the exact window. Verified 5/5 FAILING (201 instead of 403) with the fix reverted, 5/5
passing with it.
Verified: backend 40 tests, svelte-check 0 errors, e2e typecheck clean,
e2e 160 passed / 1 skipped on chromium-desktop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A comprehensive role-based E2E audit (guest/host/admin, across browser
sessions) surfaced one critical and several smaller issues; this addresses
them and hardens the tests that missed them.
Critical
- The client upload pipeline was fully broken: the IndexedDB v1->v2 upgrade
opened a *new* transaction inside the upgrade callback, which throws during
a version-change transaction and aborted the whole upgrade, leaving the
queue object store uncreated -- so no UI upload ever fired. Reuse the
version-change transaction the callback provides, and bump the DB to v3 with
a contains() guard so installs already corrupted by the shipped bug
self-heal on next load. Re-enabled the previously-fixme'd UI upload E2E test.
High / Medium
- Event lock is uploads-only again: likes, comments and browsing stay open
while the event is locked (USER_JOURNEYS 9.3 / FEATURES) -- it was wrongly
freezing social interaction. Updated the event-lock spec accordingly.
- get_original now excludes soft-deleted and ban-hidden uploads, and direct
/media/originals/** serving is blocked, so a hidden user's originals can no
longer be pulled by UUID (all originals go through the checked alias).
- The upload handler reads the file field with an early-abort size cap chosen
from the declared content-type, instead of buffering the entire body before
the size check.
Low
- unban_user mirrors the ban role guard (a host can no longer unban a
host/admin banned by an admin).
- reset_user_pin's UPDATE is event-scoped.
- Admin login returns and stores a real identity (user_id + display name)
instead of a blank session.
- The host user list no longer renders target-actions (ban/promote/demote/PIN)
on the caller's own row, where the backend always rejected them.
- /diashow gains a client-side auth guard like the other protected routes.
- The join page shows the event name via a new public GET /api/v1/event.
Verified: backend cargo build clean, frontend svelte-check 0 errors, full
Playwright E2E suite 144 passed / 1 skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The round-2 re-review (full suite green) found two real defects the passing
tests missed, both now fixed and covered:
B1 — banned users could still edit/delete their OWN content: edit_upload,
delete_upload, delete_comment skipped the is_banned guard that upload/
like/comment got (round-2 removed the global extractor block and missed
these three). Added the guard (using auth.is_banned — no extra query).
The moderation ban test now asserts 403 on DELETE upload, PATCH upload,
and DELETE comment for a banned owner (+ upload survives) — it previously
only exercised POST /upload, which is why the gap shipped green.
B2 — H3 live-eviction was dead code: 'user-hidden' and 'comment-deleted' were
missing from KNOWN_EVENTS, so EventSource never listened and the handlers
never fired. Added both. New browser-driven sse-eviction test asserts a
hidden user's card actually evicts from an open feed with no reload — the
backend-only SseListener checks couldn't catch this.
Minor items folded in:
- admin dashboard bounces a mid-session-demoted admin to /feed via live
/me/context (mirrors the host page) instead of a dead error screen.
- feed "new posts" pill cleared on any full refresh (pull-to-refresh no longer
strands it).
- corrected the stale sse.rs comment (banned users may hold a read-only stream).
Verified: backend 35 unit tests; svelte-check 0 errors; e2e 04-host + comment-ui
+ export-leak 13/13 on chromium (incl. the two new regression tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
H1: corrected a round-1 over-reach. Bans are read-only per USER_JOURNEYS §10:
the base AuthUser extractor no longer rejects banned users (they keep read
access); Require{Host,Admin} and write handlers enforce the ban via
auth.is_banned → 403 (incl. self-unban). Demoted hosts still lose powers
immediately (live DB role) and are bounced off the dashboard. Also fixed the
login/recover redirect regression on unauthenticated 401.
H2: failed compression now self-heals instead of leaving a permanent broken
card — refund the quota, delete the orphaned original, soft-delete the row;
the frontend toasts the uploader and evicts the card on upload-error.
H3: self-delete, ban-with-hide (user-hidden), and comment-deletes now broadcast
SSE; feed, diashow, and lightbox evict the affected content live instead of
waiting for a reload. sse-eviction.spec.ts covers it.
Riders: feed/+page.svelte also carries the medium truncated-delta pill; host.rs
also carries the low atomic release_gallery claim; admin/+page.svelte also drops
the dead compression_concurrency control (medium).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings the batch-2 security hardening into main (forked from the same base as
the UX batch; auto-merged cleanly — verified both sides' edits to social.rs /
main.rs coexist):
- Cross-event authorization: toggle_like / list_comments / add_comment now
event-scope the upload via Upload::find_by_id_and_event (404 on cross-event
access); delete_comment uses Comment::soft_delete_in_event. Closes the gap
where a guest could like/comment/list across events by upload UUID.
- Upload OOM backstop: the /upload route gets DefaultBodyLimit::max(576 MiB)
instead of disable(), so a multi-GB body can't be buffered before the
handler's per-class size checks run.
- upload.rs per-class size-limit refactor, XSS allowlist, deploy hardening
(Caddyfile, Dockerfiles, docker-compose, .env.example), and a data-mode
doc-comment clarifying the original-media route is capability-(UUID-)gated.
The event-scope checks sit before, and batch-3's best-effort count broadcasts
after, the like/comment mutations — both preserved.
Verified: cargo build clean, svelte-check 0 errors.
Post-commit review follow-ups for the batch-3 feed work.
Frontend — VirtualFeed: the guarded setOptions keyed only on (count, scrollMargin),
so a rotation (or the first-paint 0->real container width) changed colWidth / card
heights without invalidating the cached measurements, leaving getTotalSize and the
scrollbar stale until each row scrolled back through the window. Track appliedWidth
and call virtualizer.measure() on a width delta so heights re-estimate at the new
width (rendered rows re-measure immediately via their ResizeObserver). Covers list +
grid and the first-paint 120px-estimate case.
Backend — social.rs: the fresh like/comment count SELECT used `.await?`, so a
transient DB failure would 500 the request after the like/comment had already
committed. Make the count + broadcast best-effort (if let Ok { ... }); the mutation
succeeds regardless and the next event / pull-to-refresh reconciles the count.
Docs — FOLLOWUPS: record the review findings left as-is (grid-prepend tile reflow,
filtered-grid auto-load which is pre-existing, comment-delete stale count, and the
last-write-wins count ordering note).
Verified: svelte-check 0 errors, cargo build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address the seven findings from the security & deployment review.
High:
- upload: make infer authoritative — reject files it can't identify
(SVG/HTML/JS) and require the detected MIME to be on an ALLOWED_MEDIA
allowlist; derive the stored MIME and on-disk extension from the
detected type, ignoring client filename/Content-Type. Closes the
stored-XSS vector via media served on-origin.
- deploy: rename docker-compose.override.yml -> docker-compose.dev.yml
so the default `docker compose up -d` no longer publishes Postgres
5432 to the host; the port map is now opt-in via -f. README updated.
Medium:
- upload: DefaultBodyLimit::disable() -> max(576 MiB) as an HTTP-level
OOM backstop; handler still enforces precise per-class size limits.
- docker: run backend and frontend as non-root users.
Low:
- social/upload: event-scope toggle_like, list_comments, add_comment,
delete_comment, edit_upload, delete_upload via find_by_id_and_event /
soft_delete_in_event — cross-event IDs now resolve to 404.
- Caddy: site-wide HSTS / nosniff / X-Frame-Options / Referrer-Policy,
plus Content-Disposition: attachment on /media/originals/*.
- .env.example: replace default Postgres password with a CHANGE_ME hint.
Out of scope: localStorage JWT (root cause fixed; httpOnly cookies are a
larger change tracked separately).
Verified: cargo build (no new warnings), cargo test (3 passed),
caddy validate, docker compose config (no 5432 published by default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the comprehensive code review. Five batches:
1. Cross-event authorization: host_delete_upload, unban_user, and
host_delete_comment now scope by auth.event_id. Adds
Upload::find_by_id_and_event / soft_delete_in_event and a
Comment::soft_delete_in_event variant that joins through upload.
2. Token exposure: SSE auth no longer puts the JWT in the URL.
New /api/v1/stream/ticket endpoint mints a short-lived single-use
ticket bound to the session; the EventSource passes ?ticket=...
instead. Refuse to start in APP_ENV=production with the dev JWT
sentinel; warn loudly otherwise.
3. Account hardening: per-IP+name rate limit on /recover (mitigates
targeted lockout DoS), per-IP rate limit on /admin/login, random
32-char admin recovery PIN (replaces "0000"), structured tracing
events for wrong PIN, lockout, failed admin login, ban/unban/role
change/pin-reset/host-delete.
4. DoS / correctness: comment listing paginated (LIMIT 50 + ?before=
cursor), hashtag extraction whitelisted to ASCII alnum+underscore
(≤40 chars) with unit tests, display_name / caption / comment body
length validated in chars rather than bytes.
5. Cleanup: session-touch failures now logged, DATABASE_MAX_CONNECTIONS
env var (default 10).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Cursor-based feed endpoint using v_feed view with hashtag filtering
- Like toggle (INSERT ON CONFLICT), comments CRUD
- Feed delta endpoint for SSE-driven incremental updates
- SSE client with Page Visibility API (pause/reconnect)
- Responsive photo/video grid with infinite scroll
- Hashtag filter chips, lightbox modal with comments
- Media file serving via tower-http ServeDir
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>