logicspike/docs

Media Service

Media access audit and private storage (2026-10-12)

Status (2026-10-12): private bucket + CORS created; new secrets set (forms key on media/forms; new INTERNAL_SERVICE_TOKEN on media, forms, collections, content-engine, mailbox); core migration applied; workers deployed: media a5dd2f5e (rollback fb32afd8), forms d3145564 (061f57c2), mailbox 4d65a9ee + token (95b5ab06), content-engine 01f4e334 (8dd2d581), mcp-gateway 1d2394c7 (283345cd). Janitor armed and ran clean. Data done: the 4 form files moved to vlozi-media-private (r2.dev links 404; rows 26 public/public + 4 private), main pushed (87f6d7ff..aa74e013, dashboard), the 4 files filed under their submissions with stored links stripped; mailbox had nothing to fix. Clean-up and lock-down done (founder's go-ahead, same day): 32 orphaned objects of 11 deleted workspaces deleted (G23), the old bucket logicspike-media emptied, made private and deleted (G13), scripts/set-r2-cors.ps1 removed (G14); lock-down applied (default private, CHECK validated) and v1 tokens retired, media redeployed 9e941d68 (G17). The public bucket now holds exactly the 26 tracked public files. In-app checks passed (2026-10-12): a mail with 2 attachments reached the founder's Gmail under their real names, and the staged copies were released from media; a form file opened through the short-lived link (the 4 legacy files are fake smoke-test bytes, so Chrome can't render them); a Library upload landed Workspace/private at full size with no public link (the R2 key reaches the new bucket). Rollout complete. G24 shipped the same day (Library visibility switch at upload and in the detail panel; media 5b619234, rollback 9e941d68; dashboard via the main push). Rolling media back to a pre-branch version is no longer possible without first dropping the CHECK (old code doesn't set storage). Owner: founder for every rollout step (secrets, buckets, migrations, deploys); code by the build session. Plan: can-we-start-planning-misty-kernighan.md (session plan). This file is the record.

The question was: is the media service properly connected to the mailbox, does it enforce access, and what other gaps exist.

Short answer. The mailbox's own gates were right. The weak point was underneath: every media file, whatever its visibility, sat in one public R2 bucket, and several routes handed out the permanent public link for "workspace", "private", form and mail files. So a form CV marked workspace-only was downloadable by anyone holding its link, forever. The branch moves every non-public file to a bucket with no public access and replaces permanent links with short-lived ones.


1. Findings

# Sev Finding Status
F1 High One public bucket for every visibility. Routes returned https://pub-….r2.dev/<key> for non-public files (internal upload, resolve, presign, ingest). Form uploads stored that URL in the submission, and it flowed into webhooks, Flows events, the public submit response, admin/MCP list/get/export and webhook redelivery. Nothing could revoke it. Fixed (phases 2, 4)
F2 Med-High A former member could manage files they uploaded to a workspace they left (the uploader rule and the "own files" list ignored the tenant). Fixed (phases 1, 2)
F3 Med Form uploads appeared in the Media library for anyone with "Read files". Fixed (phase 4 + migration script)
F4 Med Mailbox: a sender could attach any workspace file by id (a Library document, a form CV) (R1); the staging mailbox was never checked; an unresolved attachment was silently dropped and the mail still went; recipients got <random>.pdf; the media copy was never deleted. Fixed (phase 5)
F5 Med One shared internal token for five services; secrets compared with !==; media reachable on workers.dev. Compare + workers.dev fixed; shared token logged (G1)
F6 Low-Med Upload hygiene: presign trusted the client's size and /complete never checked; users could claim delegated; the IPv6 SSRF guard never matched (URL.hostname keeps brackets); ?limit=-5 → 500; permanent delete swallowed R2 errors (untracked bytes); bytes written before the row. Fixed (phase 2)
F7 Low A public form submission could carry a forged {__file, url} that the dashboard rendered as an attachment. Fixed (phases 3, 4)
F8 Low Inline cid: images wrote the sender's content type into a data: URI unescaped. Fixed (phase 5)
F9 Info Config/docs drift: production bucket named -local; two r2.dev hosts; stale CORS script; docs/media/vision.md out of date; cleanup cron never ran (no cron slots). Janitor DO + docs fixed; rest logged (G6, G13, G14)

2. Decisions (founder, 2026-10-12)

  1. Separate private bucket. Public files (blog, newsletter, social, collections, logos, avatars) stay where they are with unchanged links. Every other file moves to vlozi-media-private, which has no public access. Old links to private files stop working; that is intended.
  2. Form files are Forms-only. Out of the Media library; opened from the submission by roles with forms:read; stored delegated to forms under the submission id.
  3. Expiring links for integrations. Webhooks and Flows events carry a 7-day signed link (the SigV4 maximum), MCP get_submission a 1-hour one. The public submit response, lists and exports carry no link.

3. How access works now

Who reads a file How Limits
Anyone, a public file (blog image, newsletter art) Its permanent CDN URL on the public bucket Unchanged
A workspace member, in the dashboard /api/media/files/:id/content → media's content route Re-checks access on every request; never the CDN for non-public files
A form submission's viewer Forms GET /admin/submissions/:id/files/:mediaId → content route with a 1-hour token forms:read; token bound to viewer (their session), workspace and submission; an hour so a PDF viewer's Range requests keep working
A webhook receiver / Flows Signed R2 link in the payload 7 days; this submission's files only
An MCP agent Signed link in get_submission 1 hour
Mailbox, sending Bytes over the binding (/internal/read) Only files this sender staged, under a mailbox they can send from
Content-engine, publishing Signed link from /internal/resolve 6 hours (Meta fetches video late)

Where bytes live is the row's storage column (public / private), and every reader follows it. During the migration a non-public row can still say public (bytes not moved yet); it is then served from the public bucket through the same checks, and never given its CDN URL.

Tokens: v2 capability tokens (packages/core-media/src/media.capability.ts) bind tenant and aclRefId (mailbox / submission); each service signs with its own key (mailbox MEDIA_CAPABILITY_SECRET, forms MEDIA_CAPABILITY_SECRET_FORMS on media). v1 tokens are still accepted until the lock-down.

Cleanup: a MediaJanitor Durable Object alarm (every 6 h, armed on the first request after deploy) removes uploads never completed (1 day), archived non-public files (30 days after archiving) and mail attachments never sent (30 days). Bytes are deleted before rows; a failed byte delete keeps the row for the next run. Public files are never auto-deleted (their URLs live in published content).

4. Commits

Commit Phase What
120d516e 1 storage column + migration; v2 tokens; tenant-scoped uploader rule
c6ab8a0f 2 Media: private bucket, signed links, /internal/read + /release, janitor, upload hygiene, tests (228)
69b5ff3c 4 Forms: delegated uploads, no stored link, 7-day/1-hour links, file-link route, forged refs refused, release on delete (227 tests)
e0574719 5 Mailbox: read over binding, 422/503, real filenames, release, cid allowlist, strict-access tests (795)
a0437d64 3 Dashboard: open form files by short-lived link; name expired attachments (706 tests, next build ok)
499cc307 6 Content-engine: 6-hour links (293 tests)
037b818a 7-8 Migration scripts (forms, mailbox) and the lock-down SQL + script
367a990e — Forms: constant-time gateway key
e3037eb3 — This document; vision.md brought up to date
6b4912b7 review Fixes from the independent branch review (§7, "Review fixes")

5. Phase 0 facts (read-only checks, 2026-10-12)

  • The worker's bucket logicspike-media-local is served at pub-a2acd43000854b76860fa1b2f86aa406.r2.dev. The older bucket logicspike-media is also public (pub-7855…r2.dev) but no row points into it (all 30 keys: 200 on the new host, 404 on the old).
  • media.vlozi.app has no DNS: only a dashboard fallback name, not an entry point.
  • Production has 30 media rows: 26 public (blog 15, content 6, collections 4, newsletter 1) and 4 form uploads (tenant). No mailbox, avatar or logo rows.
  • The 4 form files were publicly downloadable (HTTP 200 on their r2.dev links).
  • The R2 S3 credentials in apps/media-service/.dev.vars get 400 from R2: they are not the production ones.

6. Rollout runbook (founder)

Run from the repo root in PowerShell, with $env:CLOUDFLARE_ACCOUNT_ID="72fe41e9f39ad60cfb6937bb26ab50c3" set (wrangler caches a stale account otherwise). Each step is safe to stop after; the order matters.

1. Private bucket + CORS.

npx wrangler r2 bucket create vlozi-media-private
npx wrangler r2 bucket cors list logicspike-media-local     # copy these rules
# Write them to cors.json with https://go.vlozi.app among the origins (PUT, GET, HEAD;
# headers Content-Type, Content-Length), then:
npx wrangler r2 bucket cors set vlozi-media-private --file cors.json

Leave public access off (no r2.dev URL, no custom domain). In the dashboard, check the R2 API token used by media (R2_ACCESS_KEY_ID) can read and write both buckets; presigned links fail otherwise.

2. Secrets. Two new values: the forms capability key (media + forms) and a new shared INTERNAL_SERVICE_TOKEN. The token is needed because, on 2026-10-12, mailbox and content-engine had no INTERNAL_SERVICE_TOKEN secret at all (G21, G22): mail attachments were unavailable and content-engine's media resolve was refused. Nobody holds a copy of the old value (secrets can't be read back), so all five holders get a new one. Only media checks it; forms, collections, content-engine and mailbox send it. Use secret bulk with files written by node (PowerShell's Set-Content adds a BOM, and piping into secret put corrupts it). See the step-by-step guide given to the founder for the exact commands; in short: write media.json (MEDIA_CAPABILITY_SECRET_FORMS, INTERNAL_SERVICE_TOKEN), forms.json (MEDIA_CAPABILITY_SECRET, INTERNAL_SERVICE_TOKEN) and token.json (INTERNAL_SERVICE_TOKEN) to %TEMP%\vlozi-keys; secret bulk media, forms, collections and content-engine back to back (form and collections uploads fail for the seconds in between); keep token.json for mailbox in step 5; delete the folder after step 5. Forms' key must not equal mailbox's MEDIA_CAPABILITY_SECRET.

3. Core migration (before the media deploy: the new worker selects storage).

node packages/core-database/scripts/apply-media-storage.mjs           # dry run
node packages/core-database/scripts/apply-media-storage.mjs --apply
npx tsx scripts/check-prod-schema.ts --app core

4. Deploy media. cd apps/media-service; npx wrangler deploy --minify. Then check:

  • npx wrangler deployments list shows the new version; note the previous one (rollback target);
  • the workers.dev URL no longer answers (workers_dev = false);
  • the janitor runs: npx wrangler tail logicspike-media shows [janitor] done {…} about a minute after the first request (its counts say what it removed). /internal/janitor/{status,run} exist for service-binding callers only. The first run deletes uploads pending over a day and archived non-public files archived over 30 days ago; that is the cron that never ran.

Old forms and old mailbox keep working against new media (old forms still gets a non-empty fileUrl; old mailbox gets same-origin signed links).

5. Deploy forms, mailbox, content-engine, and mcp-gateway (mcp-gateway carries the updated forms tool descriptions). Note each previous version. The old dashboard keeps working against them: it opens existing form files through the content route; only files uploaded between this step and step 6 won't open until the dashboard is pushed.

6. Push the dashboard (Vercel deploys on push). Not before step 5: the new drawer opens form files only through forms' new file-link route, which the old forms worker doesn't have, so every form file would show "can't be opened" until forms is deployed.

7. Data.

node apps/forms-service/scripts/migrate-form-media.mjs                   # dry run: read the report
node apps/forms-service/scripts/migrate-form-media.mjs --apply --strip-urls
node apps/mailbox/scripts/migrate-mailbox-media.mjs                      # expected: nothing to do
node apps/media-service/scripts/move-private-objects.mjs                 # dry run: lists the 4 form files
node apps/media-service/scripts/move-private-objects.mjs --apply

The move copies, verifies byte-for-byte, flips storage, deletes the public copy and checks the old r2.dev link answers 404. Re-run it to finish anything it reports.

8. Live checks.

  • An old r2.dev link to a form CV → 404.
  • The file opens from the submission in the dashboard.
  • A test webhook delivery carries a signed link dated 7 days out.
  • A mail sent with an attachment arrives with its real filename (test mail only to the founder's own Gmail).
  • A Library upload (Workspace visibility) works (exercises the private bucket's CORS).

9. Lock-down (after a quiet week).

node packages/core-database/scripts/apply-media-storage-lockdown.mjs     # refuses while any row breaks the rule
node packages/core-database/scripts/apply-media-storage-lockdown.mjs --apply

Then in code: mediaFiles.storage .default("private") in packages/core-database/src/schema.ts; drop v1 token parsing in media.capability.ts; run the schema check.

Rollback. Workers: npx wrangler rollback <version> per worker. Data: until a file's public copy is deleted, setting its storage back to public restores the old path; after the move, the private copy is the only one (it is not lost, just not public). The schema changes are additive.

7. Gap log (found, not fixed in this round)

ID Gap Why not now Owner
G1 One shared INTERNAL_SERVICE_TOKEN across forms, mailbox, content-engine, collections and media; any of them can call any /internal/* route as any service. Interim: remove the unused media bindings from flows-service and mcp-gateway. A secrets rotation across five workers founder
G2 No storage quota, although storage_mb and "Extra Storage" are sold. Product decision founder
G3 Content-engine accepts any workspace file id in a slot's mediaIds (a colleague's workspace file can be published). Private files are already refused. Needs a product rule for shared files build
G4 Collections uploads bypass media permissions. Separate module build
G5 GATEWAY_SECRET is one secret for every service behind the gateway. Same as G1 founder
G6 The public bucket is served on r2.dev, which Cloudflare rate-limits and advises against for production. A custom domain needs DNS, and changes every public URL already published. DNS + URL migration founder
G7 Large private downloads go through the Vercel proxy (/api/media), which has response limits. Follow-up: a 302 to a presigned link for big files. Not hit yet build
G8 Deleting a workspace doesn't purge its media. Part of the data-lifecycle work build
G9 A draft reloaded later can't preview its staged attachment (no preview link any more). UI work; the file still sends build
G10 A 7-day webhook link can't be revoked before it expires except by rotating the R2 key. Inherent to presigned links founder (accepted)
G11 Mailbox: forward drafts survive a mailbox removal; deactivating a mailbox drops no drafts. Mailbox scope build
G12 A Neon password is hardcoded in scripts/run-migration.mjs and in git history: rotate it. Secret rotation founder
G13 ✅ Done 2026-10-12: the old public bucket logicspike-media (5 orphaned objects, nothing pointing at it) was emptied, made private and deleted; its host removed from next.config.ts. — —
G14 ✅ Done 2026-10-12: scripts/set-r2-cors.ps1 (old bucket, old origins) deleted; use wrangler r2 bucket cors. — —
G15 Form files of a deleted submission are released best-effort; if media is down at that moment the file stays (filed under a submission that no longer exists, so nothing can open it). A sweep needs the forms DB. Rare; needs cross-DB job build
G16 ✅ Done 2026-10-12: migrate-form-media.mjs filed the 4 legacy form files under their submissions. — —
G17 ✅ Done 2026-10-12: lock-down applied (default private, CHECK validated), schema.ts default private, v1 tokens retired (media 9e941d68). — —
G18 A sent mail's attachment staged more than 30 days ago is gone (janitor); sending answers 422 and the composer asks to re-attach. By design (D: 30-day staging) —
G19 move-private-objects.mjs has no automated tests (it shells out to wrangler against live buckets). Mitigations: dry run first, byte-for-byte verify before flipping a row, the flip checks the row still exists, every step is safe to re-run, and it moves 4 objects. Test harness would need an R2 fake behind wrangler build (accepted)
G20 The SSRF guard checks hostnames and IP literals, not where a hostname resolves: a public name pointing at a private address passes (DNS rebinding). Workers egress can't reach most private ranges anyway. Needs a resolve-then-connect fetch build
G21 Production mailbox (logicspike-mailbox) has the media binding but no INTERNAL_SERVICE_TOKEN secret (checked 2026-10-12): attachment uploads answer 501 "not available", which is why media has no mailbox rows. Fixed by rollout step 2/5 (new token) founder
G22 Production content-engine has no INTERNAL_SERVICE_TOKEN secret: it sends an empty token, media refuses resolve (403), so posts with media can't get their image/video URLs. Fixed by rollout step 2 (new token, set 2026-10-12) founder
G24 ✅ Done 2026-10-12 (aed0d6ec, media 5b619234, rollback 9e941d68): the Media library had no visibility choice (every upload Workspace, nothing changeable later). Now: upload zone has a Workspace / Only me / Public switch whose prompt states the consequence, starting at Workspace on every visit (not remembered); the detail panel has the same switch (→Public asks first; Public→ anything warns that the public link breaks now); cards mark 🌐 public and 🔒 Only-me files. Rules (canChangeMediaVisibility in core-media, mirrored in the dashboard only to grey options out with a reason): Media library uploads only (Blog/Social/Newsletter/Collections files show "Set by …"; form and mail files stay service-managed; avatars have no workspace), live (archived → "Restore it"), same workspace; the uploader may pick any of the three, an admin (media:files.admin or owner) may switch a colleague's file Public ↔ Workspace but never into or out of Only me. Route PATCH /files/:id/visibility: Workspace ↔ Only me is one update (both in the private bucket); crossing public moves the bytes: copy with metadata → switch visibility+storage only if unchanged (409 CHANGED otherwise, copy removed) → delete the old copy, or switch back and drop the new copy (502 MOVE_FAILED; if even the switch-back fails both copies are kept so the row never points at nothing). Each change logs media.visibility_changed. No migration. — —
G25 No bulk visibility change: the switch is per file. Not asked for; per-file first (founder's design 2026-10-12) build
G26 A public Library file can't be picked from the Blog/Newsletter/Social editors' media pickers; those editors upload their own public copy. Separate picker work build
G27 Who made a file public is only a media.visibility_changed log line (Workers logs, short retention), not a record in the app. Belongs with the gateway audit log (RBAC Phase 5). Needs the audit-log work build
G28 Dashboard test notification-bell.test.tsx › useAttention fails under the full suite's load and passes alone (6/6): timing-flaky, unrelated to media, seen 2026-10-12. Not this change build
G23 The public bucket logicspike-media-local holds 57 objects but media_files tracks 30 (dashboard, 2026-10-12): 27 objects no row points to (abandoned uploads, or rows deleted without their bytes before this branch). They stay publicly readable by key, and the move script only moves tracked rows. Listed 2026-10-12 (Cloudflare API, read-only): after the move the bucket has 53 objects, 26 tracked, 27 untracked (15 blog/, 8 content/, 3 media/ Library uploads, none forms/ or mailbox/); the old bucket logicspike-media holds 5 more, all media/. All 32 belong to 11 workspaces that no longer exist (gap G8: workspace deletion leaves media bytes). ✅ Deleted 2026-10-12 with the founder's go-ahead (re-verified at deletion: untracked and workspace gone); the public bucket now holds exactly the 26 tracked files. — —

Review fixes (independent review of the branch, 2026-10-12)

Finding Fix
Dashboard pushed before forms → no form file opens Runbook order: forms before the dashboard (step 5/6)
Release not tied to the submission (deleting B could delete A's file named in B's data) /internal/release requires aclRefIds; forms sends the deleted submission ids, mailbox the mailboxes it read under
Move script: a row deleted mid-copy left an untracked private copy The flip uses RETURNING; a vanished row's private copy is deleted and reported
Form migration archived the files of a submission still being written Orphans must be over an hour old
5-minute viewer token broke PDF/video seeking View token is 1 hour (still bound to the viewer's session, workspace, submission)
SSRF: localhost., [::127.0.0.1], [2002:7f00:1::], multicast passed Trailing dot stripped; ::/96, 2002::/16, ff00::/8, 2001:db8::/32 refused
A forged old form reference to a Library file still got a link Resolve with aclRefIds: a workspace file resolves only if that service uploaded it
v1 tokens accepted for forms v1 only from mailbox (removed at the lock-down)
Logo/avatar saved a URL when /complete failed (now cleaned up after a day) Those routes fail with 502 instead
Restore could revive a pending upload or a released file Restore only changes archived rows (409 otherwise)
Media Service