Skip to content

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-proxy instead of buffering and base64-encoding it. Pipe the upstream body through, preserving Content-Type; reject on Content-Length before 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. proxyExternalImage keeps its signature and builds a blob: URL, so neither existing caller changes; proxyExternalImageBlob is added alongside it for task 3a. (Done. arrayBuffer() → Buffer.from → base64 → { dataUrl } is replaced by pipeline(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 on Content-Length before a byte is touched, and the test asserts that as a negative: an oversized upstream is refused with its body getter never invoked.* capBytes exists because "no Content-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's fetch rejects, 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 to tokenizeImage / adjustTransform, which return their own canvas-produced data URL. blob: is also already a supported input there — loadImageForProcessing has converted blob URLs since before this release — so neither caller changed at all.* The route's own doc comment names arrayBuffer(), base64 and dataUrl in 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-Length checked against the 25 MB / 50 MB caps before arrayBuffer(), one global concurrency lane around the decode/resize section shared by upload, upload-file and upload-from-library, and sharp's own concurrency(1) + limitInputPixels so 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 through storage.upload → processImageWithLOD, and so does the library import worker. Wrapping processImageWithLOD makes 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 through storage.upload and three providers, so it rides an AsyncLocalStorage tag 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 own sharp(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 only processImageWithLOD would have left the 700-file case exactly as unbounded as before.* limitInputPixels is a per-construction option with no global setter, so it is applied in one sharpInput helper and a guard asserts no bare sharp(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 becomes ImageTooLargeError, which all three handlers map to a 400; validateImage had to learn it too, or its catch-all would have relabelled a legible image as corrupt. Content-Length is checked before arrayBuffer() 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 uploadImage take the client path for http(s) sources. Proxy the bytes to the browser, then re-enter the existing File | Blob path, which already tries presigned and generates LODs client-side. Falls back to today's POST /api/images/upload on 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 an http(s) value now fetches the bytes through the proxy, wraps them in a File and 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 the File path as written would have quietly started storing the original 6000×4000 JPEG instead, so the proxied blob goes through compressFileForUpload first — 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's uploadImage guard 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 isServedImagePath source makes the server HTTP-fetch its own S3 object back and rebuild every variant to produce a copy. Point the new record at the existing storagePath instead — 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. describeStoredImage mints 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 on isServedImagePath, 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-base returns the prefix the deployment serves from, probed once per session and cached exactly like presignedSupported.* 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 return width/height from the copy it built, and uploadArtwork writes them into the artwork record. An <img> reports naturalWidth without CORS — only reading pixels needs it — so one cached image load replaces a full server-side decode.* The cache-busting ?t=… that TokenModal and 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, mimeType and size are derived rather than looked up: there is no image table to look them up in, and no client code reads them — id and url are what callers use, and both are exact.)
  • [x] 3c. Give map import the presigned path. mapImport.ts posts a File straight to /api/images/upload-file, bypassing uploadImage — so the flow most likely to carry a very large image is the one that guarantees the server holds it. (Done. The hand-rolled FormData post is uploadImage(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. uploadImage takes a token where mapImport had an authFetch, so the option is added and MapList supplies 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. tryPresignedUpload starts generateLODs before 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 against api.openai.com and generativelanguage.googleapis.com with 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 used x-goog-api-key and 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: genai 0.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 now src/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 with Buffer. Now x-goog-api-key and atob, 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 an authFetch that 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.* /extend was 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 an express.json limit 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: an AbortError is 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 read getImageGenKey straight off the platform prop type, and genai's CI resolves @purple-cat/shared by 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 platform main.* modules.hosted.json and modules.dev.json move 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's output_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 to process.env. Covers /extend as 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/generate is untouched — 4b would have added a path around it, not replaced it — and its image sibling POST /api/images/upload is 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 periodic process.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 to enabled on the shared app-env module, 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 with OutOfMemoryError, 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, and alarm_email gives 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-fetch is 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 and SourceOptionPane has had a branch for it, but nothing ever enumerated moduleRegistry.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. authFetch comes from useImageGenAuthFetch, not useAuthFetch — 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. And getImageGenKey is passed, or this surface would silently proxy where Edit Token goes direct. A module source hands back an image URL, which is what resolve already takes, so it joins the same single upload path as every built-in — no deferred blob: reaches the database and the artwork is minted once, in uploadArtwork. The modal header reads the module's own label rather than falling back to "Add Source Art", and a module: option whose module is not loaded now says so instead of claiming the feature does not exist.)