Commit Graph

3250 Commits

Author SHA1 Message Date
Marcel
782a34e34b test(mocks): add shared $app/forms interceptor mock body
Single home for the non-trivial form-interceptor enhance() shared by the
four complex consumers: it intercepts submit, invokes the SubmitFunction,
and fires the returned callback with a configurable result. setFormResult()
drives the success/failure branch; an embedded beforeEach resets it before
every test so isolation is structural. Consumed via vi.mock('$app/forms',
() => ({ ...formsMock })) through the $mocks alias. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
30f450b0d1 build(frontend): register $mocks in kit.alias for tsconfig resolution
The vite resolve.alias (added for the client + coverage runs) does not
reach svelte-check, which resolves paths through the generated tsconfig.
Declaring $mocks in kit.alias feeds both the generated tsconfig paths and
the sveltekit() vite plugin, so editor/type-check resolve it too. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
d4c0287e92 docs(adr): amend ADR-012 with no-factory ban + shared-mock dedup (#560)
Records the 2026-06-02 revision from #560: (1) no-factory vi.mock of a
SvelteKit virtual module is forbidden (the PR #657 partial-mock failure),
guarded by a seventh enforcement layer; (2) shared mock body + per-spec
sync factory via the $mocks alias is the sanctioned dedup; (3) Option C
config-level auto-resolve is rejected. Also corrects the stale 4.1.0
patch filename to 4.1.6 and links #657. Part of #560.
2026-06-03 11:38:22 +02:00
Marcel
301cfc5c9e test(meta): ban no-factory vi.mock of virtual modules
A vi.mock('$app/navigation') with no factory does not auto-resolve to a
__mocks__ file for SvelteKit virtual modules — it substitutes some
exports and leaves others (replaceState) bound to the live router, which
is exactly the PR #657 failure. This Node-mode source scan, mirroring
no-async-mock-factories and no-duplicate-mock-ids, fails at every vitest
invocation if any *.svelte.{spec,test}.ts reintroduces the pattern, and
forecloses ADR-012's rejected Option C. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
724c3881e4 build(frontend): add $mocks alias for shared browser-test mock bodies
Declares $mocks -> src/__mocks__ in both vite.config.ts and
vitest.client-coverage.config.ts so shared mock modules resolve in the
client test run and the coverage job alike. Enables the sync-factory
dedup pattern from ADR-012 (vi.mock('$app/forms', () => ({ ...formsMock }))).
Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
fab2930ca8 build(frontend): exact-pin @vitest/browser-playwright to 4.1.6
Drop the caret so the version cannot float off the patched release.
patches/@vitest+browser-playwright+4.1.6.patch backports vitest PR #10267
(the duplicate-mock-id birpc race, ADR-012) and only applies to 4.1.6; a
caret range could resolve to a version the patch rejects. A top-level
"//" key records the removal condition since package.json forbids
comments. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
d83707ec3b refactor(admin-tags): migrate tag-edit page from $app/stores to $app/state
The legacy $app/stores subscription API is replaced with the modern
$app/state reactive proxy (page.url.pathname), per ADR-012's
architectural follow-on. The two spec mocks of $app/stores are replaced
with sync-factory $app/state mocks, matching the existing convention in
aktivitaeten/documents specs. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:38:22 +02:00
Marcel
caea0d5633 test(persons): assert the card title by exact message, not regex
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m13s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m36s
CI / fail2ban Regex (push) Successful in 44s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m4s
toHaveAttribute compares by equality, so passing a regex asserted against
the literal RegExp object and failed. Assert the full title against
m.person_correspondents_search_title(...) instead — it names both persons
and avoids retyping the copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
2bf14aeab9 docs(e2e): fix stale spec listing after Briefwechsel removal
The e2e README still listed the deleted korrespondenz.spec.ts. Replace it
with the new briefwechsel-removed.spec.ts guard entry — closing the last
dangling reference flagged in review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
5b565d5271 docs(adr): record the bilateral->unidirectional search regression (ADR-030)
Removing the Briefwechsel view retargets its one inbound link to document
search, which filters sender AND receiver — A->B only. The bidirectional
"replies" direction is intentionally dropped. ADR-030 records the
context, decision and consequences, and notes a bidirectional search
filter as the superseding future enhancement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
df0f4879b8 docs: remove Briefwechsel from architecture, routes and glossary
Drop the Briefwechsel route and the conversation derived-domain /
conversation-thread prose from the route tables (CLAUDE.md,
frontend/CLAUDE.md), ARCHITECTURE.md, the C4 frontend/backend diagrams,
and GLOSSARY.md (term + derived-domain list). Delete the two superseded
Briefwechsel design specs. Historical ADRs and dated analyses are left
untouched as point-in-time context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
98d081397e chore(api): regenerate TS client without the conversation endpoint
Drop the /api/documents/conversation path and its getConversation
operation from the generated client to match the removed backend
endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
4e68b81bf7 feat(document): remove conversation repository queries
Delete findConversation and findSinglePersonCorrespondence (no remaining
callers after the service methods were removed) and their integration
test section. Drops the now-unused LocalDate import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
985b31f71f feat(document): remove conversation service methods
Delete getConversationFiltered (the endpoint's only caller is gone) and
the dead 2-arg getConversation(personA, personB) which had zero callers,
along with both getConversationFiltered test blocks. The hasSender/
hasReceiver specifications stay — document search still uses them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
3fb312b1c6 feat(document): remove the conversation endpoint
Delete GET /api/documents/conversation and its controller handler — the
only client was the removed Briefwechsel view. Drops the now-unused Sort
import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
e2ec45f819 refactor(document): move ConversationThumbnail into lib/document
With the Briefwechsel view gone, lib/conversation/ held a single shared
component whose only consumer is lib/document/ThumbnailRow. Move it (and
its spec) into lib/document/, update the import, delete the now-empty
lib/conversation/ folder, and fix the stale frontend/CLAUDE.md lib map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
7d9526440a feat(i18n): remove orphaned conversation message keys
Drop the 22 message keys that only the deleted Briefwechsel view used
(conv_* except the still-used conv_sort_newest/oldest, plus
nav_conversations, doc_conversation_title and person_correspondents_hint,
all now superseded by the retargeted card's new search keys).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
13bbfa7abd test(briefwechsel): guard the removed /briefwechsel route returns 404
Add an active e2e spec asserting /briefwechsel 404s on the styled app
error page. The old assertion lived in stammbaum.spec.ts inside a
test.skip() block (never executed) and asserted the opposite — remove it.
Drop /briefwechsel from the auth protected-route loop; /documents (the
redirect target) sits behind the same authenticated() rule, so coverage
is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
975223c972 feat(briefwechsel): remove the standalone Briefwechsel view and its tests
Delete the /briefwechsel route in full (page, server load, eight
components and all co-located unit tests) and its end-to-end coverage
(briefwechsel-rows.visual, briefwechsel-a11y, the bilateral-correspondence
fixture, and the stale korrespondenz spec which targeted the route's
former /korrespondenz path). The card link now deep-links into document
search, so this view has no remaining inbound references.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
403a043d51 feat(persons): retarget frequent-correspondents card to document search
The "Häufige Korrespondenten" card linked into the standalone Briefwechsel
view. Retarget each chip to the existing document search pre-filtered by
sender and receiver (/documents?senderId=A&receiverId=B), naming both
persons in a search-action title, swapping the chat-bubble icon for a
magnifier, and clarifying that the ×N badge counts shared letters in both
directions (not the unidirectional search result count).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:26:54 +02:00
Marcel
e259908d6a fix(stammbaum): order keyboard tab stops by visual layout, not DB order (#718)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m21s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m40s
CI / fail2ban Regex (push) Successful in 43s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
Person nodes rendered in `nodes` array order (backend/DB row order), so
Tab focus hopped between nodes unrelated to their on-screen position,
failing WCAG 2.4.3 Focus Order (Level A).

Render the node loop in reading order instead: sort by layout y (top
generation first) then x (left-to-right within a row), via a
`nodesInReadingOrder` derived. Nodes without a layout position sort last
(mirroring the `{#if pos}` guard); node.id is the final tie-break for a
total, deterministic comparator. Shift+Tab and reload-stability fall out
for free (reversed render order; x/y independent of backend order).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 07:55:47 +02:00
Marcel
7d37e610da test(frontend): exclude mentionNodeView from server coverage (#628)
Some checks failed
CI / fail2ban Regex (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Semgrep Security Scan (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI's node coverage run (vite.config.ts, 'measure utility + server-side logic
only') counts every .ts under the include globs via all-files, but the Tiptap
NodeView builds live ProseMirror DOM and only runs in the browser editor — it is
exercised by the client project's browser tests, not the node run. Left in, it
showed 0% and dragged global functions (78.68%) and branches (78.48%) below the
80% gate.

Exclude it alongside the .svelte / browser-only UI files this config already
measures around. Restores the gate: statements 88.82%, branches 82.3%,
functions 87.27%, lines 89.77% (server project, verified locally).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
9c1eb7608b fix(transcription): harden re-edit pencil hit-testing + disable sync (#628 review)
Addresses the clean-agent review of PR #717:

- C1: the hidden pencil was opacity-0 only, which still hit-tests; its 44px box
  overhangs adjacent text, so a click in the gap between two mentions could land
  on the invisible button and spuriously open the dropdown (AC-8 hole). Add
  pointer-events-none while hidden, re-enabled with the opacity reveal on
  hover/focus.
- C2/N1: editor.setEditable() emits "update", not a ProseMirror transaction, so
  the NodeView's 'transaction' listener missed a mid-session disable flip (stale
  aria-disabled/tabindex; the comment was wrong). Listen on 'update' instead —
  which also skips selection-only changes, so it fires far less often.
- N2: track the node across update() so the pencil opens with the live
  displayName (hardening; relink only swaps personId today).

Tests: structural guard that the hidden pencil is pointer-events-none + reveals,
and a mid-session disable-flip test (fixture gains an onReady setDisabled hook).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
9bba5e4a7a feat(transcription): announce re-edit context via the existing live region (#628)
Passes editingDisplayName into MentionDropdown; the persistent aria-live region
announces person_mention_editing_announce({displayName}) on re-edit open and
falls back to the prompt/empty/count copy once the user edits or results arrive.
Routed through the SAME sr-only region as the result count — no second live
region (avoids the double-announce bug Leonie S-2 fixed). Fresh-@ passes an
empty editingDisplayName, so its announcements are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
751a48b22c test(transcription): AC-7 disabled, AC-8 no-mention, security clip/provenance (#628)
- AC-7: disabled editor → pencil is disabled + aria-disabled + tabindex -1, and
  neither keyboard nor pointer activation mounts a dropdown (WCAG 2.1.1, not just
  pointer-events-none).
- AC-8: plain text shows no pencil/dropdown; two adjacent mentions each keep one
  pencil with no spurious gap pencil and no auto-open; a doc-start mention still
  renders its pencil.
- Security: an oversized stored displayName clips the search query to 100 chars
  while the preserved node text stays full-length; re-link sources personId
  solely from the picked Person (p-anna), never the reflected/clipped text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
58a30a6e2e test(transcription): AC-6 single-dropdown invariant + stale-fetch guard (#628)
Locks in the single-owner controller guarantees: pencil→pencil, fresh-@→pencil
and pencil→fresh-@ all leave exactly one dropdown open; the request-token bump
on open discards a superseded open's in-flight fetch (open A → open B → A
resolves, deterministic, no sleeps). Plus a #380 AC-1 regression guard that the
fresh-@ path still inserts the typed text as displayName after the controller
refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
2430092e43 feat(transcription): dismiss + keyboard-operate the re-edit dropdown (#628 AC-4/AC-9)
Adds a visible × dismiss control to MentionDropdown (shared by the fresh-@ and
re-edit paths) and, for the re-edit path which has no Tiptap suggestion plugin
to forward keys, focuses the search input on open and handles its own keyboard:
Escape dismisses (AC-4), Arrow/Enter reuse the exported selection logic so the
dropdown is navigable on its own (AC-9 parity with the fresh-@ dropdown).

Both close paths (Escape + ×) leave the mention node attrs + text byte-identical
(AC-4) — close() never touches the document. Controller wires ondismiss=close
(+refocus editor) and focusOnMount only for the re-edit open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
4a93543645 feat(transcription): re-edit @mention via a pencil affordance (#628)
Hosts each mention as a Tiptap NodeView (mentionNodeView.ts) that renders the
@displayName token (textContent — never innerHTML) plus a contenteditable=false
pencil button in a fixed-width slot, revealed on whole-token hover and keyboard
focus (instant opacity swap, no reflow). Activating the pencil (click or Enter/
Space) opens the single mention dropdown via the controller, anchored at the
token and pre-filled with the stored displayName.

commitRelink swaps ONLY personId in place via setNodeMarkup, sourcing the id
solely from the selected Person — the stored displayName is preserved by
construction (AC-3), even after the search input is edited (AC-5, the #380 AC-1
invariant). renderHTML/renderText stay for serialization + clipboard.

AC-1/AC-2/AC-3/AC-5 + serializer round-trip covered by browser tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
b453c13bae refactor(transcription): lift @mention dropdown lifecycle into a single controller
Pulls mountedDropdown / requestId / debouncedSearch / dropdownState ownership
out of Tiptap's suggestion.render() closure into one createMentionController().
render() becomes a thin adapter: onStart→open, onUpdate→update, onExit→close.

This is the single-owner structure #628 needs for the AC-6 single-dropdown
invariant — the upcoming pencil re-edit affordance opens via the same
controller.open() instead of racing the suggestion plugin over module state.
open() now also bumps the request token so an open-A→open-B sequence discards
A's in-flight fetch (preserved increment-on-open semantics). No behaviour
change for the fresh-@ path — existing browser suite is the regression guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
599c3977fb feat(i18n): add re-edit @mention keys (edit/editing-announce/dismiss)
Keys for the re-edit affordance landing in #628:
- person_mention_edit_label   — pencil button aria-label
- person_mention_editing_announce — aria-live editing context
- person_mention_dismiss_label — dropdown close button aria-label

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:55:28 +02:00
Marcel
03e2615fa7 ci(deploy): use ::error:: annotations for smoke-test failures
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 22s
CI / Backend Unit Tests (push) Successful in 3m37s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
nightly / deploy-staging (push) Successful in 2m1s
Convert the two bare failure echoes (gateway detection, /actuator status) to
::error:: so Gitea renders them as CI log annotations, consistent with the rest
of the deploy steps. No behaviour change. Raised in review (Leonie).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:41:07 +02:00
Marcel
3db6a3bf8f ci(deploy): correct stale POSTGRES_HOST --env-file comment
obs.env documents POSTGRES_HOST but does not set a value, so obs-secrets.env
does not 'override' it — it is the only source. Reword the carried-over comment
to match reality. Raised in review (Tobias).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:40:52 +02:00
Marcel
0e06626eef ci(deploy): guard deploy-obs heredoc stays unquoted (#603)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
The unquoted <<EOF delimiter is load-bearing — under a composite action secrets
come from $VAR (env), not Gitea ${{ secrets }} substitution, so a re-quote to
<<'EOF' would write literal $VAR strings and the five-key non-empty guard would
not catch it. Adds a self-testing grep guard (matching the ci.yml 'Assert no X'
convention) so a future re-quote fails CI instead of shipping broken obs auth.
Raised in review (Felix, Sara, Tobias).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:38:36 +02:00
Marcel
a47564934d ci(deploy): harden deploy-obs config step with set -euo pipefail
A failed cp/mkdir in the deploy-configs step was previously swallowed (the step
had no set -e), so a broken config copy could still reach the validate step. The
five-key guard catches empty secrets but not a failed copy. -u also catches a
typo'd env var name. Raised in review (Sara, Tobias).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:37:56 +02:00
Marcel
02fb16a0bd docs(ci): document composite actions in ci-gitea.md
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m39s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Adds a Composite actions section covering the checkout-first ordering rule, the
secrets-via-inputs + unquoted-heredoc constraint (with the five-key guard and
shell: bash requirement), and a step-by-step for adding an input. Notes that the
inline Reload Caddy example now lives in the reload-caddy action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:25:32 +02:00
Marcel
4757a174c9 docs(adr): add ADR-029 composite actions for cross-workflow deploy logic
Records the decision to extract the shared obs-deploy/reload-caddy/smoke-test
logic into three composite actions instead of a reusable workflow or shared
shell script. Numbered 029 (028 was taken by the pdf.js wasm ADR on main since
the issue was filed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:24:20 +02:00
Marcel
75293c6aa8 ci(deploy): extend Renovate privileged-digest watch to .gitea/actions
The reload-caddy pinned alpine digest moved out of the workflow files into a
composite action. Add .gitea/actions/** to the manual-review digest rule so the
digest stays watched and never silently goes stale (#603).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:23:56 +02:00
Marcel
4e9b13c0e4 ci(deploy): wire release.yml to composite deploy actions
Replaces the four inline obs steps with one uses: ./.gitea/actions/deploy-obs,
and the Caddy reload + smoke test with one uses: each (host
archiv.raddatz.cloud, postgres_host archiv-production-db-1, PROD_* secrets).
Removes all three '# Keep in sync with nightly.yml' comments — the shared
definition now enforces the invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:23:41 +02:00
Marcel
ad27c1f757 ci(deploy): wire nightly.yml to composite deploy actions
Replaces the four inline obs steps with one uses: ./.gitea/actions/deploy-obs,
and the Caddy reload + smoke test with one uses: each (host
staging.raddatz.cloud, postgres_host archiv-staging-db-1, STAGING_* secrets).
checkout@v4 stays the first step; the #526 /import mount guard stays inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:23:05 +02:00
Marcel
0e30e5c570 ci(deploy): extract deploy-obs composite action
Five required, no-default inputs (incl. grafana_db_password for the #651
read-only reader role). Four named run: blocks keep the four CI log sections:
deploy configs, validate, start, assert health.

Secrets map to env: and are written via an unquoted <<EOF heredoc ('$VAR'
expands at the shell layer; a quoted delimiter would write the literal var
name and config --quiet would pass anyway). A five-key non-empty guard runs
right after the write, and chmod 600 is the final operation so the file is
never world-readable. ADR-016 absolute paths and the two-file --env-file
ordering are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:21:28 +02:00
Marcel
a6a8552a48 ci(deploy): extract smoke-test composite action
Parameterises the public-surface smoke test by host (one required input,
mapped via env: HOST). Keeps the three checks verbatim — login reachable,
HSTS value pinned, Permissions-Policy present, /actuator -> 404 — plus the
/proc/net/route gateway-detection and RESOLVE-array rationale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:20:09 +02:00
Marcel
b0d28c1e0b ci(deploy): extract reload-caddy composite action
First composite action in the repo (establishes the convention). Lifts the
Caddy reload step verbatim from nightly.yml/release.yml — DooD privileged
sibling + nsenter to systemctl reload caddy, pinned alpine digest, reload
not restart. No inputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:19:36 +02:00
Marcel
420c0e3e10 docs(adr): record pdf.js wasm same-origin serving + future-CSP constraint
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 3m18s
CI / OCR Service Tests (push) Successful in 21s
CI / Backend Unit Tests (push) Successful in 3m45s
CI / fail2ban Regex (push) Successful in 44s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m3s
nightly / deploy-staging (push) Successful in 2m14s
Promote the future-CSP constraint from an inline Caddyfile comment to a
durable ADR-028: serve the pdf.js wasm decoders same-origin (never a
CDN), any future CSP must allow 'wasm-unsafe-eval' + worker-src 'self'
blob:, and the build-time guard keeps the wasm shipping. Caddyfile now
points at the ADR.

Addresses re-review: Markus (constraint should be an ADR, not a comment).

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:17:41 +02:00
Marcel
cb61e63b02 fix(document): polish PDF error state — warning icon, 44px target, warmer copy
Address the remaining UI/UX polish: add a warning-triangle icon so the
failure is signalled by shape, not colour alone (WCAG 1.4.1); give the
recovery download link a full 44px tap/focus target (inline-flex
min-h-[44px]); and soften the message copy in de/en/es.

Addresses re-review: Leonie (colour-only, undersized link, copy warmth).

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:17:41 +02:00
Marcel
8eb321ccea chore(frontend): enforce rel=noopener on target=_blank via eslint (CWE-1022)
Enable svelte/no-target-blank so reverse-tabnabbing is caught at lint
time instead of relying on review (the very gap that left the viewer
download link exposed). Repo is already clean — all existing
target="_blank" anchors carry rel="noopener noreferrer".

Addresses re-review: Nora (optional detection-for-free).

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:17:41 +02:00
Marcel
e16b7402bd fix(document): make the PDF error state accessible (alert + larger link)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
The error block was a colour-only, visually-small dead end. Add
role="alert" so screen readers announce the failure, bump the message to
text-base and the recovery download link to text-sm with a py-2 tap
target — the only escape hatch, sized for the archive's older readers.

Addresses re-review: Leonie (a11y of the error state).

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:12:23 +02:00
Marcel
229c1b0539 test(document): exercise the real render-failure path in PdfViewer test
The "render failure" test rejected getDocument().promise — the load
path, not the render path — and only asserted a template constant. Now
the fake loads the document successfully and rejects the page render
(the actual #708 wasm-decode failure class), plus a negative companion
asserting the message is absent on a successful render. Also reset
renderTask to null on the render-error path.

Addresses re-review: Felix, Sara (mislabeled test / asserted a constant).

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:12:23 +02:00
Marcel
f24c415b04 fix(document): localize loadDocument error too — no raw pdf.js text
The render path was localized but loadDocument still stored the raw
pdf.js message (and an untranslated English fallback), contradicting the
"never leak raw error text" principle. Both load and render failures now
set the localized doc_render_failed message.

Addresses re-review: Felix, Nora (raw error leak on the load path).

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:12:23 +02:00
Marcel
4c57a2262f test(frontend): guard wasm shipping at build time, drop CI-fragile pixel test
The in-browser pixel-render fixture test was green locally but flaky in
CI: the real pdf.js worker could not fetch /pdfjs-wasm/ in the CI
Chromium container, so the CCITT canvas stayed blank (0 sampled pixels)
and failed the suite — green locally, red in CI, root cause not locally
reproducible. A flaky gate is worse than none.

This bug is a build/serve parity failure, so guard it deterministically
where it actually breaks: a postbuild assertion that jbig2.wasm and
openjpeg.wasm shipped into build/client/pdfjs-wasm/ (non-empty). It runs
after `npm run build` — including the Docker build stage — and fails the
build loudly if a future pdfjs bump makes the static-copy glob match
nothing. Combined with the getDocument(wasmUrl) unit guard and the
negative-path render test, the regression is covered without CI flake.

Addresses re-review: Tobias (no automated parity check), Sara (pixel
test not pinned). Render-decode correctness verified manually via
`node build` serving /pdfjs-wasm/jbig2.wasm as application/wasm.

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:12:23 +02:00
Marcel
b8e01f997d docs(caddy): note future CSP must allow wasm-unsafe-eval for pdf.js
If a Content-Security-Policy is ever added, it must permit
'wasm-unsafe-eval' (script-src) and 'self' blob: (worker-src) or the
pdf.js wasm decoders and worker break and scanned PDFs render blank.
Forward-looking note so the future CSP author doesn't silently
reintroduce #708.

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:12:23 +02:00