A Scanner That Cannot Upload: Privacy as Architecture
scan.deplyra.com is a set of five document tools that run entirely in your browser: scan to PDF, JPG to PDF, PDF to JPG, compress, and ID card scan. Its home page makes one promise: your document never leaves your browser. Plenty of sites say something like that. This article is about the difference between saying it and building it, because a privacy promise kept by policy can drift one analytics snippet at a time, and a promise kept by architecture refuses to build when someone tries.
Policy drifts; architecture refuses
A policy is a sentence on a page and a norm in a team. Nothing stops next quarter's well-meaning change, an error-reporting SDK, a fonts CDN, an analytics tag, from quietly making the sentence false while it goes on being displayed. Nobody decides to lie; the lie accretes.
So the sentence on this site is enforced in four independent places, and the interesting property is that each would have to be defeated separately:
- The server cannot receive a document. The site is static files. nginx answers 405 to any request method that carries a body; only GET, HEAD and OPTIONS are served at all. There is no upload endpoint to misuse, and the build refuses if that block disappears from the config or a
proxy_passappears. - The code cannot send one. At build time, a scanner walks every engine and shared module looking for fetch, XMLHttpRequest, WebSocket, sendBeacon, EventSource, form submission and service-worker registration. One occurrence anywhere in tool code and the site does not build. Not a warning: a refusal.
- The browser is told to refuse. The Content-Security-Policy sets
connect-src 'self'andform-action 'none', so even a misbehaving vendored library that smuggled a network call past the scanner would be stopped by the user's own browser. - The whole thing is watched in a real browser. On every build, all five tools are driven in Chromium with real files while every network request is recorded. A single request leaving the origin fails the run.
And because a law is only as good as the proof it can fail, a script called prove_laws.sh plants ten deliberate faults, a fetch in an engine, a form tag, a weakened CSP, a deleted 405 block, and requires ten refusals, plus a control run proving the unplanted build still passes. The laws have caught real mistakes, including one of our own: a press page that quoted the banned marketing phrases in order to say we ban them. It was reworded rather than exempted, because a law with an exemption is a preference.
Where the architecture nearly leaked anyway
Two incidents from the record show why the belt needs the braces.
The PDF library's quiet CDN habit. The site vendors pdf.js and pdf-lib rather than loading them from a CDN, since every page promises no third-party requests. But pdf.js also fetches character maps and standard fonts on demand, and its example configuration points those paths at unpkg. Left at the default, a visitor opening a Japanese or Cyrillic PDF would have made a third-party request while the page said it made none. The maps and fonts are vendored too now, and the Chromium test is the reason we are confident there is no third such path: it watches requests, not source code.
One module at two URLs. The page loaded tool-core.js?v=abc while engines imported tool-core.js. To a browser those are different modules, so every listener existed twice and exports ran twice, the second time with no file, which surfaced as an exported zip with nothing in it. The fix rewrites every import at build time to carry the imported module's content hash, transitively, so a URL means exactly one module and a fix to a leaf invalidates its parents. Not a privacy bug, but the same moral: the build pipeline is where correctness laws live, because it is the one place that sees everything.
The 14-kilobyte detector, and the audit that corrected us
The scan tool needs to find the four corners of a page in a photo. The standard answer is OpenCV.js, which we downloaded and measured at 10.3 MB, 3.3 MB gzipped, for one function. That is a cost every visitor pays on a page whose entire purpose is arriving quickly from a search result, so it was rejected, and the detector is hand-written: Otsu threshold, largest connected component, convex hull, four extreme corners, then a projective warp with bilinear sampling written out by hand because a canvas has no perspective transform.
Honesty compels two admissions here. First, the detector is worse than OpenCV at its job, and the site's FAQ says so rather than hiding it; when it is unsure it refuses rather than guessing, and dragging the four corners manually is always available and exact. Second, we ourselves misdescribed it: our own copy called it a seven-kilobyte detector until a claim audit measured the file and found 14 KB of source, 5 KB gzipped. The corrected figures are the ones you are reading. Auditing your own claims occasionally catches you, which is evidence the audit is real.
Being precise about the boundary
Precision matters most at the edge of a claim like this, so here is the edge. The five web tools process documents in the browser, and the four enforcement layers above are why that sentence can be printed. The Scanly mobile app is a different product with a different architecture: its text extraction, translation and explanation run on Deplyra's servers when online, with on-device text recognition as the offline fallback. The app's own pages say that; the web tools' pages claim only what the web tools do. Blurring the two would make a true sentence false by association, and the distinction was tightened during the same audit that fixed the kilobyte figure.
Free needs a stated reason
One design rule surprised people internally: the tools are free with no account, no caps and no watermark, and the site explains why in plain terms, they run on your device and cost us nothing per use. The explanation is a privacy feature. Free with an unexplained reason reads as a trap, and the reader who wonders what the catch is has been given the honest answer: there is no server doing work, so there is nothing to meter.
If you want to build the same guarantee
- Make the promise structural before you print it. Serve static files, reject body-carrying methods, and let the absence of an endpoint do the heavy lifting.
- Enforce at build time, verify at run time. A source scanner catches what your team writes; a browser watching real requests catches what your dependencies do.
- Write the CSP as the second wall, not the first.
connect-src 'self'andform-action 'none'cost two lines and turn a class of future mistakes into console errors. - Plant faults on purpose. An enforcement you have never watched refuse something is a hope. Ten plants, ten refusals, one clean control.
- Measure the numbers you publish. Ours was off by half until we measured it. The command was
wc -c.
Need this done, not just read about?
Deplyra builds, ships and runs exactly this in production — as code, with GitOps, handed over documented.
Start a project →