297 Commits

Author SHA1 Message Date
Brummel 11bbeeaefb feat(watch): startup cleanup for stale markers and orphan audio
Two retention sweeps run once on app launch before the sync worker
spawns. Mirrors doctate-client-core::startup + ::pending_cleanup.

- Marker sweep: synced markers older than 72h are dropped via
  caseStore.cleanupStale; unsynced markers (guarding pending uploads)
  are preserved indefinitely.
- Orphan audio sweep: m4a without sidecar (or vice versa) older than
  24h goes; tmp leftovers go unconditionally. Paired files survive
  regardless of age — the upload worker owns deletion of valid pairs.

Best-effort: failures are logged but never thrown. A hostile filesystem
must not stop the watch from starting.

9 new tests (PendingCleanupTest 6, StartupCleanupTest 3) covering the
window, sibling, and tmp-leftover edges. 52 tests green total.
2026-04-26 23:39:54 +02:00
Brummel 5e4af12822 feat(watch): persistent upload queue with sidecar pairs
Recording flow no longer uploads inline. AudioRecorder writes the m4a
straight into filesDir/recordings/unsynced/{caseId}_{utc}.m4a; the VM
follows up with an atomic sidecar (.meta.json) holding case_id + recorded_at.
Sync trigger is a SyncTrigger seam (no-op for now, fleshed out in Phase 4)
so the screen pops back to the case detail immediately after the queue
write — uploads happen out-of-band.

PendingStore mirrors doctate-client-core::upload (sidecar shape, atomic
tmp+rename, scan FIFO sorted by recorded_at, deletePair idempotent).
Audio file naming uses recorded_at_to_filename_stem semantics — colons
swapped for dashes — so paths round-trip with the desktop client and
filesystems that disallow colons.

8 new PendingStoreTest cases (atomic write, FIFO order, orphan skip,
unreadable sidecar resilience, idempotent delete). 43 tests green total.
2026-04-26 23:38:25 +02:00
Brummel 24644a0144 feat(watch): replace CaseStoreStub with disk-backed CaseStore
Per-case JSON markers under filesDir/recordings/cases/, atomic tmp+rename
writes, kotlinx.coroutines Mutex serialising mutations. Wire format mirrors
doctate-client-core::CaseMarker exactly (snake_case keys, RFC3339 strings,
internally-tagged OnelinerState) — markers round-trip with the desktop client.

mergeServerSnapshot adds/updates only; reconcileWithServerSnapshot removes
synced markers inside the response window that the server stopped reporting.
Pending markers are never touched. Local Manual oneliners stick against
non-Manual server overrides.

DoctateApp now exposes caseStore as a lazy property and wires a
distinct-head flow collector to TileService.requestUpdate, decoupling
the store from Android Tile APIs (testable on the JVM).

JVM test infra: real org.json:json:20240303 plus
testOptions.unitTests.isReturnDefaultValues=true to bypass android.jar
'not mocked' stubs for Log/JSONObject. 35 unit tests green incl. 13 new
DiskCaseStoreTest cases (bootstrap, merge/reconcile semantics, mutex
ordering).
2026-04-26 23:35:00 +02:00
Brummel 6d47b6b99b feat(watch): lift CaseEntry to OnelinerState sealed type
Mirrors doctate-common/src/oneliners.rs::OnelinerState (Ready/Empty/Error/Manual)
in Kotlin. CaseEntry gains syncedToServer; manualOneliner boolean is gone — the
Manual variant carries that semantic. UI render sites use displayText() so the
'…' placeholder shows up consistently for null/Empty/Error states.

Phase 0 of the Wear-OS full-implementation plan: shape the data, no behaviour
change. All 30 JVM tests green.
2026-04-26 23:27:35 +02:00
Brummel 9a00b592f0 fix: Preserve manual oneliner across delete and reset paths
Consolidate the sticky-Manual rule for oneliner.json into a single
helper paths::delete_oneliner_unless_manual that holds the per-case
lock, reads the state, keeps OnelinerState::Manual, and removes any
LLM-derived variant. Three previously-direct deletion paths now route
through it: handle_delete_recording, reset_case_artefacts (admin
single-reset), and bulk_reset (admin bulk action). Before this fix a
clinician's manual override was wiped by a recording delete, letting
the LLM auto-regen path overwrite it on the next view.

Tests: 4 unit tests for the wrapper contract, integration tests for
the recording-delete and case-reset endpoints (Manual preserved,
Ready wiped), and a static-scan invariant that fails on direct
remove_file(... ONELINER_FILENAME) calls outside paths.rs.
2026-04-26 22:09:08 +02:00
Brummel eaed8f0dcd fix: Inline recording-delete confirm + stable redirects
- Replace native confirm() in the recording list with an inline
  two-step confirm row; hide the player via visibility:hidden
  while open so the row height stays stable.
- Hard-redirect recording delete back to /web/cases/{id}/recordings
  instead of relying on the Referer header (which silently fell
  back to /web/cases when stripped).
- Switch analyze/reset to a hidden return_to form field so the
  source page (case detail vs. case list) is preserved reliably,
  with same-origin /web/ prefix validation.
- Tighten delete_recording_test on the concrete Location header;
  adjust analyze/reset redirect expectations to the new
  case-detail fallback.
2026-04-26 17:52:29 +02:00
Brummel 03736b6993 feat: Add web UI for manual oneliner overrides
Adds a new POST endpoint for web-based oneliner overrides and updates
the UI to allow manual editing. The person icon replaces the pencil icon
for doctor-authored titles, signifying manual authorship rather than
editability.
2026-04-26 17:17:09 +02:00
Brummel d732fdd8ec feat: Add inline oneliner editing
Introduces inline editing for oneliner text within the case list. This
feature allows users to directly modify oneliner entries in the UI, with
changes being optimistically applied locally and then asynchronously
sent to the server.

Includes:
- New `OnelinerEditState` to manage the editing buffer and focus.
- `SaveResult` struct to communicate the outcome of background save
  operations.
- UI logic to toggle between read and edit modes for oneliner entries.
- Handling of keyboard inputs (Enter, Esc) and button clicks for saving
  or canceling edits.
- Toast banner to display save errors to the user.
- Asynchronous `put_oneliner` calls for background server updates.
- Local storage update for optimistic UI updates.
2026-04-26 16:35:14 +02:00
Brummel d5e6c334c9 feat: Add function to update oneliner locally
Introduces `set_oneliner_local` to `CaseStore` for optimistic UI
updates.
Also adds `case_update` module with `put_oneliner` to handle server-side
persistence.
2026-04-26 16:12:03 +02:00
Brummel c769abe3d5 feat: Add per-case dir oneliner locks
Introduces `OnelinerLocks` to serialize read-modify-write operations on
`oneliner.json`. This prevents race conditions between the manual
override API (`PUT /api/cases/{case_id}/oneliner`) and the transcription
worker's auto-regeneration process.

The manual override now acquires a lock specific to the `case_dir`
before writing. The transcription worker's `update_oneliner` function
also acquires the same lock around its post-LLM re-read-and-write
sequence. This ensures that a doctor's manual edit always takes
precedence, even if it arrives while an auto-regeneration is in
progress.

This change also includes:
- A new `routes::oneliner_override` module for the PUT handler.
- Validation for the oneliner text length and emptiness.
- Integration tests for the override functionality, covering happy path,
  error conditions, early latching, race conditions, and parallel PUTs.
2026-04-26 16:05:01 +02:00
Brummel 73fa099b51 Add OnelinerState::Manual variant
This commit introduces a new `Manual` variant to the `OnelinerState`
enum, allowing for manual overrides of the oneliner text. This change
affects the client and server components to handle and display this new
state.

A `OnelinerOverrideRequest` struct is also added for API requests to set
manual oneliners. New tests are included to ensure the `Manual` variant
serializes and deserializes correctly.

The `compute_oneliner_display` function in
`server/src/routes/user_web.rs` is updated to treat `Manual` states the
same as `Ready` states for display purposes. The
`cases_needing_oneliner_in` function in
`server/src/transcribe/recovery.rs` is updated to not retry cases that
are in a `Manual` state.
2026-04-26 15:44:13 +02:00
Brummel 1b689d1c8c feat: Add scheduled_tasks.lock file
The newly created `scheduled_tasks.lock` file tracks scheduled tasks for
Claude, ensuring efficient and organized task management.
2026-04-26 14:36:22 +02:00
Brummel 506804e4cd Add tap-to-edit oneliner on case detail screen
Tap opens the Wear OS system input picker (voice/keyboard/handwriting),
pinned to de-DE for medical German vocabulary. Doctor-authored oneliners
latch a manual flag that blocks the simulated LLM burst from overwriting
them; a subsequent manual edit still wins.
2026-04-24 16:22:32 +02:00
Brummel edcee399b4 Split recording flow into case detail + recording screens
Tap on a case in the list (or Tile continue-case) now opens a new
CaseDetailScreen showing date + oneliner + record EdgeButton. Only
hitting record enters RecordingScreen, which is itself trimmed to
just a live mm:ss counter and a stop EdgeButton. Swipe-right on the
recording screen discards the in-flight recording (no confirmation).
"New case" button still bypasses the detail step.

State machine: Recording now counts elapsedSeconds (up) instead of
secondsLeft. New OnStopTap and OnDiscardTap events, new reducer
transitions. Safety cap at 300 s.

ViewModel: startRecordingFlow split into a cancellable ticker job
plus a separate finalizeAndUpload run in applicationScope so the
upload survives the pop that follows a clean stop. AtomicBoolean
finalizationStarted guards against double-finalization between the
stop, discard, and auto-stop paths.

Display: FLAG_KEEP_SCREEN_ON on the recording screen via
view.keepScreenOn. DisposableEffect.onDispose doubles as the
discard trigger for swipe-right / back / host destruction;
discardIfStillRecording() is a no-op after a clean stop.

CaseDetailScreen lays out three vertical thirds: date/time at top
(Heute/Gestern/dd.MM.yy + HH:mm:ss), oneliner in the middle,
EdgeButton at the bottom. formatTime extracted to TimeFormat.kt
so list and detail never drift apart.
2026-04-24 15:42:52 +02:00
Brummel 028342d4a4 Add "minutes ago" time formatting
Introduces a `minutesAgo` helper function and uses it in `CaseStoreStub`
to populate `CaseEntry` objects with relative timestamps for recent
activities. Also updates `formatTime` in `CaseListScreen` to display
"Gerade eben", "Vor 1 Minute", or "Vor X Minuten" for entries younger
than an hour. This provides a more user-friendly display for recent
cases.
2026-04-24 14:34:55 +02:00
Brummel 50fbc2690e Add scrolling to newest case
Display cases in reverse chronological order for better UX. Scroll to
the last item when data is loaded.
2026-04-24 14:29:46 +02:00
Brummel f653a6a447 Update demo data and time formatting
Refactor `seedDemoData` to use `java.time` for more accurate date and
time generation, including specific times for today, yesterday, and
older dates.
Update `formatTime` in `CaseListScreen` to leverage `java.time` for a
more robust and localized date and time formatting, supporting "today",
"yesterday", and older date formats.
2026-04-24 13:58:52 +02:00
Brummel 816e8b278d Add vertical spacing to case list 2026-04-24 13:44:15 +02:00
Brummel cb39f95ff0 Export ANDROID_SERIAL for precise device selection
This ensures that the `ANDROID_SERIAL` environment variable is exported
when
`ADB_SERIAL` is set. This is crucial because Android Gradle Plugin's
`android.injected.device.serial` property can silently fall back to
selecting
all attached devices if the serial doesn't match exactly. Exporting
`ANDROID_SERIAL` provides a deeper level of filtering for spawned `adb`
binaries, maintaining the intended device selection.
2026-04-24 13:40:40 +02:00
Brummel 5176ea26f4 Refactor CaseRow styling and text behavior
Improve the visual presentation of individual case entries by adjusting
styling and text handling. This includes setting a minimum height for
rows, applying consistent padding, and enabling text truncation with an
ellipsis for longer oneliner descriptions.
2026-04-24 12:46:55 +02:00
Brummel 3b2b62865c Fix: Disable auto-centering on ScalingLazyColumn 2026-04-24 12:24:42 +02:00
Brummel 46880e1b06 chore(watch): require explicit target in run.sh and prompt on ambiguity
Drop the legacy "(none)" target fallback that quietly picked the first
attached device. Every device-bound subcommand now requires a `watch` or
`emulator` prefix (or DOCTATE_TARGET env var).

The emulator path never auto-picks a running emulator: even with exactly
one running, the menu shows so the user can boot a different AVD
instead. ADB_SERIAL / WEAR_AVD still bypass the menu for scripted runs.

Menu results now travel through a global RESOLVED_SERIAL/PICKED instead
of stdout+$(), so `die` inside the resolvers aborts the main shell
directly rather than getting swallowed by a command-substitution subshell.
2026-04-24 12:12:30 +02:00
Brummel b02cc0c870 Add Wear OS Tile and Complication support - PoC
This commit introduces support for Wear OS Tiles and Complications,
enabling users to view case information and launch the app directly from
their watch face.

