epic 2.2: publish a PDF, view it, and control the viewer - #55
Merged
Merged
Conversation
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
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.
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
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
parsePdfContentin the newlib/pdf.js, andland at
<slug>/source.pdfwithcontentType: 'application/pdf'.pdfjoinsSOURCE_EXTinlib/artifact-files.js, soTYPES, the/sourceroute and the T2.1.9 type-change cleanup all pickit 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
storeZipArtifactwould have had to repeat all of it.Three URLs:
/a/<slug>for the viewer page,/a/<slug>/file.pdffor the bytes inline, and?download=1for the attachment./a/<slug>/sourcehands the file over as an attachment instead ofthe
text/plainother types get.Max size is 7 MB, measured on the decoded bytes.
express.jsonis at10mband base64 costs 4bytes 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 meantroughly 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.htmlmarksthe 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 nofalse positive.
docs/formats.mdnow says a browser can accept the type and paint nothing, ratherthan claiming the children always show.
T2.2.2 - three viewer modes and a download toggle
Per-artifact
pdf: {mode, download}next toframe,visibility,tagsand the rest, settable overthe API, the CLI and the dashboard. Modes:
standard,presentation,minimal.docs/formats.mdhas a section headed "What 'disable download' actually does" that says plainly it isnot protection:
/a/<slug>/file.pdfand/a/<slug>/sourcestill answer with the bytes,#toolbar=0is 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_artifacton a PDF destroyed the bytes, with no way to put them back. The MCPschemas declared
type: z.enum(['html','jsx','tsx','md','redirect']), sopdfwas not expressible.Combined with
storeArtifact'stype = 'html'default and the T2.1.9 cleanup, an agent had twooptions and both were bad: passing
type:'pdf'answeredMCP error -32602: Invalid enum value, andomitting
typeanswered 200 with no warning while the directory went frommeta.json source.pdftoindex.html meta.json source.htmlanddropStaleObjectsdeleted 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 backwith.
docs/mcp.mdtold callers to passtypeon every update of a jsx, tsx, md or redirectartifact; 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:
pdfis in both enums so a deliberate conversion is expressible, andstoreArtifactrefuses a replace of a pdf that omitstype, next to the existing zip refusal.Worth knowing for the reviewer: the first fix did nothing over MCP.
update_artifact.typewas.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
typesilently 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
Buffer.from(x, 'base64')never throws andsilently drops invalid characters, and the only check was the 5-byte
%PDF-magic. Dropping onecharacter 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.
%%EOFis now required in the last 1 KB.duplicateignored apdfoverride and never validated it, while every other setting on thatendpoint is honoured and validated.
{"pdf":{"mode":"bogus"}}returned 201 where the same valueis a 400 on POST, PUT and PATCH.
?download=with any value attached, so?download=0and?download=falseboth meant "yes,download". Only a truthy value does now.
minimalmode madedownload: trueinert: the bar is gone in minimal andtoolbar=0also hid thebrowser's own, so a minimal artifact with downloads on offered no way to get the file while the
dashboard said "PDF download: on".
toolbar=0is now added for presentation, and for minimal onlywhen downloads are off.
Security
The security lens found no high or critical issues. Content-Type is pinned and not
caller-influenceable,
nosniffis set, and every auth gate on the new sub-paths holds. It probedprivate with no token, private with a wrong token, password-protected without unlocking, disabled and
expired, across the page,
file.pdf,?download=1and/source, and all twenty combinationsanswered 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>/sourcefor a PDF carried no CSP, unlike/file.pdf, so it now gets the same headers. AndpdfDownloadNameinterpolatedmeta.sluginto aquoted
Content-Dispositionvalue; the comment saidSLUG_REmade that safe, which is true for everypath the server writes, but this file already defends against a hand-edited
meta.jsonin two otherplaces. With
meta.slugedited on disk the server emitted a chosen download filename. It re-checksthe 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
%%EOFcheck andnothing more, so a caller can store arbitrary bytes under a
.pdfartifact. Nothing serves them witha renderable content type, so this is a limit worth writing down, not a hole.
UX
flags.barwas true (the bar isdropped 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.
.act .label { display: none }removes thetext 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"⇩". Witharia-labelthey now read"Show the document full screen", "Open the PDF in a new tab" and "Download the PDF".
body too large (10mb json / 50mb zip / 16kb on credential routes), which nevermentions PDFs or 7 MB, because anything over about 7.5 MB decoded hits the express limit before
parsePdfContentruns. The dashboard guards the size client-side now and the server message namesthe cap in MB.
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 noallow="fullscreen", so presentation mode's headline buttonworked only because the default Permissions Policy for
fullscreenisself. It no longer dependson a default.
&was unescaped inside the embed fragment attribute, andcli.js:112carried an em dash on a linethis 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
PUTof a pdf and the F1 refusal; and the<object>fallback markupincluding that downloads-off drops the fallback links.
One pre-existing assertion had to invert with the minimal-mode fix:
minimal mode keeps the browser toolbaris nowminimal mode hides the browser toolbar too.Filed, not built
Appended to the backlog as T2.2.4 to T2.2.6:
express.json({limit:'10mb'})is anapp.usethat runs before routing, so the body is parsed before
requireAuthsees it, and there is no ratelimiter on
/api/artifacts. Measured on a fresh process: 40 concurrent 9.33 MB bodies with noAuthorization 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.
toolbar=0andnavpanes=0remove the browser'spage counter and prev/next and we add none. Scrolling and arrow keys still work.
140px of chrome before the document, two of them near-empty and one in a different color scheme.
Deliberately unchanged
shells/frame.htmlalready ships glyph arrows, so this is the incumbent housestyle.
cli.js sourcereading bytes rather than text. This was a real behaviour change to an existingcommand, 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.clean.
git diff origin/main..HEAD -U0 | grep "^+" | grep "—\|–"returns nothing.https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea