Architecture
How the zero-upload guarantee is enforced, and how to check it without trusting us.
Pagecraft’s one claim is that Document bytes never leave the device. Every competitor making a similar claim is asking you to believe a privacy policy. This document is written for somebody who would rather not — a security reviewer, a data protection officer, or anybody who has been burned before.
Nothing here asks for trust. Every section ends with something you can check.
The shape of the thing
packages/core the engine. MIT. No network, no filesystem, no DOM.
packages/ai provider adapters. MIT. No credentials, no endpoint.
packages/cli the engine, as a command. MIT.
apps/web the site. AGPL. Static files.
There is no packages/server, and there is no API. Not “we do not use it” — it
does not exist. The build output of apps/web is a directory of files
(ADR-0008),
which is why self-hosting is cp -r and why there is no place a document could
be sent even by mistake.
Check it:
grep -rn --include='*.ts' -E \
"(^|[^.[:alnum:]_])fetch\(|XMLHttpRequest|new WebSocket|from '(node:)?(http|https|net|dgram|dns|tls)'" \
packages/core/src
One hit, and it is a comment: types.ts shows a host fetching a font file to
hand the engine as a capability, which is the seam this section is about. There
is no call site.
1. The engine cannot reach a network
@pagecraft/core has no fetch, no socket, no HTTP import and no endpoint
string. This is a constraint the package is built under rather than a habit
(ADR-0001),
and it has a practical consequence: the package works in a sandbox with no
network available at all, which is how its own test suite runs.
Everything the engine cannot do for itself arrives as a capability injected by the host — a rasterizer, a recognizer, a font source, the location of the data pdf.js reads. The engine never goes and gets one. That is the whole design of the seam: the set of things that could possibly touch a network is a short, named list, declared in one type, and every one of them is supplied from outside where you can see it.
Check it: run the engine in a Node process with no network — unshare -rn node your-script.mjs on Linux — and merge two PDFs. It works.
2. The page is not allowed to reach one either
The site ships a Content-Security-Policy, identical on every page, emitted
both as a header in _headers and as a <meta> on every page so that a host
which ignores one still enforces the other
(ADR-0029).
The directive that matters is connect-src. It is 'self' plus the AI
providers this build was configured to admit, named as origins, never as a
scheme. https: would admit every address on the internet and end the
sentence this file exists to let you read.
The rest is narrow for its own reasons: default-src 'self', object-src 'none', frame-src 'none', form-action 'none' — the site has no form that
submits anywhere — and script-src by SHA-256 hash per inline script rather
than 'unsafe-inline', which would admit anything an injection managed to
write. The one loosening is 'wasm-unsafe-eval', which a browser requires for
WebAssembly.instantiate and which admits compiling WebAssembly and nothing
else: no eval, no new Function.
Check it: curl -sI https://pagecraft-pdf.pages.dev/ | grep -i content-security-policy,
or read the <meta> in view-source on any page. Then open the network panel and
use any tool. Nothing is requested that is not an asset of the site.
3. There is nothing to send, because there is nothing to send it with
- No telemetry, no analytics, no error reporting. Not a self-hosted one either (ADR-0009).
- No accounts, no cookies, no storage of documents. The workspace — the files you have open — lives in the tab and is gone when you close it (ADR-0021).
- No third-party origin at runtime. No font CDN: the typeface is a file
already in
node_modules, named outright at build time. No script from anywhere but this origin. - No source maps pointing at a service, no beacon, no prefetch of anything off-origin.
Check it: the network panel again, with “third-party requests” filtered.
It is empty. Or read apps/web/dist/_headers after a build.
4. Work happens on a worker, and the bytes are lent
A 200MB document would freeze a tab, so operations run in a Web Worker. The document is lent rather than copied: its bytes are transferred to the worker and transferred back when the run ends (ADR-0018). A worker is same-origin by definition and is subject to the same policy.
Where a browser will not give us a worker, the same engine runs on the calling thread. A Tool cannot tell which it got, and neither can a document.
Neither the worker nor the page holds the whole engine. Every operation is
declared in one module and performed in another, so starting the engine fetches
about 130KB and running merge fetches merge
(ADR-0048).
It arrives in three stages, and reading a tool page is none of them: no engine
until there is a document, because opening one is the engine reading it; and no
operation until you press the button.
Check it: apps/web/src/engine/worker.ts is the whole of it, and the
transport it speaks is packages/core/src/engine/protocol.ts. Watch the network
panel on a tool page: nothing under _astro/ arrives while you read it, one
worker-* chunk arrives with your first file, and exactly one *.run-* chunk
arrives when you press the button.
5. The service worker is the only thing that sees every request
It is worth reading, so it is shipped readable: sw.js is transpiled from
apps/web/src/offline/worker.ts with its comments intact rather than bundled
and minified, precisely because it is the file a sceptic should read.
What it does: serves pages from a cache it filled at install time, so the site works with the network unplugged; and answers a share from the operating system by handing the file to the organiser page. What it does not do: touch a document, keep one, or send one anywhere.
The precache is worked out from dist after the build, split into what a page
needs to be a page, the pages themselves, and the engine — so nobody downloads
a PDF engine to read one page. The build id is a hash of the file contents, not
a timestamp, so two builds of the same source are the same build.
Check it: open sw.js on the deployed site. It is a few hundred lines and
it is commented.
6. AI is the one thing that can leave the device, and it is gated
The AI features send text to a model, because there is no other way for them to work. So the whole design is about consent being real rather than assumed (ADR-0005):
- The provider is the user’s. Six kinds, including two that run on the user’s own machine and need no key at all. There is no Pagecraft-operated inference, proxy or relay.
- The key is the user’s. It lives in a vault encrypted with the user’s passphrase, in their browser. The store holds ciphertext and the test suite asserts that by reading the store.
- The grounding text is shown before it is sent. A session refuses to send until the host has declared the text is on screen, and what it sends is the string that was shown, byte for byte (ADR-0032).
- The addresses are admitted at build time. A policy is written before anybody has pasted a key and cannot be replaced afterwards, so a build admits the providers it was configured with and nothing else (ADR-0035). A build that admits none is a build where the AI features can reach nothing.
Check it: paste a key, open a tool, and watch the network panel. Nothing goes anywhere until you have read the grounding text and pressed the button, and what goes is what you read.
7. Passwords and redaction, where “on the device” is not enough
Two places where the guarantee needs more than “no upload”.
A password is spent and not kept. A password given to open a document is used on the call that opens it and kept nowhere: not in the workspace, not in a saved recipe, not in any store, and never in a URL or a log (ADR-0043).
Redaction removes content. It is not a black rectangle drawn over the words. The text is taken out of the content stream, the images are re-encoded without the redacted region, and the structure is rewritten — and the test suite asserts the words are absent from the bytes, not merely invisible. Anything less is a document that leaks the moment somebody selects text.
Check it: redact a name, then strings redacted.pdf | grep 'the name'. It
is not there. packages/core/src/ops/redact.test.ts asserts exactly that.
8. What we do not claim
Being honest about the edges is part of the claim being worth anything.
- Permissions in a protected PDF are honoured by the reader, not enforced by the file. Only the open password actually withholds content. The product says so on the page.
- A signature here is not a qualified electronic signature. Pagecraft places a visible signature and can apply a PKCS#7 signature with a certificate you supply. It is not a certificate authority.
- Office conversion is a documented subset, not full fidelity.
- OCR accuracy is OCR accuracy.
- Your browser is still your browser. If the machine is compromised, or an extension reads the page, nothing here can help — and nothing that claims otherwise is telling you the truth.
9. How big a document can be, and what happens at the edge
Everything runs in a tab on your own machine, which is the whole argument and also the whole of the memory there is: there is no server to move the work to when a five-hundred-page scan is rendered at print density. So the engine is arranged to hold as little as it can, and to say something useful at the point where it cannot hold any more.
Most of it is bounded by construction. Pages are rasterized one at a time and each one is encoded and let go of before the next starts, so drawing three hundred pages costs one page of pixels rather than three hundred. Thumbnails are handed over the moment each exists, and travel to the tab by being moved rather than copied, so the engine is not still holding them. Text is read a page at a time. A Document handed to a worker is lent rather than copied — its bytes move across and move back — so a 200MB file exists once, wherever the work is.
Two things are not bounded, and both have a stated ceiling
(packages/core/src/internal/ceilings.ts):
- One drawing surface may come to 268,435,456 pixels, and no edge may be longer than 32,767. That is a platform limit rather than a preference: no current browser will give out a canvas past it, and — this is the part that matters — it refuses by handing back a blank canvas rather than by failing, which is how a silently empty PDF gets made. Asking for more is refused with a message naming the density that would have fitted. An A4 page at 600 DPI is well inside it; a poster at 1200 DPI is not.
- One run may hand back 2GB of Artifacts in total. Past that the tab is going to die assembling the download, and on a phone it will die a long way before. The Operations whose output is not bounded by their input — the ones that write a file per page — count as they go and stop with a sentence saying to take fewer pages at a time.
Both refusals carry the code TOO_LARGE, so a surface can catch them
specifically, and the web app says them in the reader’s own language.
What is not stated is a maximum file size, because there is not an honest one to state. What a browser will do with a 500MB PDF depends on the machine, the other tabs, and whether the file is mostly scanned images or mostly text. A phone with 4GB of memory will struggle with documents a laptop will not notice. The engine will attempt what it is given and fail with the ceiling above rather than with a crash, and the honest advice for very large documents is the same as it has always been: split first, work on the pieces.
10. How to audit a deployment that is not ours
Somebody self-hosting this could, in principle, have changed it. So:
git clonethe tag,pnpm install --frozen-lockfile,pnpm build.- Compare the built
distwith what the deployment serves. - Read
_headersand the<meta>policy on the page you were given. Ifconnect-srcnames an origin you do not recognise, that build admits it. - Read
sw.js. It is the only script that sees every request. - Watch the network panel while doing real work.
The build id in the service worker is a hash of every file in dist, so two
builds of the same source are byte-identical in that respect — which is what
makes step 2 worth doing at all.
Where to go next
SECURITY.mdin the repository — what is in scope, and where to send a finding.docs/adr/— every decision above, with what was considered and rejected.- Self-hosting — running your own copy.