Commit Graph
1281 Commits
Author SHA1 Message Date
Marc-André Bombeck 95ee088646 Merge branch 'feat/biomarker-detail-page' into release/v1.25.0 2026-06-28 14:42:04 +02:00
Marc-André Bombeck ccbf4e07ae Merge branch 'wave/v125-delight' into release/v1.25.0 2026-06-28 14:41:41 +02:00
Marc-André Bombeck 52e8dab3d7 Merge branch 'wave/v125-sec' into release/v1.25.0 2026-06-28 14:40:39 +02:00
Marc-André Bombeck fb03ddcc6e Merge branch 'wave/v125-perf' into release/v1.25.0 2026-06-28 14:40:19 +02:00
Marc-André Bombeck f17131dbdf test(labs): cover the biomarker assessment, detail page, and marker delete/range
Add coverage for the new biomarker-assessment seam and the marker detail
surface:

- the generator serves today's cache, re-stamps unchanged readings without an
  LLM call, and regenerates only when the latest-reading fingerprint changes
- the read-only endpoint returns the envelope, 422s a missing id, and honours
  the auth + insightStatus gates
- the detail page paints the stat strip, the chart placeholder, and the
  assessment card
- the biomarker resource route drops the marker with its readings in one
  userId-narrowed transaction and rejects an inverted target range

Hold the input gate independent of the worker's forced run so an idle marker
re-stamps cached text instead of regenerating, and list the new route on the
non-Coach gate allowlist.
2026-06-28 13:58:43 +02:00
Marc-André Bombeck 0790a96ca4 feat(labs): add an AI assessment and stat strip to the biomarker detail page
Give each biomarker a detail page that matches the metric sub-pages: a
numbers-first Min/Max/Median/Mean strip over the numeric readings and a
data-grounded AI assessment card.

The assessment is served by a new read-only endpoint
GET /api/insights/biomarker-assessment?biomarkerId=&locale=, returning the
same envelope as /api/insights/metric-status so InsightStatusCard consumes
it unchanged. A new generator reads the marker's LabResult history and
reuses the shared provider runner, the stale-while-revalidate read-only
path, and the persist helper. Regeneration is gated on a fingerprint of the
latest reading, so the assessment is rebuilt only when a new reading lands;
an idle marker re-stamps the cached text without an LLM call. The
biomarker: scope rides the existing on-demand status queue.

The stat strip and the assessment card both self-gate on numeric data, so a
qualitative-only or brand-new marker paints neither.
2026-06-28 13:43:14 +02:00
Marc-André Bombeck d8c82910a7 fix(insights): localize and auto-send the coach assessment prompt
Thread an autoSend flag through the coach-launch flow so a card's
"ask the coach about this" hand-off dispatches its seeded prompt as
the conversation's first turn instead of only seeding the composer.
The flag rides the launch context, the layout mount, the drawer, and
the shared conversation surface, where a ref-guarded mount effect fires
the send exactly once for a fresh thread.

Localize the previously hardcoded English assessment opener in the
metric status card behind insights.coach.assessmentPrompt with a
{metric} placeholder, and localize the resting-pulse coach prefill.
Both keys land in all six locales.
2026-06-28 13:20:24 +02:00
Marc-André Bombeck 4bf45efcab revert(insights): drop hydration logging
Remove the in-app water-intake logging feature: the WATER_INTAKE
measurement type, the per-user daily-goal field, the goal-ring card and
its endpoint, and the related query keys, OpenAPI route, and locale
strings. The feature is dropped from this release; the baseline-drift,
breathing-screening, and labs-changes insight cards are unaffected, and
the pre-existing TOTAL_BODY_WATER body-composition type stays intact.
2026-06-28 11:39:26 +02:00
Marc-André Bombeck a827fe2ca7 fix(insights): correct medication compliance and per-user insight bucketing
A cluster of related correctness gaps in the compliance denominator and the
insight surfaces that read it:

