dorfteich/docs/architecture/security.md
Claude Opus 5 6377faf332
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m28s
CI / Build container images (pull_request) Successful in 2m7s
CI / Auth e2e pack (pull_request) Successful in 9m37s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s
CD / Build and push images (push) Successful in 23s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m47s
CD / Promote to Int (push) Successful in 16s
CI / Lint, typecheck, test (push) Successful in 7m25s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m41s
CI / Import/export fidelity gate (push) Successful in 1m12s
#306: instance branding — logo and favicon, cropped in the browser
An instance had no way to look like itself: the top bar said "Dorfteich"
whatever the operator called their instance, `instance.name` was never
rendered in the running app at all, and there was no favicon anywhere —
`index.html` had no `<link rel="icon">` and `public/` held only fonts and
theme-init.js.

Where the line is drawn, and why:

- **The api never decodes an image.** Cropping, scaling and the conversion
  to PNG happen on a canvas in the browser; the api checks the PNG
  signature, reads the IHDR dimensions at their fixed offsets and enforces
  the caps. An image library would put a decoder in front of
  attacker-supplied bytes AND would have to be carried through the
  `--network none` offline build. Reading two big-endian integers is not
  decoding.
- **SVG is refused**, with its own error message rather than a generic
  "not a PNG": it can carry script, and serving it from our own origin
  would be a cross-site-scripting vector. An operator who tried one should
  learn that it is deliberate.
- **The crop is driven by number inputs, not by dragging.** A drag-only
  cropper excludes keyboard and switch users outright; a number input is
  arrow-key operable and screen-reader readable without any custom aria.
  The resulting pixel size is stated in text, not only drawn as a frame.
- **The variant is chosen by CSS, not JavaScript.** `theme-init.js` has
  already resolved `data-theme` before first paint, so the correct logo is
  the one painted rather than the one that appears after a flash. Without a
  dark variant the LIGHT logo carries both themes — the operator's own
  asset shown unchanged beats one they did not choose (the rule #307
  extends to ponds). The settings screen warns; it never blocks.
- **The favicon link is static, its resource dynamic.** index.html stays a
  static file and the api answers with the uploaded icon or a shipped
  default — that route must never 404, or the browser keeps its generic
  icon for good. The default is generated by a script from Node's own zlib
  (`gen-default-favicon.mjs`), for the same offline-build reason.
- Both favicon sizes are uploaded together: one source, one crop, so the
  tab icon and the home-screen icon can never disagree.
- Branding is served WITHOUT a session, because the login screen carries it
  and the browser fetches the favicon before anyone signs in. The admin
  screen says so — an operator may not expect their logo to be public.
- The metadata is not writable through the settings endpoint: it describes
  bytes on disk, and hand-writing it would claim an asset that is not
  there.

`./data/branding` follows the three-step rule #303 paid for: env default +
`data-dirs.ts` entry, compose volume (repo AND the stages on ONE), and the
`mkdir`/`chown` line in the api Dockerfile. `data-dirs.test.ts` is new and
closes the hole that made #303's variant invisible: the nightly archive
skips a missing directory WORDLESSLY, so the fence now demands that every
`*_DIR` the backup env declares actually travels in the archive. Verified
against the real defect — removing the line fails it by name.

Audit catalogue v1.7 (`branding.changed`), carrying `scope` from the start
so #307 is the same event with a different scope, not a second id.

Verified: api suite 103 files green (a lone `public-api` ECONNRESET under
local parallel load, green in isolation — the documented local flake);
branding suite 12 tests against a real directory; crop arithmetic unit
tests; a11y pack 11/11 in both schemes; /admin measured at 320px with the
new section (overflow 0); and the whole flow walked in the browser: upload
→ crop 780×180 → stored as 512×118 → logo in the sidebar linking home with
the instance name as its accessible name → topbar wordmark following
`instance.name` → light logo still shown under `data-theme="dark"`.
2026-08-01 19:30:52 +02:00

375 lines
24 KiB
Markdown
Raw Permalink 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.

# Security concept
Threat-driven summary; detailed mechanics live in the referenced ADRs.
## Assets & main threats
Wiki content (possibly confidential per pond/label), user credentials and
e-mail addresses, instance availability. Threat actors: anonymous internet
(public instance with self-signup), malicious registered users, malicious
or sloppy plugin authors, compromised dependencies.
## Authentication & session security (ADR 0007)
- Argon2id password hashing; opaque server-side sessions in HttpOnly,
Secure, SameSite=Lax cookies; CSRF protected by SameSite + origin checks
on mutating requests (double-submit token for the file-download edge
cases).
- The origin check **fails closed** (issue #189): a cookie-carrying
mutation without `Origin` and `Referer` (or with an unparsable one) is
rejected with `403 csrf_origin_mismatch`. Non-browser clients
authenticate with a PAT/bearer token and no cookie, which never reaches
the check — the exception is structural, not a header loophole; a
request that does carry the session cookie is always checked. Scripted
cookie clients must send `Origin: <APP_BASE_URL>`.
- Session bounds are configurable (issue #190): an absolute lifetime
(`SESSION_ABSOLUTE_HOURS`, default 7 days, never extended by activity —
also the cookie `maxAge`) and an idle timeout (`SESSION_IDLE_HOURS`,
default 3 days), both enforced server-side, the idle bound against
`lastSeenAt`.
- E-mail verification (double opt-in) before an account can create content;
password reset via single-use hashed tokens; both rate-limited.
- Rate limiting (DB-backed) on login, signup, reset, and API; lockout
backoff on repeated failed logins per account+IP.
- Feed tokens (issue #149) authenticate feed URLs via `?token=` — feed
readers cannot send headers, which is why the credential lives in the
URL at all. Moving it into a path segment was rejected (issue #191): a
path lands in the same proxy and request logs as a query string.
Instead: the instance switch `feeds.enabled` hides the whole feed
surface with 404 semantics (the VS-NfD reference configuration turns
feeds off), tokens are stored hashed, and the api's request log masks
`?token=` values (`common/mask-token-param.ts`), so no code path logs
the credential.
- Self-registration can be disabled instance-wide; personal-pond quotas
(editors/readers/ponds/storage) bound the blast radius of spam accounts.
## External authentication (OIDC, issue #214, ADR 0021)
- **Authorization Code + PKCE**, discovery-configured, ID tokens validated
against the IdP's JWKS with an explicit `RS256`/`ES256` allowlist —
built on `jose` (the vetted library from #188) plus `fetch`, so no new
dependency enters the supply chain for a security base function.
Nothing is IdP-specific; Keycloak is the reference IdP.
- **Deploy-level configuration** (who authenticates users is a platform
decision, not a Site-Admin setting): `OIDC_ISSUER`, `OIDC_CLIENT_ID`,
optional `OIDC_CLIENT_SECRET` (public client uses PKCE alone),
`OIDC_SCOPES` (default `openid profile email`), `OIDC_PROVIDER_LABEL`
(login-button text). Enabled iff issuer + client id are set; the login
page discovers this via `GET /auth/methods`.
- **State, nonce and the PKCE verifier** travel in a signed, HttpOnly,
SameSite=Lax cookie (10 min TTL) whose key is HKDF-derived for the
dedicated `oidc-state` purpose (ADR 0020) — the callback binds the
IdP's `state` and the ID token's `nonce` to exactly that browser.
- **Identities**: `provider = "oidc:<issuer>"`, `subject` from the token.
First login creates the account just-in-time (ACTIVE, mail verified —
refused if the IdP does not supply a verified address). An existing
local account with the same address is NEVER adopted silently (that is
an account-takeover path): the login is refused with
`oidc_link_required`, and the owner links explicitly via
`GET /auth/oidc/link` from a logged-in session (audited as
`auth.identity_linked`).
- **One session mechanism**: OIDC produces the same server-side session
as the password login (#190 bounds apply). IdP-initiated single logout
is deliberately NOT implemented: sessions are short-bounded, and the
claim-mapping revocation path (#217) plus the account-disable flag
cover the leaver case — recorded in ADR 0021.
- **The hard local-auth switch (issue #216)**: `AUTH_LOCAL_ENABLED=false`
closes every local credential flow with 404 — login, signup, e-mail
verification, resend, password forgot/reset/change — enforced centrally
in the auth guard via a route marker with an enumeration fence.
Deploy-level on purpose (a compromised Site Admin cannot flip it back).
Sessions, logout and PAT/feed-token issuance for externally
authenticated users keep working; stored password hashes remain
(documented, ADR 0021). Bootstrap: complete setup before flipping.
- **Trusted-proxy / mTLS path (issue #215)** — for perimeters that
authenticate before the application. **The trust boundary, precisely:**
the identity header (`AUTH_PROXY_HEADER`) is honoured if and only if
the request's **TCP peer address** — never a forwarded header — is on
`AUTH_PROXY_TRUSTED_PEERS`. Off unless both are set; nothing about the
header is ever guessed. A request carrying the header from any other
peer is rejected outright (403) and audited (`auth.proxy_rejected`) —
that is a spoof attempt, not a misconfiguration. A session cookie
riding alongside the header never escalates beyond the header identity;
with the feature off the header is inert. Mapping is explicit
(`AUTH_PROXY_MAP`: the value is the local username or e-mail; no
just-in-time creation — the header carries no verified address). The
mTLS variant (`AUTH_PROXY_MODE=mtls-dn`) expects the TLS terminator to
forward the client-certificate subject DN in the same header and maps
the configured attribute (`AUTH_PROXY_DN_ATTRIBUTE`, default CN).
Everything upstream of the trusted peers — TLS termination, certificate
validation, header hygiene (the proxy MUST strip the header from
incoming traffic) — is the operator's platform responsibility.
- **Keycloak verification procedure** (repeatable): run
`docker run --name keycloak-local -p 8089:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:26.0 start-dev`;
via `kcadm.sh`: create realm `dorfteich`, a public client
`dorfteich-web` with redirect URI
`<APP_BASE_URL>/api/v1/auth/oidc/callback`, and a user with password +
verified mail. Start the api with the OIDC variables pointing at
`http://localhost:8089/realms/dorfteich`, then drive
`GET /auth/oidc/login` → Keycloak form login → callback with a cookie
jar (curl suffices) and confirm `GET /auth/me` returns the
just-in-time account. Last verified 2026-07-31 against Keycloak 26.0.
## Authorization
- Single resolution algorithm (`permissions.md`) in `packages/shared`,
enforced in API guards and at collab token issuance — never in the client.
- Default-closed: no grant → no access. Public access is always an explicit
grant.
- Admin actions are audit-logged (`operations.md`).
## Content & upload security
- Editor content is structured (ProseMirror schema) — no raw HTML from
users. The HTML render endpoint escapes everything outside the schema;
link protocols allowlisted (`https`, `http`, `mailto`).
- Uploads (ADR 0011): MIME/extension allowlist, size limits, magic-byte
checks, SVG sanitization or rejection, `Content-Disposition: attachment`
for non-image types, no user content served same-origin as executable
(`X-Content-Type-Options: nosniff`; uploads path never serves
`text/html`).
- **Font upload (issue #303, ADR 0016 §#303)**: Site Admins — not Pond
Admins — may upload licensed font families. The api validates the magic
number (`wOF2`/`wOFF`) and a per-file size cap and then stores the bytes;
it deliberately does **not** parse the font. Family, category and licence
come from the form, so nothing is gained by entering a font parser's
memory-safety surface. OTF is not accepted (no consumer needs it). Files
are served from `/api/v1/fonts/custom/<slug>/<file>` with a pinned
`font/woff2`-or-`font/woff` content type and the instance-wide `nosniff`
header; the path is validated against the file's own slug prefix, so it
cannot reach another family's directory. Uploads and deletions are
audited (`font.uploaded`, `font.deleted`).
- **Branding upload (issue #306)**: Site Admins upload an instance logo and
favicon; the pond-level override (#307) puts the same surface in the hands
of ordinary Pond Admins, so the rules are identical at both levels. **SVG is
refused** — it can carry script, and serving it from our own origin would be
a cross-site-scripting vector. Cropping, scaling and the conversion to PNG
happen in the BROWSER on a canvas; the api validates the PNG signature, the
IHDR dimensions (fixed offsets — no decoding) and a size cap, then stores
the bytes. No image library runs in the api: it would put a decoder in front
of attacker-supplied bytes and would have to be carried through the
`--network none` offline build. Assets are served from
`/api/v1/branding/…` with a pinned `image/png` content type under the
instance-wide `nosniff` header. The serving routes are **unauthenticated by
design** — the login screen carries the branding and the browser fetches the
favicon before anyone signs in; the admin UI states this. Changes are
audited (`branding.changed`).
- App CSP (strict): `default-src 'self'`; `font-src 'self'` (ADR 0016);
no third-party origins at all — the GDPR posture is "zero external
requests". Operator-uploaded fonts are served from the instance itself
like the catalog ones, so this is unchanged by #303.
- **Attachment integrity (issue #199)**: every upload stores the SHA-256
of its bytes, computed from the in-memory buffer as it is written (never
by re-reading disk). Every download re-hashes the stored object BEFORE
the first byte leaves and fails closed on mismatch with
`attachment_integrity_failure` (HTTP 500); the mismatch is recorded in
the audit trail (`file.integrity_failed`). §52 VSA leaves detecting
manipulation of the application's own payloads to the application —
only it knows what the file should be. Operator response to a
verification failure: treat the object as tampered/corrupt, restore the
affected file from backup (restore runbook), then re-download to
confirm; the audit entry carries both hashes for the report.
Pre-existing rows are hashed by the nightly backfill (part of the
orphan-file-sweep job) and served unverified only until it reaches
them; unreadable files are logged and retried, never silently skipped.
- The full-text index holds **no trashed content** (issue #195): trashing
a page or pond clears the affected `search_vector`s, restore rebuilds
them, `reindexAll` converges to the same invariant, and a one-off
migration backfilled pre-existing trash. The query-side
`deleted_at IS NULL` joins stay in place as the second, independent
layer — a future query path that forgets them still finds no trashed
vectors. (The plaintext cache row itself remains until purge; the index
is the concern here because it is queryable.)
## Security response headers & CORS (issue #197)
Every api response carries this header set, stamped by a hand-rolled
15-line middleware (`apps/api/src/common/security-headers.middleware.ts`)
rather than `helmet` — the set is small enough to own, each value is a
deliberate decision, and the api gains no transitive dependency. It is
wired through the AppModule's `MiddlewareConsumer`, so the e2e harness
boots the exact production middleware.
| Header | Value | Why |
| --------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Strict-Transport-Security` | `max-age=31536000` | One year, **no** `includeSubDomains` — the api cannot speak for sibling subdomains it does not control. Browsers ignore HSTS over plain http, so it is sent unconditionally. |
| `X-Content-Type-Options` | `nosniff` | No MIME sniffing, anywhere (the uploads path relies on this too, see above). |
| `Referrer-Policy` | `no-referrer` | Page paths are permission-scoped knowledge; leak them to no destination. |
| `X-Frame-Options` | `SAMEORIGIN` | Deliberately **not** `DENY`: the plugin sandbox (ADR 0008) embeds `/api/v1/plugins/<id>/<version>/frame` same-origin, and the frame's CSP has no `frame-ancestors` — this header governs its framing. |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=(), usb=()` | Powerful browser features denied outright; nothing in the app uses them. |
**CORS** is a stated decision, not an implicit default: no foreign origin
is granted anything. The middleware echoes `Access-Control-Allow-Origin`
(plus `Allow-Credentials: true`) only for the `APP_BASE_URL` origin
itself — where browsers never consult CORS anyway, since the SPA calls
the api same-origin (the dev server proxies `/api`). The echo documents
the stance rather than enabling a caller; consequently there is no
preflight handling (same-origin requests never preflight), and every
response carries `Vary: Origin` for cache correctness. Cross-origin API
access is cookie-less by design anyway (PAT/Bearer, see Public API), and
non-browser clients are unaffected by CORS.
Regression fence: `apps/api/src/common/security-headers.e2e.test.ts`
(header set, foreign origin gets no ACAO) and the frame assertion in
`plugins.e2e.db.test.ts`. TLS termination itself is the reverse proxy's
job (out of scope, below).
## Plugin sandboxing (ADR 0008, operational)
- Code plugins: opaque-origin iframes, no network (`connect-src 'none'`),
capability-scoped postMessage API executed with the **viewer's**
permissions server-side; declared capabilities surfaced to the Site Admin
at install time.
- Style plugins: CSS sanitized (no `@import`/external `url()`), scoped
class names.
- Install surface restricted to Site Admins; packages size-limited and
schema-validated; the `plugins/` directory watcher only trusts the volume
(host-level access implies game over anyway).
## Collaboration layer
- WebSocket connect requires a short-lived (≤ 60 s) single-purpose JWT
bound to user + page + mode; write revocation closes sessions via
LISTEN/NOTIFY (`realtime-collaboration.md`).
- Update size and document size ceilings prevent resource-exhaustion via
crafted CRDT updates.
## Secrets & configuration
- Secrets (DB password, token root key, SMTP credentials) live only in
the stage `.env` (mode 600, never in git) and container env — not in the
database (`instance_settings` stores non-secret config; the SMTP password
entered in the setup wizard is written to the env-backed secret store,
not to a DB row).
- Token key hierarchy (ADR 0020, issue #188): `COLLAB_TOKEN_SECRET` is a
ROOT key. Each token purpose uses its own HKDF-SHA-256 subkey
(`deriveTokenKey` in `packages/shared/src/token-crypto.ts`): `collab`
for the collaboration JWTs (signed and verified by `jose`, HS256 as an
explicit allowlist), `unsubscribe` for the digest unsubscribe links. No
code path signs with the root key directly, so a compromise of one
purpose's tokens is not transferable to the other.
- Dual-verify window: REMOVED early by operator decision at the ADR 0020
acceptance (issue #296, 2026-07-31). Verification is subkey-only;
unsubscribe links minted before the key separation no longer work —
recipients use the in-app notification settings instead. A regression
test pins that the legacy derivation (root key + purpose prefix) can
never verify again.
- Key rotation: rotating the root key rotates every derived subkey at once
(desired: one secret to rotate) via env change plus rolling restart;
procedure documented in `operations.md` runbooks.
- Dependencies: lockfile-pinned; monthly update batch; images pinned to
digests in Prod.
## Supply chain artefacts (issue #202)
- **SBOMs**: every release run (`release.yml`) generates CycloneDX 1.6
SBOMs with a pinned `anchore/syft` container — one per released image
(scanned from the freshly built image tar, OS packages included) and one
for the pnpm workspace (scanned from `pnpm-lock.yaml`) — and attaches
them as build artefacts named `supply-chain-vX.Y.Z` on the release's
action run. Provenance: the workflow file records the exact syft
version; regenerate any of them with
`docker save <image> -o image.tar && docker run … anchore/syft:<pinned>
scan docker-archive:/image.tar -o cyclonedx-json` respectively
`scan dir:<workspace> -o cyclonedx-json` at the release tag.
- **License policy**: `scripts/check-licenses.mjs` holds the documented
allowlist (permissive licenses, plus MPL-2.0 and CC-BY-4.0 with recorded
reasoning, plus a per-package exception table for wrong upstream
metadata). CI runs the gate on every pull request; the release run
additionally stores the full `pnpm licenses` report next to the SBOMs.
A dependency outside the allowlist fails the build — extending the
policy is a reviewed change to that script, never a build fix.
## Logging
- Application logs are pino JSON on stdout; `authorization` and `cookie`
headers are redacted, request bodies are never logged, and feed-token
query values are masked (issue #191). Log forwarding and retention are
the container runtime's job (SIEM division of labour — the application
side of that contract is the stable event catalogue, issue #201).
- **Audit event catalogue** (issue #201): the versioned contract SIEM
rules are written against — every emittable event id with trigger,
severity, actor/target semantics and fields — lives in
`docs/architecture/audit-events.md`. The action set is a typed union in
code (an uncatalogued id cannot be emitted), audit stdout lines carry
the catalogue `severity`, and a CI fence (`audit-catalogue.test.ts`)
fails when document and code drift. Forwarding path: container stdout →
the operator's collector; deliberately no application-side syslog
client.
- The persistent audit trail (`audit_log`, issue #86) records auth and
admin events — who changed access or configuration, not who edited
what; content activity stays log-only by design.
- Audit retention (issue #196): entries are kept for
`audit.retentionDays` (instance setting, default 365) and pruned by the
daily `audit-retention` job; each pruning run is itself recorded as
`audit.pruned` with count and cutoff, so a gap in the trail is always
explainable. The read-access trail (#222#225) is deliberately not
covered by this period — it gets its own.
- **Read-access trail** (issue #222, ADR 0023): reads of pages with
`classification = vs_nfd` land as `read_events` rows — only classified
pages, which is what keeps the purpose limitation defensible (variant A).
Master switch `readTrail.enabled`, **default off** (#225): off means no
event is written anywhere, including stdout, and the api announces the
switch position once per boot so an eventless trail is never ambiguous.
The written purpose limitation lives in
`docs/vs-nfd/60-sicherheitsdokumentation.md` §7.
The instrumented channels, and the emission point of each:
- `page_view` — authenticated SPA state fetch (`GET /pages/:id`, the
by-slug variant), the rendered read view (`/read/...`), the public JSON
content route, the plugin-API content route, and an expanded embed of a
classified page inside another page's rendering.
- `no_js_shell` — the server-rendered `/public/:pond/:page` document.
- `public_api``GET /api/public/v1/.../pages/:slug` and the MCP
`read_page` tool (same emission point); the write echo of the public
API's create/update counts as a read of the returned page.
- `attachment``GET /media/:fileId` when the attachment's effective
classification (#212 semantics) is `vs_nfd`.
- `export` — per-page markdown download, one event per classified page in
a pond ZIP or the account data export, and the queued
`.docx`/`.odt`/`.pdf` export (recorded at enqueue — the user's action;
the worker's conversion is machinery, not a second read).
- `collab_join` — collab-token issuance, the api-side proxy for the
collab WS join: the collab server has no permission context, and the
60 s token TTL yields per-minute granularity for live sessions.
Each event carries timestamp, actor (or the documented `anon` marker),
session key (`session:`/`token:`/`job:`/`anon`), page, pond, channel and
the classification at read time (a later reclassification never rewrites
history). **Failure is not silent**: a failed trail write aborts the read
with a 500 — the deliberate contrast to the audit trail's swallow-and-log,
because a lost event is a gap in evidence (ADR 0023). Deliberately NOT
instrumented (recorded residual): content _fragments_ — search-result
snippets, task-overview rows, backlink titles — and the Atom feeds
(disabled in the VS-NfD reference configuration, #227). Digest mails
carry titles only (the #231 residue).
## Privacy (GDPR)
- No external requests from the browser (fonts self-hosted, no CDNs, no
analytics by default).
- Instance-configurable legal pages (imprint, privacy policy) are a core
feature; dorfteich.online uses the operator's standard texts.
- Data minimization: username, e-mail, password hash, locale — nothing
else required. Account deletion: personal pond and authored ponds
follow the trash/purge path; authorship on shared content is pseudonymized
("deleted user"). A data-export endpoint (own profile + own ponds as
Markdown/ZIP) supports access/portability requests.
- IP addresses appear only in rate-limit counters (short TTL) and reverse
proxy logs (host-level rotation) — documented in the privacy-policy
template.
- Sent mail is not kept forever (issue #234): SENT and permanently FAILED
`mail_outbox` rows are deleted after `mail.outboxRetentionDays`
(default 30) by a daily job. This bounds the copy of content-adjacent
data — digest bodies name page titles and actors. That digest mails
carry page titles at all is a recorded, accepted residue (issue #231):
there is no per-page classification marking yet to key a suppression
on (that lands with ADR 0022 / M32, revisit there), and a VS-NfD
reference configuration (#227) can leave SMTP unconfigured entirely.
## Out of scope (v1, explicit)
- No end-to-end encryption of page content (server sees plaintext — needed
for search, export, rendering).
- No plugin marketplace/signing — installation is a deliberate Site Admin
act of trust in the reviewed package.
- No SSO in MVP (OIDC-ready per ADR 0007).