Wave 2: twelve parity items, plus thirty two review findings - #56
Merged
Merged
Conversation
…ds get put and delete resolved a key against `root` and stopped there, while getBuffer, head and get went through statFile, which refuses a symlink and re-checks realpath against realRoot. With a slug directory symlinked to somewhere outside the store, a delete removed the outside file and a put wrote a new one out there, while a read of either correctly answered null. Both write paths now run assertInsideRoot, which shares the containment check with statFile. It follows the deepest part of the path that exists, so a new file is judged by the directory it lands in. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
…ld orphans staleKeys only drops what the recorded old type owned, so an artifact that collected orphans before that cleanup landed keeps them for good. Two halves. copyArtifact ran copySlug, which copies every content object under the namespace and prunes nothing, so duplicating one of those artifacts minted a new namespace carrying dead bytes on day one, and committed them on the git backend. It now runs dropOrphanObjects on the copy after copySlug and before the copy's meta.json. For installs that already have orphans on disk, `node cli.js sweep` walks every artifact and names the content files its type does not own. It prints them and removes nothing; --apply removes them. Safe to run more than once. It runs on the host against the store the server uses rather than over HTTP, so there is no new route and nothing to reach from the network, and it costs nothing on a boot that does not need it. A type this build does not know answers with no orphans at all, so a zip site and a record that will not parse are left alone. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
…s3 requests withMetaChain had no ceiling and storage/s3.js set no request timeout, so one storage call that never settled parked every later PATCH, PUT, DELETE and duplicate on that slug for the life of the process. Reads kept working, so the artifact looked healthy and could not be managed, and nothing logged it. The chain moved to lib/write-queue.js, where a test can hand it a promise that never settles. One write gets STORAGE_TIMEOUT_MS (30 seconds, storage/index.js, one line to change). Past that the queue logs the slug, gives it back to the next writer, and answers the caller 503 with Retry-After: 5. 503 is the honest code: the server is fine, that one call is not, and the request is worth making again, which a 500 does not say. The clock starts when the write starts, so a writer queued behind a slow but healthy write is not failed for waiting its turn. The s3 backend now signs every request with an AbortSignal carrying the same deadline. get() is the exception: its body is piped to the viewer, so a slow reader would have a healthy download cut in half; the queue ceiling covers a read that never answers. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
…s them express.json runs as an app.use before routing, so a 10 MB body was parsed before requireAuth saw the request. Measured on a fresh process: 40 concurrent 9.33 MB bodies with no Authorization header took RSS from 42 MB to 551 MB, all answering 401. The write routes under /api/artifacts now spend a budget per client IP: 20 large bodies a minute, where large means a declared length of 256 kB or more, or no declared length at all. Over that is a 429 with Retry-After. The gate sits above the body parser, so a refused request costs a header read. The same 40-body flood now measures 42 MB to 341 MB, with 20 refused before anything was buffered. Small bodies are never counted, so the dashboard's one-field PATCH, a redirect, a markdown page and the smoke suite's 226 writes go through untouched. A publish from a CLI or from CI is one large body, sometimes a handful. ratelimit.js gained `count` as a second name for its counter: the credential gates spend budget only on a failure, while this one spends it on every large body, because what it caps is memory a successful publish costs too. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
An expired link and a missing one answered the same way to a reader: one bare
line of text for the 410, and for the 404 either the branded card or the same
bare line depending on which route caught it. Someone landing on a lapsed link
had no way to tell a typo from a link that used to work.
shells/not-found.html now holds its title, status number, headline and message
in slots, and lib/status-page.js holds the two sets of words. One card renders
both pages, so an operator's logo, accent and footer reach the 410 without a
second template to keep in step. The rendered 404 is byte for byte what it was:
test/fixtures/shells-default/not-found.html is unchanged.
Who gets a page and who gets a line comes off the Accept header. A browser
navigation sends text/html and gets the card. curl, fetch(), an <img>, a script
and a range read do not, and keep the one-line body they have always had, so an
asset request never gets HTML back in place of the asset. GET /a/:slug still
always answers with the card, as it already did for a miss. The JSON API routes
keep their {error} bodies.
Tests: test/status-page.test.js covers the Accept split, both copy sets and the
"no unfilled slot" guard. test/shells-default.test.js renders not-found with the
404 copy alongside the branding slots and still matches the untouched fixture.
Smoke gains the branded 410 page, the sub-path Accept split, and the browser half
of the zip miss.
Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
Branding was config-driven with no way in but a hand-written PUT. An operator opened Settings, saw every other global knob, and had to go read the API docs for this one. The feature worked and nobody could reach it. Settings gains a Branding section with the five fields: product name, logo, favicon, accent and footer line. The accent gets a color picker beside its text box, because the field also takes rgb() and hsl() and a picker can only write hex; the picker follows the text field when that holds a hex value. Blank clears a field. A refused value shows the server's own message under the input it names, found by stripping the `branding.<field>` prefix every refusal already carries. Logo and favicon hold the same-origin line T2.6.10 drew. The panel says the rule under the field, and a remote URL or an SVG data URI is caught before the request goes out, so the operator hears it while the field is still in front of them. The server refuses those anyway; this is the faster half of the same answer. The cli gains the same five as flags on `artifacts config`: --brand-name, --brand-logo, --brand-favicon, --brand-accent, --brand-footer, with `none` to clear one. A flag nobody passed is not sent, so setting one field leaves the other four alone. The settings panel now borrows `.form label.f`, which also gives the four markdown selects the label treatment every other form in the app uses. Checked at 1280px and at 420px: nothing overflows and the panel reads as finished rather than half styled. Tests: five in test/branding.test.js for the flag mapping, `none` clearing a field, a missing flag staying out of the patch, and every parser refusal naming its own field, which is the contract the panel reads. Smoke covers the cli flags end to end, one field surviving another field's save, a hotlinked logo refused, the refusal still naming its field, and the panel shipping all five inputs with an error slot each. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
Every viewer-facing surface reads the branding block. The one the operator looks at all day did not: the tab said artifacts, the sign-in card and the header both drew the built-in anvil, and /favicon.ico was a hardcoded 204. public/index.html is a shell now, read once at boot and filled per request the way shells/ already are. The tab title, the sign-in heading and the wordmark take the product name; both marks take the logo; the head gets a favicon link; and the accent takes over --molten, --molten-deep, --rose and --rose-tint, which is every accent role the console has. The logo keeps the mark's height and takes whatever width that leaves it, so a wide logo is not squashed into a 30px square. The button and the checkbox glyph both drew in --espresso on a --molten fill. --espresso is #0b0d0f, which reads 1.00:1 against a near-black accent: an invisible label on a perfectly valid color. Both now take the same label the unlock button picks, whichever of dark or white reads better on the chosen accent. /favicon.ico follows the block too, for anything that asks for the well-known path without reading the page head. A path redirects there, and it cannot leave this origin because parseBrandUrl already refused anything but a single-leading-slash path or an inline image. An inline image is decoded and served, since a data: URI is not a location. Unset keeps the 204. An unbranded install is untouched. test/dashboard-brand.test.js renders the page with empty branding and compares it against test/fixtures/dashboard-default.html, which is the file exactly as it stood at the previous commit, byte for byte. One thing found on the way. The suite runs under `set -o pipefail`, so `curl | grep -q` on a large body is a race: grep matches, exits, curl dies of SIGPIPE with 141, and pipefail reads the pipeline as a failed check. It bit every assertion against the 113 kB console, and which one it hit changed run to run. Every page-sized assertion added on this branch now fetches once into a file and greps the file, which also drops 16 requests. The older small-body pipes are left alone; they are the same shape but the body fits in one pipe buffer. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
…ping it
An html, jsx, tsx or zip artifact with the frame off serves the bytes its author
published, untouched, so the preview tags never render. The API, the CLI, the MCP
tools and the dashboard all took description and ogImage anyway, and the list
reported them as set. The operator found out from a chat app that showed nothing.
The repo owner already decided the stored bytes stay the author's: no splicing
tags in at publish time, no injecting them on the way out. So every surface that
sets a preview now says when it will not show.
lib/social.js gains previewReach({type, framed}), which is the one rule the three
surfaces share. The dashboard's "Description…" and "Preview image…" dialogs read
the artifact's own frame setting against the global pair and say the frame is off
for this one when it is, rather than the old note that only ever said "shows while
the frame is on". artifacts preview, publish and deploy print a warning: line to
stderr after setting a preview nothing will render. The value still stores, the
list still marks the row, and the exit status is unchanged: a warning is not a
refusal.
Two things the code turned out to say that the docs did not. A pdf carries the
tags with the frame off, because buildPdfHtml puts them in the head it builds, so
docs/formats.md claiming "only md carries them with no frame" was wrong and the
dashboard's note was wrong for pdf too. Both now name md and pdf. And a redirect
gets its own sentence rather than being blamed on the frame: it answers 301 with
no page, so a preview never shows for one whatever the frame says.
Verified live, not just asserted: an unframed html serves no og:description, an
unframed pdf and an unframed md both do.
Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
toolbar=0 took away the browser's page counter and its prev/next, and the shell added none of its own. A reader opening a deck saw page one and nothing to say there were three. Scrolling and the arrow keys worked; nothing on the page said so. Building our own was the first thing tried and it does not work. An <object> holding a PDF exposes no current page to the document around it, and reassigning the object's data to jump to #page=N blanks the viewer in Chromium rather than moving it. Checked in a browser, not assumed. Without a page total and without a way to drive the document, our own prev/next would be two buttons that guess. So presentation asks only for the side panel to go, and leaves the browser's toolbar up. That toolbar is a real page counter with the real total, a page box you can type into, and prev/next. What makes the mode is untouched: one whole page at a time, the dark backdrop, the full-screen button, no side panel. Chrome hides that toolbar in full screen, which is where a deck is actually read. One combination cannot have both, and the docs now say so instead of leaving it to be found. `download: false` works by asking that same toolbar to go, because it carries a download button and a print button. The download setting wins, so presentation with downloads off still has no page counter. A reader there still moves with the arrow keys and the wheel. Verified in a browser on a 3-page PDF: before, our bar and nothing else; after, "1 / 3" and working page controls. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
The default view of a PDF artifact is framed and standard, and at 1200px it put about 144px of chrome above the document: the frame's own bar at 44px, ours at 44px, and the browser's PDF toolbar at 56px. Two of the three were nearly empty and one was in a different color scheme. Ours was the one earning least, and framed it was already half gone: the shell blanks its title there because the frame's bar above says the same words. That left Open and Download sitting over a browser toolbar that already carries download and print. So framed standard mode drops our bar. The framed view is two bars now, about 100px, measured the same way. Nothing is taken away. Unframed, our bar is the only one on the page and renders in full, title and both buttons. presentation keeps its bar framed or not, because the Full screen button lives nowhere else. minimal never had one. Standard with downloads off already drew no bar, so there is nothing there to drop. And a browser that refuses to render a PDF still gets Open and Download from the fallback in the middle of the page. Whether the page is framed is only knowable in the browser, so the shell makes the call and pdfViewerFlags().hideBarInFrame is the switch the server hands it. That keeps the rule with the other viewer rules, where a test can read it. Checked in a browser on a 3-page PDF, framed and unframed, in standard and in presentation. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
Z's call was to keep the behaviour and fix the docs. A PUT is a replace and `type` falls back to html when the body leaves it out, so an update that omits it rewrites a jsx, tsx, md or redirect artifact as an html page and deletes the files the old type owned. Every other field keeps its stored value, which is what made the one exception easy to miss. - docs/api.md gets the rule as its own bullet, above the pdf bullet that now reads as the exception it is. - docs/cli.md shows `--type` on the `update` usage line, which cli.js has had all along, and says the CLI always sends a type inferred from the file. - docs/formats.md says repointing a redirect has to carry `type: "redirect"`. - docs/mcp.md and the `update_artifact` tool description already said it after the pdf work landed, so a test pins all four surfaces instead. test/docs-put-type.test.js fails when any of the four drops the warning. Smoke gains the live proof: an md artifact PUT with no type comes back as html, and a zip site refuses the inline PUT either way and keeps serving. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
… explaining it The row menu gets an Embed item next to QR code. It writes the whole iframe tag, built from the server's BASE_URL rather than the origin the console happens to be open on, pointing at ?raw=1 so the frame holds the artifact and not a second toolbar. A Copy button, a link that opens exactly what the frame will load, and a warning on the two visibilities that cannot be embedded at all. A redirect gets no Embed item, the same reason it gets no Frame item. docs/embedding.md is the page: the snippet, the URL, why ?raw=1, the visibility table, and the header contract. The trap worth naming is the private one. A ?k= link in the src does not rescue it, because the unlock cookie is SameSite=Lax and a browser will not set it inside another site's frame, so the exchange runs and the redirect lands on a 404. Nothing about what a browser is allowed to do changed. ARTIFACT_CSP already carried no frame-ancestors, so a public artifact was always embeddable. Smoke pins that contract now: no X-Frame-Options, no frame-ancestors on the artifact, a frame load with no ?raw=1 still gets the bare page, and the toolbar page still refuses to be framed. Left for Z: the per-artifact frame-ancestors toggle the item also names. Its shape (a boolean or an origin allowlist) and its default are a product call nobody has made, and an oEmbed endpoint proper is a second one, since it would be a new route that answers without a key. test/fixtures/dashboard-default.html refreshed by the recipe in test/dashboard-brand.test.js. The diff is 86 added lines and nothing removed. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
Rate limiting (T2.2.4). The publish gate read `req.path` against a lowercase prefix while express routed case-insensitively, so `POST /API/artifacts` reached the handler and skipped it; a real artifact was published that way. It also only covered `/api/artifacts`, leaving `POST /mcp`, `POST /api/keys`, `PATCH /api/keys/:id` and `PUT /api/config` buffering 10 MB apiece before auth: 20 anonymous 9 MB bodies to /mcp took RSS from 108,640 KB to 308,624 KB. And it spent the budget before requireAuth, so 20 anonymous bodies from one address locked the operator out of publishing for a minute, which under cloudflared is one visitor and the whole install. Case sensitive routing is on. The gate now keys on the write methods and on whether the caller carries a credential this server accepts, and an anonymous write gets a 256 kB parser rather than the publish-sized one, so an unnamed caller cannot make the server buffer a publish either way, chunked or not. The same flood now answers 413 at 172,208 KB. An authenticated 9 MB publish is unchanged. The write queue. `capped` raced the call against the ceiling and the chain tail was built from the race, so the slug came free while the real write was still in flight: a later write landed underneath it and the stalled write then put back the snapshot it had read before the timeout, visibility, passwordHash and tokenEpoch included. The tail now follows the call. The caller still hears 503 at the ceiling; the retry queues behind the stalled write instead of under it. The ceiling itself was STORAGE_TIMEOUT_MS, which is the per-request s3 deadline, applied to a whole chained handler. A 2000 file zip deploy is one such handler: 2.5 s on a local SSD, 60 to 160 s at an ordinary s3 round trip. It answered 503 while the extraction kept running with the slug released, and since meta.json is written last the retry found nothing and started a second extraction into the same namespace. The queue gets WRITE_CEILING_MS, sized for a whole publish. `sweep --apply`. It read every meta up front and deleted without re-reading, so a PUT that converted an artifact mid-run had the sweep delete the files the new type owns: a lone meta.json, still listed, body gone. An orphan is now only removed once it is older than 24 hours, which the historical orphans this verb exists for always are and a file a conversion just wrote never is, with `--older-than <hours>` to move that floor and 0 to drop it. head() reports mtime on local, s3 and git; a store that cannot say keeps its files and says why. The record is read again immediately before each delete. Dry run is still the default, and the run now reports what it kept as well as what it removed. docs/cli.md said the risk was "a conversion landing in the same second"; it was neither that narrow nor that harmless. Settings on a phone. `.popover` is `position: fixed` under 480px with no max-height, and the branding block pushed the panel to 1036px in an 844px viewport with Save at y=1093 and nothing able to scroll it into view. Nothing in Settings could be saved below 480px. The panel now scrolls itself. dashboard-check.mjs asserted the literal `<title>artifacts</title>`, so setting a product name turned the repo's own end-to-end suite red on the branch's headline feature. It now asserts there is a non-empty title. The accent. `dashboardBrandSlots()` emitted the raw accent as the console's primary fill and brand mark with nothing checking it: #050505 gives a 1.05:1 button fill and a 1.05:1 mark, #fafafa a 1.04:1 status line and unlock button on the light card. An accent under 3:1 on either the dark console or the white card is refused at save time, with both numbers and which way to move. That covers the pre-existing light-theme failures on the 404/410 card, md links and the unlock button too. It does turn down some ordinary-looking colors, #1d4ed8 among them at 2.90:1. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
The 410 on `/a/:slug` was changed on this branch to always send the card, so curl, fetch() and every embed that had been reading "artifact expired" got 3 kB of HTML instead. It goes back through the same Accept split the sub-paths use, and smoke asserts both halves. None of the split 404 and 410 responses carried `Vary: Accept`, so a shared cache could hand one visitor's card to the next caller's fetch(), and the plain text branches set neither `Cache-Control` nor `X-Content-Type-Options` while their HTML siblings set both. All three headers now travel with either body. `withMetaChains` capped every step, so the outer slug's clock started before the inner slug's queue wait: a rename standing behind two healthy 40 ms writes on the second name was answered 503 at 208 ms without either write going near a 200 ms ceiling, and then landed anyway. Only the innermost step is capped now. `previewReach()` could not tell a per-item frame from the master switch, so with frames off for the server it told the operator to turn on a frame that was already on. It says "Frames are off for the whole server" in that case, in the CLI and in the dashboard menu. `button:hover` swaps the fill to a lighter mix of the accent while keeping the label picked for the un-hovered fill: white on the hover mix of #0055ff reads 3.35:1 where the same white on the resting fill reads 5.61:1. The hover fill gets its own label, on the console and on the unlock button. #0055ff now measures 5.82:1 hovered. The Accent text box had no accessible name: its label wrapped two controls, so it named the color picker and the text field fell back to its placeholder, "#c73d1d". Every branding field is now a div with an explicit label, its hint and its error reaching the input through aria-describedby, which also stops the Logo field's 46 word hint from being read as the field's name. `syncAccentPicker()` matched only #rrggbb, so #f00, rgb() and hsl() left the swatch on the built-in orange while a different color was saved; the browser resolves all four forms now. Branding refusals were not announced: no aria-invalid, no aria-describedby, the message was not a live region, focus stayed on Save. The message span is a live region, the input is marked invalid, and focus moves to it. `frameBrandSlots()` hid the wordmark under 560px and left the logo at its full 120px, so a 390px viewport gave the artifact's own title 79px for 140px of text. The logo caps at 56px there; the title now fits. `.wordmark` had no overflow handling, so a 15 character one word product name pushed the header to 439px in a 390px viewport with Sign out 50px off screen, and a 40 character one wrapped it to five lines. One line, clipped. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
`UnsafeKeyError` was only mapped inside serveObject, so the local backend's realpath guard refusing a write through a symlinked slug directory surfaced as a bare 500 with nothing in it to act on. It is a 409 naming the slug now, and the error carries the key that caused it. `GET /` refilled 119 kB of shell and hashed an ETag over the result on every unauthenticated request: 1.030 ms of the server's own time, median over 500 keep-alive requests, where sendFile used to stream the file with a stat-based ETag. The fill and its ETag are cached and dropped when the config is saved. Same measurement now reads 0.349 ms. That route also changed the dashboard's Cache-Control from no-store to no-cache on the one page carrying the admin session. The stated reason, a branding change showing on the next load, is served just as well by no-store. `/favicon.ico` serves operator-supplied bytes. DATA_IMAGE_RE pins the type to one of four raster formats so nothing is reachable through it, but it now sends X-Content-Type-Options: nosniff like everything else that streams bytes. In the viewer frame the brand logo rendered `alt=""` while the wordmark is suppressed whenever a logo is set, so a screen reader got no publisher identity at all. The product name is the alt when there is one. docs/formats.md celebrated standard mode losing its bar inside the frame without saying that a framed deck in presentation mode still pays the whole 144px three bar stack. It says so. Both it and the lib/pdf.js comment claimed the browser's PDF toolbar carries "the page counter and the prev/next"; Chromium draws a typeable page field and the total and no prev/next buttons, so the counter claim stays and the rest goes. docs/embedding.md said "Only a public artifact can be embedded" and "Embedding widens nothing". True across origins, false on a single-origin self-host: every artifact shares one origin and ARTIFACT_CSP carries connect-src 'self', so script in a public artifact can fetch a private one's body with the visitor's unlock cookie. Pre-existing, but the doc stated a flat guarantee an operator would rely on. It now names the condition and says the guarantee holds once artifacts have their own origin. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
…thod Two holes the previous pass left in the middleware it rewrote, both reachable without a credential or with the weakest one an operator can issue. The gate keyed on isWrite() (POST/PUT/PATCH/DELETE). body-parser has no method filter: it reads a body from any request that carries one and matches the content type, GET included. So on a GET the gate returned early, nothing marked the request, and the parser pick fell through to the 10 MB one. Measured with no credential at all, 40 concurrent 9 MB bodies, RSS from ps -o rss=: GET /healthz 40x 200 89,280 -> 580,960 KB GET /api/artifacts 40x 401 109,328 -> 592,256 KB GET /robots.txt 40x 200 107,728 -> 597,280 KB OPTIONS /api/artifacts 40x 200 109,984 -> 769,120 KB That is an unauthenticated OOM of the whole install, reachable on the healthcheck. It now keys on whether the request carries a body at all, which is what body-parser itself keys on. Same four cases after: 20x 413 and 20x 429 each, 172,864 / 143,648 / 174,096 / 170,176 KB. identify() also did no scope check, so a read key skipped the budget and got the 10 MB parser before requireAuth answered 403. A read key exists to be handed to something you do not fully trust. It now asks for publish authority instead: 40 concurrent 9 MB bodies under a read key were 109,792 -> 565,584 KB, all 403, and are now 20x 413 plus 20x 429 at 107,488 -> 172,336 KB. Checked that nothing legitimate lost its 10 MB: an 8 MB publish under the bootstrap bearer, a publish key and a full key all still answer 201/200, so does PUT /api/config, and a read key still lists artifacts and runs its MCP read tools. POST /mcp is the one route a read key reaches with a body, and its tools re-check scope, so a read key's large MCP body was refused anyway, just after 10 MB had been buffered. Smoke: three new cases in the flood block, which only exercised POST. 296 ok-lines, up from 293.
…l limit Three items from the wave-2 security re-review. A writer queued behind a wedged slug hung forever with no answer. The ceiling was set up inside the chain step, so it only ran once the step started. The caller whose write was running got its 503; the callers in line never started, so their clocks never ran, and server.js sets no server.timeout, so those sockets sat open for the life of the process. At ceilingMs 200 the wedged write was rejected and the two behind it were still pending after 1500ms. Now every caller is answered no later than the caller in front of it. A write past the ceiling is still holding the slug and may never come back, so everyone queued behind it hears the same 503 at the same moment, and their writes are dropped where they stand: nothing of theirs has run, so the retry cannot land the same change twice. Only a storage timeout travels down the queue; a 404 on a missing slug or a 409 on a taken one belongs to the caller that made it. The chain tail still hangs off the real call, so a timed out write still cannot revert a later one. Covers the two-name form too, so a rename queued behind a wedged write is answered as well. The 413 named limits the caller did not hit. An anonymous 300 kB body was refused at 256 kB and told to cut to "10mb json / 50mb zip / 16kb on credential routes", every one of which it was already under. raw-body puts the limit that actually applied on the error, so the message reads it off there: "body too large: the limit on this request is 256 kb". Verified live at 256 kb, 16 kb and 10 mb. Docs: three claims about the limiter were false after the gate was rewritten. It no longer keys on a path prefix, a chunked body no longer counts (it declares no length), and only a caller below publish scope counts. docs/api.md, docs/deploy.md and README.md now say what the middleware does. The write-queue bullet in docs/api.md also said "30 seconds" where the code says 3 minutes and said the slug is handed to the next writer, which stopped being true when the revert bug was fixed; both corrected alongside the queued-caller behaviour. npm test 255 passing, 0 failures. Smoke exit 0 at 297 ok-lines without admin credentials.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Twelve backlog items built in sequence on one branch, then three review passes and three fix
commits. The items overlap heavily in
server.js,public/index.htmland the branding library, sothey were built one after another rather than on separate branches.
Tests went 180 to 255. Smoke went 274 to 297 ok-lines, exit 0.
Items
0728ef6)06dfa9e)c59ccda)e7587ee)e765c85)a618701)c415df8)569af26)1f4a0ac)1ff340b)3ce11da)995c979)8578b4e,4e0f1b7,6d6fae6)dbfa195,4660c8b)What the reviews changed
Three lenses ran on the twelve items: adversarial, security, and QA plus UX. They confirmed twenty
seven findings against a running server. A fourth pass then re-reviewed the fixes, because they
touched
lib/auth.jsand the serve path, and found five more. All thirty two are closed.The features held up. What did not hold up was the safety machinery added alongside them, twice in
the same middleware. Both rounds are worth reading before trusting a future change there.
The publish rate limit did not stop the flood it was written for. It gated on
req.path.startsWith('/api/artifacts'), and express routes case-insensitively by default, soPOST /API/artifactsskipped the gate and published a real artifact. The gate also never covered/mcp,/api/keysor/api/config, which all buffer 10 MB before auth. Forty unauthenticated9 MB bodies to
/mcptook RSS from 39 MB to 474 MB, which is the same flood the commit messageclaimed to have fixed.
The first fix swapped the path prefix for a method list, and reopened the hole at full size.
body-parserhas no method filter:express.json()parses any request carrying a body,GETincluded. Forty bodyless-method requests with a 9 MB body took RSS to 580 MB on
GET /healthz, anendpoint that needs no credential at all. The gate now asks whether the request has a body, which is
the same question
body-parserasks. Measured after: 101 MB to 116 MB across forty eightconcurrent bodies on four methods, every one refused.
The rate limit also traded a memory problem for a worse availability problem. Every request
spent budget whether or not it authenticated, and
TRUST_PROXYdefaults tonone, so behind thecloudflared deploy the docs recommend, twenty anonymous requests a minute stopped the operator
publishing. Budget is spent only on requests without publish authority now. A read-scope key used to
count as trusted and got the 10 MB parser, which is the wrong credential to extend a large buffer
to.
cli.js sweep --applydeleted live artifact bodies. It read everymeta.jsonup front, thendeleted without re-reading, so a
PUTlanding mid-run made it delete the files the new type owns.An md artifact converted during a sweep ended up as a lone
meta.json, still listed, body gone forgood. The fix is an age floor rather than a narrower window: only orphans older than 24 hours are
touched, with
--older-thanto override. Orphans are historical, left from before the T2.1.9cleanup existed, while a file caught in a live conversion is seconds old, so the age floor makes the
race impossible rather than smaller. The meta is re-read before each delete as well.
On sqlite and postgres this makes
--applykeep everything by default, because neither store keepsa timestamp and guessing an age is the race again. Those two print why, and
--older-than 0is theway through.
docs/cli.mdsays so.A timed-out write could revert a later one. The write queue chained the next writer on the race
against the clock rather than on the real call, so a stalled write landed on top of a newer one and
restored its pre-timeout snapshot, which holds
visibility,passwordHashandtokenEpoch. Anoperator who rotated a token, got a 503 and retried could have the rotation silently undone. Two
lenses found this independently. The chain tail now hangs off the real call.
That fix cost a guarantee, and the test asserting the old behaviour was rewritten rather than
deleted quietly: a backend that wedges now parks that slug instead of freeing it. On s3 this cannot
happen, since every request carries an
AbortSignal. On local, git, sqlite and postgres it can, andthe log line says so. A silently reverted token rotation is the worse outcome.
The first version of that fix left anyone queued behind a wedged write hanging with no answer at
all, since the ceiling only started once their step began, and nothing sets
server.timeout. AStorageTimeoutErrornow travels down the queue, so everyone waiting is answered. An ordinary 404or 409 does not travel.
A 2000 file zip publish would have 503'd on s3. The per-call storage deadline was being used as
the budget for a whole handler. That publish is one chained write wrapping a sequential put loop:
2.27 s on local SSD, 60 to 160 s at a normal s3 round trip. It got a 503 while the extraction kept
running and the slug was released, and because
meta.jsonis written last, the retry found nothingand started a second concurrent extraction into the same namespace. The queue has its own ceiling
now, separate from the per-call deadline.
Branding
Five fields, settable from Settings and from the cli, filling the dashboard, the 404 and 410 cards,
the viewer frame, the password page and the md and jsx shells.
The security lens tried ten payloads against every slot:
</script>,</title>breakouts, a CSSinjection through the accent,
javascript:anddata:URLs in the logo, and a slot marker in avalue. Every one is refused at the door or escaped at the slot.
fillShellis a single passfunction replacement, so a value cannot steal another slot.
The accent needed a guard. The contrast-picked button label worked, but nothing guarded the fill or
the derived link and focus colours, and it is a colour picker now rather than an env var and a
restart. Measured on the shipped page:
#050505gave a button fill at 1.05:1 and a focus ring at2.41:1, and
#1d4ed8gave 2.90:1. An accent is now refused below 3:1 against both the dark consoleand the light card, with the message under the field. That one guard also closes a set of
pre-existing light theme failures, including the 404 status line at 1.04:1 on a pale accent.
The band this leaves is narrow, roughly 0.112 to 0.300 relative luminance.
#f0502a,#c73d1d,#0055ffand#2563ebpass.#1d4ed8does not, and the code comment that called it a valid accentis now wrong. Deriving per-surface variants from one brand colour is the better answer and is filed
rather than built.
Smaller things the reviews caught
viewport with no scroll, so nothing in Settings could be saved below 480px, including the frame
and markdown settings that worked before.
dashboard-check.mjsasserted the literal<title>artifacts</title>, so this branch's headlinefeature turned the repo's own end-to-end suite red for any operator who used it.
/a/:slugroute sent 3 kB of HTML to curl on an expiry, where main sent one line oftext. Restored, and
Vary: Acceptnow sits on every split response so a shared cache cannot handone representation to the wrong client.
the screen.
name.
globally.
Verification
npm test: 255 pass, 0 fail. Was 180 on main.scratch data directories.
GET /healthz,GET /api/artifacts,OPTIONS /api/artifactsandPOST /mcp, RSS 101,216 KB to 115,936 KB, all413 or 429.
off, the embed dialog, the branded console and the 410 card. Console clean.
git diff origin/main..HEAD -U0 | grep "^+" | grep "—\|–"returns only the 12 pre-existing hitsmirrored into
test/fixtures/dashboard-default.htmlfrompublic/index.htmlcomments.Filed rather than built
POST /api/auth/logoutdoes not invalidate the session token. Replay the cookie afterlogging out and it still publishes. Pre-existing on main, reproduced during this review.
GET /oembed?url=would tell anyone whether a private slug exists.docs/embedding.mdnow names the condition on its guarantee. On a single-origin self-host apublic artifact can
fetch()a private one's body with the visitor's unlock cookie, allowed byconnect-src 'self'. Pre-existing, and the reason the split-origin deploy exists.curl | grep -qpipes, which race SIGPIPE againstpipefail.PATCH /api/artifacts/:slugsilently ignorestitle.deleteSlug,moveandcopySlugstill resolve against root only. Safe today because readsrefuse a symlinked slug before those are reached, and because
fs.rm,renameandfs.cpdo notfollow a symlink at the final component.
https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea