Images in page comments

Comments on pages can now carry screenshots. Paste, drop, or pick up to four images in any comment or reply; readers get inline thumbnails and a lightbox, and agents reading a thread get a fetchable URL for every image. Built, validated, and verified live in the dev app.

What a commenter gets

Comments panel showing a comment with a small image thumbnail
A thread whose first comment carries an image, served through the media host with a signed ticket.
Reply composer with typed text and an attached image preview ready to send
The reply composer after attaching a PNG through the picker — the eager upload finished, the chip is ready, send is enabled.
The posted reply rendering its image inline in the thread
The posted reply after a full app reload: the image comes back from the server, not from the local preview.

How it works

The storage layer had been built with this feature anticipated: attachments.parentKind already carried "comment", the usercontent Worker already served /files/<fileId> behind signed tickets, and the fileUrl helper's doc comment named comments as a consumer. Nothing minted file tickets until now — this change is that machinery's first producer.

1 · Record Composer asks the API to record the upload and hand back a presigned PUT. Declared type must be image/*, size ≤ 10 MB. pageComment.createImageUpload
2 · Upload Bytes go straight from the client to R2 — never through the API. The row stays pending; abandoned uploads are swept in a day. presigned PUT → R2
3 · Verify On create/reply the server checks the object exists at its declared size, then sniffs the leading bytes — the stored type is what the bytes are, never what the client claimed. Non-images are refused. HEAD + ranged GET + sniff
4 · Attach Attachment rows insert and the file flips pending → ready inside the comment's own transaction, so ready ⇔ attached — no state where a file is one but not the other. one transaction
5 · Serve Every list/create/reply response mints a signed file ticket per image; the media host serves the bytes with no database lookup. Expiry rounds to hour windows so URLs stay stable across refetches. ticket → media host

Allowed types, decided by sniffed bytes: PNG, JPEG, GIF, WebP, AVIF — everything Chromium renders in an <img>. Deliberately absent: SVG (a document with script) and HEIC (browsers can't decode it). Access to an image always derives from access to its comment's page; a file id alone is not authority to read it, and an upload belonging to someone else, or already parented elsewhere, is not attachable.

Cleanup

Deleting a thread — or the whole page — now detaches the comments' files and reaps the orphans, R2 bytes included, through shared helpers (detachAll / reapOrphanFiles). Rewriting page-delete onto those helpers also fixed a pre-existing leak: staged page assets were never cleaned up when a page was deleted between staging and publish.

New environment variable

MEDIA_URL threads the media host into the API (the Worker already had its own copy). Wired through all the places docs/environment-variables.md demands: env schema, both templates, turbo.jsonc, both deploy workflows, and the setup port-allocator for per-worktree dev values.

Where it lands, per surface

Desktop Full composer + rendering, in the pin popover and the All-comments panel (shared packages/ui).
Web viewer Same shared components; unauthenticated public views never receive comment data, so tickets never leak.
Mobile Renders attachments in comment sheets. Composer upload is a follow-up (the chat composer's picker is ready prior art).
CLI pages comments list prints an [image name] url line per attachment — an agent reading a bug report can fetch the screenshot.
MCP pages_comments_list returns the router shape verbatim, so agent callers inherit attachments with no changes.

Validation

Integration tests 8 pass against a real database: round trip with sniff correcting a lying declaration, reply + list serving tickets, HTML-behind-a-PNG-label refused, never-uploaded refused, foreign upload refused, double-attach refused, thread-delete reap, page-delete reap.
Unit tests green — schema refinements (image-only comments, dedupe, caps), plus updated fixtures across cloud-client and ui.
Typecheck 42/42 packages. attachments is a required field on the comment shape, so the compiler enumerated every construction site.
Lint · i18n clean — seven new strings hand-translated into all 16 non-English locales; check:i18n passes.
Live (CDP) verified in the real dev app: panel render through a genuine ticket, lightbox open/close, typed reply + picker attach + eager upload to ready, send, and the posted image re-served after a full app reload. Zero console errors.

One honesty note on the live run: local dev has no object store on R2_ENDPOINT, and the Worker's local R2 is a separate miniflare store — so the upload leg ran against a small S3 stub and the freshly-uploaded bytes were mirrored into the Worker's store by hand. Everything else — app, API, database, ticket signing, media serving — was the real stack. The presigned-upload leg against real R2 is covered by the integration suite and, in prod, is the same transport the shipped cloud-workspace attachment flow already uses.

Also fixed en route: a new client against an older API (comments without the field) would have crashed the comments UI — the read paths now tolerate its absence while the types stay strict.

Before this ships

1 · Set the repo secret

gh secret set MEDIA_URL — prod https://media.supersetusercontent.com. Workflows and env schema are wired; deploys fail env validation until the secret exists.

2 · One prod-desktop CSP check

The renderer PUTs presigned uploads directly to the R2 endpoint, and the desktop CSP's connect-src doesn't obviously admit that origin. The shipped cloud-workspace attachment flow (#7622/#7633) uses the identical renderer-side PUT — so either both work in prod or both are broken. Worth one manual check in a packaged build. (Dev quirk found on the way: the CSP admits 127.0.0.1 but not localhost.)

3 · Hands-on QA is one click away

The dev stack and the usercontent Worker are still running in two terminal panes of this workspace, with a seeded “Image Comments Verify” page under Pages — you can paste a screenshot into a reply right now.

Follow-ups worth their own branch