- Paused medications. A doctor-ordered pause flipped `active` off and stamped
  `pausedAt`, but resume nulled `pausedAt`, so the interval was irrecoverable
  and every paused day became a missed dose after resume — collapsing
  adherence across the Health Score, the BP gate, the Coach snapshot and the
  report. Persist pause intervals as durable eras (new `MedicationPauseEra`,
  migration 0211), close the open era on resume, thread the eras through the
  compliance context and drop slots inside a paused interval from the
  denominator. Every compliance call site now fetches and passes the eras.

- Health Score eras. The Health-Score fast path was the only compliance
  caller that skipped the shared schedule select and schedule revisions, so
  after a schedule edit it scored past days against the current schedule
  while every other surface used the historical era. Switch it to the shared
  select and thread revisions (and pause eras) like the canonical path.

- Insight bucketing timezone. The per-metric status series bucketed every
  user in Europe/Berlin, so a near-midnight reading landed in the wrong day
  and the relative-day phrasing was off for non-Berlin self-hosters. Thread
  the resolved user timezone and key off `userDayKey`.

- Correlation significance. User-facing correlations surfaced from five
  paired points with strength read straight off the coefficient. Route them
  through the rigorous engine (at least twenty pairs, Student-t p below 0.05)
  so small-N coincidences no longer render as confident findings.

- Resting-pulse input. The mood-versus-"resting pulse" correlation fed the
  raw PULSE day-mean, which folds in workout HR, under a "Resting pulse"
  label. Feed `resolveRestingPulseSeries` (clean RESTING_HEART_RATE, else a
  daily low-percentile proxy) so the label matches the input.
2026-06-28 03:28:37 +02:00
Marc-André Bombeck 9e6a58b186 fix(illness): escalate sustained fever and low SpO2 on write
A sustained fever (three calendar-consecutive days at or above the fever
red-flag floor) or sustained low SpO2 was already classified "seek care",
but the only escalation path ran inside a GET on the episode correlation
route — no push fired unless the user opened that tab, and no cron drove
it. The proactive on-write safety check covered only blood pressure and
glucose; the two signals with the strongest safety case had the weakest
delivery.

Add sustained-run temperature and SpO2 checks to the on-write
`runSafetyFloorCheck`, wired from both measurement-ingest seams. The run
length and clinical floors are shared with the retrospective detector (no
duplicated thresholds), day bucketing honours the user's timezone, and
escalation reuses the existing red-flag notification with its push-attempt
dedup so a febrile user logging repeatedly is alerted at most once per
reason per day.
2026-06-28 03:28:11 +02:00
Marc-André Bombeck d85ac3e555 fix(measurements): recompute rollups after cumulative consolidation drains
The two cumulative drains soft-deleted per-sample rows and wrote a daily
total but never recomputed the affected DAY rollup bucket. The rollup tier
reads only `deleted_at IS NULL`, so the stored `sumValue` kept the
pre-drain double-counted total indefinitely — dashboard and every rolled-up
week/month/year step or energy figure stayed wrong and never self-healed,
because the backfill only re-flags days with no DAY row and the
daily-series read-swap only re-folds on a day-count change.

Call `recomputeBucketsForMeasurement` after each per-day write, mirroring
the daily-mean drain that already does this. Added per-drain (not hoisted)
so the mean drain, which recomputes inside its own writer, is not
double-recomputed.
2026-06-28 03:27:55 +02:00
Marc-André Bombeck c371310fc0 fix(integrations): anchor Oura and Polar daily records at local noon
Date-only daily scalars from Oura and Polar were stored at UTC midnight
(`${day}T00:00:00.000Z`). On read these rows re-bucket through
`userDayKey(measuredAt, userTz)`, so for any west-of-UTC user the day
shifted back one: a June 1 SpO2 or readiness row filed under May 31 and
fell out of alignment with the HR/sleep rows that carry true instants.

Extend the existing `canonicalDailyTimestamp` helper to accept an optional
timezone and anchor a date-only day at local noon (noon UTC when no zone
is supplied) — a full twelve hours inside the calendar day, so it
round-trips through `userDayKey` to the same day for every zone within
plus/minus twelve hours. Route every Oura day-keyed mapper (via the shared
`dayAnchor` fallback) and all five Polar daily mappers through it; real
wake/measurement instants stay untouched. Fitbit already anchored at noon
and is now consistent; Whoop and the Withings cursor anchors were verified
not to be measurement anchors.
2026-06-28 03:27:46 +02:00
Marc-André Bombeck ec3fcea84f feat: baseline-drift, breathing-screening and lab-change insight cards
Add three read-only awareness cards to the Insights overview, each drawn
off an existing engine and each honest about empty state — the section
un-mounts rather than surfacing a husk.

- Health status: surfaces what is drifting from the user's own normal by
  folding the personal-band deviations (coincident-deviation engine) with
  the dated, sustained level shifts (changepoint detector) at
  GET /api/insights/health-status. Awareness only, never a diagnosis.
- Breathing screening: summarises the last 30 nights of the per-night
  sleep-breathing-disturbance index plus device-flagged events at
  GET /api/insights/breathing-screening. Mirrors the device's own
  classification and states plainly that it is a screening signal, not a
  diagnosis — discuss with a clinician.
- Lab changes: pairs the two most-recent numeric lab panels and reports
  each shared analyte's signed delta and reference-band standing at
  GET /api/insights/labs-changes. Neutral framing only.

Each reshaping helper is pure and unit-tested for its present/absent
states. Register the three sections in the insights-layout universe so
they merge in as default-visible, wire them into the overview and the
edit-mode title map, add the query-factory keys, document the routes in
the OpenAPI contract, and translate every new string across all six
locales.
2026-06-28 03:20:49 +02:00
Marc-André Bombeck 31c35baec2 feat: hydration logging and daily goal ring
Add a WATER_INTAKE measurement type and a per-user daily goal so the
Insights overview can track water intake against a goal ring. Logging
rides the existing measurements create path (type WATER_INTAKE, unit ml,
summed cumulatively); GET/PATCH /api/hydration read the day total and own
the goal. The goal-ring math (empty / partial / met / exceeded) lives in
a pure helper, free of Prisma and React, and is unit-tested in isolation.

Wire the new type through the measurement enum, value ranges, cumulative
HealthKit handling, categories, display transform, list metadata, record
direction, and the chartable-token allowlist. The HealthKit DietaryWater
import stays on the nutrition hold, so the type carries no import mapping
by design.

Document GET/PATCH /api/hydration in the OpenAPI contract.
2026-06-28 03:20:28 +02:00
Marc-André Bombeck 84683f4ce9 perf: bound heavy-tenant memory on feature, consolidation, backup and auth paths
Cap and page the unbounded reads that let a multi-year tenant pull its
whole measurement history into memory on hot paths.

- extractFeatures: read newest-first with a 6000-row cap (mirrors the
  Coach snapshot) and reverse to ascending, so a 365-400 day window no
  longer materialises hundreds of thousands of PULSE / glucose rows per
  Coach turn and nightly briefing. Full-history extremes still come from
  readAllTimeExtremes, so the cap never relabels an extreme.
- Add a (userId, measuredAt) index on measurements (migration 0210) for
  the type-less range scans that extractFeatures and the consolidation
  passes drive; the existing (userId, type, measuredAt) cannot serve them
  as a prefix.
- runConsolidation: keyset-paginate the per-(user, type) source scan on
  (measuredAt, id) instead of one unbounded findMany, removing the giant
  single result-set allocation that drove the boot storm. Output is
  identical; covered by a paging unit test.
- Stagger the boot-time backfill enqueues with increasing startAfter
  offsets so the heavy full-history loads no longer all drain in parallel
  onto one tenant at startup. Non-boot callers keep their immediate send.
- Backups: replace load-all-then-stringify with paged narrow-select reads
  for the weekly and off-host writers, bounding transient heap. Off-host
  still includes soft-deleted rows. Covered by a serialize/restore
  round-trip test.
- Drop the per-request auth.bearer.success audit insert; the wide event
  already records auth_method and user_id, so the polling client no longer
  drives a write + pool checkout on every request. Failure-path audit
  stays.
- Coach conversation detail: cap the message include to the newest 200 so
  a long thread no longer decrypts every message on each open; response
  shape unchanged.
- Admin audit-log: floor the scan at the retention horizon so the listing
  and its count stop sweeping open-ended over the substring filters.
