Skip to content

epic 2.2: publish a PDF, view it, and control the viewer - #55

Merged
kuyazee merged 4 commits into
mainfrom
task/epic-2-2
Aug 29, 2026
Merged

kuyazee merged 4 commits into
mainfrom
task/epic-2-2

Conversation

@kuyazee

@kuyazee kuyazee commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Two items from epic 2.2, plus one commit closing every finding the review gauntlet confirmed. PDF is
the first binary artifact type this server has held.

Items

  • T2.2.1 - publish and view a PDF (commit 0794397)
  • T2.2.2 - three viewer modes and a download toggle (commit b6e3c86)
  • Review findings from all three lenses, nineteen of them (commit e288d00)

Tests: 108 to 122, no failures. Smoke: 203 to 245 ok-lines, exit 0.

T2.2.1 - publish and view a PDF

Bytes arrive base64 in the JSON body, get decoded by parsePdfContent in the new lib/pdf.js, and
land at <slug>/source.pdf with contentType: 'application/pdf'. pdf joins SOURCE_EXT in
lib/artifact-files.js, so TYPES, the /source route and the T2.1.9 type-change cleanup all pick
it up without a special case.

Base64 in the body rather than a separate raw endpoint, because the whole existing publish path
(slug chaining, visibility, tags, expiry, duplicate, QR) then works unchanged. A raw-body endpoint
like storeZipArtifact would have had to repeat all of it.

Three URLs: /a/<slug> for the viewer page, /a/<slug>/file.pdf for the bytes inline, and
?download=1 for the attachment. /a/<slug>/source hands the file over as an attachment instead of
the text/plain other types get.

Max size is 7 MB, measured on the decoded bytes. express.json is at 10mb and base64 costs 4
bytes per 3, so 7 MB of PDF is about 9.33 MB of body, the last size that fits with room for the rest
of the request.

On pdf.js, since the item named it

The viewer is a browser-native <object type="application/pdf">, not pdf.js. Vendoring pdf.js meant
roughly 1.7 MB of minified JS into a repo whose identity is no build step and plain files, plus a
hand-written toolbar to replace one that browsers already ship. All three review lenses agreed that
was the wrong trade for this repo.

The done-when said "renders cross-browser mobile", and that clause is partly met. The QA lens
drove it: Chromium renders correctly at 1200px and 390px in all three modes. Headless WebKit reports
navigator.pdfViewerEnabled: true, accepts the type, paints nothing, and never shows the <object>
children, so the reader got a toolbar over a blank white page. That is the same shape Chrome on
Android has historically had.

So the fallback is no longer left to the browser refusing the type outright. shells/pdf.html marks
the object on load and, 1.5 seconds after window load, swaps in the fallback when the mark never
arrived. Measured after the fix: on WebKit the object is removed and the fallback renders at
1200x756 with both links; on Chromium the object stays and dataset.loaded === "1", so there is no
false positive. docs/formats.md now says a browser can accept the type and paint nothing, rather
than claiming the children always show.

T2.2.2 - three viewer modes and a download toggle

Per-artifact pdf: {mode, download} next to frame, visibility, tags and the rest, settable over
the API, the CLI and the dashboard. Modes: standard, presentation, minimal.

docs/formats.md has a section headed "What 'disable download' actually does" that says plainly it is
not protection: /a/<slug>/file.pdf and /a/<slug>/source still answer with the bytes, #toolbar=0
is an Acrobat parameter only Chrome reads, and any browser can print a rendered page. The dashboard
dialog repeats it. Buttons come out of the markup rather than being hidden with CSS, so no dead URL
sits in the page source. The security lens verified the bytes really are still reachable with
downloads off, so the caveat is accurate rather than overselling.

Review findings

Three lenses ran: adversarial, security, and QA plus UX. Nineteen findings were confirmed and
reproduced against a live server. All nineteen are fixed in e288d00. Three more were filed rather than
built.

The one that mattered

An MCP update_artifact on a PDF destroyed the bytes, with no way to put them back. The MCP
schemas declared type: z.enum(['html','jsx','tsx','md','redirect']), so pdf was not expressible.
Combined with storeArtifact's type = 'html' default and the T2.1.9 cleanup, an agent had two
options and both were bad: passing type:'pdf' answered MCP error -32602: Invalid enum value, and
omitting type answered 200 with no warning while the directory went from meta.json source.pdf to
index.html meta.json source.html and dropStaleObjects deleted the file.

