Project One

Media: Photo Library & VSL

One action-dispatched endpoint for member media (/api/media, session-scoped in live mode) plus the public stable VSL redirect that persisted landing pages embed. The VSL flow is a three-step direct-upload dance: ticket → browser PUT → confirm — the video is far too big for a serverless body, so the server only mints the upload slot and verifies the result.

Endpoint Auth Rate limit Body cap
POST /api/media (all actions) session (live) / public (demo) 20/min per IP 6 MB
GET /api/vsl?i= public 60/min per IP —

Shared behaviors: unparseable body → 400 { "ok": false, "error": "Send JSON." }; unauthenticated live call → 500 (generic, not 401); SchemaNotReadyError (migration 0031 not pushed) silently degrades live → demo; any other live failure → honest 500, never a fake demo success.


POST /api/media — action: "photos"

Adds ad-photo library images as small base64 JSON files.

Request

{ "action": "photos", "files": [{ "name": "...", "mediaType": "image/jpeg", "base64": "..." }] }

Sanitizer rules: mediaType must be image/png|jpeg|webp; decoded size (base64.length × 0.75) ≤ 4 MB per photo; max 8 files kept. Invalid entries are silently dropped — a mixed batch succeeds with a smaller count.

Responses

Status Body
200 (live) { "ok": true, "registered": { "photos": n }, "message": "Got them — your next ad images will draw from these." }
200 (demo) same + "mode": "demo" (count echoed, nothing stored)
400 { "ok": false, "error": "Add JPG, PNG or WebP photos under 4 MB each." } when zero files survive

Side effects (live): per photo — storage upload to client-media/{instanceId}/photos/{ts}-{safeName} + a media_assets row (kind: 'photo', status: 'active'). Telemetry photos_added (count only). Filenames rewritten by safeFileName (80-char cap, separators stripped).

Caller note: compare registered.photos against what you sent — silent drops are the design.


POST /api/media — action: "vsl_ticket"

Mints a tenant-scoped signed-upload slot for the VSL video.

Request

{ "action": "vsl_ticket", "filename": "...", "contentType": "video/mp4", "sizeBytes": 123456 }

Validation (live): contentType must match video/(mp4|quicktime|webm); sizeBytes finite, > 0, ≤ 512 MiB.

Responses

Status Body
200 (live) { "ok": true, "path": "<instanceId>/vsl/<ts>-<name>", "token": "<upload token>", "bucket": "client-media" }
400 { "ok": false, "reason": "bad_type" | "too_big" | "bad_name" } — note reason, not error
200 (demo) fake ticket: "token": "demo", "path": "demo/vsl/…", "mode": "demo" (demo validates only size)

Flow: PUT the file to exactly the returned path/token (Supabase uploadToSignedUrl), then call vsl_confirm with that same path. Every ticket mints a unique timestamped path.

Honesty note: the ticket validates declared facts only — the signed URL enforces neither size nor MIME. Confirm is where reality gets checked.


POST /api/media — action: "vsl_confirm"

Verifies the object actually landed in storage, validates the real object's size and MIME (client-declared values are ignored — #97), archives any prior VSL, and registers the new one as the single active VSL.

Request

{ "action": "vsl_confirm", "path": "<the path from the ticket>" }

Live validation order: path must start ${instanceId}/vsl/ with no .. → bad_path; object must exist in storage → not_uploaded; real mimetype must be a video type → bad_type; real size within 512 MiB → too_big.

Responses

Status Body
200 (live) { "ok": true, "message": "Your video is in. It goes onto your page the next time the page is put together or published." }
400 { "ok": false, "reason": "bad_path" | "not_uploaded" | "bad_type" | "too_big" } or { "ok": false, "error": "Missing path." }
200 (demo) success message + "mode": "demo" for any non-empty path (no checks, nothing stored)

Single-active semantics: archive-then-insert is race-guarded by a partial unique index (migration 0034); a lost race (Postgres 23505) is absorbed as ok: true — double-clicked confirms are idempotent and exactly one active VSL survives.

Isolation: the path-prefix check makes cross-tenant confirms impossible; you can only register objects under your own instance prefix.


GET /api/vsl?i=<instanceId>

The public, stable VSL URL baked into persisted lead-page HTML (#95). Resolves the instance's current active VSL and 302-redirects to a freshly signed 1-hour storage URL — so pages rendered months ago still play.

Responses

Status Behavior
302 location: <signed URL, 3600s TTL>, cache-control: no-store — follow the redirect; never persist the signed URL
404 plain-text Not found for all failure modes: malformed i (must be a UUID), keyless demo, no active VSL, signing failure, internal error — deliberately indistinguishable (anti-enumeration)
429 shared envelope

Source: app/api/media/route.ts, app/api/vsl/route.ts, src/lib/media/assets.ts, src/lib/live/media.ts