v0.65.0 — Client-Side Image Ingestion
Alpha's API container was OOM-killed on 2026-08-22 while one user added three pieces of
artwork to one character. The container was small, but that is not why three images killed
it: those three were fetched, decoded, resized and re-encoded on the server, by a
client sitting right there with an OffscreenCanvas, a GPU, and a working direct-to-S3
upload path it already uses for every other kind of image. This release closes the one
transport left — "I have a URL" — and bounds the server paths that legitimately remain.
Spec: 18-client-side-image-ingestion — read § The delta, stated plainly first, then § Why it was built that way for why CORS is not the obstacle it was. Tasks are requirements.md, one commit each, in order.
Tasks 2 and 5 are the ones that actually stop the bleeding and depend on no UI decision. If this release is ever cut short, they are the part to keep.
Stop server memory scaling with image size
- [x] 2a. Stream
/api/image-proxyinstead of buffering and base64-encoding it. Pipe the upstream body through, preservingContent-Type; reject onContent-Lengthbefore reading, and count bytes past the cap when the upstream sends none. Keep the SSRF blocklist and the auth requirement exactly as they are — they are why this endpoint is safe to widen use of.proxyExternalImagekeeps its signature and builds ablob:URL, so neither existing caller changes;proxyExternalImageBlobis added alongside it for task 3a. (Done.arrayBuffer()→Buffer.from→ base64 →{ dataUrl }is replaced bypipeline(Readable.fromWeb(response.body), capBytes(50MB), res). Server memory for this endpoint is now O(chunk) rather than O(file), and the wire payload loses base64's 33%. The size check moving is the substantive half, not the piping. The old check ran after the buffer existed, which meant the 50 MB limit protected storage and not memory — the thing it was there to protect. It now runs onContent-Lengthbefore a byte is touched, and the test asserts that as a negative: an oversized upstream is refused with itsbodygetter never invoked.*capBytesexists because "noContent-Length" must not mean "no limit". A chunked upstream is counted as it flows and the stream is failed the moment it passes the cap. Headers are already sent by then, so there is no status left to send and the socket is destroyed instead — deliberately, because a truncated image arriving as a clean 200 would be uploaded as a corrupt one. The client'sfetchrejects, and task 3a's fallback takes the server path (principle 6). §9.1's cheap check came back clean. Nothing persists what the proxy returns: both callers hold it in component state and feed it totokenizeImage/adjustTransform, which return their own canvas-produced data URL.blob:is also already a supported input there —loadImageForProcessinghas converted blob URLs since before this release — so neither caller changed at all.* The route's own doc comment namesarrayBuffer(), base64 anddataUrlin explaining what it stopped doing, so the guard asserting their absence has to strip comments first. Third release running that this trap has been hit; it is now written down in the test.) - [x] 2b. Bound the server-side upload path that legitimately remains.
Content-Lengthchecked against the 25 MB / 50 MB caps beforearrayBuffer(), one global concurrency lane around the decode/resize section shared byupload,upload-fileandupload-from-library, and sharp's ownconcurrency(1)+limitInputPixelsso a decompression bomb is a 400 rather than an OOM. (Done. All three, plus the pieces each one turned out to need. The per-file cap bounds one image; the gate bounds how many can be decoded at once, which is the quantity that actually killed the container. The gate went in one level below where the task describes it. "Around the decode/resize section shared by the three routes" reads as three call sites, but all three reach sharp throughstorage.upload→processImageWithLOD, and so does the library import worker. WrappingprocessImageWithLODmakes the lane genuinely process-wide instead of three wrappers to keep in step and every future caller to remember.* Which meant the kind of work had to arrive there without being threaded throughstorage.uploadand three providers, so it rides anAsyncLocalStoragetag set at the route and worker boundary. §9.4's "one lane, with library work at a lower effective concurrency inside it" is then literal: bulk work takes a library permit and a global one, so at most one bulk item is ever decoding and the rest of the lane stays available to someone who is clicking. The import worker decodes outside the storage pipeline, with its ownsharp(imageBuffer)for dimensions and the thumbnail — the full-size decode, on the path the task names as the reason to have a gate at all. It takes the gate and the ceiling explicitly; gating onlyprocessImageWithLODwould have left the 700-file case exactly as unbounded as before.*limitInputPixelsis a per-construction option with no global setter, so it is applied in onesharpInputhelper and a guard asserts no baresharp(buffer)is left. 50 MP by default — ~8660×5773, above any consumer camera and far below anything that could be an accident. sharp's refusal becomesImageTooLargeError, which all three handlers map to a 400;validateImagehad to learn it too, or its catch-all would have relabelled a legible image as corrupt.Content-Lengthis checked beforearrayBuffer()and the old post-hoc check stays, because an upstream that declares no length is exactly the case the header cannot cover.)
Route external URLs through the client
- [x] 3a. Make
uploadImagetake the client path forhttp(s)sources. Proxy the bytes to the browser, then re-enter the existingFile | Blobpath, which already tries presigned and generates LODs client-side. Falls back to today'sPOST /api/images/uploadon any proxy failure. One change covers Google image search picks, "Add by URL" and encounter art auto-select — all three reach the server through this exact branch. (Done, and it really is the one branch:typeof source === 'string'with anhttp(s)value now fetches the bytes through the proxy, wraps them in aFileand recurses into the path a dropped file already takes. The transport test asserts the negative that matters — one proxy call, one presigned PUT, zero server uploads. Switching transport is not licence to change what gets stored. The server used to resize these to the category maximum and re-encode them as WebP on the way past. Re-entering theFilepath as written would have quietly started storing the original 6000×4000 JPEG instead, so the proxied blob goes throughcompressFileForUploadfirst — which is principle 1 stated literally, the client doing the pixel work, rather than nobody doing it. Best-effort: if canvas encoding fails the original bytes upload rather than the upload failing.* Video needs no special case and gets one anyway, for free: the proxy refuses a non-image content type, so a video URL throws and lands on the server path — which is correct, since the presigned path is WebP-only. Our own served images are deliberately left behind. The proxy's SSRF blocklist refuses a localhost API origin, so routing them through it would fail on every dev machine and fall back anyway. 3b deletes that round trip rather than relocating it, and a test pins the exclusion so it is a decision rather than an oversight.* One trap re-armed: v0.42.0'suploadImageguard slices the function body from the first/api/images/upload`it finds, so writing that path in backticks inside a new comment moved the slice and failed an assertion about code fifty lines below.) - [x] 3b. Stop round-tripping images the server already stores. An
isServedImagePathsource makes the server HTTP-fetch its own S3 object back and rebuild every variant to produce a copy. Point the new record at the existingstoragePathinstead — the bytes are identical and both records are owned by the same user. (Done, by the preferred route: nothing is copied and nothing is fetched.describeStoredImagemints the record from the URL and returns, with zero network calls on the relative path. The task as written would have been a no-op in production. It is framed onisServedImagePath, which only matches the relative/images/…form — and only the local storage provider produces that shape. On S3, every stored image URL is absolute and indistinguishable in the browser from any third-party image, so the round trip the incident report describes would have survived untouched on alpha and prod. Fixing the case the spec names without fixing the case it is about seemed worse than adding the one thing needed to tell them apart:GET /api/images/storage-basereturns the prefix the deployment serves from, probed once per session and cached exactly likepresignedSupported.* A failed probe ingests rather than assumes. Minting a record that points at a URL we may not own is the one failure here that would be silent and permanent, so the safe answer is the slow one. Dimensions had to be recovered, not dropped. The server used to returnwidth/heightfrom the copy it built, anduploadArtworkwrites them into the artwork record. An<img>reportsnaturalWidthwithout CORS — only reading pixels needs it — so one cached image load replaces a full server-side decode.* The cache-busting?t=…thatTokenModaland encounter art stamp on stored URLs is stripped before the path is read, or one object would have become two on every re-save.storagePath,mimeTypeandsizeare derived rather than looked up: there is no image table to look them up in, and no client code reads them —idandurlare what callers use, and both are exact.) - [x] 3c. Give map import the presigned path.
mapImport.tsposts aFilestraight to/api/images/upload-file, bypassinguploadImage— so the flow most likely to carry a very large image is the one that guarantees the server holds it. (Done. The hand-rolledFormDatapost isuploadImage(file, { category: 'map', campaignId, skipResize: true }), which tries presigned first and falls back to the identical multipart call on local storage — the fallback is asserted, since "nothing regresses there" is the claim that makes this safe.uploadImagetakes a token wheremapImporthad anauthFetch, so the option is added andMapListsupplies it. The upload leg no longer travels on the injected fetch wrapper at all, which the test pins directly: it is the point of the task, not an incidental. It exposed an unhandled rejection that predates it.tryPresignedUploadstartsgenerateLODsbefore the presigned request and abandons that promise when the request 400s — which is every image upload on a local-storage backend. A LOD failure there had nobody left to catch it. It now degrades to no variants rather than rejecting: losing an optimisation should cost the optimisation, not push the upload back onto the server path this release exists to stop using.)*
genAI: skip the server when the key is already in the browser
- [x] 4a. Spike: confirm a real browser-origin call succeeds. Preflights pass for all
three providers; a green preflight is not a green call. One real generation from a
browser against Gemini and against
gpt-image-1, with a user key. If either fails, that provider keeps proxying and this task records why. (Answered: both providers 200 OK from a browser origin, with a user key. Gemini and OpenAI each accepted an authenticated cross-origin call and let the response be read — no CORS block, no auth-layer rejection of the browser origin. That is the question §9 raised, and it is green, so 4b is worth writing. Run by the user againstapi.openai.comandgenerativelanguage.googleapis.comwith the authenticated-GET tier. The paid tier — one real generation per provider — was not run, so the residual unknown is whether the generation route carries a different policy than the listing route on the same host. 4b's fallback to the server route is what makes that a bounded risk rather than a bet: a provider that rejects a direct call degrades to today's behaviour instead of failing. One thing the spike changed about 4b before it was written. The server-side Gemini adapter passes the key as a?key=query parameter. The spike usedx-goog-api-keyand it works, so the browser path uses the header — a key in a URL is a key in referrers, history and logs, and moving this call to the browser is exactly the moment not to carry that over.* The harness is committed: spike-4a.js, pasted into the devtools console with the app open. It reads the keys from the app's own store, never prints them, and sends each only to the provider it belongs to. Two tiers, because they answer slightly different questions and only one of them costs money.await spike4a()makes authenticated GETs, which proves the browser can reach the provider authenticated and read the response — that is the auth-layer question §9 raises.await spike4a({ paid: true })additionally makes one real image generation per provider, because a generation route can carry a different policy than a listing route. Grok was not tested and does not need to be: §9's table says 4a is worth nothing for it, because its bytes cross on the upload leg, which task 3a already fixed.) - [x] 4b. Direct-to-provider generation and extend, in the genai client bundle.
(Done, across both repos:
genai0.6.0 (committed and tagged, not yet published) and the platform contract that makes it possible, MODULE_API_VERSION 1.26.0. The adapters moved rather than being copied.src/server/providers/is nowsrc/providers/, imported by both entries, so no provider knowledge enters the platform and there is no second copy to drift. Two things had to change for "runs in a browser" to be true rather than intended, and nothing had pinned either: Gemini authenticated with?key=in the URL — a key in a URL is a key in referrers, history and logs — and OpenAI's extend decoded base64 withBuffer. Nowx-goog-api-keyandatob, both tested.* The platform had to answer a question it had never been asked. The rule is about the key, and only the platform knows whose key a request would spend — the module receives anauthFetchthat injects the header, and cannot see inside it.getImageGenKey(provider)is that answer, on both artwork props, optional so a host older than 1.26.0 simply keeps proxying. It hands over the key rather than a wrapped fetch deliberately: each provider authenticates differently, and choosing between those is provider knowledge that belongs in the module. Nothing is widened — the module bundle runs in the page and could already read the same store — but the dependency is now visible instead of ambient.*/extendwas the case that most needed this and the one the spec calls already broken: it sends the source up as a base64 data URL and takes another back, past anexpress.jsonlimit of 10 MB that an 8 MB source exceeds once encoded. Direct removes the ceiling instead of raising it. Everything falls back to the server route, because 4a's green listing call is not a green generation call. The single exception is cancellation: anAbortErroris re-thrown, not retried, since restarting a generation the user cancelled — and paying for it — is the opposite of what they asked for. Shipped as 0.6.1, not 0.6.0. 0.6.0's publish failed: it readgetImageGenKeystraight off the platform prop type, and genai's CI resolves@purple-cat/sharedby checking out the platform's default branch — where 1.26.0 does not exist yet. A module cannot publish until the platform release merges, and the release flow publishes modules first, so that is a deadlock rather than a bug in either repo. 0.6.1 declares the prop locally (WithImageGenKey<P>), which is the same statement its optionality already makes, and a guard pins the general rule: a prop from an unreleased MODULE_API_VERSION is read through a local declaration, never off the imported type. Verified by compiling genai against a checkout of platformmain.*modules.hosted.jsonandmodules.dev.jsonmove 0.5.2 → 0.6.1 now that it is published, which also finally delivers the unreleased 0.5.3 and 0.5.4 work — including v0.60.0'soutput_format: 'webp', which had never reached a hosted environment.) The rule is about the key, not the provider: direct whenever a user key is in play, proxy whenever we would fall back toprocess.env. Covers/extendas well as/generate— extend is the worse case and already 413s on a large source. - [x] 4c. Leave the server route intact. It remains for server-key deployments and as
the fallback. The goal is that no client-driven flow chooses it, not that it stops
existing.
(Holds by construction and is tested.
POST /api/modules/genai/generateis untouched — 4b would have added a path around it, not replaced it — and its image siblingPOST /api/images/uploadis asserted unreachable from every client-driven flow by 6a's matrix while remaining the fallback that 3a's proxy-failure test exercises. Nothing here deprecates either.)
Make the next one visible before it happens
- [x] 5a. See memory before the kill, not after. Container Insights is off on both
non-prod clusters, so an OOM is only legible from the stopped task's
stoppedReason. Enable it for alpha and dev, or emit a periodicprocess.memoryUsage()line, plus an alarm on exit code 137 routed to the existing Slack failure notifier. (Done as Terraform, not applied — the non-prod root is applied by hand and this release does not touch it. Container Insights goes toenabledon the sharedapp-envmodule, which is alpha and dev and nothing else; prod is a different root and is out of scope by §7. Two signals, because they answer different questions. The alarm fires while memory is merely high, over two five-minute periods so a single decode spike does not page anyone — that is the one that can be acted on before anything dies. The EventBridge rule fires on a task stopped withOutOfMemoryError, which is not a warning but a receipt; it is the difference between learning about a kill now and learning about it when a user complains. Its raw event is a wall of ARNs, so an input transformer reduces it to cluster, time and reason. "Routed to the existing Slack failure notifier" could not be done as written. That notifier is a GitHub Action driven by a webhook secret, and CloudWatch cannot call it — a Slack incoming webhook is not an SNS-compatible subscriber. Bridging needs AWS Chatbot (authorised against the workspace by hand, outside Terraform) or a forwarding Lambda. The SNS topic is created and both signals publish to it, so either attaches without touching the file, andalarm_emailgives a working subscription today. Recorded here rather than quietly substituted.)*
Regression coverage
- [x] 6a. A transport test that fails if the client stops participating. Assert per
entry point which transport is chosen. The value is the guard, not the assertions:
the delta this whole project fixes was one
typeof source === 'string'branch quietly not adopting a path everything else uses, and without a test naming the transport it will drift back. (Done, as a table: eleven entry points, each named with the transport it must take, classified from the calls that were actually made rather than from what the code says.server-fetchis a value the classifier can return — the regression has a name in the enum, so reverting any of §3 fails with the transport it drifted to rather than with a bare count. Both deployment shapes are covered, and the local-storage row is the one that says something the S3 rows cannot: no S3 removes the presigned PUT, not the client's participation. The bytes still reach the browser and the server still never decodes them — it only stores what it is handed. The Library row states the converse, that a source on the server's disk correctly has no client to hand it to. One assertion runs under every row: no client-driven flow posts to/api/images/upload. That is principle 4 as a test — the endpoint is not deprecated, it just stops being what a browser reaches for.)
Playtest
- [x] The character sheet's Artwork modal never showed module sources. Found while
playtesting this release: genAI is offered by Edit Token but not by the newer Artwork
modal, so the direct-to-provider path 4b adds was unreachable from the primary
artwork surface.
(Done, and it is older than this release — nothing here caused it. The nav type has
carried
module:${id}since v0.47.0's task 1d.1 andSourceOptionPanehas had a branch for it, but nothing ever enumeratedmoduleRegistry.getArtworkSources(), so the branch was unreachable and the branch's whole content was the sentence "This source is provided by a module that is not available here yet." The contract test is why it lasted. It asserted/Modules may add more sources/— the comment promising the feature, which is exactly as good as asserting nothing. It now asserts the enumeration, the ordering, and that the option pane renders the component; the comment is not evidence.* Two props are the easy half to get wrong, so both are pinned.authFetchcomes fromuseImageGenAuthFetch, notuseAuthFetch— without it a user-level provider (Gemini and Grok in prod, where there is no server env var) is absent from the model list and the source renders offering nothing. AndgetImageGenKeyis passed, or this surface would silently proxy where Edit Token goes direct. A module source hands back an image URL, which is whatresolvealready takes, so it joins the same single upload path as every built-in — no deferredblob:reaches the database and the artwork is minted once, inuploadArtwork. The modal header reads the module's own label rather than falling back to "Add Source Art", and amodule:option whose module is not loaded now says so instead of claiming the feature does not exist.)