Skip to content

Host plan images in Chopin - #441

Merged
lavaman131 merged 2 commits into
mainfrom
feat/hosted-plan-images
Oct 9, 2026
Merged

lavaman131 merged 2 commits into
mainfrom
feat/hosted-plan-images

Conversation

@lavaman131

@lavaman131 lavaman131 commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

A Planner or MCP client can now put a generated picture into a Chopin document. Until now the dialect accepted only absolute https: images and nothing could upload one, so a picture drawn for a plan (for example by pdd-kit's plan_image) could only appear as alt text.

What changes

  • upload_image MCP tool: { id, data (base64), mimeType } returns { path: "/images/<sha256>.<ext>", markdown }.
    • Only callers who may update the document can upload; archived and deleting documents are refused.
    • PNG, JPEG, WebP and GIF only, at most 1 MiB, and the file signature must match the declared type. Never SVG.
    • Stored under the SHA-256 of the bytes, so uploading the same bytes again returns the same path.
    • Refusals: document-unavailable, repository-forbidden, document-archived, too-large, unsupported-type, signature-mismatch.
  • GET /images/:file: serves the image only to a signed-in user with pull access to a document it was uploaded to.
    • Everyone else, unknown hashes, and an extension that doesn't match the stored type get the same no-store 404.
    • Responses send content-type, nosniff, private, max-age=31536000, immutable, and content-security-policy: default-src 'none'; sandbox.
  • Storage: migration 018_plan_images (keyed (channel_id, sha256), deleted with its document), plus Postgres and in-memory stores that share one contract test.
  • Dialect and editor: images may use /images/<64 hex>.<png|jpg|jpeg|webp|gif> as well as absolute https: URLs. Every other relative image is still rejected, and these images count toward the image limit. The editor keeps such images and lets you edit their alt text.
  • MCP body limit: raised to fit a 1 MiB image as base64 plus envelope. Plan size limits are unchanged.
  • Docs: upload, access and storage are documented, with the tool added wherever the document tools are listed.

End-to-end evidence (real run)

e2e/hosted-images.e2e.ts runs against the real built server, the e2e Postgres containers, and Chromium. It uses a real picture drawn by gpt-image-2.5-sunburst, 1536×1024 WebP, 50,812 bytes.

$ bun run e2e -- e2e/hosted-images.e2e.ts --project chromium --trace on
  ✓  1 [chromium] › e2e/hosted-images.e2e.ts:57:0 › an uploaded picture renders for a reader of its document and nobody else (1.8s)
  1 passed (11.3s)

The screenshot attached below is the document as the author sees it after update_document placed the hosted path. In the same run the browser loaded the image with status 200 and naturalWidth 1536.

HTTP trace from the run (written by the test; no tokens or cookies)

Hosted image requests

1. Create the document

  • POST /mcp tools/call create_document as author (push) (octo-org/score) -> HTTP 200
    • isError: false
    • structuredContent: {"id":"ceac76c4-d098-5e14-b771-fbffe19cd0ed","title":"Hosted images 3ab45d24","brief":{"goal":"Show the refund cap","constraints":[],"settledDecisions":[],"openQuestions":[],"repositoryFindings":[]},"source":"# Refund cap\n\nRefunds stop at the cap.\n","revision":0,"url":"/documents/octo-org/score/hosted-images-3ab45d24"}

2. Upload the picture twice

  • POST /mcp tools/call upload_image as author (push) (50812 bytes declared image/webp) -> HTTP 200

    • isError: false
    • structuredContent: {"path":"/images/86f452e1abec59f59990fd05e2aa656c2e3b96a92dd6f10b3339925ddcb41fbd.webp","markdown":"![](/images/86f452e1abec59f59990fd05e2aa656c2e3b96a92dd6f10b3339925ddcb41fbd.webp)"}
  • POST /mcp tools/call upload_image as author (push) (50812 bytes declared image/webp) -> HTTP 200

    • isError: false
    • structuredContent: {"path":"/images/86f452e1abec59f59990fd05e2aa656c2e3b96a92dd6f10b3339925ddcb41fbd.webp","markdown":"![](/images/86f452e1abec59f59990fd05e2aa656c2e3b96a92dd6f10b3339925ddcb41fbd.webp)"}

3. Place the picture in the document

  • POST /mcp tools/call update_document as author (push) (revision 0) -> HTTP 200
    • isError: false
    • structuredContent: {"id":"ceac76c4-d098-5e14-b771-fbffe19cd0ed","title":"Hosted images 3ab45d24","brief":{"goal":"Show the refund cap","constraints":[],"settledDecisions":[],"openQuestions":[],"repositoryFindings":[]},"source":"# Refund cap\n\nRefunds stop at the cap.\n\n![The cap is a balance of 100.](/images/86f452e1abec59f59990fd05e2aa656c2e3b96a92dd6f10b3339925ddcb41fbd.webp)\n","revision":1,"url":"/documents/octo-org/score/hosted-images-3ab45d24"}

4. Open the document in the browser

  • GET /images/86f452e1abec59f59990fd05e2aa656c2e3b96a92dd6f10b3339925ddcb41fbd.webp as the author's browser loading the -> HTTP 200
    • content-type: image/webp
    • content-length: 50812
    • cache-control: private, max-age=31536000, immutable
    • content-security-policy: default-src 'none'; sandbox
    • content-disposition: inline
    • x-content-type-options: nosniff

5. Fetch the picture with the author's session

  • GET /images/86f452e1abec59f59990fd05e2aa656c2e3b96a92dd6f10b3339925ddcb41fbd.webp as author (browser session) -> HTTP 200
    • content-type: image/webp
    • content-length: 50812
    • cache-control: private, max-age=31536000, immutable
    • content-security-policy: default-src 'none'; sandbox
    • content-disposition: inline
    • x-content-type-options: nosniff
    • body: 50812 bytes, identical to the fixture: true

6. Fetch the picture without read access

  • GET /images/86f452e1abec59f59990fd05e2aa656c2e3b96a92dd6f10b3339925ddcb41fbd.webp as a signed-out client (no cookie) -> HTTP 404

    • content-type: text/plain; charset=utf-8
    • content-length: 15
    • cache-control: no-store
    • x-content-type-options: nosniff
    • body: "image not found"
  • GET /api/session as outsider (signed in, no access to octo-org/score) -> HTTP 200

    • content-type: application/json; charset=utf-8
    • content-length: 316
    • cache-control: no-store
    • x-content-type-options: nosniff
  • GET /images/86f452e1abec59f59990fd05e2aa656c2e3b96a92dd6f10b3339925ddcb41fbd.webp as outsider (signed in, no access to octo-org/score) -> HTTP 404

    • content-type: text/plain; charset=utf-8
    • content-length: 15
    • cache-control: no-store
    • x-content-type-options: nosniff
    • body: "image not found"
  • GET /images/0000000000000000000000000000000000000000000000000000000000000000.webp as author (browser session) -> HTTP 404

    • content-type: text/plain; charset=utf-8
    • content-length: 15
    • cache-control: no-store
    • x-content-type-options: nosniff
    • body: "image not found"

7. Refused uploads

  • POST /mcp tools/call upload_image as author (push) (71 bytes declared image/svg+xml) -> HTTP 200

    • isError: true
    • structuredContent: {"code":"unsupported-type"}
  • POST /mcp tools/call upload_image as author (push) (50812 bytes declared image/png) -> HTTP 200

    • isError: true
    • structuredContent: {"code":"signature-mismatch"}
  • POST /mcp tools/call upload_image as reader (pull, no push) (50812 bytes declared image/webp) -> HTTP 200

    • isError: true
    • structuredContent: {"code":"repository-forbidden"}

The full Playwright trace.zip (663 KB) from this run was not attached: gh --attach takes only images and video, and the trace contained the run's throwaway session cookies. It was deleted with the run's scratch files after merge; re-running the command above with --trace on produces a fresh one.

Verification

  • bun test on the MCP, storage, image route, dialect, editor URL, router and skills tests: 334 passed, 0 failed.
  • Postgres tests (TEST_DATABASE_URL=… bun test apps/server/src/storage/postgres) against a throwaway database: 80 passed.
  • Full bun test: 4424 passed, 4 skipped, 1 failed. The failure is apps/connector/src/workspace.test.ts, which also fails on main on this machine (global git signing config) and passes with GIT_CONFIG_GLOBAL=/dev/null.
  • E2E: the new test passed (1 of 1). Next to document-creation and mcp-update: 18 passed, 0 failed.
  • bun run types exits 0; bun run ci (dprint, oxlint, token and design checks) passes. oxlint findings are the same as on main.

Notes for review

  • The fake GitHub gains score-reader- (pull without push) and score-outsider- (can't see octo-org/score) handle prefixes for the e2e. Every other handle behaves as before.
  • The route is /images/:file matched by pattern, because the router matches whole segments.
  • For an image held by several documents, the route tries each repository until one grants pull. A GitHub outage returns 503.
  • The slash-menu "Insert image" stays https:-only, and the hosted Planner's prompt still says images must be absolute https: URLs (the Planner's tools were out of scope).

Assistant-model: Claude Opus 5.5 (fast)
Assistant-workflow: inline (implementation and e2e delegated to worker subagents on anthropic-api/claude-opus-5-5-fast)
Assistant-duration: 47m converged, estimated 45-90m
Assistant-verification: playwright e2e passed: bun run e2e -- e2e/hosted-images.e2e.ts (upload, idempotency, placement, browser render naturalWidth 1536, reader 200 with headers and identical bytes, signed-out/outsider/unknown 404, three refusals)
Assistant-verification: bun test passed: touched areas 334 pass; Postgres storage 80 pass
Assistant-verification: types and ci passed: bun run types; bun run ci
User-preference: Host plan images in Chopin, visible only to readers of the image's document, stored in Postgres, admin-merged
User-preference: Prove image upload with a real end-to-end scenario and attach its trace to the PR
User-preference: Use fast subagents for everything

The uploaded picture rendered in the Chopin document for its author (e2e run)

Add an upload_image MCP tool that stores a PNG, JPEG, WebP or GIF of at most
1 MiB for a document under its SHA-256, an authenticated GET /images route that
serves it only to readers of a document it belongs to, and dialect support for
the same-origin /images/<sha256>.<ext> path.
Upload a real WebP through MCP upload_image, place it with update_document,
and check that the signed-in author's browser renders it while a signed-out
client, a signed-in user without access, and an unknown hash all receive 404.
The fake GitHub gains score-reader- and score-outsider- handles for pull-only
and no access to octo-org/score.
@lavaman131
lavaman131 merged commit 3c87347 into main Oct 9, 2026
3 checks passed
@lavaman131
lavaman131 deleted the feat/hosted-plan-images branch October 9, 2026 22:09
@coolify-githubnext-app

Copy link
Copy Markdown

The preview deployment for chopin failed. 🔴

Open Build Logs | Open Application Logs

Last updated at: 2026-10-09 22:09:19 CET

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant