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"`.
375 lines
24 KiB
Markdown
375 lines
24 KiB
Markdown
# 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).
|