For md, jsx and redirect the destroyed source is text the caller was holding anyway. For a PDF the
bytes are not reproducible from an agent's context, and there was no type:'pdf' to convert back
with. docs/mcp.md told callers to pass type on every update of a jsx, tsx, md or redirect
artifact; pdf was missing from that list, and it was the one type where the advice would have been
impossible to follow.

Fixed in both halves: pdf is in both enums so a deliberate conversion is expressible, and
storeArtifact refuses a replace of a pdf that omits type, next to the existing zip refusal.

Worth knowing for the reviewer: the first fix did nothing over MCP. update_artifact.type was
.default('html'), so zod filled the field in before the server could tell "omitted" from
"html". It is .optional() now, and the refusal fires.

This is not a fix for T2.1.19, the general "a PUT that omits type silently converts to html" item,
which is still open and still waiting on a decision. It stops this branch's new type from being the
one where the loss is unrecoverable.

Correctness

  • A truncated or corrupted upload got a green 201. Buffer.from(x, 'base64') never throws and
    silently drops invalid characters, and the only check was the 5-byte %PDF- magic. Dropping one
    character from position 40 stored 192 bytes of shifted garbage with a 201; truncating the base64 to
    its first 60 characters stored 45 bytes with a 201. Both gave a viewer page that rendered nothing
    and no server-side signal. %%EOF is now required in the last 1 KB.
  • duplicate ignored a pdf override and never validated it, while every other setting on that
    endpoint is honoured and validated. {"pdf":{"mode":"bogus"}} returned 201 where the same value
    is a 400 on POST, PUT and PATCH.
  • ?download= with any value attached, so ?download=0 and ?download=false both meant "yes,
    download". Only a truthy value does now.
  • minimal mode made download: true inert: the bar is gone in minimal and toolbar=0 also hid the
    browser's own, so a minimal artifact with downloads on offered no way to get the file while the
    dashboard said "PDF download: on". toolbar=0 is now added for presentation, and for minimal only
    when downloads are off.

Security

The security lens found no high or critical issues. Content-Type is pinned and not
caller-influenceable, nosniff is set, and every auth gate on the new sub-paths holds. It probed
private with no token, private with a wrong token, password-protected without unlocking, disabled and
expired, across the page, file.pdf, ?download=1 and /source, and all twenty combinations
answered correctly, because the gates sit in the shared preamble ahead of the pdf branches.

Two hardening items came out of it and are fixed. /a/<slug>/source for a PDF carried no CSP, unlike
/file.pdf, so it now gets the same headers. And pdfDownloadName interpolated meta.slug into a
quoted Content-Disposition value; the comment said SLUG_RE made that safe, which is true for every
path the server writes, but this file already defends against a hand-edited meta.json in two other
places. With meta.slug edited on disk the server emitted a chosen download filename. It re-checks
the slug now. Both need filesystem write access, so they are defense in depth rather than reachable
bugs.

Also documented rather than coded: the type check is a magic number plus the %%EOF check and
nothing more, so a caller can store arbitrary bytes under a .pdf artifact. Nothing serves them with
a renderable content type, so this is a limit worth writing down, not a hole.

UX

  • Standard mode with downloads off rendered a 44px empty toolbar: flags.bar was true (the bar is
    dropped only in minimal) but there were no actions, and inside the frame the title is blanked too.
    It read as a rendering bug. The bar is dropped when it would be empty.
  • The toolbar buttons had no accessible name below 480px. .act .label { display: none } removes the
    text from the accessible name, and the buttons carried only a title, which does not help on touch.
    Measured at 390px: the computed names were "↗" and "⇩". With aria-label they now read
    "Show the document full screen", "Open the PDF in a new tab" and "Download the PDF".
  • An oversize PDF got body too large (10mb json / 50mb zip / 16kb on credential routes), which never
    mentions PDFs or 7 MB, because anything over about 7.5 MB decoded hits the express limit before
    parsePdfContent runs. The dashboard guards the size client-side now and the server message names
    the cap in MB.
  • The full screen button did not toggle its label, a file-picked PDF reported as "pasted pdf", and the
    native file input was the one light-grey control in an otherwise fully styled dark dialog. All three
    fixed.
  • shells/frame.html's iframe had no allow="fullscreen", so presentation mode's headline button
    worked only because the default Permissions Policy for fullscreen is self. It no longer depends
    on a default.
  • & was unescaped inside the embed fragment attribute, and cli.js:112 carried an em dash on a line
    this branch already edits. Both fixed.