2026-06-28 03:07:15 +02:00
Marc-André Bombeck 5ea17398bc feat(security): encrypt medication free-text notes at rest + password-change MFA step-up
Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and
MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext
PHI columns left after the v1.23 note-encryption rollout. Add the three
*Encrypted Bytes columns (migration 0208), register them in the encrypted-
column rotation set, route every read/write through the note cipher, and add
a transactional fail-closed boot-time backfill for legacy rows. Plaintext
columns drop in a later release once the backfill reports zero remaining rows.

Require a fresh second factor on password change for MFA-enrolled accounts,
matching the existing step-up on MFA-disable and recovery-code regen. Redact
free-text note values from the Zod validation excerpt that can reach a wide
event.
2026-06-28 03:05:10 +02:00
Marc-André Bombeck 9bc3b0b101 fix(mcp): validate the write preview so it matches the commit outcome
The confirm:false preview echoed the would-be record without running the
type / range / instant validation, so a preview could show a value the
commit then refused. Run the same shared validation in the preview and
surface wouldFail with the error + reason the commit would return, so a
preview and its commit never disagree.

Also note at the REST read-method allowlist that the MCP-audience guard
assumes GET/HEAD/OPTIONS are side-effect-free — a future side-effecting
GET must revisit that audience binding.
2026-06-28 02:00:44 +02:00
Marc-André Bombeck b9c164c4bb feat(mcp): expose medication schedule, integration status, and preventive-care reads
Add three grounded read tools, each a thin façade over an existing
server-authoritative engine (no new analytics, userId from the session):

- get_medication_schedule: next-due / overdue per active medication via
  the canonical recurrence engine (computeDisplayDue), with as-needed
  flagged and no scheduled due time.
- get_integration_status: per-provider sync health (connected, last sync,
  reauth-required/failing) from the integration-status ledger, so a user
  can see why their data is stale. No secrets or tokens.
- get_preventive_care: the user's already-configured Vorsorge reminders
  with their persisted next-due dates and an overdue flag; it surfaces
  configured reminders only and invents no screening logic.

Each declares an output schema, read-only annotations, and {present:false}
on absence. Page the search tool across the full assembled result set
(the prior 200-row scan made later items unreachable even through the
cursor) and give each result a per-item deep link where an id exists.
2026-06-28 02:00:37 +02:00
Marc-André Bombeck 1d9cc4b3d2 refactor(mcp): share one doctor-visit summariser across the resource and prompt
The doctor-visit summariser was duplicated and had drifted: the
healthlog://report/doctor-visit resource omitted mood, the glucose
panel, the wellness composites, and illness episodes that the
doctor_visit_summary prompt already carried. Extract one shared reducer
(doctor-visit-summary.ts) using the richer superset and have both
surfaces call it, so they ground identically and a future change reaches
both wires at once. Both keep the grounded latest+aggregate shape and
omit absent sections honestly.
2026-06-28 02:00:26 +02:00
Marc-André Bombeck 82b43f135e fix(mcp): bound the write timestamp like the manual route
The MCP measurement and blood-pressure write tools accepted a
client-supplied measuredAt with no temporal bound and persisted it
verbatim, so a far-future or ancient instant could poison "latest"
reads and rollup buckets. Bound it through the shared entry-instant
validator both manual capture paths use — no future beyond a 5-minute
clock-skew tolerance, nothing before 1900 — inside the write cores, and
return the structured out_of_range refusal rather than throwing.