Key changes include:
- **New Complication Service:** `DoctateComplicationService.kt` provides
  a simple complication that displays "Doctate" and launches the app's
  case list upon tap.
- **New Tile Service:** `DoctateTileService.kt` serves a dynamic tile
  showing the current case's one-liner. It includes tappable areas to
  navigate to the case list, create a new case, or continue an existing
  one.
- **UI Navigation:** `AppNav.kt` and related files (`CaseListScreen.kt`,
  `MainActivity.kt`, `NavCommand.kt`) set up the navigation structure
  for the Wear OS app, handling intents from the Tile and Complication.
- **Data Structures:** `CaseEntry.kt` defines the minimal data structure
  for case information displayed on the watch.
- **In-Memory Data Store:** `CaseStoreStub.kt` acts as a temporary,
  in-memory store for case data, ensuring consistency across the
  activity, Tile, and Complication.
- **Dependency Updates:** Added necessary Wear OS libraries to
  `build.gradle.kts` and `libs.versions.toml`.
- **AndroidManifest:** Configured the `MainActivity` to handle
  `singleTask` launch mode and added necessary intent filters for
  complications.
- **Demo Data:** Seeded `CaseStoreStub` with demo data for initial
  testing and visualization.
2026-04-23 23:07:03 +02:00
Brummel cea8ed8c06 Refactor Wear OS UI to use native surfaces 2026-04-23 22:00:12 +02:00
Brummel 66d13e741a Update Pixel Watch 2 testing status
The project plan has been updated to reflect the successful testing of
the Pixel Watch 2 hardware. The plan now includes details about
ADB-over-WiFi pairing and testing against a local development server. It
also mentions the successful manual verification of the end-to-end flow
on the real device.

Further testing, including LTE mode, doze mode, and long-term foreground
service behavior, is still pending.
2026-04-23 19:40:09 +02:00
Brummel cca41fe5ea chore: untrack .idea/ (IDE workspace state, not team-shared)
These files mutate on every IDE click and leak per-developer state
(last deployed device, recent activity) into commits. A single root
`.idea/` ignore replaces the scattered per-file rules in
watch/wearos/.gitignore.
2026-04-23 17:57:26 +02:00
Brummel 3429e8bdf7 feat(watch): support real Pixel Watch 2 via ADB-WiFi + LAN dev server
Real hardware has no USB and can't resolve 10.0.2.2, so we pair over
ADB-WiFi and point the app at the dev laptop's LAN URL. run.sh gains a
`watch`/`emulator` target prefix so both devices can be attached in
parallel without serial-picking roulette.

- network_security_config.xml: whitelist 192.168.178.27 for cleartext
- build.gradle.kts: resolveProp() threads Gradle CLI `-P` overrides
  into BuildConfig (SERVER_URL, API_KEY) ahead of local.properties
- run.sh: adb_cmd wrapper honours ADB_SERIAL; new `devices` and
  `connect [ip:port]` subcommands; `.watch_serial` caches the paired
  endpoint so `./run.sh watch install` just works on subsequent runs
- .gitignore: ignore `.watch_serial`
2026-04-23 17:54:15 +02:00
Brummel 2e3cb50ceb Update projektplan.md with current status 2026-04-23 16:44:27 +02:00
Brummel ed993a5c6d feat(watch): vertical PoC — record, upload, playback end-to-end
Completes the 5-second-record-and-upload flow:
- RecordingViewModel orchestrates recorder, countdown, and upload
- MediaRecorder wrapper produces 16kHz/AAC-LC m4a (Whisper-compatible)
- Compose RecordingScreen renders the state pyramid
  (Idle / RequestingPermission / Recording / Uploading / Success / Error)
- UiState + pure reducer with full JVM unit-test coverage
- run.sh "test" subcommand wraps the unit and instrumented tiers
  (set SKIP_INSTRUMENTED=1 to bypass the on-device tier)

