Server-side PDF export for reading/sharing (ADR 0009), rendered by a new
internal Gotenberg (headless Chromium) sidecar.
- Sidecar: `gotenberg/gotenberg:8` in the compose stack (internal, pinned,
healthcheck); api `GOTENBERG_URL` env; a `renderer` readyz check at
warning-level (mirrors the converter) so PDF export degrades gracefully when
Gotenberg is down without failing readyz.
- Export HTML: `buildPdfHtml` renders a self-contained document (no app chrome)
— the page's content with images inlined as data URIs, the pond's fonts
inlined as base64 `@font-face` + applied via CSS variables (ADR 0016), print
CSS (A4, page-break rules, a title header), and page numbers from Gotenberg's
footer. Plugin-block fallbacks are a marked TODO(#79) for M7.
- Fonts in the api image: the api Dockerfile now bakes the font catalog in
(`build-fonts.mjs` with FONTS_OUT) so the exporter can read a pond's chosen
WOFF2 and inline them; a missing file falls back to the system stack.
- Job flow: `POST /pages/:id/export {format: pdf}` builds the HTML (read
permission checked by the guard) and enqueues an `export_pdf` job on the #62
queue with the HTML as input; the worker branches `to === 'pdf'` to the
`GotenbergRenderer` (html → pdf) instead of pandoc, retrying an unreachable
sidecar and failing a refused render (`renderer_unavailable`/`render_failed`,
de+en). The client polls and downloads `GET /jobs/:id/result`.
- Frontend: the page-menu PDF button is now a real export (PDF added to
EXPORT_FORMATS; the disabled placeholder removed).
- Tests: export.service.db PDF cases (HTML has title/font-variable/inlined
image; renderer-down fails with `render_failed`); e2e PDF export self-skips
without a Gotenberg sidecar (like the .docx case). Verified locally against
real Gotenberg — a valid PDF with the pond font embedded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
Import/export conversions run asynchronously against an internal pandoc-server
sidecar with limits and graceful failure (ADR 0009). This is the plumbing;
the import (#63) and export (#65) features enqueue jobs onto it.
Sidecar & config:
- pandoc/core:3.6 in HTTP server mode added to the Compose stack, internal
network only, with a wget healthcheck on /version; the api depends on it
healthy and reaches it via the new PANDOC_URL env (default http://pandoc:3030).
- readyz gains a warning-level `converter` check: an unreachable sidecar
degrades import/export but never flips the instance to unready (new `warn`
status on ReadinessCheck).
Conversion flow (apps/api/src/import-export/):
- ConversionJob table (per-request work queue, distinct from the name-keyed
maintenance Job table): owner, formats, input/result bytes, status, attempts,
lockedAt. Migration + owner cascade.
- PandocConverter (abstract) + PandocServerConverter: POST / with
{text,from,to,standalone}; binary input formats (docx/odt/…) are base64-encoded
in `text`; 60 s AbortController timeout; input/output size caps. Failures map
to distinct localized codes — converter_unavailable / converter_timeout
(retryable) and conversion_failed (final).
- ConversionWorker: claims one job at a time with `FOR UPDATE SKIP LOCKED`
(safe against overlapping sweeps and a second process), recovers a stale
RUNNING lock, retries transient failures up to 3 attempts then fails. A 2 s
sweep plus wake-on-enqueue means a queued job survives an API restart.
- ConversionJobService.enqueue (size-limited) + owner-scoped GET /jobs/:id
(poll) and GET /jobs/:id/result (stream the output); a foreign/unknown id is
404. ConversionJobView in @dorfteich/shared.
Tests:
- conversion-job.e2e.db.test.ts (fake converter injected via a new createTestApp
override hook): enqueue→convert→poll→result; foreign/unknown job 404; a
persisted PENDING job picked up by a fresh app's worker (restart survival);
sidecar-down fails after 3 retries while the API stays healthy.
- pandoc.converter.test.ts: success, non-200→conversion_failed, refused→
converter_unavailable, and a delay-injecting server→converter_timeout.
- Verified locally against a real pandoc/core:3.6 container: markdown→html,
markdown→docx (valid PK/OOXML bytes), and a docx→markdown round-trip.
Local: typecheck, lint, i18n:check, build all green; api 193 tests
(9 new), shared 121, web 50.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
The api mints a short-lived (60 s) HS256 JWT per page open after an interim
permission check; the collab server authenticates every connection with it
(ADR 0003/0007 — the only JWTs in the system).
- packages/shared: browser-safe token schema/types in `collab-token`, and the
Node `crypto` sign/verify in `token-crypto` behind its own subpath export
(`@dorfteich/shared/token-crypto`) so the web bundle never pulls in
`node:crypto`. Only HS256 is produced/accepted; the signature is checked in
constant time before any untrusted field is read.
- api: `GET /pages/:id/collab-token` (auth-required) returns
{token, mode, expiresInSeconds}; `mode` is rw/ro via the interim access
service; issuance is logged at debug level without the token value.
- collab: `onAuthenticate` verifies the token, checks the pageId matches the
document name, stores {userId, mode} context, and enforces `ro` via
Hocuspocus' read-only connection flag. Hocuspocus' own signal handling is
disabled so index.ts remains the single shutdown owner.
- Shared COLLAB_TOKEN_SECRET env for api + collab (compose, dev overlay,
.env.example, stage docs); a dev default keeps native dev/test/CI running.
Tests: shared token round-trip/rejection; api endpoint e2e (auth required,
claims, 404 for non-members/unknown ids); collab integration via
HocuspocusProvider (valid token connects; expired/tampered/mismatched-page/
wrong-secret rejected; read-only writes dropped, verified with two clients).
Closes#34
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bootstrap apps/collab as a Hocuspocus WebSocket server (ADR 0003):
- pino JSON logging (service=collab) and shared Zod env validation
(collabEnvSchema); structured connection open/close logs.
- /healthz endpoint (process liveness + PostgreSQL ping) served via the
onRequest hook, matching the container-internal path and the proxied
/collab/healthz path; any WebSocket handshake is accepted for now
(authentication arrives with #34, persistence with #35).
- Dockerfile (ESM workspace build) and a compose service on the frontend
and internal networks with a healthcheck; dev overlay service and a new
COLLAB_PORT variable.
- CD builds, pushes, and promotes the collab image; CI builds it on PRs;
the smoke suite asserts /collab/healthz through the reverse proxy.
- deployment.md/stages.md: proxy routing, per-stage COLLAB_PORT, checklist.
Closes#33
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the FileStorage abstraction (uploads/<pondId>/<fileId> on the
mounted volume), the attachments model, and POST /ponds/:id/files, GET
/media/:fileId, DELETE /files/:id. Uploads are validated by sniffing
magic bytes rather than trusting the client's Content-Type/filename
(catches a renamed .html-as-.png), checked against the max_file_bytes
and storage_bytes quotas, and served with nosniff + immutable caching.
Closes#27
- compose: pass APP_BASE_URL and SMTP_* through to the api container so
stages can use a real relay (defaults still match the dev Mailpit
overlay); document the new keys in .env.example and stages.md
- seed: FIXTURE_ADMIN_PASSWORD / FIXTURE_USER_PASSWORD env overrides so
shared stages get non-public fixture passwords; credential is re-hashed
on every run so re-seeding applies a changed password
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpQz6ypHJsLfMf4S6fyQEB
MailService renders transactional mails (verify-email, reset-password)
from the new de/en `mails` i18n namespace — text plus minimal HTML
with escaped interpolation — and enqueues them into mail_outbox.
MailWorker delivers pending rows every 15s through an injectable
transport (nodemailer; faked in tests) with quadratic backoff and a
permanent FAILED state after five attempts, logged as a warning.
SMTP_* and APP_BASE_URL join the environment schema with defaults
matching the new Mailpit container in the dev overlay.
Closes#12
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Multi-stage images: web (workspace build baked into unprivileged
nginx with SPA fallback, asset caching, /healthz) and api (pnpm deploy
bundle with the prisma CLI for migrate-on-start, non-root, node-based
healthcheck). deploy/compose/docker-compose.yml defines the stage
stack (web, api, db) with frontend/internal networks, localhost-only
published ports for the host reverse proxy, log rotation, and named
volumes; .env.example documents every variable. compose.dev.yml layers
hot-reloading dev containers (or database-only usage) over the same
definition. Verified locally: full stack healthy, SPA fallback, readyz
green after automatic migration, db not reachable from outside.
Closes#6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
apps/api gains Prisma (instance_settings as the first model) with the
initial migration applied automatically at startup via prisma migrate
deploy, a lazy-connecting PrismaService, and GET /api/v1/readyz
reporting named checks (database reachable, migrations applied) with
200/503. DATABASE_URL joins the validated environment schema;
MIGRATE_ON_START=false skips deploys for tests and tooling. An
idempotent seed script and a Compose dev overlay with PostgreSQL
(host port 5434 — 5433 is taken locally) complete the loop.
Closes#3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>