Extract the type/range/instant checks into shared pre-write validators so
the same verdict can be reused by the confirm-gate preview.
2026-06-28 02:00:18 +02:00
Marc-André Bombeck 391c78674e Merge branch 'wave/v124-ewe-oauth' into release/v1.24.0 2026-06-28 01:22:14 +02:00
Marc-André Bombeck 67877a6aaa Merge branch 'wave/v124-ewc-prompts' into release/v1.24.0 2026-06-28 01:22:14 +02:00
Marc-André Bombeck 4f2c2c25e3 Merge branch 'wave/v124-ewb-resources' into release/v1.24.0 2026-06-28 01:22:14 +02:00
Marc-André Bombeck af8afde63a feat(mcp): expose glucose, sleep, workout, illness, and cycle reads
Bridge the five already-built, grounded Coach retrieval tools the
inventory already advertised but the wire was missing — closing the
advertise-but-missing self-inconsistency — each a thin wrapper over the
existing read with the same gates and present:false contract. Add a
multi-metric fan-out that loops the single-metric read, and a per-analyte
lab-history mode on the labs read. Declare a structured output schema on
every remaining tool so structured content validates for both a miss and
a hit. Page the large reads (search, lab history, multi-metric) behind an
opaque cursor. Return plain-text, citation-ready summaries from fetch
with per-item deep links instead of a JSON dump.
2026-06-28 01:21:07 +02:00
Marc-André Bombeck b40a963cb1 feat(mcp): support arbitrary date ranges and per-analyte lab history
Add an optional explicit {from,to} range to the comparison and
changepoint reads, served over the same rollup tier the five fixed
trailing windows use (reach back to the lower bound, filter to the
span) — so a before-vs-after-a-date question is answerable without
recomputing analytics. Add a per-analyte lab trajectory read (latest to
recent history) over the existing lab resolver, page-bounded so a heavy
panel cannot balloon the response. Introduce an opaque, stable base64url
cursor helper that decodes any malformed or foreign token to the first
page rather than throwing.
2026-06-28 01:20:57 +02:00
Marc-André Bombeck 7e77a974d2 feat(mcp): add a confirmed blood-pressure write
Blood pressure is two values, so it cannot ride the single-value
log_measurement tool. Add log_blood_pressure: it writes the systolic and
diastolic rows atomically under one shared idempotency namespace and one
measured-at instant, range-validates each value, rejects an implausible
pair (systolic at or below diastolic), and keeps the same confirm-flag
preview gate, per-credential write budget, MCP provenance, and audit
trail the existing writes use. Declare a structured output schema on
every write tool so a preview, a commit, an already-logged no-op, and a
refusal all validate as structured content.
2026-06-28 01:20:49 +02:00
Marc-André Bombeck acc8f1cfc6 feat(mcp): add the browseable resource surface — templates, completion, instructions, icons
Fill the discoverable half of the MCP server. Add RFC 6570 resource
templates for per-item reads — healthlog://metric/{type},
healthlog://metric/{type}/{window}, healthlog://lab/{analyte},
healthlog://medication/{id}, healthlog://report/doctor-visit/{window} —
plus fixed resources for the biomarker catalogue, the data inventory, and
the default doctor-visit summary. Each template is a thin facade over the
same server-authoritative read its matching tool already uses, scoped to
the session user; a foreign id resolves to { present: false }, never
another tenant's row.

Wire user-scoped argument completion and listing on the templates: window
completes the fixed enum, metric and analyte complete only from the user's
own inventory and lab set, so autocomplete doubles as honest discovery.

State the data-grounding contract once in the server instructions —
server-authoritative values (do not recompute), { present: false } is
honest absence, free-text fields are data not instructions, discover
before fetching, data and context only. Set the server and resource icons
for connector-directory listing.
2026-06-28 01:07:00 +02:00
Marc-André Bombeck d70651d373 feat(mcp): add the prompt skill library
Extend the MCP prompt surface beyond doctor_visit_summary with six
ranked, installable review prompts. Each assembles a grounded,
structured data block from an existing server-authoritative engine and
frames the host to narrate from those facts only — read-only, no
diagnosis, surfacing absence as { present: false }.

- weekly_review: 7-day trends, adherence, sleep, recovery, drivers, and
  the readiness/recovery + illness/cycle context the daily briefing reads
  (derived-briefing + illness-cycle-briefing).
- medication_check: cadence-aware adherence alongside a linked vital.
- recovery_check: illness-recovery composites + personal baselines +
  discovered drivers.
- glucose_review: the clinical glucose panel (TIR/GMI/CV%/eA1c).
- sleep_review: per-night sleep, debt, chronotype, and drivers.
- lab_trend_brief: per-analyte trajectory from the labs store, against
  each lab's own stored reference range (ranges are never recomputed).

