All checks were successful
CI / Build container images (pull_request) Successful in 3m53s
CI / Auth e2e pack (pull_request) Successful in 8m42s
CI / Auth e2e pack (push) Successful in 8m41s
CI / Lint, typecheck, test (pull_request) Successful in 6m30s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 18s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Deploy to Test (push) Successful in 16s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m41s
CI / Build container images (push) Has been skipped
CI / Import/export fidelity gate (push) Successful in 52s
An operator holding a font licence could only use it by baking the file into a custom image, which tied every change to a rebuild and left the file out of the backup. ADR 0016 said there is no runtime font management. It also listed this exact case under "Alternatives considered" — *may become a Site-Admin- level feature later*. The amendment takes that option and answers the two objections it raised: licensing risk (Site Admins only, licence recorded with the family) and file-format attack surface (magic-byte check and a size cap, never a parse). - `CUSTOM_FONTS_DIR` (default `./data/fonts`) — a sibling of uploads and plugins, NOT inside the image-baked `FONTS_DIR`, where a deploy would overwrite it and no backup would ever see it. - One list of data directories (`apps/backup/src/data-dirs.ts`) now feeds both the nightly archive and the restore, so they cannot drift. #306 and #307 add one line each instead of a second mechanism. - Both Dockerfiles bake the path. The backup image sets its volume paths itself ("self-sufficient without compose env" — #71's lesson) and reads no *_DIR from compose; without the ENV entry the archive would have skipped the directory silently. - The PDF path already read WOFF2 from disk at request time, so it only had to pick the other base directory for a custom family. - `fontStack`/`fontEntry` take the instance's uploaded families as an argument — they are runtime data. The catalog is searched first, and a colliding family name is rejected at upload, so a custom font can never shadow a catalog one. - Deletion is never blocked by usage: an unknown family already falls back to the system stack, so affected ponds degrade instead of breaking. The count of affected ponds travels into the audit entry. - Audit catalogue v1.6 (`font.uploaded`, `font.deleted`). Verified: api full suite against a fresh database, 102 files / 571 tests. The upload suite writes into a real temp directory and reads the bytes back off disk, so the storage layer is exercised rather than mocked.
85 lines
4.3 KiB
Markdown
85 lines
4.3 KiB
Markdown
# ADR 0016: Self-hosted Google Fonts, per-pond font configuration
|
||
|
||
- Status: accepted
|
||
- Date: 2026-07-04
|
||
|
||
## Context
|
||
|
||
The vision: Pond Admins choose fonts for headings, body text, and monospace
|
||
from a set of free Google Fonts; fonts must be served from the instance
|
||
itself (never from Google's CDN) to avoid GDPR issues. Defaults: Roboto 400
|
||
(headings), Roboto 200 (body), Fira Code (monospace).
|
||
|
||
## Decision
|
||
|
||
- **Curated font catalog**: the repo contains a maintained list (~15–25
|
||
families) of OFL/Apache-licensed families with the needed weights. A build
|
||
step (`deploy/fonts/`) downloads the WOFF2 files **at image build time**
|
||
from google-webfonts-helper/upstream sources and bakes them into the `web`
|
||
image under `/fonts/<family>/`. No runtime download, no third-party
|
||
requests from visitors' browsers — CSP allows `font-src 'self'` only.
|
||
- **Per-pond configuration**: pond settings store three font slots
|
||
(`heading`, `body`, `mono`), each referencing a catalog entry + weight.
|
||
The app applies them as CSS custom properties
|
||
(`--font-heading`, `--font-body`, `--font-mono`) on the pond's root
|
||
element; `@font-face` rules for the catalog are generated once.
|
||
- Defaults per vision: Roboto 400 / Roboto 200 / Fira Code. (Roboto 200 is
|
||
provided via the variable font or the 200 static weight; fallback stack
|
||
`system-ui` chain.)
|
||
- Licensing: each catalog entry records its license (OFL/Apache); the
|
||
catalog page in the app shows attribution.
|
||
- Exports: the PDF renderer (ADR 0009) inlines the pond's fonts so PDFs
|
||
match the on-screen look.
|
||
|
||
## Consequences
|
||
|
||
- Adding a font is a catalog PR + image rebuild — no runtime font
|
||
management surface (deliberately small attack/complexity surface).
|
||
- Image size grows by a few MiB per family (WOFF2, subset to latin/latin-ext
|
||
by default) — negligible.
|
||
|
||
## Decisions taken in #303 (2026-08-01)
|
||
|
||
This ADR's "Consequences" said adding a font is a catalog PR plus an image
|
||
rebuild, with **no runtime font management surface**. That is amended here,
|
||
for the case this ADR already anticipated under "Alternatives considered":
|
||
_arbitrary font upload … may become a Site-Admin-level feature later_.
|
||
|
||
An operator running a private instance holds a licence for a typeface and
|
||
wants to use it on screen and in exported PDFs. Baking it into a custom
|
||
image works but ties every font change to a rebuild, and the file then
|
||
lives in the image rather than in the backup.
|
||
|
||
- **Site Admins only.** Not Pond Admins — which is what closes the
|
||
licensing-risk objection above: the operator who holds the licence is
|
||
the only one who can upload, and the licence is recorded with the font.
|
||
Uploaded families are additive; they never replace or shadow a catalog
|
||
family, and a name collision with one is rejected.
|
||
- **Uploads are data, not code.** The api stores the submitted bytes and
|
||
serves them back with a pinned content type. It validates the magic
|
||
number (`wOF2`/`wOFF`) and a size cap, and it does **not** parse the
|
||
font — family, category and licence come from the form. This is the
|
||
answer to the file-format attack-surface objection: font parsers are a
|
||
known memory-safety surface and we gain nothing from entering it.
|
||
- **WOFF2 required, WOFF optional, OTF not accepted.** WOFF2 covers both
|
||
consumers we have — the browser and Gotenberg's Chromium — and is the
|
||
format the PDF path already inlines. Storing OTF would enlarge uploads
|
||
and backups for no runtime benefit.
|
||
- **The bytes live under `./data/`, not in `FONTS_DIR`.** `FONTS_DIR` is
|
||
the catalog directory baked into the image: anything written there is
|
||
lost on the next deploy and is never backed up. Custom fonts go to
|
||
`CUSTOM_FONTS_DIR` (default `./data/fonts`), a sibling of the uploads
|
||
and plugins directories, and are registered with the backup so a
|
||
restore brings them back.
|
||
- **The GDPR guarantee is untouched.** Custom fonts are served from the
|
||
instance itself like the catalog ones; `font-src 'self' data:` stays as
|
||
it is, and a visitor's browser still makes zero third-party requests.
|
||
|
||
## Alternatives considered
|
||
|
||
- **Runtime font download by the server on admin selection**: flexible but
|
||
adds an outbound dependency, cache invalidation, and licensing bookkeeping
|
||
at runtime; rejected for v1.
|
||
- **Arbitrary font upload by Pond Admins**: licensing risk and file-format
|
||
attack surface; may become a Site-Admin-level feature later.
|