Coverage

Smoke went 221 to 245 ok-lines. New: the auth gates on both new sub-paths across wrong token,
password, disabled and expired; pdf added to the type-conversion walk in both directions; a duplicate
keeping its settings; a PUT of a pdf and the F1 refusal; and the <object> fallback markup
including that downloads-off drops the fallback links.

One pre-existing assertion had to invert with the minimal-mode fix:
minimal mode keeps the browser toolbar is now minimal mode hides the browser toolbar too.

Filed, not built

Appended to the backlog as T2.2.4 to T2.2.6:

  • A large JSON body is buffered before auth runs. express.json({limit:'10mb'}) is an app.use
    that runs before routing, so the body is parsed before requireAuth sees it, and there is no rate
    limiter on /api/artifacts. 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. With a publish key and real
    PDFs, 748 MB. Pre-existing, but PDFs make a 9 to 10 MB body a normal request rather than an odd one.
  • Presentation mode has no page navigation, since toolbar=0 and navpanes=0 remove the browser's
    page counter and prev/next and we add none. Scrolling and arrow keys still work.
  • The default view stacks three toolbars: the frame bar, our PDF bar, and the browser's own, about
    140px of chrome before the document, two of them near-empty and one in a different color scheme.

Deliberately unchanged

  • The glyph buttons. shells/frame.html already ships glyph arrows, so this is the incumbent house
    style.
  • cli.js source reading bytes rather than text. This was a real behaviour change to an existing
    command, so the QA lens diffed it against main on UTF-8 md and html fixtures: identical md5 on
    stdout and on -o, identical error path. A PDF would have been written back corrupt otherwise.

Verification

  • npm test: 122 pass, 0 fail. Was 108 on main.
  • Smoke: exit 0, 245 ok-lines, run twice, once on a reused data directory and once clean.
  • Browser: Chromium and WebKit at 1200px and 390px, all three modes, downloads on and off. Console
    clean.
  • git diff origin/main..HEAD -U0 | grep "^+" | grep "—\|–" returns nothing.

https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea

A PDF is the first artifact type whose body is not text. It goes up base64-encoded in
the same `content` field every other type uses, so the whole publish path (slug
chaining, visibility, tags, expiry, type-change cleanup) works on it unchanged. The
decode, the `%PDF-` check and the size cap live in lib/pdf.js where a test can run them
with no server.

Three URLs per artifact: /a/<slug> is a viewer page, /a/<slug>/file.pdf is the file, and
?download=1 on that sends the same bytes as an attachment. /a/<slug>/source hands over
the uploaded file too, the way it does for every other type.

The viewer is a thin shell around the browser's own PDF viewer, which is where the page
controls come from. Vendoring pdf.js would mean about 1.7 MB of minified JavaScript in a
repo with no build step plus a hand-written toolbar, so this ships the smaller version
and documents what it costs: the toolbar looks different per browser, and a browser with
no PDF viewer gets the <object> fallback with Open and Download links instead.

Max 7 MB of PDF, measured on the decoded bytes. The publish body parser stops at 10 MB
of JSON and base64 costs 4 bytes for every 3, so that is the last size that still leaves
room for the rest of the request.

The CLI infers pdf from the extension and reads `source` as bytes rather than text. The
dashboard takes a dropped .pdf and offers a file picker when the compose type is pdf.
Two per-artifact settings on a new `pdf` field, taken by POST, PUT and PATCH, offered in
the dashboard row menu, and set from the CLI with `artifacts pdf <slug> <setting>`.