The n-of-1 intervention review and a preventive 'what to check next'
prompt are deferred: the first waits on an anchored before/after read,
the second has no screening engine to reuse.
2026-06-28 01:04:50 +02:00
Marc-André Bombeck 01e2373255 feat(mcp): identify the authorization-server issuer per RFC 9207
Advertise authorization_response_iss_parameter_supported in the
authorization-server metadata and echo the iss parameter on every
authorization response — both the success redirect carrying the code and
the access_denied error redirect. A client can now pin the response to
this server's issuer and reject a code substituted by a look-alike
authorization server in a multi-server deployment.

Additive and backward-compatible: existing clients that ignore iss are
unaffected; PKCE S256-only, audience binding, redirect-URI exact match
and the rate limits are untouched.
2026-06-28 01:00:45 +02:00
Marc-André Bombeck fb986bc09d merge: bring the MCP server onto the v1.23 base
Combine the remote MCP server (stdio + remote /mcp + OAuth 2.1 + read
tools + deep-value reads + a confirmed, audience-bound write surface)
with the v1.23 account-security line (MFA, known/trusted devices,
free-text note encryption).

- schema: keep every v1.23 User relation (MFA, known/trusted devices)
  alongside the McpOAuthConnection relation; the MeasurementSource enum
  carries the MCP value.
- api-handler: the MCP audience guard (a token scoped to
  health:read/health:write is bound to /mcp and refused a REST write)
  and the step-up MFA helpers coexist.
- maintenance queues: register the MCP token-cleanup sweep next to the
  note-encryption backfill and key-rotation queues; shift it to 04:00
  so the two daily discovery ticks do not collide.
- migrations: renumber the MCP migrations to 0206/0207 so they follow
  the v1.23 chain (0199-0205).
- MCP mood writes route the note through the v1.23 at-rest cipher.
2026-06-28 00:52:04 +02:00
Marc-André Bombeck 2c1151dd40 fix(auth): guard the webauthn login challenge kind and require user verification
Mirror the TOTP login path: both the security-key verify and verify-options
routes now reject any challenge whose kind is not "login" with the same generic
401, so a future step-up or non-login challenge can never mint a login session
through these endpoints. Set the registration and authentication ceremonies to
userVerification:"required" to match the server's UV enforcement — a key that
passes the browser but skips UV would otherwise fail server-side.
2026-06-27 22:22:46 +02:00
Marc-André Bombeck c01d7f9626 chore(auth): drop the unused recovery-code schema export 2026-06-27 21:55:40 +02:00
Marc-André Bombeck 26db4295d2 test(auth): cover trusted-device revocation in the session teardown
destroyAllSessions and destroyOtherSessions now also clear trusted devices;
extend the db mock so the existing teardown assertions exercise that path.
2026-06-27 21:51:04 +02:00
Marc-André Bombeck a81303e55c feat(privacy): add a Data & Privacy dashboard
Bring the scattered data-sovereignty controls into one /settings/privacy pane.
It assembles already-shipped pieces rather than rebuilding them: the encryption-
at-rest summary and configured retention windows (read from the server so the
copy stays truthful), links to the export surfaces (including the passphrase-
encrypted option) and the account-deletion / data-reset surface, the active-
session list, trusted devices, and the security-activity log. The backup↔
deletion lag is disclosed honestly — deletion is immediate, but encrypted
off-host backups still hold the data until they roll off retention.
2026-06-27 21:50:30 +02:00
Marc-André Bombeck c8bef61344 feat(security): let an operator require a second factor
Wire the previously-unused mfaEnforced flag into a real policy. An admin can
require a second factor instance-wide (an AppSettings toggle in the General
section) or per user (a toggle in user management); the effective requirement is
the OR of the two. When required and the account has no active factor — no
confirmed TOTP, no registered security key, and no primary passkey — the user is
walled behind a forced-enrollment interstitial after sign-in, with only the
security settings and sign-out reachable until a factor is enrolled.

