dorfteich/docs/architecture/adr/0016-self-hosted-fonts.md
Claude Opus 5 b96997501a
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
#303: operator-uploaded fonts — storage, API, PDF embedding, backup
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.
2026-08-01 14:49:13 +02:00

85 lines
4.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (~1525
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.