Skip to content

Wave 2: twelve parity items, plus thirty two review findings - #56

Merged
kuyazee merged 17 commits into
mainfrom
task/wave-2
Aug 30, 2026
Merged

kuyazee merged 17 commits into
mainfrom
task/wave-2

Conversation

@kuyazee

@kuyazee kuyazee commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

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.html and the branding library, so
they 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

  • T2.1.21 the realpath check reads get, now on writes too (0728ef6)
  • T2.1.20 a duplicate prunes to its own type, plus a sweep for old orphans (06dfa9e)
  • T2.1.17 a ceiling on a chained write and a deadline on the s3 requests (c59ccda)
  • T2.2.4 rate-limit large publish bodies before the parser buffers them (e7587ee)
  • T2.6.11 an expired artifact gets its own branded page (e765c85)
  • T2.6.12 set branding from Settings and from the cli (a618701)
  • T2.6.13 brand the operator's own console (c415df8)
  • T2.1.15 say when a link preview will not show (569af26)
  • T2.2.5 give presentation mode back its page counter (1f4a0ac)
  • T2.2.6 stop stacking three toolbars over a framed pdf (1ff340b)
  • T2.1.19 say plainly that a PUT with no type converts the artifact (3ce11da)
  • T2.5.2 a copyable embed snippet and a page about embedding (995c979)
  • Review findings, twenty seven of them (8578b4e, 4e0f1b7, 6d6fae6)
  • Security re-review findings, five of them (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.js and 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, so
POST /API/artifacts skipped the gate and published a real artifact. The gate also never covered
/mcp, /api/keys or /api/config, which all buffer 10 MB before auth. Forty unauthenticated
9 MB bodies to /mcp took RSS from 39 MB to 474 MB, which is the same flood the commit message
claimed to have fixed.

The first fix swapped the path prefix for a method list, and reopened the hole at full size.
body-parser has no method filter: express.json() parses any request carrying a body, GET
included. Forty bodyless-method requests with a 9 MB body took RSS to 580 MB on GET /healthz, an
endpoint that needs no credential at all. The gate now asks whether the request has a body, which is
the same question body-parser asks. Measured after: 101 MB to 116 MB across forty eight
concurrent 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_PROXY defaults to none, so behind the
cloudflared 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 --apply deleted live artifact bodies. It read every meta.json up front, then
deleted without re-reading, so a PUT landing 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 for
good. The fix is an age floor rather than a narrower window: only orphans older than 24 hours are
touched, with --older-than to override. Orphans are historical, left from before the T2.1.9
cleanup 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 --apply keep everything by default, because neither store keeps
a timestamp and guessing an age is the race again. Those two print why, and --older-than 0 is the
way through. docs/cli.md says 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, passwordHash and tokenEpoch. An
operator 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, and
the 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. A
StorageTimeoutError now travels down the queue, so everyone waiting is answered. An ordinary 404
or 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.json is written last, the retry found nothing
and 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 CSS
injection through the accent, javascript: and data: URLs in the logo, and a slot marker in a
value. Every one is refused at the door or escaped at the slot. fillShell is a single pass
function 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: #050505 gave a button fill at 1.05:1 and a focus ring at
2.41:1, and #1d4ed8 gave 2.90:1. An accent is now refused below 3:1 against both the dark console
and 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,
#0055ff and #2563eb pass. #1d4ed8 does not, and the code comment that called it a valid accent
is 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

  • Settings could not be saved on a phone. The branding block made the panel 1036px tall in an 844px
    viewport with no scroll, so nothing in Settings could be saved below 480px, including the frame
    and markdown settings that worked before.
  • dashboard-check.mjs asserted the literal <title>artifacts</title>, so this branch's headline
    feature turned the repo's own end-to-end suite red for any operator who used it.
  • The primary /a/:slug route sent 3 kB of HTML to curl on an expiry, where main sent one line of
    text. Restored, and Vary: Accept now sits on every split response so a shared cache cannot hand
    one representation to the wrong client.
  • A brand logo ate the artifact title at 390px, and a 15 character product name pushed Sign out off
    the screen.
  • Branding refusals were not announced to a screen reader, and the accent text box had no accessible
    name.
  • The preview warning told operators to turn on a frame that was already on, when frames were off
    globally.
  • The 413 named limits the caller had not hit.

Verification

  • npm test: 255 pass, 0 fail. Was 180 on main.
  • Smoke: exit 0, 297 ok-lines without admin credentials, 303 with them. Two clean runs on separate
    scratch data directories.
  • The flood gate verified by hand after the fix: 48 concurrent 9 MB bodies across GET /healthz,
    GET /api/artifacts, OPTIONS /api/artifacts and POST /mcp, RSS 101,216 KB to 115,936 KB, all
    413 or 429.
  • Browser: Chromium at 1280px and 390px, all three pdf modes framed and unframed, downloads on and
    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 hits
    mirrored into test/fixtures/dashboard-default.html from public/index.html comments.

Filed rather than built

  • T2.1.22, POST /api/auth/logout does not invalidate the session token. Replay the cookie after
    logging out and it still publishes. Pre-existing on main, reproduced during this review.
  • Per-surface accent variants, so one brand colour works on both grounds instead of being refused.
  • A per-artifact embed toggle, and whether an oEmbed endpoint should exist. A keyless
    GET /oembed?url= would tell anyone whether a private slug exists.
  • docs/embedding.md now names the condition on its guarantee. On a single-origin self-host a
    public artifact can fetch() a private one's body with the visitor's unlock cookie, allowed by
    connect-src 'self'. Pre-existing, and the reason the split-origin deploy exists.
  • A sweep of the smoke suite's curl | grep -q pipes, which race SIGPIPE against pipefail.
  • PATCH /api/artifacts/:slug silently ignores title.
  • deleteSlug, move and copySlug still resolve against root only. Safe today because reads
    refuse a symlinked slug before those are reached, and because fs.rm, rename and fs.cp do not
    follow a symlink at the final component.

https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea

kuyazee added 17 commits August 29, 2026 14:19
…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.
@kuyazee
kuyazee merged commit 2fc62ed into main Aug 30, 2026
7 checks passed
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