The gate is surfaced like the onboarding gate: a hint cookie the proxy reads
without a DB round-trip, recomputed from the database on every sign-in and on
/api/auth/me so a tampered cookie self-corrects. Nobody is locked out — the
enrollment surface stays reachable, and toggling the policy off (or the
disable-mfa CLI) is the operator's recovery path.
2026-06-27 21:49:53 +02:00
Marc-André Bombeck 135bb04f0d feat(auth): allow trusting a device to skip the second factor
Add an opt-in "remember this device" at the second-factor step. On opt-in
a random token is minted, set in an httpOnly+Secure+SameSite=Strict cookie,
and only its HMAC hash is stored in a new TrustedDevice table. A later sign-in
from the same browser still requires the password (factor 1); a matching,
unexpired trusted device lets the login skip factor 2 only.

The token is never accepted for step-up — a trusted-device login does not stamp
mfaVerifiedAt — so a stolen device cookie cannot reach a destructive action.
Trusted devices are listed and revocable from the security surface, are revoked
wholesale when the second factor is removed or the user signs out everywhere,
and cascade away on account deletion. A recovery-code login never trusts the
browser. Migration 0204 adds the table (and the instance-wide mfa_required
policy column that the enforcement work reads).
2026-06-27 21:49:39 +02:00
Marc-André Bombeck 45a49f3d6d fix(security): keep the first sign-in from alerting on a fresh account
Record the very first device a user is ever seen on silently. The
new-device sign-in alert only carries meaning once there is an
established device to contrast against, so an empty device ledger
establishes the baseline without firing a notification; alerts begin
with the second distinct device. Registration's silent first-device
behaviour is unchanged.

Also extend the destructive-action step-up to treat a registered
security key as an active second factor, matching requireFreshMfa, so a
key-only account cannot delete or reset without a fresh factor.
2026-06-27 20:53:48 +02:00
Marc-André Bombeck b271a39fe4 merge: remove the bug-report integration for v1.23
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/app/admin/[section]/renderer.tsx
2026-06-27 20:47:24 +02:00
Marc-André Bombeck 268c5cef05 merge: integrate the v1.23 data export and encryption-status wave 2026-06-27 20:46:08 +02:00
Marc-André Bombeck c458d955e4 merge: integrate the v1.23 security settings and MFA wave
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/lib/openapi/routes/auth.ts
#	src/lib/query-keys/auth.ts
2026-06-27 20:46:03 +02:00
Marc-André Bombeck 4c4231838c merge: integrate the v1.23 sessions and login-alert wave 2026-06-27 20:42:57 +02:00
Marc-André Bombeck 64ea8e4102 feat(passkeys): surface last-used, rename, and an upgrade nudge
Stamp each passkey's last-used time on a successful assertion and return
it from the list, add a rename endpoint, and add a dismissible
add-a-passkey nudge for accounts that have none. Share the relying-party
configuration with the second-factor path.
2026-06-27 20:40:53 +02:00
Marc-André Bombeck 402ea90d45 feat(security): add a TOTP and security-key setup hub
Introduce a dedicated /settings/security section that gathers every
account-protection control in one place: TOTP enrolment (QR plus manual
secret, confirm, recovery codes shown once with copy and download),
WebAuthn security keys as a second factor (register, list with last-used,
rename, step-up-gated removal), recovery-code count and regeneration, and
primary passkey management.

The second-factor WebAuthn path keeps its own credential store, separate
from primary passkeys, and uses non-resident keys so an account can hold
many. Relying-party resolution is shared with the passkey path. A status
endpoint backs the hub; the step-up gate now recognises a registered
security key as an active second factor so a WebAuthn-only account can
still reach the gated actions.
2026-06-27 20:40:36 +02:00
Marc-André Bombeck de33f1ad4c chore: remove the bug-report integration
Drop the in-app bug-report surface end to end: the user submission page,
the feedback intake and admin inbox, the optional GitHub-issue
escalation, the operator config, and the dedicated achievement badges.

- delete the /bugreport page and the bugreport / feedback / admin-feedback API routes
- drop the Feedback model, its enums, and the AppSettings GitHub-issue and enable columns (migration 0205)
- remove the admin Feedback section, the Bug Report integration card, and the system-status row
- drop the bug-report nav entry and the app-settings provider that only carried its flag
- remove the bug-hunter achievement badges and their metric
- prune the related i18n keys across every locale and the encrypted-column and key-rotation registries
2026-06-27 20:37:35 +02:00
Marc-André Bombeck 9269043e91 feat(security): list active sessions, sign out everywhere, and show security activity
Add the user-facing account-security surface in Settings:

