Skip to main content

Architecture

One image, outprint, runs in two roles. The reasons for the split are in ADR 0001; this page describes the roles, the contract between them, the request flow and how errors are mapped.

browser --> injector --> Outline (adds the button script to HTML pages)
| |
| +-- /pdf/* --cookie--> Gateway --cookie--> Outline
| |
| | job bundle (no cookie, no URL, no secret)
| v
| Worker (no network, no secrets) --> PDF

A third deployable, the injector, is a separate slim image (Injector and download button). It is not a role of the outprint image.

Roles​

RoleCommandTalks toHolds
Gatewayoutprint gatewaythe browser, Outline, the Workerthe user's cookie, for one request
Workeroutprint workerthe Gateway onlynothing; at most a shared bearer token

Both load and validate all packages at startup and refuse to start, naming every cause, if anything is wrong (the same check as outprint validate). The Gateway needs the packages to resolve the Metadata Block; the Worker needs them to render.

Gateway​

GET /render/{documentId} renders the Document and answers with the PDF. It is a GET with the id in the path so that the "Download PDF" button can be a plain link: the browser sends the user's cookie by itself and downloads the file. The endpoint changes nothing on the server, and the response is Cache-Control: no-store. GET /healthz answers 200. GET /button.js serves the button script (embedded from web/button.js; text/javascript, nosniff, max-age=300, no cookie needed). The Gateway sends no CORS headers: it is reached through the injector's /pdf prefix, on the origin of Outline (Injector and download button).

GET /status/{documentId} tells the button script whether to show the button (see Status endpoint). The Template and Configuration come from the Collection's Binding block (Binding) and nowhere else: no query parameter or header selects or overrides them.

Configuration (environment):

VariableMeaningDefault
OUTLINE_URLbase URL of Outline; the only origin the cookie is sent torequired
OUTLINE_PUBLIC_URLOutline's own URL setting, when OUTLINE_URL is an internal address; a redirect to this origin is fetched from OUTLINE_URL (Images)none
OUTPRINT_WORKER_URLbase URL of the Workerrequired
OUTPRINT_WORKER_TOKENoptional shared bearer tokennone
OUTPRINT_LISTENlisten address:8080
OUTPRINT_TIMEOUTrender limit; the Gateway waits 30 s longer for the Worker60s
OUTPRINT_SERVICE_CONFIGservice configuration file
OUTPRINT_ADMISSION_SLOTS, _QUEUE_DEPTH, _PER_USER, _MAX_WAIToverride the admission keys of the service configuration, see Limits and admission control1, 4, 1, 60s
OUTPRINT_LOG_LEVELdebug, info, warn, errorinfo

Worker​

POST /render takes a bundle and answers application/pdf or a JSON error {"phase", "kind", "job_id"}. GET /healthz answers 200 and needs no token. The pipeline behind it is described in Worker pipeline.

  • One job per instance. While a job runs, a second request gets 503 with kind busy.
  • A fresh 0700 job directory below OUTPRINT_WORK_DIR per job, removed afterwards. Production mounts the work root as a tmpfs.
  • After a timeout, a kill, a panic or any unexpected failure the Worker sends its response, then shuts down and exits non-zero. While it is shutting down it answers busy. The container runtime restarts it (restart: always), so the next job starts in a clean process. Ordinary document failures (a TeX error, an image problem) do not restart it.
  • If OUTPRINT_WORKER_TOKEN is set, /render requires Authorization: Bearer <token>; the comparison is constant-time and a mismatch is 401.
  • It refuses to start as UID 0 and logs its security posture: uid, gid, no_new_privs, whether the root file system is read-only, the effective capabilities, the number of network interfaces that are up, and the names (never values) of environment variables that look like secrets. Anything weaker than the shipped baseline is logged as a warning.
  • Server limits: request body capped by bundle.MaxEncodedBytes, header read 10 s, body read 2 min, idle 60 s.

The job bundle​

The bundle (internal/bundle) is the only thing that crosses from the Gateway to the Worker. It is JSON, Content-Type: application/json:

{
"markdown": "Dear ...\n",
"values": {"title": "Spring plant sale", "recipient": ["Ada Example", "1 Sample Lane"]},
"template": "neutral",
"documentType": "letter",
"configuration": "example",
"images": {"fig-1.png": "<base64>"}
}
FieldMeaning
markdownthe Document body, without its Metadata Block
valuesthe merged, typed field values of the Document Type: string, list of strings or bool
template, configurationpackage names; the Worker also checks the pairing against its allow-list
documentTypeone of the Template's Document Types
imagesimage files by bundle name (images/<name> in the Markdown). Fetched and canonicalised by the Gateway (an SVG is checked and passed on as .svg; the Worker converts it to a vector PDF), see Images

There is no field for a cookie, a URL or a secret, and unknown fields are rejected, so none can be added by accident. A test serialises a bundle built from a request with a canary cookie and asserts that neither the canary nor the Outline host reaches the Worker.

The Worker validates every bundle: names must be identifiers, value names and types are checked, and the limits below apply. The limits live in internal/bundle:

LimitValue
Markdown1 MiB
images50, 10 MiB each, 50 MiB in total
values200 names, 64 KiB per value, 200 list items, 1 MiB in total
PDF returned by the Worker50 MiB
pages of the PDF500

The per-job limits (time, pages, PDF size, TeX runs) and the container limits are named in one place, internal/limits; see Security.

Request flow​

  1. The browser calls GET /render/{documentId} with the user's cookie.
  2. The Gateway checks the id ([A-Za-z0-9_-], at most 100 characters; an Outline UUID or the urlId at the end of a /doc/<title>-<urlId> path) and that a cookie is present.
  3. It calls Outline documents.info with that cookie and no other credential. Outline's own permissions decide whether the user may read the Document.
  4. It selects the Binding. It reads the overview of the Document's Collection with collections.info and the same cookie, on every render and without a cache, and binding.Resolve turns it into a Binding: the first yaml block with a binding: key, or the default Binding of the service configuration only when the overview has no block at all. A broken block, a second block, an unknown name or a pair off the allow-list is a 422 with source: collection, never a fallback to the default. No block and no default is a 422 no_binding. A Document without a Collection has no block. See Binding.
  5. metadata.Resolve merges the Metadata Block with the derived values: title is the Outline title and date is the last-modified date (updatedAt, in UTC, as an ISO date).
  6. The Gateway builds and validates the bundle and posts it to the Worker.
  7. It streams the PDF back with Content-Disposition and Cache-Control: no-store.

The user's cookie leaves the Gateway at steps 3 and 4 only, and only to the origin in OUTLINE_URL. The Outline client never follows a redirect, so a redirect cannot carry the cookie elsewhere.

Status endpoint​

GET /status/{documentId} (cookie forwarded, nothing rendered) answers 200 {"available": true|false, "reason": "..."} with Cache-Control: no-store:

availablereasonwhen
truecollection_bindingthe Collection has a valid block
truecollection_binding_invalidthe Collection has a block that does not validate; the author sees the error on click
truedefault_bindingno block, and the installation has a default Binding
falseno_bindingno block and no default Binding

Failures are {"message","phase"} with the status of the cause: 400 for a bad id, 401 without a cookie or when Outline answers 401, 403 and 404 when the user cannot read the Document or its Collection, 500 otherwise. A client must treat any non-200 answer as "do not decide" and may keep the button hidden.

Admission control (Limits and admission control) sits in gateway.process. After the Binding is selected the Gateway asks Outline for the confirmed user id (auth.info) and counts the render against that user, before the images are fetched; a slot of the global limit is taken right before the Worker call. Both are released by defer on every path. A refusal is a 429 with Retry-After and an error list with source: service, code busy or user_limit. The status endpoint does neither.

File name​

The file name comes from the Outline title: control characters (CR and LF included), path separators, quotes and : * ? < > | ; % are replaced by a space, whitespace is collapsed, leading and trailing dots and spaces are trimmed, and the name is cut at 100 characters. An empty result is document. The header carries an ASCII fallback and the UTF-8 name per RFC 5987:

Content-Disposition: attachment; filename="Gruesse aus Koeln.pdf"; filename*=UTF-8''Gr%C3%BC%C3%9Fe%20aus%20K%C3%B6ln.pdf

Error mapping​

Validation problems are the structured body of Metadata: {"errors":[{"field","source","code","message","line"}]}. Every other error is {"message", "phase", "job_id"}, where the message is a fixed sentence. No response carries a log excerpt, TeX output, Document content or a cause.

SituationStatusNotes
invalid document id400
no cookie, or Outline answers 401401
Outline answers 403403
Outline answers 404404
metadata errors422the error list; all problems at once
no Binding422one error with source: collection, code: no_binding
invalid Binding block422errors with source: collection and a binding_* code, see Binding
Outline answers 403 or 404 for the Collection403 / 404phase: binding, no fallback
Markdown, bundle or PDF over a limit413
too many pages (pages_exceeded), PDF too large (pdf_too_large)413phase: output
the job ran out of memory or scratch space (resource)413the Worker restarts
Worker busy429with Retry-After
renderer at capacity (busy), user already has a render (user_limit)429admission control, with Retry-After; error list with source: service, see Limits and admission control
Outline does not confirm the user401 / 403 / 500never counted by address
Worker timeout504phase: timeout
Outline or Worker unreachable, TeX error, any other failure500only phase and job id

Logging​

Both roles log with log/slog to stderr.

  • The Gateway logs one line per request: job, document (Outline's id), phase, kind, status, duration_ms. A 5xx adds the cause, which is an error of the clients and carries no response text. The job is the Worker's job id when the Worker answered, else a Gateway request id.
  • The Worker logs, per failed job, job, phase, duration_ms, exit_code and the first TeX error line, see Worker pipeline. The first TeX error line stays in the Worker's log and does not travel to the Gateway; find it by the job id.
  • Neither role logs Document content, metadata values or the cookie.