Verified end-to-end: emulator captures "Hallo, hallo", lands at
tmpdata/<user>/<case>/*.m4a, Whisper transcribes, Ollama classifies,
document.md is written — full pipeline in ~5s.

Known: MockWebServer-based UploadClientTest hangs in setUp on the
Wear OS 34 emulator (likely SELinux socket policy). Deferred — the
real-server end-to-end already validates the wire protocol.
2026-04-23 16:30:57 +02:00
Brummel 8306dd8c47 feat(watch): scaffold upload client and app service locator
Builds the network foundation for the Wear OS vertical PoC:
OkHttp-based UploadClient mirroring doctate-client-core's wire protocol
(multipart /api/upload, X-API-Key, transient/terminal classification),
BuildConfig-backed Settings sourced from local.properties, CaseId factory,
DoctateApp service locator, MockWebServer-based UploadClientTest for
wire-format assertions, plus the manifest/permissions/network-security-
config plumbing for emulator-loopback cleartext.

MainActivity is still the placeholder UI; audio recording, ViewModel,
and recording screen come in follow-up commits.
2026-04-23 15:25:53 +02:00
Brummel 673e0aeb9d Add deviceManager.xml to project settings
Adds the deviceManager.xml file, which configures device sorting within
the IDE, to the `.idea` directory. This ensures consistent device
management across different developer environments.
2026-04-23 12:57:57 +02:00
Brummel 41adb89763 chore(watch): add run.sh helper for build/install/run
Inner-loop tooling so the dev cycle does not require Android Studio.
Subcommands: build, install, start, stop, logcat, shot, clean
(default = install + start). Auto-detects JAVA_HOME (bundled JBR) and
adb. Boots an emulator on demand when no device is attached: interactive
menu of available AVDs, with WEAR_AVD=<name> env-var bypass for
non-interactive use. Waits for sys.boot_completed (180s timeout) before
installing.
2026-04-23 12:29:11 +02:00
Brummel 9a72619f23 chore(watch): set header text to "Doctate!" 2026-04-23 12:29:06 +02:00
Brummel 8956b74c09 feat(watch): scaffold Wear OS app via Android Studio
Initial skeleton for the Pixel Watch dictation client (Plan Phase 5b).
Generated by Android Studio's "Empty Wear App" template, kept verbatim
to defer bikeshedding until real friction appears.

Toolchain pinned via wrapper / version catalog:
  AGP 9.2.0, Kotlin 2.2.10, Gradle 9.4.1, JDK 21 (auto-provisioned).
  compileSdk 36, minSdk 30 — covers Pixel Watch 1/2/3.

UI starter: Wear Compose Material3 1.5.6 with TransformingLazyColumn
and EdgeButton — already close to the planned per-case full-screen
list pattern.

Manifest declares the app standalone, so the Wear OS Network Proxy
will tunnel uploads transparently when the watch lacks LTE/WiFi.
No Phone-side companion module required.

Multi-module split (:core-domain, :core-audio, :core-sync, :core-http,
:core-storage, :app-mobile) intentionally postponed — current single
:app module is the smallest thing that compiles, which gives us a
known-good baseline before restructuring.

AS's default .gitignore left as-is. A handful of .idea/ project-config
files (compiler.xml, gradle.xml, misc.xml, codeStyles, runConfigurations)
are committed by AS's design; revisit if machine-specific paths or
multi-machine drift become painful.
2026-04-23 11:52:45 +02:00
Brummel e09108ec35 Update projektplan with CSRF and security headers 2026-04-23 09:38:56 +02:00
Brummel 813fb896ff refactor: consolidate bulk actions, URL assembly, and case-artefact filenames
Pulls three pattern groups into shared helpers so a rename or wire-format
tweak edits one file instead of 10+:

- BulkAction enum in doctate-common replaces the "close"/"analyze"/"reset"
  string literals that were duplicated between the bulk handler and 3
  attack/CSRF test files. FromStr preserves the exact "Unbekannte Aktion"
  error shape; the handler match is now exhaustive over the enum.
- join_url helper in doctate-common absorbs 7 identical
  trim_end_matches+format! call sites across client-core, client-desktop,
  server/transcribe (ollama, whisper), and server/analyze (llm).
- server/tests/common/artefacts.rs re-exports ONELINER_FILENAME
  (doctate-common), DOCUMENT_FILE + ANALYSIS_INPUT_FILE
  (doctate-server::analyze), and CLOSE_MARKER (doctate-server::paths) so
  test files reference the canonical name instead of inlining literals.

Also lifts client-desktop local duplication:
- paths.rs: project_path helper collapses 4 identical ProjectDirs chains
- main.rs: or_die helper replaces 4 eprintln!+exit(1) blocks
- app.rs: named RecordingContext struct replaces (Uuid, String) tuple
  at 3 sites around the ffmpeg-flush finalization path

Verification: 397 tests pass (baseline was 389; +8 for new unit tests on
BulkAction and join_url), 0 failed, 4 ignored (unchanged). clippy clean.
2026-04-22 12:09:46 +02:00
Brummel af3377a6bc refactor(tests): migrate remaining batches to tests/common/
Finishes the lift of shared helpers into `tests/common/`. Covered:
- health, web, oneliners_api, upload (31 tests; upload exercises the
  new `multipart_upload_body` helper driven by doctate_common field
  constants)
- sse_integration, sse_cleanup, transcribe, oneliner_heal_decoupled,
  silent_case_empty, failed_only_case_empty_oneliner,
  transient_failure_retries (24 tests)

Also applied cargo fmt across the test tree and fixed one clippy
needless_borrows_for_generic_args warning in analyze_test.

All 387 tests pass; 3 ignored (as before).

Side effect: health_test previously used a hardcoded `/tmp/doctate-test`
data path, which parallel `cargo test` runs could collide on. The
migration replaces it with the common unique-tmpdir pattern, removing
a latent flake.
2026-04-22 10:51:33 +02:00
Brummel f438e972d4 refactor(tests): introduce shared tests/common/ support module
Lift duplicated test-harness code (User factories, TestConfig builder,
login/CSRF flow, form/json helpers, case-directory seeding, URL path
builders) into `server/tests/common/` so future API changes touch one
file instead of every integration test.

Migrated batches: csrf_attack, auth, login, magic_link, security_headers
(28 tests); analyze, case_page, delete_recording, close_watermark,
retention_sweep (67 tests). All 95 tests still green.

Net line delta across these 10 files: +1113 / -1896 (~780 lines removed),
plus ~500 lines of new shared infrastructure under tests/common/.
2026-04-22 10:42:51 +02:00
Brummel 2e3b5efe86 feat: render CSRF token into state-changing forms
Adds the csrf_token hidden field to every POST form in the web UI
(logout, bulk, purge-closed, close, reopen, analyze, reset,
delete-recording). Login stays without a token — SameSite=Strict
already blocks the relevant attack shapes and forced-login CSRF
has no impact here.

Shape:
- New askama macro partials/csrf_field.html renders the hidden
  input; 3 templates import + {% call csrf::field(csrf_token) %}
  inside each <form method="POST">.
- AuthenticatedWebUser carries csrf_token (cloned out of the
  session once during extraction) so render handlers don't need a
  second store lookup.
- 3 template structs (MyCasesTemplate, CasePageTemplate,
  CaseRecordingsTemplate) gain the field; render sites pass
  user.csrf_token through.

Safety-net tests:
- Each rendered page must contain the exact session csrf_token in
  a hidden input — catches anyone adding a new form without the
  macro.
- Happy-path round-trip: fetch page, parse token from HTML, POST
  with token → 303. Catches drift between rendered and accepted
  token formats.
2026-04-22 10:04:21 +02:00
Brummel bf3e9ba6cc feat: server-side CSRF token validation
Introduces CsrfForm<T>: a FromRequest extractor that looks up the
session's csrf_token and constant-time-compares it to a csrf_token
field in the form body. Drop-in replacement for Form<T> on every
state-changing /web/ POST handler. Missing or expired session →
redirect to /web/login; malformed body → 400; token mismatch → 403.

WebSession carries csrf_token, minted alongside the session token at
login and magic-link consume. Not rotated per request — rotation
would break multi-tab use and buys little over SameSite=Strict
cookies.

Handlers: bulk, purge-closed, reset, close, reopen, analyze,
delete-recording, logout all now require CsrfForm<_>. Login stays
unprotected (SameSite=Strict alone is sufficient — forced-login CSRF
has no impact on this codebase). /api/... is header-auth, exempt.

Constant-time compare via subtle::ConstantTimeEq avoids timing
oracles on the token.

Template work is NOT in this commit — browser forms still post
without csrf_token, so the live web UI will 403 until Schritt 6
(templates render the hidden field).

Tests: all 12 red specs in csrf_attack_test.rs now green, #[ignore]
removed. Integration tests that POSTed to protected endpoints
switched to a new create_router_and_session_store entrypoint which
returns a handle to the store so tests can read csrf_token for the
active session.
2026-04-22 09:59:17 +02:00
Brummel f3d0380dbd feat: add defense-in-depth security-header layer
Attaches a SetResponseHeaderLayer stack to create_router_with_state
(not main.rs) so tests observe the same response shape as production.
`if_not_present` mode so per-route overrides (magic.rs already sets
its own Referrer-Policy) are preserved.

Sets: X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
Content-Security-Policy, Permissions-Policy. HSTS is deliberately
omitted until TLS termination is in place — a cached max-age on a
plain-HTTP deployment is irreversible.

CSP uses 'unsafe-inline' for script/style since templates contain
inline scripts; revisit if any user input ever renders unescaped.

Removes #[ignore] from the 10 attack-confirming header tests; two
regression anchors (no-HSTS, no-duplicated magic header) were already
green.
2026-04-22 09:33:46 +02:00
Brummel 34aa633d32 feat: Add CSRF and security headers tests
Adds comprehensive tests for CSRF protection and security headers to
ensure robust defense against common web attacks.
2026-04-22 09:19:25 +02:00
Brummel ba6aea06e5 Update project plan documentation
Add details about the `purge-closed` endpoint, including its admin-only
nature and confirmation requirement.
Clarify the admin-only checks for bulk actions, emphasizing that they
are now performed at the entry handler level.
Refine the explanation of the case list display, particularly the
`open_count/total_count` grouping and how closed cases are handled.
Introduce the concept of analysis preview in the case list, explaining
its implementation using CSS `line-clamp` and avoiding server-side
truncation.
Enhance the description of admin
2026-04-21 21:59:48 +02:00
Brummel 1182f4817e Enforce admin-only for bulk and purge actions
Move admin checks from individual bulk actions to the main handler for
`bulk.rs` and `case_actions.rs`. This consolidates the authorization
logic for these sensitive operations.

Additionally, refine the HTML template to conditionally render bulk
action UI elements and the purge form only when the user is an admin.
This ensures that non-admin users do not see or have access to these
administrative functions.

Update tests to reflect these changes, ensuring that non-admin users are
correctly rejected for these actions and that the UI is properly hidden.
2026-04-21 19:48:24 +02:00
Brummel d13bd5307e Refactor analysis display to full HTML
Replaces `analysis_preview` with `analysis_html` to display the full
markdown-rendered document content instead of just a plain-text preview.
This allows for richer content presentation and enables client-side
expansion for detailed views.

The HTML template has been updated to include a new `.analysis`
container that handles the expand/collapse functionality using
JavaScript. This approach avoids server-side truncation and relies on
CSS for presentation.
2026-04-21 18:21:04 +02:00
Brummel 87a04c4b27 Refactor case display for better readability
Introduce a dedicated case-link class for clickable case titles.
Enhance the line1 div to use flexbox for better alignment of time,
title, and recordings count.
Add a separate link for recordings, improving the structure and user
experience of the case list.
2026-04-21 17:47:42 +02:00
Brummel 75ede79d26 Refactor: Improve case grouping and counting logic
Introduce `count_closed_by_date` and `most_recent_date_label` to
efficiently count closed cases for display purposes when `show_closed`
is false.

Modify `group_by_utc_date` to accept a `closed_extra` map, allowing it
to correctly calculate both open and total case counts per day.

Update the `my_cases.html` template to display the new
`open_count/total_count` format for case groups.
2026-04-21 17:41:35 +02:00
Brummel 661ea6215e Add analysis preview to case list
Introduce a `preview_lines` setting in `users.toml` to control the
number of visible lines for the analysis preview in the case list. This
feature extracts the first paragraph of the `document.md` file, strips
markdown formatting, and displays it, capped by the configured
`preview_lines`. The actual line clamping is handled client-side via CSS
`line-clamp`.
2026-04-21 17:25:14 +02:00
Brummel 26b202ec07 Refine system prompt for keyword extraction 2026-04-21 17:10:55 +02:00
Brummel 3218fc62bd feat: server-authoritative case window via users.toml
The /api/oneliners time window is now read per-user from users.toml
(window_hours, default 72h). Clients no longer carry a window:
client.toml oneliner_window_hours, SyncConfig.window_hours, the
?hours=N query param, and the render_case_list cutoff filter are
gone. ETag suffix keeps the effective hours so an admin edit to
users.toml invalidates client caches on the next request.
OnelinersResponse.window_hours stays in the wire format, but now
exists solely to anchor client reconciliation.
2026-04-21 16:48:01 +02:00