- Active sessions (closes #64): GET /api/auth/me/sessions lists each browser
  login with a coarse device label, masked IP, resolved location, the sliding
  last-active stamp, and the current-session marker. DELETE the collection
  signs out everywhere else (keeps the current session, revokes other sessions
  and native refresh tokens, leaves API tokens alone); DELETE one revokes a
  single session, scoped to the owner so a foreign id 404s.
- A sliding last-active stamp on the session, written throttled and
  fire-and-forget off the request-resolution path.
- Security activity: GET /api/auth/me/security-activity, the shared read over
  the audit log filtered to sign-ins, second-factor changes, password changes,
  session revocations, exports, and deletions, with masked IPs.

OpenAPI regenerated for the new endpoints.
2026-06-27 20:34:43 +02:00
Marc-André Bombeck bed6588699 feat(security): alert on a new-device sign-in
On a completed sign-in (password, second-factor, or passkey) from a device or
location not seen before, send one notification through the dispatcher cascade,
honouring the user's per-channel preferences via a new SECURITY_ALERT event
type. Dedupe on a one-way, per-user fingerprint: a SHA-256 over the userId, the
coarse User-Agent family, and a country/ASN-level geo signal, stored in the new
user_known_devices ledger (migration 0203). The first sighting of a fingerprint
alerts and records; later sightings only bump last-seen and stay silent. The
raw User-Agent and IP are never persisted — only the salted hash and a coarse
label. Registration records its device silently so a fresh account does not
alert on its own first session. The whole path is fire-and-forget and never
blocks or fails a sign-in.
2026-06-27 20:34:31 +02:00
Marc-André Bombeck b882698a44 feat(auth): warn on breached passwords at password set
Check a newly chosen password against HaveIBeenPwned through the k-anonymity
range API: only the first five hex characters of the SHA-1 digest leave the
host, the suffix is matched locally, and Add-Padding hides the bucket size.
A confirmed match is rejected so a fresh credential is never a known-breached
one; the check fails open, so an unreachable HIBP never blocks the operation.
Covers registration, password change, and admin reset. Existing passwords are
never retroactively blocked at login.

Also lands the six-locale strings for the password-breach notice and the
account-security surfaces that follow.
2026-06-27 20:34:20 +02:00
Marc-André Bombeck 01689f77bc feat(security): require fresh verification before destructive account actions
Account deletion and the keep-account data reset now demand a recent
second-factor re-verification for accounts that have a second factor
active; single-factor accounts are unaffected and keep the typed-
confirmation-only contract. The gate runs through requireFreshMfaIfEnrolled,
which is cookie-only by construction — a Bearer transport for an MFA-enrolled
account cannot satisfy step-up and surfaces the auth.stepup.required envelope
so the UI can launch a re-verification flow.
2026-06-27 20:34:11 +02:00
Marc-André Bombeck bb24ad5c14 feat(admin): allow a guarded key-rotation run
Add an optional admin-triggered key rotation on pg-boss that re-encrypts
the whole encrypted-column corpus to the active key, reusing the same
column registry as the command-line tool (which stays the canonical path).
POST /api/admin/encryption/rotate enqueues a singleton run and is gated by
cookie-only admin plus a fresh second factor. The run is idempotent
(rows already on the active key are skipped), active-key-only, and never
adds or drops a key — the env key map stays operator-owned, so a key drop
remains a deliberate redeploy. It fails closed per row and reports totals
into the audit trail the status view reads.
2026-06-27 20:27:40 +02:00
Marc-André Bombeck e5f9fb7c4a feat(admin): show encryption coverage and rotation progress
Add a read-only admin Encryption section that surfaces what the rotation
tooling computes: per encrypted-column rows-per-key-id, legacy-key row
counts, and overall rotation progress, plus a 'safe to drop the legacy
key' state once every column sits on the active key. GET
/api/admin/encryption/status drives it and returns only key ids and
counts — never key material. A shared scan over the canonical encrypted-
column registry backs both this view and the rotation run. Cookie-only
admin.
2026-06-27 20:27:31 +02:00