mode is standard (our toolbar, browser controls untouched), presentation (one whole page
at a time on a dark backdrop, with a full-screen button) or minimal (the document edge to
edge, no toolbar of ours and the browser's asked to hide its own). download decides
whether the viewer page offers the file at all.

A patch naming one key leaves the other alone, {"pdf": null} restores both defaults, and
an unknown key or value is a 400 rather than a silent drop. So is a pdf field on an
artifact that is not a pdf, which has no viewer page for it to apply to. Both defaults
store nothing, so a row only carries the field once somebody has configured it.

The toggle removes the buttons from the markup rather than hiding them with CSS, because
a Download link that is only display:none is still a URL sitting in the page source.

What it does not do is protect anything, and the docs say so in their own section:
/a/<slug>/file.pdf still answers with the bytes, the #toolbar=0 hint only works in
Chrome, and any browser can print a page it has rendered. It keeps a reader on the page;
it does not keep the file from a reader who wants it.
Three review lenses ran against the two PDF commits on this branch. What each
one turned up and what changed:

An update that omits type destroyed a PDF's bytes with no way back. The MCP
enums did not carry "pdf", so a client could either send type "pdf" and get an
enum error, or send no type and get a 200 that rewrote the artifact as html and
deleted source.pdf. Both enums now carry "pdf", update_artifact's type is
optional rather than defaulting to html (the default filled the field in before
the server could tell "omitted" from "html"), and storeArtifact refuses a
replace of a pdf that names no type, the way it already refuses a zip. T2.1.19
is still the open item about that html fallback in general; this only shuts the
door on the one type whose loss cannot be undone.

The object viewer painted nothing on WebKit and the fallback never fired. The
object took the whole stage, navigator.pdfViewerEnabled was true, and the
fallback measured 0x0, so the reader got a toolbar over a blank page. The shell
now marks the object loaded from an onload attribute and, 1.5 seconds after the
page finishes loading, moves the fallback out of the object and drops the object
when that mark is missing. Measured: Chromium fires load and keeps the document,
WebKit never fires it and gets the fallback at full size. docs/formats.md no
longer claims the fallback covers a named list of browsers.

Standard mode with downloads off rendered an empty toolbar: 44px of white strip
with a border and nothing in it, because the bar survives outside minimal while
the actions are all gone and the frame blanks the title. A bar with no buttons
is no longer drawn.

The toolbar buttons had no accessible name under 480px, where the CSS drops the
label to display:none and there is no hover for the title tooltip. All three
templates carry an aria-label as well.

duplicate ignored a pdf override and never validated one: a bogus mode answered
201 while the same value is a 400 on POST, PUT and PATCH. The copy now takes the
override through parsePdfSettings, before any bytes are copied.

?download= attached on any value, including 0 and false. Only a truthy value is
a download request now.

A truncated or corrupted upload got a green 201: the magic number only proves
five bytes, and Node's base64 decoder skips bad characters rather than stopping.
parsePdfContent also requires %%EOF in the last 1 KB and says the file looks
truncated when it is missing.

/a/<slug>/source for a pdf carried no CSP while /file.pdf carried the full set.
It is the one source route served as its own type, so it gets the same headers.

pdfDownloadName interpolated meta.slug into a quoted header. Reachable only with
filesystem write access, but the file already re-checks hand-edited meta in two
other places, so the slug is re-checked here too.

minimal mode made download: true inert: our bar is gone and toolbar=0 took the
browser's away too, so an artifact whose row said "PDF download: on" offered no
way to the file. minimal now leaves the browser's toolbar alone; presentation and
downloads-off still ask for it to go.

An oversize PDF got the body parser's message, which mentions neither PDFs nor
7 MB. The dashboard checks the size before uploading, with the real number, and
lib/pdf.js names the cap in MB as well as in bytes.

Smaller ones: the embed URL is escaped, so the & between open parameters is not
a bare ampersand in an attribute; cli.js swaps an em dash for a colon; the
dashboard reports a picked PDF by its filename instead of "pasted pdf"; the file
input is styled to match the rest of the dialog; the Full screen button toggles
its label and title; the frame's iframe carries allow="fullscreen" rather than
leaning on the default Permissions Policy.

Docs: formats.md now states both type checks and what is left to the caller,
which modes hide the browser's controls and why, and that a bar of ours with
nothing in it is not drawn. api.md and mcp.md carry the PUT rule, the duplicate
override, and the truthy ?download.

Smoke gains 24 checks: the auth gates on both pdf sub-paths (wrong token,
password before unlocking, disabled, expired), pdf in both directions of the
type-conversion walk with the leftover files asserted, a duplicate keeping its
settings and serving its own bytes, a PUT and the no-type refusal, the object
fallback markup with and without downloads, the empty-bar case, the accessible
names, the escaping, the truncated body and the falsy ?download.

npm test 122 pass. smoke 245 ok, exit 0.
# Conflicts:
#	docs/api.md
#	docs/cli.md
#	server.js
@kuyazee
kuyazee merged commit da2513f into main Aug 29, 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