All posts

All posts

Publishing the legal documents as pages of the site

The contracts and policies moved from a repository to pages of the site, each with a stored PDF, a link per clause and the security.txt its policy promised.

The terms, the privacy policy, the data processing agreement and the rest were written, reviewed and versioned in the repository, and nowhere a client could reach them. Linking to files on a code host is not publication, and a contract nobody can open is one nobody agreed to. On 2026-09-22 they became pages of the public site, in English and French, and each one gained a PDF a reader can keep.

Rendered at build time#

The documents stay Markdown in the repository, where they are reviewed like any other change. The build renders them to HTML, so no Markdown library reaches a browser, and a reader with scripting off still gets the whole text: the page is prerendered, like every other page of the site.

One view serves every document. It reaches its document through a route guard and a module-level cache rather than Vue's Suspense, because the server pass and the hydration in the browser have to agree exactly on what they render, and an asynchronous component gives them two chances to disagree. Each document is its own chunk. A reader opens one policy, and folding them together would put every policy in every visitor's payload.

The dev server was the first surprise. The documents live outside the site's Vite root, so the server had to be allowed to read them, and naming a directory in server.fs.allow replaces Vite's default list rather than adding to it. The site lost permission to serve its own source, and every route painted as a plain background. Nothing that runs on a production build could have caught it, since the option does not exist there; the fix restates the workspace root beside the new directory.

What a contract needs that a web page does not#

A clause has to be citable. The build writes a link into every heading, labelled with the heading's own text, because a single shared label would give a screen reader a list of identical links. A clause reached through that link stops clear of the pinned header, and the router is taught the same offset the browser reads, or the two ways in would land in different places.

Cross-references between documents had a quieter flaw. A link took the target's word for its own slug while the route table decided what that slug resolves to, and nothing compared the two. They agreed because the documents were authored carefully, which is exactly the kind of agreement that builds clean and then serves a link to a route that does not exist. The site now hands that comparison to the build, which refuses a reference to a document it does not route.

Printing had to work too, because for a while Ctrl+P was the download button. The print rules drop the site's chrome, release the reading measure and restate the colour tokens: dark mode is a class on the document, nothing in CSS can remove it, and grey on white is what prints otherwise.

A PDF per document, per language, per version#

A client filing a policy before signing, or an auditor after, needs a file rather than a page. Every legal page now links a PDF of itself, named by document, language and version, so a copy in a downloads folder says what it is, and an amendment gets a new address rather than new words under an old one.

The copies are printed at build time from the prerendered pages, in a headless browser with scripts off, so there is no second renderer to disagree with the page. The build assertions refuse a link with no file behind it, a file no page links to, a version the page does not state, and a file over its byte budget, so the link and the file cannot drift apart. If the brand typeface fails to load during the print, the build fails rather than filing copies in a fallback face. The browser that prints is installed in the image's builder stage, which is discarded, so neither runtime image carries it.

The security.txt the policy promised#

The vulnerability disclosure page told a researcher to look at /.well-known/security.txt, and nothing served one. A published policy pointing at a 404 is worse than a policy that says nothing: someone holding a flaw follows it, finds an error page, and concludes there is nobody to tell.

The file is generated rather than dropped in as a static asset, because every absolute URL in it carries the origin the build was made for, so a staging copy names itself rather than sending a reporter to production. Its Policy field comes from the route the disclosure document is published at, so renaming that route cannot leave the two pointing different ways. Expires decides whether a reader trusts the rest of the file, since RFC 9116 lets a reader ignore one past its own date, so it is computed a day short of a year from each build, truncated to midnight so two builds on one day emit the same bytes. It is written even on builds that refuse indexing: a host nobody is meant to find is exactly where an unsolicited report needs somewhere to go.

Four days later the application's origin got its own copy. It is where a researcher is most likely to look, and it had been answering that path with the application shell and a 200. The prerender now renders the file once per origin through one function, so only Canonical differs. I chose that over a redirect, which RFC 9116 tells readers to distrust across origins, and over a proxy, which would have made the application depend on the site being up.

The repaint nobody saw#

A day after launch, the performance gate showed legal pages painting their largest element at about 3.6 s against a 2.5 s budget on its mobile profile, and only sometimes. The body is markup the build rendered, bound with v-html, and Vue 3.5 re-applies a template's dynamic properties while it hydrates, innerHTML among them. Every paragraph, heading and table cell was parsed afresh once the entry, the message catalogue and the document chunk had arrived one after another, and no hydration mismatch is logged when it happens, so every existing check stayed green.

The body now goes through a small render-function component whose node declares no dynamic properties, so hydration adopts the server's nodes, and a later navigation still replaces them. The prerender also preloads each page's own document chunk alongside the entry instead of three requests deep. Those pages now paint at their first paint, about 1.7 s. The render gate holds every prerendered text block to surviving hydration, by keeping hold of the nodes before hydration and checking that they are still in the document after it; checking an ancestor would have missed this.