# Decision: media serving — unauthenticated UUID vs. signed gateway

**Status:** OPEN — needs a call. Written 2026-06-30 from the 2026-06-27 audit
(`fix/audit-2026-06-27-critical-medium`), which implemented the signed-gateway option that
`main` did not adopt.

**Scope:** how `main` serves uploaded photos/videos (originals + previews/thumbnails) to the
`<img>`/`<video>` tags in the feed, lightbox, and diashow.

---

## The two models

### A. Current `main` — unauthenticated, UUID-as-capability
- `/api/v1/upload/{id}/original` — **no auth**; the unguessable upload UUID *is* the capability.
  (Documented as intentional in `frontend/src/lib/data-mode-store.ts`.)
- `/media/*` — static `axum` `ServeDir`, **no auth layer**, serves preview/thumbnail files by path.
- No expiry, no signature, no per-request authorization on the bytes.
- **Works with** plain `<img src>` (no Authorization header needed) and browser caching, at zero
  per-request backend cost.

### B. Audit branch — authenticated signed gateway
- `/media/{kind}/{id}?sig=…` via `handlers::media::serve`.
- HMAC signature (keyed off `jwt_secret`), **time-boxed** (~24 h, bucketed to a 1 h
  URL-stability window so warm fetches stay cacheable).
- Authorizes by **signature + uploader visibility**, not requester identity.
- Still `<img>`-compatible (capability rides in the query string, not a header).
- Cost: token mint/verify, ~2 DB queries per *cold* fetch, and the HMAC key currently reuses
  `jwt_secret` (see "Media HMAC domain separation" in [SECURITY-BACKLOG.md](SECURITY-BACKLOG.md)).

---

## What actually differs (the tradeoff)

| | A. UUID (main) | B. Signed gateway (audit) |
|---|---|---|
| Leaked URL/UUID (forwarded link, browser history, referer, logs) | **Permanent** full-res access | Access **expires** (~24 h); URL can't be re-minted |
| Banned / departed guest | Retains **permanent** access to every original whose UUID they hold | Can't mint new URLs; can replay **held** URLs ≤ TTL only |
| Revocation | None (UUID is forever) | Rotate the signing key → all outstanding URLs die |
| Enumeration | Mitigated by UUIDv4 unguessability | Same, plus signature |
| Per-request cost | Zero (static serve) | Token verify + ~2 DB queries (cold) |
| Plumbing / failure surface | Minimal | Token mint/verify, key mgmt, cache-bucket logic |

Neither model solves the fundamental `<img>`-can't-send-a-Bearer constraint: once a browser holds
a media URL, it is replayable for that URL's lifetime. Model B simply **bounds that lifetime** and
**adds revocability**; Model A's lifetime is *forever*.

## Threat model (this deployment)

Private single-box event app, ~100 guests, ~1 000 files, one event at a time. Media is **shared
with all guests by design** — it is personal but not secret *within* the event. The real risk is
**a URL escaping the event boundary** (a guest forwards a link; it lands in chat history, a public
post, or server/proxy logs) granting an outsider — or a removed guest — access.

## Recommendation

This is a judgment call, not a clear-cut bug, so it's yours to make. My leaning:

- **Adopt Model B (signed gateway)** if post-event link leakage or removed-guest access is a real
  concern for you — it's the materially stronger posture (bounded exposure + key-rotation
  revocation) and the implementation already exists on the audit branch. If adopted, also do the
  cheap **HKDF domain-separation** for the HMAC key (backlog 🅱).
- **Keep Model A (UUID)** as an *explicitly accepted risk* if you're comfortable that a leaked
  link = permanent access at this scale. If so, two cheap hardening steps are still worth doing:
  1. **Log hygiene** — ensure the upload UUID never lands in access logs with enough context to
     correlate (the `TraceLayer` logs the request *path*, and `/api/v1/upload/{id}/original`
     puts the UUID *in the path*). Confirm logs aren't shipped/retained where that matters.
  2. **Document the decision** in `README`/`PROJECT.md` so "unauthenticated media" is a recorded
     choice, not an oversight.

**Sharpest single fact to decide on:** in Model A, a guest you *ban* keeps full-resolution access
to every original they ever loaded, forever. In Model B, that access dies within ~24 h. If that
asymmetry matters for your events, adopt the gateway.

## If you adopt B
The implementation lives on `origin/fix/audit-2026-06-27-critical-medium`:
`backend/src/handlers/media.rs` (+ `media_token`), the `/media/{kind}/{id}` route in `main.rs`, and
the feed/upload/host changes that emit signed URLs (`models/upload.rs`, `handlers/feed.rs`). It
would need re-basing onto current `main` (which has since diverged across ~80 files).
