Commit Graph

239 Commits

Author SHA1 Message Date
1f56f34113 #296: remove the unsubscribe-token dual-verify window early
All checks were successful
CD / Smoke tests against Test (push) Successful in 1m25s
CD / Promote to Int (push) Successful in 12s
Release / Build release images and notes (push) Successful in 3m31s
Release / Release-candidate operations QA (push) Successful in 46s
CI / Build container images (push) Has been skipped
Prod deploy / Deploy the released images to Prod (push) Successful in 58s
CI / Import/export fidelity gate (push) Successful in 59s
CI / Lint, typecheck, test (push) Successful in 6m40s
CI / Auth e2e pack (push) Successful in 8m21s
Restore drill / Restore the latest backup into a scratch stack (push) Successful in 1m18s
CI / Build container images (pull_request) Successful in 2m53s
CI / Auth e2e pack (pull_request) Successful in 8m34s
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 14s
Operator decision at the ADR 0020 acceptance: verification is
subkey-only now instead of waiting for the stated 2026-11-01 expiry.
Links in digest mails sent before the #188 key separation stop working;
recipients use the in-app notification settings. A regression test pins
that the legacy derivation (root key + purpose prefix) can never verify
again; security.md records the removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 23:03:03 +02:00
9b7acab294 #232: plugin allowlist with SHA-256 hash pinning
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m21s
CI / Build container images (pull_request) Successful in 3m59s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m27s
CI / Import/export fidelity gate (push) Successful in 58s
CI / Lint, typecheck, test (push) Successful in 6m30s
The install path records the SHA-256 of the delivered bundle ZIP
(plugins.bundle_hash; pre-#232 installs show it as unknown until
reinstalled). plugins.allowlist in instance_settings names permitted
ids with their pinned hashes: empty (default) = not enforced, existing
instances unchanged; non-empty = installs of unlisted or deviating
bundles are rejected (plugin_not_pinned / plugin_hash_mismatch, 403),
and an installed plugin outside the list or with a deviating hash does
not load — absent from pond mount lists, frame/assets 404. Every
rejection is audited (plugin.rejected, catalogue v1.5). A version bump
changes the hash and therefore requires an explicit re-pin — the
intended friction (ADR 0025). Admin UI shows observed vs pinned hash
per plugin with pin/re-pin/unpin. Scope stated honestly in
plugin-architecture.md: the pin answers "is this the reviewed bundle";
post-install disk tampering is platform integrity (ADR 0019), sandbox
containment stays the sandbox's job. Hardening guide row + catalog
advisory triage; residual risk R-03 resolved. e2e: empty-allowlist
compatibility, pinned load, unpinned and tampered installs rejected and
audited, pin drift blocks loading while the admin still sees the
mismatch, version bump needs re-pin. Full api suite 101 files / 561
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 21:26:36 +02:00
4d9f913845 #246: mode enforced — reject profile-violating configuration writes
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m18s
CI / Build container images (pull_request) Successful in 4m3s
CI / Auth e2e pack (pull_request) Successful in 8m43s
CI / Import/export fidelity gate (pull_request) Successful in 1m0s
CD / Build and push images (push) Successful in 23s
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Deploy to Test (push) Successful in 12s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m27s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m24s
CI / Import/export fidelity gate (push) Successful in 58s
In enforced mode the ONE settings write path every caller uses rejects
catalog-violating values with the stable code vs_nfd_profile_violation
(403 — the request is well-formed, the policy says no). Existing
violating values are reported at startup (log line, database-less boots
must not fail) and on the admin card, never auto-changed. The UI
renders as in hidden (#245 already keys on hidden|enforced). The
hardening guide now names enforced as the recommended mode for VS-NfD
reference operation. Tests: violating write rejected with the stable
code and nothing stored; compliant writes pass; the same violating
write passes in marked and hidden (own app boots); pre-existing
violation reported and untouched. Full api suite 100 files / 555 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 19:48:37 +02:00
0d95e1304e #245: mode hidden — hide profile-violating options, mark the hiding
All checks were successful
CI / Lint, typecheck, test (push) Successful in 6m24s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m34s
CI / Import/export fidelity gate (push) Successful in 1m1s
CI / Build container images (pull_request) Successful in 1m13s
CI / Lint, typecheck, test (pull_request) Successful in 6m14s
CI / Auth e2e pack (pull_request) Successful in 8m31s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
In hidden (and later enforced) mode, catalog-listed controls whose only
purpose is enabling a violation are not rendered while their saved value
is compliant (the four master switches, the Nextcloud backup block);
value-listed selects keep only their compliant choices (registration
mode, new-page classification, upload policy, SVG policy). Every
affected section shows one accessible policy note (i18n de+en) so
policy is distinguishable from missing features. A value that was
already violating is surfaced exactly like in marked — never silently
hidden. The API stays unchanged; enforcement is #246. e2e: hidden half
of the marking pack (rows disappear, note visible, already-violating
row stays marked, axe WCAG A/AA clean) — verified live locally; CI runs
it against a second api (VS_NFD_MODE=hidden, same database) behind its
own static server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 19:27:17 +02:00
5fdef95f67 #244: mode marked — flag profile-violating configuration in the UI
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m34s
CI / Build container images (pull_request) Successful in 1m19s
CI / Auth e2e pack (pull_request) Successful in 8m23s
CI / Import/export fidelity gate (pull_request) Successful in 56s
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 1m17s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m33s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m49s
CI / Import/export fidelity gate (push) Successful in 58s
Every catalog-listed control on the admin surfaces carries an accessible
deviation marking in mode marked: text + icon under the control (never
colour alone), part of the control's accessible description
(aria-describedby), i18n de+en. The check runs against the CURRENT
control value, so a violating choice is marked before saving. Covered
controls: registration mode, new-page classification, upload policy,
SVG policy, the four master switches (api/mcp/feeds/plugins), the legal
texts (violating while empty), and the Nextcloud backup toggle on the
system panel. The profile card (#243) gains the warning summary and the
hardening-guide reference. e2e: new vs-nfd-marking pack (marked half in
CI — the e2e api now runs VS_NFD_MODE=marked, which also puts the
marked state into the a11y admin scan; off half in local default runs;
both halves verified live). hidden/enforced follow in #245/#246.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 18:49:26 +02:00
da5fd7c770 #243: VS_NFD_MODE and the machine-readable hardening-profile catalog
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m12s
CI / Build container images (pull_request) Successful in 4m2s
CI / Auth e2e pack (pull_request) Successful in 8m29s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Build and push images (push) Successful in 31s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m29s
CD / Promote to Int (push) Successful in 14s
CI / Build container images (push) Has been skipped
CI / Lint, typecheck, test (push) Successful in 6m30s
CI / Auth e2e pack (push) Successful in 8m6s
CI / Import/export fidelity gate (push) Successful in 57s
The deployment declares through VS_NFD_MODE (off | marked | hidden |
enforced, default off) how the application treats configuration that
violates the VS-NfD reference profile — deploy-level like
BACKUP_ALLOWED_TARGETS, so a compromised Site Admin cannot widen it.
The catalog in shared (vs-nfd-profile.ts) is the single source of
truth: every profile-relevant setting with a decidable compliant value,
judgement calls in an explicit advisory list, and a fence test parsing
the hardening guide's reference tables so neither can drift (pattern
#201). The api evaluates the catalog against the typed settings
registry and validated env and exposes mode + verdict on
GET /admin/system/vs-nfd-profile; the admin settings view shows the
card whenever the mode is not off. Display only — the treatments land
with #244–#246 (ADR 0027, proposed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 18:24:04 +02:00
4f6596e8a2 #288: reset schema before pg_restore — partitioned tables broke --clean
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m48s
CI / Build container images (pull_request) Successful in 1m46s
CI / Auth e2e pack (pull_request) Successful in 8m22s
CI / Import/export fidelity gate (pull_request) Successful in 1m8s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Since #224 read_events is partitioned; the dump carries per-partition
primary keys as own entries, and pg_restore --clean emitted DROP
CONSTRAINT against inherited constraints, which PostgreSQL refuses. The
restore then reported FAILED although the content was restored. Dropping
and recreating the public schema first makes every --clean drop a no-op
and the restore faithful: objects created after the backup no longer
survive. Verified in the isolated environment of #220 (set
20260731-132200, exit 0, readyz green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 17:00:05 +02:00
6aac785841 #217: map IdP groups and roles onto the permission model
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m55s
CI / Build container images (pull_request) Successful in 3m0s
CI / Auth e2e pack (pull_request) Successful in 8m49s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 21s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m28s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m15s
CI / Import/export fidelity gate (push) Successful in 59s
Declarative instance setting idpMapping.rules turns ID-token claims into
pond roles and the site-admin flag on every OIDC login — configuration,
not code. Mapped grants travel through the SAME GrantsService path as
manual ones (permission cache invalidated, collab access notify fires so
live sessions revalidate — asserted by test), never raw rows.

Ownership makes precedence explicit: role_grants.origin marks mapped
rows, users.is_site_admin_managed marks a mapping-set admin flag. The
mapping only creates and revokes what it owns — manual wins: hand-made
grants and hand-promoted admins are never revoked by a missing claim (a
manual toggle clears the marker and takes ownership). Removal of a claim
revokes the mapped grant and the managed flag on the next login. Every
mapping-driven change is audited with origin idp_mapping.

Failure containment: unknown pond slugs and the last-Pond-Admin
protection log-and-skip — a mapping problem must never become a login
lockout. Tests drive real OIDC logins against the fake IdP with group
claims: grant + working access, revocation incl. notify, manual-wins,
managed site-admin promote/demote/hands-off.

Documented in permissions.md (own section), ADR 0021, data-model.md and
the hardening guide (care rule: same PR).

Refs #217.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 13:09:11 +02:00
13f0311d8e #216: hard AUTH_LOCAL_ENABLED switch over every local credential flow
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m55s
CI / Build container images (pull_request) Successful in 4m43s
CI / Auth e2e pack (pull_request) Successful in 9m13s
CI / Import/export fidelity gate (pull_request) Successful in 1m4s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
The deploy-level realization of auth.local.enabled (ADR 0021): FALSE
answers 404 on every local credential flow — login, signup, e-mail
verification, resend, password forgot/reset/change — enforced centrally
in the auth guard via the @LocalCredentialFlow() marker before any
session or CSRF logic runs. Deploy-level on purpose: a compromised Site
Admin cannot reopen the local path, so the runtime-flip residual risk
from ADR 0021 does not materialize (R-02 closed in the risk list).

An enumeration fence fails when an auth route is neither marked nor on
the reviewed allowlist, so a new credential flow cannot ship unswitched.
Stated decisions, each tested: sessions/logout keep working for
externally authenticated users; PAT and feed-token issuance stays
available (API authorization under its own switches, not interactive
sign-in). Bootstrap: complete setup (or SETUP_ADMIN_* pre-seed) before
flipping; the api warns at boot when local auth is off with neither OIDC
nor proxy auth configured. GET /auth/methods reports local:false and the
login page hides the local form and credential links.

Hardening guide: the planned auth.local.enabled row moves from 1.3 into
the live deploy table with the bootstrap ordering, and the verification
checklist gains the login-404 probe.

Refs #216.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:58:07 +02:00
4c7f001cab #215: trusted reverse-proxy header / mTLS client-certificate path
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m44s
CI / Build container images (pull_request) Successful in 4m42s
CI / Auth e2e pack (pull_request) Successful in 9m15s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
For perimeters that authenticate before the application (ADR 0021 §4).
Off unless BOTH AUTH_PROXY_HEADER and AUTH_PROXY_TRUSTED_PEERS are set —
nothing about the header is guessed. The peer check runs against the TCP
peer address only (a forwarded header is attacker-influenced): a request
carrying the header from any other peer is rejected outright and audited
as auth.proxy_rejected (catalogue v1.4) — that is a spoof attempt, not a
misconfiguration — even when a valid session cookie rides along. From a
trusted peer the header IS the identity; a session cookie never
escalates beyond it; with the feature off the header is inert.

Mapping is explicit (AUTH_PROXY_MAP: username or e-mail); deliberately
no just-in-time creation — the header carries no verified address. The
mTLS variant (AUTH_PROXY_MODE=mtls-dn) maps the configured attribute
(default CN) out of the certificate subject DN the TLS terminator
forwards, under the same peer rules. Session-less proxy requests key the
read trail per user (user:<id>).

The trust boundary is stated in security.md (the section an assessor
reads closest), the VS-NfD security documentation and the hardening
guide's deploy table. Tests cover all four decisions: off = inert,
trusted peer authenticates (username and DN mapping), untrusted peer
rejected + audited, no escalation past a session cookie.

Refs #215.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:50:09 +02:00
5796b7a5dd #214: OIDC Authorization Code with PKCE, Keycloak as reference IdP
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 14s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
CI / Auth e2e pack (pull_request) Has been skipped
External authentication (ADR 0021) built on jose (#188's vetted library)
plus fetch — no new dependency enters the supply chain for a security
base function. Discovery-configured; ID tokens validate against the
IdP's JWKS under an explicit RS256/ES256 allowlist with issuer,
audience, expiry and nonce binding. State, nonce and the PKCE verifier
travel in a signed HttpOnly Lax cookie keyed by a dedicated HKDF
purpose (oidc-state, ADR 0020).

Deploy-level configuration (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/SCOPES/
PROVIDER_LABEL): who authenticates users is a platform decision. The
login page discovers the provider via GET /auth/methods and renders the
SSO button (i18n de+en).

Identities use the existing slot (provider oidc:<issuer>, subject from
the token). First login creates the account just-in-time — ACTIVE and
mail-verified only when the IdP asserts a verified address. An existing
local account is NEVER adopted silently by e-mail (account-takeover
path): login refuses with oidc_link_required and the owner links
explicitly via GET /auth/oidc/link (audited auth.identity_linked,
catalogue v1.3). Sessions come from the one existing session service.

Tests run the full flow against a protocol-faithful fake IdP: PKCE
verifier at the token endpoint, JIT creation incl. personal pond,
invalid state/nonce/signature/issuer/audience/expiry each rejected, the
linking refusal and the explicit link flow. Verified end-to-end against
a real Keycloak 26.0 (repeatable procedure documented in security.md
§External authentication).

Refs #214.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:44:52 +02:00
4af5e6e81f #225: read-trail master switch and written purpose limitation
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m2s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 8m26s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m23s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m6s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m25s
CI / Import/export fidelity gate (push) Successful in 1m0s
New instance switch readTrail.enabled, default OFF: read logging is
employee monitoring in a works council's eyes — an ordinary instance
must not surveil reads. Off means no event is written ANYWHERE (no row,
no stdout line, verified by test); the api announces the switch position
once per boot, so an eventless trail is never ambiguous — a gap reads
as "was off", never "was lost".

The written purpose limitation ships as section 7 of the VS-NfD
security documentation (#228): what is recorded (no content, no titles,
no IPs, no fingerprinting), why (evidence for reads of marked content
only — variant A is the technical anchor of the promise), who may read
it (Site Admin, API-only), for how long (readTrail.retentionDays,
audited pruning), and what it may NOT be used for (no performance or
behaviour monitoring). The hardening guide's reference configuration
turns the trail on (reference value true) and points to that text; the
existing trail suites now enable the switch explicitly.

Refs #225.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:35:18 +02:00
2bdb0ec2cf #224: read-trail storage — partitioning, retention, admin query path
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m30s
CI / Auth e2e pack (pull_request) Failing after 5s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Failing after 2s
Convert read_events to monthly RANGE partitions on occurred_at, with a
DEFAULT partition as safety net: a lagging maintenance job must never
turn the trail's hard-failure semantics into an outage for classified
reads. The dedup unique pair (#223) moves to per-partition indexes
(PostgreSQL cannot carry it on the parent); a bucket spanning a month
boundary may record one duplicate — over-recording is acceptable, gaps
are not.

New daily job read-trail-maintenance (job-count fence 9 -> 10) creates
months ahead — each with its dedup index — and applies the trail's own
retention readTrail.retentionDays (default 365, deliberately independent
of audit.retentionDays): whole expired months are DROPped without
scanning, remainders deleted by range, every run audited as
read_trail.pruned (catalogue v1.2; the fence regex now admits an
underscore namespace).

Site-Admin query path GET /admin/system/read-events answers "who read
page X" and "what did user Y read" within a period — API-only by
design, documented. Growth measured and documented in data-model.md:
~1 MB per 1000 events including indexes.

Tests: retention pruning + audited deletion + admin queries on the
shared database; the partitioned shape, per-partition P2002 dedup,
months-ahead creation and DROP-based pruning against a fresh database
built by the real migration chain.

Refs #224.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:21:45 +02:00
fd4fd60c99 #223: dedup window for the read trail
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m29s
CI / Auth e2e pack (pull_request) Failing after 5s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Successful in 2m55s
One event per (session, page, channel) within an aligned window of
readTrail.dedupWindowMinutes (default 5): buckets are
floor(epoch / windowSeconds), and a unique (dedup_key, window_bucket)
pair collapses concurrent duplicates race-free at insert time — the
first access in a window is always recorded, a later duplicate lands on
the unique violation and is skipped quietly (a skipped duplicate is not
a gap; only real write failures still abort the read). Each row carries
windowSeconds, so the evidence states it represents a window, never a
request count.

Reconnects within a window stay one event; a new session records again
even for the same user; channels never collapse into each other; the
page-less attachment key uses the documented `-` placeholder. Load
evidence: 30 collab-token renewals inside one window produce exactly one
event (test), bounding a live editing session at ~12 events/hour/page.

Window semantics documented in ADR 0023, the VS-NfD security
documentation (#228) and as a hardening-guide line for the new setting
(care rule: same PR).

Refs #223.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:13:21 +02:00
05a979bac3 #222: read-access trail for classified pages
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m25s
CI / Build container images (pull_request) Successful in 2m58s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
Instrument every full-content read channel for pages with
classification = vs_nfd (ADR 0023, variant A): SPA state fetch and read
rendering, public JSON content, no-JS shell, expanded embeds, public API
GET (incl. the MCP read_page path and write echoes), attachment download
under the #212 effective classification, all export shapes (markdown,
pond ZIP, account data export, queued docx/odt/pdf at enqueue), and
collab-token issuance as the api-side proxy for the WS join.

Events land in the new read_events table (no FKs — evidence survives
page purges and hard user deletions) with actor, session key
(session:/token:/job:/anon), page, pond, channel and the classification
at read time. Recording failures are NOT swallowed: a failed write
aborts the read (hard failure, the deliberate contrast to AuditService —
decision recorded in ADR 0023 and security.md §Logging, together with
the recorded residuals: content fragments and feeds).

One e2e test per channel proves both the event and its absence for
unclassified pages, plus the hard-failure semantics.

Refs #222.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:12:55 +02:00
868b79c8bc #213: warn on uploads to classified pages; instance policy can block
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m15s
CI / Build container images (pull_request) Successful in 4m27s
CI / Auth e2e pack (pull_request) Successful in 9m10s
CI / Import/export fidelity gate (pull_request) Successful in 53s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 20s
CI / Lint, typecheck, test (push) Successful in 5m47s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m26s
CI / Import/export fidelity gate (push) Successful in 1m0s
The attachments panel of a classified page shows a persistent notice
naming the consequence (de+en): the file inherits the page's
classification but its content carries no marking (#212). The new
instance setting classification.uploadPolicy (default warn, documented;
the VS-NfD reference configuration blocks, #227) hardens the warning
into a server-side rejection (403 classified_upload_blocked) — enforced
in the upload service, not only in the UI. Tests: warning visible in the
local attachments pack; block enforced server-side with warn/block both
ways and open pages unaffected.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:33:34 +02:00
e505fc74dc #212: mark attachment downloads by filename prefix and companion file
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m34s
CI / Build container images (pull_request) Successful in 14s
CI / Auth e2e pack (pull_request) Successful in 9m36s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Downloads whose effective classification is vs_nfd carry the documented
VS-NfD_ filename prefix (single source classificationFilenamePrefix() in
shared; ADR 0022 records the short form for file names). Effective
classification: the linked page's level; an attachment with unset pageId
(paste-then-insert, pond-level) FAILS CLOSED to the highest level of any
live page in its pond. The pond export ZIP adds a sibling
<file>.classification.txt companion with the full marking for classified
media, next to the manifest entry (#210). Documented in operations.md,
incl. the deliberate residual risk: the file's own content carries no
marking (recorded on #231, not hidden). Tests: prefixed classified
download, unchanged open download, fail-closed orphan both ways, ZIP
companion + manifest level.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:29:16 +02:00
521ea514b4 #211: classification through feeds, public API, search and the no-JS shell
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m38s
CI / Build container images (pull_request) Successful in 4m14s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Feeds: classified entries carry a standard Atom <category>
(term=level, scheme=urn:dorfteich:classification, label=the fixed
wording); the feed document states the highest contained level once;
all-open feeds carry none. Public API: page representations (list+get)
gain the classification field, OpenAPI + public-api.md documented.
Search: every hit carries the level and the palette renders the marking
with the snippet (compact form of the banner, text token only). No-JS
shell: banner above and below the content, own markup for the separate
render path; unclassified pages unchanged everywhere. One test per
channel (feed categories + count, public API list/get with the switch
on, search hit levels, shell top+bottom).

Also: fidelity CI sidecars get per-job container names — the fixed
names collided across parallel runs on the shared host (run 547's red
fidelity job; a fixed-name cleanup could even kill a sibling's live
sidecars).

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:23:53 +02:00
68497046e9 #210: mark the Markdown ZIP export with frontmatter, imprint and manifest
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m1s
CI / Build container images (pull_request) Successful in 2m58s
CI / Auth e2e pack (pull_request) Successful in 9m6s
CI / Import/export fidelity gate (pull_request) Successful in 1m8s
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 24s
CI / Lint, typecheck, test (push) Successful in 6m37s
CD / Deploy to Test (push) Successful in 12s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m25s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Has been cancelled
A classified page's .md carries the level in YAML frontmatter AND the
marking line at top and bottom; unclassified files are byte-identical to
before. Every pond archive (incl. the per-pond folders of the account
data export) ships a manifest.json listing each file with its level and
stating the highest level once at archive level — media inherits the
highest classification among the readable pages referencing it
(fail-closed). Round trip: the importer recognizes exactly our
frontmatter block, strips it plus the imprint lines, and creates the
page at least at the imported level (content must not escape its marking
by traveling through a ZIP) — pinned by unit and e2e round-trip tests.
Foreign frontmatter passes through unchanged; the Obsidian vault import
keeps its own frontmatter modes.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:13:53 +02:00
74a9e495e4 #209: pandoc reference documents carry the VS-NfD marking for DOCX/ODT
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 6m34s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
CI / Auth e2e pack (pull_request) Has been skipped
reference-vs-nfd.docx/.odt ship as derived binaries: the pinned pandoc's
default reference documents plus a header and footer with the marking —
part of the document's page setup, so it repeats on every page in Word
and LibreOffice and is not deletable body text. Source of truth is
scripts/gen-classified-reference-docs.mjs (wording from shared
classificationMarking(); maintenance documented in assets/README.md).
The converter passes reference docs to pandoc-server via in-request
files + reference-doc; the worker attaches them for marked docx/odt jobs
(job option {marking}, as in #208). Unclassified exports pass nothing
and are unchanged (pinned by fake-converter test). Fidelity suite
asserts against real pandoc 3.6 that marked outputs carry the
header/footer parts and unmarked ones do not; per-page repetition
verified via LibreOffice 25.8 headless PDF (5/5 pages, 2 markings each,
both formats). Word: quick manual look pending (sample files in the
workspace), procedure documented in assets/README.md.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:09:47 +02:00
c2df7c0c23 #208: VS-NfD marking in the Gotenberg per-page header and footer
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m16s
CI / Build container images (pull_request) Successful in 3m0s
CI / Auth e2e pack (pull_request) Successful in 8m54s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Promote to Int (push) Blocked by required conditions
CD / Build and push images (push) Successful in 24s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m27s
CI / Lint, typecheck, test (push) Failing after 6m24s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
A classified page's PDF export carries its marking as a job option; the
renderer hands it to Gotenberg's Chromium header/footer templates, so it
repeats on every page — bold centered in the running header and next to
the existing page numbers in the footer. Unclassified pages send exactly
the pre-#208 forms (unchanged PDF, asserted by the fidelity smoke and a
lastMarking=null check). New real-Gotenberg fidelity test asserts the
marking appears twice on EVERY page of a multi-page render while the
document-level header keeps working.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:55:52 +02:00
809e071f14 #207: print stylesheet with the classification on every printed sheet
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m56s
CI / Build container images (pull_request) Successful in 1m27s
CI / Auth e2e pack (pull_request) Successful in 9m18s
CI / Import/export fidelity gate (pull_request) Successful in 1m6s
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m25s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m5s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Has been cancelled
First @media print support at all: page size/margins, navigation and
interactive chrome suppressed, break behaviour for headings, tables,
code blocks, figures and plugin blocks. The VS-NfD marking runs as
header AND footer on every sheet via a real-table PrintFrame whose
thead/tfoot browsers repeat per page — @page margin boxes are
unimplemented and position:fixed places unreliably in both engines
(verified empirically); on screen the table chain renders as plain
blocks, so nothing changes visually. Verified as PDF-from-browser in
Chromium 140 and Firefox 153 (2 markings on every page of a multi-page
document); the repeatable procedure is documented in
apps/web/e2e/README.md. Unclassified pages print without a marking.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:48:44 +02:00
adceca7358 #206: show the VS-NfD marking in web view header and footer
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m24s
CI / Build container images (pull_request) Successful in 4m24s
CI / Auth e2e pack (pull_request) Successful in 8m44s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Build and push images (push) Successful in 26s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m30s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 6m10s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m55s
CI / Import/export fidelity gate (push) Failing after 50s
ClassificationBanner renders the fixed ADR-0022 wording above and below
the content in reading view, editor and public page view; unclassified
pages show nothing. Announced to assistive tech via a localized hidden
prefix (de+en); styled from the plain text token only, so contrast holds
in both themes and under every accent with no new color pair. Public
content endpoint now carries the classification. New seed fixture
classified-note; a11y pack asserts banner top+bottom and axe-clean in
light and dark.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:18:47 +02:00
488d0d06f1 #205: classification inherits down the tree; lowering is a guarded, audited act
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m40s
CI / Build container images (pull_request) Successful in 4m34s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m0s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
New pages take max(instance default, parent level); moving a subtree
under a higher-classified parent raises every member below that level.
No move-like path (reposition, trash-promote, purge-promote) lowers a
level as a side effect — pinned by test. Raising is ordinary editorial
work; lowering requires the dedicated capability canLowerClassification
(pond-wide Pond Admin) in the central permission model. Both directions
are audited (page.classification_raised/_lowered, catalogue v1.1) with
old value, new value, actor and page.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:12:26 +02:00
183faf7710 #204: classification as first-class page metadata (ADR 0022)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m42s
CI / Build container images (pull_request) Successful in 3m56s
CI / Auth e2e pack (pull_request) Successful in 8m17s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 24s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 6m17s
CD / Smoke tests against Test (push) Successful in 3m32s
CI / Build container images (push) Has been skipped
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 8m20s
CI / Import/export fidelity gate (push) Successful in 55s
Enum field on Page (UNCLASSIFIED default, VS_NFD), migration backfills
existing pages. New pages take the instance-wide default from
classification.newPageDefault (admin-visible, de+en). The value rides in
every PageView, so no channel needs an extra request. The field is a
marking, not a protection mechanism: a test pins that permission
decisions are unchanged by it. The marking wording is fixed in ADR 0022
and sourced solely from classificationMarking() in @dorfteich/shared.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:01:50 +02:00
000d110727 #201: stable audit event catalogue for syslog/SIEM export
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m9s
CI / Build container images (pull_request) Successful in 3m5s
CI / Auth e2e pack (pull_request) Successful in 8m40s
CI / Import/export fidelity gate (pull_request) Successful in 1m2s
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m40s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m10s
CI / Import/export fidelity gate (push) Successful in 57s
The 36 audit action ids become a typed union (AUDIT_EVENTS in
audit-actions.ts) — an uncatalogued id is now a compile error; every
existing id keeps its name. The published, versioned catalogue
(docs/architecture/audit-events.md, v1.0) documents per event: trigger,
severity, actor and target semantics, and every field, plus the
compatibility promise (ids are never repurposed; retiring keeps the row
forever) and the stable stdout field set. audit-catalogue.test.ts is
the fence: it parses the document's event tables and fails when ids or
severities drift from the code (negative case verified). Audit stdout
lines now carry the catalogue severity as a routing hint — pino level
stays 30 so transport is unaffected; no DB migration.

Forwarding path documented: container stdout -> operator's collector;
deliberately no application-side syslog client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-31 04:51:09 +02:00
c4c84b33f9 #200: hard instance-wide plugins.enabled kill switch
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m38s
CI / Build container images (pull_request) Successful in 4m11s
CI / Auth e2e pack (pull_request) Successful in 8m55s
CI / Import/export fidelity gate (pull_request) Successful in 1m9s
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Failing after 51s
CD / Smoke tests against Test (push) Has been skipped
CD / Promote to Int (push) Has been skipped
CI / Lint, typecheck, test (push) Successful in 5m37s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Has been cancelled
plugins.enabled (instance setting, default on — plugins predate the
switch; the VS-NfD reference configuration turns it off) makes every
plugin surface answer 404 via a shared guard: Site-Admin
install/list/mode, pond activation and plugin list, the sandbox frame
and asset routes. The dropzone watcher quarantines drops instead of
installing. Deliberately NOT guarded: the authenticated
fallback-metadata route — it serves no plugin code and existing
plugin_block nodes need it to render their declared fallback (an image
fallback degrades to the neutral placeholder while off, because its
bytes live on the disabled asset surface). The editor offers no plugin
blocks because the pond plugin list is one of the 404ing surfaces.
Admin settings panel gets the toggle (i18n de+en) with the documented
api-restart note (in-process settings cache).

Answers "code execution inside the zone?" with one verifiable
off-switch instead of per-plugin trust machinery (#232, ADR 0025).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-31 04:42:42 +02:00
74970f6073 #199: SHA-256 integrity hashes for attachments
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m12s
CI / Build container images (pull_request) Successful in 3m4s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m2s
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 29s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m35s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m10s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Has been cancelled
Every upload stores the SHA-256 of its bytes, computed from the
in-memory buffer that is written — never by re-reading disk. Every
download re-hashes the stored object BEFORE the first byte leaves
(memory bounded by the max_file_bytes quota that gated the upload) and
fails closed on mismatch with attachment_integrity_failure; the
mismatch lands in the audit trail as file.integrity_failed with both
hashes. Detection of payload manipulation is the one integrity duty
par. 52 VSA leaves with the application — only it knows what the file
should be.

Pre-#199 rows are hashed by a bounded, idempotent backfill that rides
the existing nightly orphan-file-sweep job (no new scheduler job, job
fence untouched); unreadable files are logged and retried, never
silently skipped, and null-hash rows are served unverified only until
the backfill reaches them. Operator runbook note in security.md
(restore from backup, re-download, audit entry carries both hashes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-31 04:32:29 +02:00
6a520e27b1 #236: pin the Node version
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m40s
CI / Build container images (pull_request) Successful in 4m15s
CI / Auth e2e pack (pull_request) Successful in 8m33s
CI / Import/export fidelity gate (pull_request) Successful in 59s
.node-version (22.15.1) becomes the single authoritative Node version:
CI/CD select Node only via node-version-file, every Dockerfile pins
node:22.15.1-alpine, and the engines floor in package.json states the
same version (open-ended upwards so a newer local Node keeps working —
reproducibility rests on images and CI). An early CI step fails on any
drift between those places; update procedure in operations.md
(Update strategy). Precondition for the reproducibility claim in #219.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-31 04:14:55 +02:00
69d9072d2c #234: retention for mail_outbox
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m51s
CI / Build container images (pull_request) Successful in 3m4s
CI / Auth e2e pack (pull_request) Successful in 8m4s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Sent mails were kept forever, and digest bodies name page titles and
actors — an unbounded copy of content-adjacent data. A new daily
mail-outbox-retention job deletes SENT rows (by sentAt) and permanently
FAILED rows (by nextAttemptAt, the last attempt's stamp) once they pass
mail.outboxRetentionDays (instance setting, default 30). PENDING rows —
including failed-but-retryable ones — stay the retry loop's alone.

Decision recorded (security.md §Privacy, residual-risk note for #231):
digest mails keep carrying page titles for now — there is no per-page
classification marking yet to key a suppression on (ADR 0022 / M32
revisits), and a VS-NfD reference configuration can leave SMTP
unconfigured entirely.

Job-count fence in system.spec: 8 -> 9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 22:17:28 +02:00
ff505bc752 #233: prune conversion job payloads for every job kind
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m12s
CI / Build container images (pull_request) Successful in 3m28s
CI / Auth e2e pack (pull_request) Successful in 8m33s
CI / Import/export fidelity gate (pull_request) Successful in 1m2s
CD / Build and push images (push) Successful in 29s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Failing after 5m9s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
The raw input/result bytes of import/export conversion jobs were kept
forever; a deleted classified page could live on inside its last export.
A new daily conversion-payload-prune job nulls both once a finished
(succeeded or failed) job passes conversion.payloadRetentionDays
(instance setting, default 30) — the row survives for status/audit.
PENDING and RUNNING rows keep their payload, so the worker's stale-lock
recovery path is untouched; a hand-requeued pruned job fails finally
via conversionInputOf instead of crashing the worker.

The input column becomes nullable; the migration backfills by clearing
payloads of jobs already finished longer ago than the default period
(recent results stay downloadable until they age out).

Job-count fence in system.spec: 7 -> 8 (new scheduler registration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 22:12:32 +02:00
3c62b7b773 #197: security response headers and an explicitly restrictive CORS policy
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m15s
CI / Build container images (pull_request) Successful in 1m9s
CI / Auth e2e pack (pull_request) Successful in 7m43s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m29s
CD / Promote to Int (push) Successful in 14s
CI / Lint, typecheck, test (push) Successful in 5m24s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m35s
CI / Import/export fidelity gate (push) Successful in 55s
Hand-rolled middleware instead of helmet: the header set is small enough
to own, every value is a deliberate decision, and the api gains no
transitive dependency. HSTS (no includeSubDomains — the api cannot speak
for sibling subdomains), nosniff, Referrer-Policy no-referrer,
X-Frame-Options SAMEORIGIN (not DENY: the plugin sandbox frame embeds
same-origin and its CSP has no frame-ancestors, so this header governs),
and a minimal deny-all Permissions-Policy.

CORS grants no foreign origin anything; only the APP_BASE_URL origin is
ever echoed (where browsers do not consult CORS anyway), with
Vary: Origin on every response. No preflight handling — same-origin
requests never preflight, and cross-origin API access is cookie-less by
design (PAT/Bearer).

Wired via the AppModule MiddlewareConsumer so createTestApp boots the
identical middleware. Fences: security-headers.e2e.test.ts (header set,
foreign origin gets no ACAO) and a frame assertion in
plugins.e2e.db.test.ts (framing stays possible). Rationale table in
security.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 17:13:24 +02:00
ed2225bb77 #196: audit-trail retention job
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m5s
CI / Build container images (pull_request) Successful in 2m48s
CI / Auth e2e pack (pull_request) Successful in 7m50s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 15s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m11s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s
audit.retentionDays (instance setting, default 365) bounds the audit_log:
the daily audit-retention job deletes entries past the period and records
the deletion itself (audit.pruned with count, cutoff and period) so a gap
in the trail is always explainable. Lives in its own AuditRetentionService
because the settings service audits its writes - folding retention into
AuditService would close a constructor cycle. The read-access trail
(#222-#225) is deliberately not covered; it gets its own period.

security.md gains the Logging section the schema has cited for a while;
the maintenance-job fence moves 6 -> 7 (the deliberate new row).

Refs #196

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 14:59:28 +02:00
960a806ee3 #195: trashed content leaves the search index itself
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m4s
CI / Build container images (pull_request) Successful in 2m47s
CI / Auth e2e pack (pull_request) Successful in 7m44s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m9s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m53s
CI / Import/export fidelity gate (push) Successful in 53s
Trashing a page (promote and subtree modes) clears the affected search
vectors, restoring rebuilds them; pond trash clears every page vector of
the pond, pond restore reindexes only the live pages (pages trashed
inside stay out); the GDPR pseudonymization's personal-pond trash does
the same. reindexAll now converges to the invariant (clears trashed,
rebuilds live), and a one-off migration backfills vectors of
already-trashed content.

The query-side deleted_at guards stay untouched as the independent
second layer - the test proves both layers separately, including writing
a vector back onto a trashed page (simulating a future path that forgot
the clear) and asserting the query still hides it. New provider methods
removePond/reindexPond behind the SearchProvider seam.

Refs #195

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 14:07:34 +02:00
02c1f18fe1 adjust the maintenance-job count fence: 6 jobs with the orphan sweep
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m58s
CI / Auth e2e pack (pull_request) Successful in 7m50s
CI / Import/export fidelity gate (pull_request) Successful in 53s
CI / Build container images (pull_request) Successful in 1m12s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m21s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 5m5s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m36s
CI / Import/export fidelity gate (push) Successful in 55s
The system panel spec pins the registered-job count on purpose; the
orphan-file-sweep registration (#194) is the deliberate sixth row (CI
run 493 caught exactly this, 14x resolved to 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 13:31:03 +02:00
0bc36aa58c #194: orphan-file sweep, drop the unused Attachment.deletedAt
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m1s
CI / Build container images (pull_request) Successful in 2m48s
CI / Auth e2e pack (pull_request) Failing after 3m12s
CI / Import/export fidelity gate (pull_request) Has been skipped
Nightly sweep with two directions: attachments still unclaimed (pageId
null) after a 24 h grace period - claimed by no collab persist, page
upload, or import - are reclaimed (row, file, quota released); files on
the uploads volume without a database row (drift after a crashed
upload) are removed once older than the grace period. The grace period
protects the paste-then-insert window.

Deliberate deviation from the issue's content-reference idea, documented
in schema comment and operations.md: claimed attachments whose page
content no longer embeds them are NOT auto-deleted. The page attachments
panel lists claimed files as user-managed objects (inserting into the
document is optional there), so 'not embedded' is not 'unused' - an
auto-delete would destroy panel assets. Humans clean those up in the
panel or the pond file manager, which flags orphans already.

Attachment.deletedAt is removed by migration - deletion is hard
everywhere (sweep, purge, manual), there is no soft-delete state; the
never-true deletedAt:null filters in files/export queries went with it.

Refs #194

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 13:19:58 +02:00
402b22e05f #193: pond purge — retention job and manual Site-Admin endpoint
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m58s
CI / Build container images (pull_request) Successful in 2m47s
CI / Auth e2e pack (pull_request) Successful in 7m46s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 28s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 5m4s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s
Deletion now actually deletes: a trashed pond past the trash retention
(same clock as pages, extended trash-purge job) or purged manually via
DELETE /ponds/:id/purge (Site-Admin-only, like pond restore) is removed
with everything it holds. Files go first (idempotent rm, resumable on a
crash), then one transaction ordered around the FK actions: attachments
and labels (Restrict) precede the pond; the page delete cascades
versions, comments, content cache incl. the search vector, update log,
mentions, label assignments, favorites, outgoing links and open collab
sessions; the pond delete cascades grants, usage counters (that is the
quota correction), pond-plugin opt-ins and conversion jobs; polymorphic
watches and pond quota overrides are deleted explicitly. A purge racing
a restore or another purge is a no-op; both paths record a pond.purged
audit event.

Known residues by design, documented in operations.md: target_slug in
other ponds' page links (#235) and backups within their retention.

Refs #193

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 12:44:52 +02:00
394d1c811d #192: deploy-level backup target allowlist
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m52s
CI / Build container images (pull_request) Successful in 3m54s
CI / Auth e2e pack (pull_request) Successful in 8m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m0s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m41s
CI / Import/export fidelity gate (push) Successful in 56s
BACKUP_ALLOWED_TARGETS (comma-separated destination hosts) constrains
where backups may go, enforced twice: the api rejects settings writes
and connection tests towards non-allowlisted hosts with admin-visible
error codes and resolves a non-allowlisted configured target to null,
and the sidecar enforces the same policy at the point of egress for the
WebDAV upload and the rsync mirror alike (shared policy helpers in
packages/shared/src/backup-target-policy.ts).

BREAKING: the empty default disables every remote target - backups stay
local only, the VS-NfD reference configuration (ADR 0026). Existing
deployments with a remote target must list its host or uploads and
mirror stop. The admin UI distinguishes unavailable-by-policy from
unconfigured (i18n de+en) and shows the permitted hosts.

Refs #192 (ADR 0026)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 12:17:14 +02:00
afef45732a #191: feeds.enabled instance switch, feed-token log masking
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m52s
CI / Build container images (pull_request) Successful in 3m55s
CI / Auth e2e pack (pull_request) Successful in 7m52s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m55s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m34s
CI / Import/export fidelity gate (push) Successful in 58s
Chosen path: an instance master switch following the api.enabled/
mcp.enabled pattern — while off, both feed routes AND the feed-token
management answer 404 (existence hidden). Default ON: feeds predate the
switch, existing instances and their subscribed readers keep working;
the VS-NfD reference configuration (#227) turns it off. Admin UI gets
the toggle next to the API/MCP switches (i18n de+en).

Moving the token out of the query string is documented as rejected: a
path segment lands in the same proxy and request logs, and feed readers
cannot send headers — that is why the credential is in the URL at all.
What DID leak was our own request log (pino logs req.url): the req
serializer now masks ?token= values (common/mask-token-param.ts), so no
code path logs the credential.

Refs #191

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 11:34:43 +02:00
db4c5ce9ca #190: configurable session lifetime with a server-side idle timeout
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m53s
CI / Build container images (pull_request) Successful in 3m55s
CI / Auth e2e pack (pull_request) Successful in 7m53s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m53s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m37s
CI / Import/export fidelity gate (push) Successful in 52s
SESSION_ABSOLUTE_HOURS (default 168 h) caps a session's total lifetime
from login: expiresAt is set once at creation and never extended — the
old sliding 30-day renewal is gone. SESSION_IDLE_HOURS (default 72 h)
ends sessions unused for that long, enforced server-side against
lastSeenAt with a write throttle scaled to the idle bound so short idle
windows still renew. Expired rows are removed on validation and the
session list applies both bounds, so idle-dead sessions never show as
active. The cookie maxAge follows the configured absolute bound.

Documented in .env.example (with the VS-NfD reference values for the
upcoming hardening guide #227), compose passes the variables through,
security.md and ADR 0007 record the amendment.

Refs #190

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 11:11:15 +02:00
214e707102 fix flaky tampered-token test: flip a significant signature character
All checks were successful
CI / Build container images (pull_request) Successful in 3m27s
CI / Auth e2e pack (pull_request) Successful in 7m51s
CI / Lint, typecheck, test (pull_request) Successful in 4m49s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m54s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m51s
CI / Import/export fidelity gate (push) Successful in 54s
The tampered-token case flipped the LAST base64url character of the
signature. Its low bits are padding that decoders ignore, so whenever a
signature ends in 'A' (~1/16 of tokens) the flip to 'B' decodes to the
same bytes and the token verifies — jose compares decoded bytes, unlike
the pre-#188 homegrown code that compared encoded strings. Reproduced
deterministically (20/20 A-ending signatures accepted the flip); CI run
477 and one local full-suite failure were this, not load. Flipping the
first character makes the tamper always significant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 09:40:05 +02:00
d32c8c3730 #189: make the CSRF origin check fail closed
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 1m51s
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
A cookie-carrying mutation without Origin and Referer (or with an
unparsable one) is now rejected with 403 csrf_origin_mismatch instead
of passing unchecked. The exception for non-browser clients stays
structural: PAT/bearer requests carry no session cookie and never reach
the check, and a request that does carry the cookie is always checked.

The test harness injects the matching Origin (supertest simulates a
browser page of this instance) with an explicit suppression header for
the negative cases; the Playwright fixture contexts send the header on
their manual seeding calls; release-qa.sh pins APP_BASE_URL and sends
the matching Origin. Dedicated spec covers: missing headers 403,
mismatch 403, unparsable 403, match passes, GETs untouched, PAT
mutation without headers passes, cookie+bearer still checked.

Refs #189

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 09:34:44 +02:00
3d1f4fda53 #188: purpose-bound token keys via HKDF, jose replaces the homegrown JWT
All checks were successful
CI / Build container images (pull_request) Successful in 3m51s
CI / Auth e2e pack (pull_request) Successful in 7m49s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CI / Lint, typecheck, test (pull_request) Successful in 4m43s
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m54s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m39s
CI / Import/export fidelity gate (push) Successful in 59s
COLLAB_TOKEN_SECRET becomes a root key: every purpose derives its own
HKDF-SHA-256 subkey (deriveTokenKey), and no code path signs with the
root key directly. Collaboration tokens are signed and verified by jose
with HS256 as an explicit allowlist; the sign/verify API turns async at
its three call sites. Unsubscribe tokens move from a purpose-prefix
string to the structural subkey, with a documented dual-verify window
(legacy derivation accepted until 2026-11-01, covering the 90-day TTL
of links in already-sent mail).

The cross-runtime property that justified the homegrown implementation
is now proven by a test: the built CJS and ESM dist artefacts round-trip
tokens in both directions in child processes (jose v6 reaches CJS via
Node's require(esm), pinned Node 22 images). Negative tests cover
cross-purpose subkeys, root-key-signed tokens, alg:none and RS256.

Refs #188 (ADR 0020)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 06:41:11 +02:00
b5d2a436e0 #186: pond accent theming — scoped derivation, cascade pond > user > default
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m44s
CI / Build container images (pull_request) Successful in 4m2s
CI / Auth e2e pack (pull_request) Successful in 10m50s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 17s
CD / Smoke tests against Test (push) Successful in 4m2s
CI / Lint, typecheck, test (push) Successful in 4m47s
CI / Build container images (push) Has been skipped
CD / Promote to Int (push) Successful in 14s
CI / Auth e2e pack (push) Successful in 10m7s
CI / Import/export fidelity gate (push) Successful in 56s
Release / Build release images and notes (push) Successful in 1m11s
Release / Release-candidate operations QA (push) Successful in 1m0s
Prod deploy / Deploy the released images to Prod (push) Successful in 17s
pondSettingsSchema gains theme = { accent: '#rrggbb' | null } (null =
inherit the viewer's theme), exposed as a top-level key of the flat
updatePondInputSchema and included in the PondsService settings merge
(the known silent-no-op pitfall). The server validates only the hex;
conformance arises at render time: PondThemeScope (mounted around the
page content next to PondFontScope) derives the accent pair for the
EFFECTIVE mode via useEffectiveTheme and sets it as inline custom
properties — inline beats both tokens.css and the user-theme <style>,
which IS the cascade precedence pond > user > default.

Pond settings get a PondThemeSection (inherit | presets | custom color
with per-mode preview swatches, explicit save like the font manager);
AccentSwatches extracted for reuse; i18n de+en. The no-JS public shell
stays deliberately un-themed (ADR 0018 amendment).

Tests: pond DB test (theme merge keeps fonts, invalid hex 400), e2e
pond-theme.spec (scope boundary content vs. chrome, per-mode
re-derivation, axe on the pond settings page; resets the fixture pond).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-29 09:09:36 +02:00
83a2fe470e #184: user accent theming — presets and free color as one mechanism
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m41s
CI / Build container images (pull_request) Successful in 4m4s
CI / Auth e2e pack (pull_request) Successful in 11m40s
CI / Import/export fidelity gate (pull_request) Successful in 52s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
apply-theme.ts derives BOTH modes' accent tokens from the stored choice
(ui.theme.accent: preset id or {custom:'#hex'}) and writes them as
<style id="user-theme"> with :root:root + :root:root[data-theme='dark']
blocks — the doubled :root beats tokens.css regardless of document
order, since theme-init.js injects the ui.theme.css cache during <head>
parsing, before the bundle styles. The default preset means NO override
(hand-tuned tokens.css values stay). main.tsx re-derives from the
choice at startup, healing stale caches after app updates.

Settings: accent radiogroup inside the Appearance section (visible
names, color never the only cue) with per-mode preview swatches on
each mode's canonical background, plus a custom color input; i18n
de+en. The second fieldset made bare .settings-fieldset locators
ambiguous — theme specs now scope via input[name] (fence stays).

Tests: apply-theme unit pack, BASE_PALETTE<->tokens.css drift fence in
theme-contrast.test.ts, e2e theme-accent.spec (instant apply, pre-paint
persistence, default removes override, axe smoke with garish yellow in
both modes). ADR 0018 amendment documents the stage-B details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-29 08:59:27 +02:00
b799ad180b #182: top-bar theme toggle — cycle light/dark/system without a menu
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m40s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 8m30s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
An IconButton between the notifications bell and the user menu cycles
the theme mode in radio order (sun/moon/monitor mirror the CURRENT
choice). New useThemeMode() hook is the single write path (persist +
apply + same-document event), so the settings radios and the toggle
stay in sync; AppearanceSection now uses it too. Also rendered for
signed-out visitors — the mode is a device-local preference. i18n de+en;
unit tests for cycle/setter, theme.spec covers cycling, radio sync,
persistence, and the signed-out top bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-29 08:43:42 +02:00
77df813f16 #180: settings jump-nav fence — nine sections since the Appearance section
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m38s
CI / Build container images (pull_request) Successful in 1m29s
CI / Auth e2e pack (pull_request) Successful in 7m44s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 2m36s
CD / Promote to Int (push) Successful in 21s
CI / Lint, typecheck, test (push) Successful in 4m46s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m18s
CI / Import/export fidelity gate (push) Successful in 56s
CI run 453 caught it: settings-nav.spec.ts pins the user-settings section
count, which #180's Appearance section raised from 8 to 9. Verified
locally against a fresh e2e environment (both pack tests green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-28 21:58:43 +02:00
4e0ad82220 #180: dark-mode test fence, both-scheme a11y pack, theme e2e, ADR 0018
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m41s
CI / Build container images (pull_request) Successful in 3m57s
CI / Auth e2e pack (pull_request) Failing after 5m26s
CI / Import/export fidelity gate (pull_request) Has been skipped
theme-contrast.test.ts parses tokens.css and asserts every real UI colour
pairing (4.5:1 text, 3:1 UI) for BOTH palettes, so palette drift fails
unit tests instead of review. theme.test.ts covers resolve/apply logic
(Node >= 22 ships a shadowing undefined localStorage global — the test
brings its own in-memory storage). The a11y pack now runs its four scans
in light AND dark via emulateMedia; the new theme pack exercises the
three-way switch end to end (instant apply, reload persistence, live OS
follow in system mode, override beats OS). ADR 0018 records the theming
model broadly: modes now, accent themes by derivation later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-28 20:46:25 +02:00
2c571f9f5e #180: dark mode — Light/Dark/System setting with token-based dark palette
The dark palette lives as a single :root[data-theme='dark'] block in
tokens.css; theme.ts and the pre-paint public/theme-init.js (external file
because the prod CSP forbids inline scripts) always resolve the stored
ui.theme.mode to a concrete data-theme, so 'system' needs no @media
duplicate and follows live OS changes via matchMedia. color-scheme flips
per theme (native controls/scrollbars), paired theme-color metas track the
effective theme, and the new Appearance settings section offers the
three-way choice as native radios (device-local, like #170). Label chips
gain a chip-outline ring so arbitrary user colors stay separated on the
dark canvas; useEffectiveTheme() is exported for the later pond-scoped
theming stage (ADR 0018).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-28 20:40:47 +02:00
5034b7a80f #180: promote remaining hardcoded base.css colors to design tokens
New tokens (light values, dark arrives with the theme block): surface-muted
(was fallback-only), danger-contrast, danger-strong, badge ok/error/warn
pairs, chip-outline. Replace the phantom --color-primary with the real
--color-accent and unify the three danger reds (#a02818/#b91c1c/#ab091e)
on --color-danger. attachments-panel maps to bg-subtle, matching its old
6%-grey fallback. Light rendering is visually unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
2026-07-28 20:37:44 +02:00