Status (2026-10-12): private bucket + CORS created; new secrets set (forms key on media/forms; new
INTERNAL_SERVICE_TOKENon media, forms, collections, content-engine, mailbox); core migration applied; workers deployed: mediaa5dd2f5e(rollbackfb32afd8), formsd3145564(061f57c2), mailbox4d65a9ee+ token (95b5ab06), content-engine01f4e334(8dd2d581), mcp-gateway1d2394c7(283345cd). Janitor armed and ran clean. Data done: the 4 form files moved tovlozi-media-private(r2.dev links 404; rows 26 public/public + 4 private),mainpushed (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 bucketlogicspike-mediaemptied, made private and deleted (G13),scripts/set-r2-cors.ps1removed (G14); lock-down applied (defaultprivate, CHECK validated) and v1 tokens retired, media redeployed9e941d68(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; media5b619234, rollback9e941d68; dashboard via themainpush). Rolling media back to a pre-branch version is no longer possible without first dropping the CHECK (old code doesn't setstorage). 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)
- 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. - Form files are Forms-only. Out of the Media library; opened from the submission by roles with
forms:read; storeddelegatedto forms under the submission id. - Expiring links for integrations. Webhooks and Flows events carry a 7-day signed link (the SigV4 maximum), MCP
get_submissiona 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-localis served atpub-a2acd43000854b76860fa1b2f86aa406.r2.dev. The older bucketlogicspike-mediais 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.apphas 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.varsget 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.jsonLeave 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 core4. Deploy media. cd apps/media-service; npx wrangler deploy --minify. Then check:
npx wrangler deployments listshows 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-mediashows[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 --applyThe 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 --applyThen 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) |