Commit Graph
91 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 3e45a7ba53 fix(dashboard): disambiguate quick-add submenu labels (no more double "Hinzufügen")
The dashboard "Add" dropdown rendered two menu items whose visible text
both resolved to bare nouns ("Messung" / "Stimmungseintrag" in DE,
"Measurement" / "Mood entry" in EN). Combined with the trigger label
("Hinzufügen" / "Add"), screen-reader users effectively heard the same
verb three times in a row with no signal which row did what.

Update both translations to self-contained verb-phrases:
- DE: "Messung erfassen" / "Stimmung erfassen"
- EN: "Log measurement" / "Log mood"

Add `src/app/__tests__/quick-add-labels.test.ts` to lock the contract:
both labels must be non-empty, distinct from each other, and distinct
from `common.add` (the trigger). The test loads `messages/{en,de}.json`
directly so any future translator who collapses the labels back to
"Hinzufügen" or "Add" gets a red bar before merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:16:57 +02:00
Marc-André BombeckandClaude Opus 4.7 8e17f227e4 fix(admin): rate-limit /api/admin/backups/run to 3/min per admin
pg-boss does not deduplicate by payload, so each click on the manual
"Backup now" button enqueues a fresh data-backup job. Without a
rate-limit, an impatient admin (or compromised admin session via XSS
or token theft) can stack dozens of identical fan-out-per-user jobs
on the queue.

Three runs per minute per admin is plenty for ad-hoc snapshots — the
weekly cron in `reminder-worker` already carries the regular cadence.

Closes H3 from .planning/phase-6-code-review-findings.md (also addresses
M-1 from .planning/phase-6-security-findings.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:42:20 +02:00
Marc-André BombeckandClaude Opus 4.7 b3f282adab fix(security): block force-logout from targeting the admin's own session
The UI already disabled the per-row force-logout button for the active
admin (user-management-section.tsx:295), but the route handler had no
matching server-side check. A direct curl/DevTools call (or a
compromised admin session) could revoke the caller's own sessions and
bounce them to /auth/login mid-action.

Mirrors the UI gate with a 422 + an `admin.user.force-logout.denied`
audit entry tagged `reason: self_target`. Admin-on-admin force-logout
remains permitted (today's intentional behaviour).

Closes H1 from .planning/phase-6-code-review-findings.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:41:46 +02:00
Marc-André BombeckandClaude Opus 4.7 88c8db1abc refactor(v1.5): apply simplify-review safe-to-apply suggestions
Drops dead `id` props and `scroll-mt-28` classes from admin section
components (load-bearing in v1.4 monolithic admin, dead since the v1.5
phase-4b dynamic-route split). Also removes the unused
`LEGACY_ANCHOR_TO_SLUG` map, the `admin-shell` re-export shim, the
`BERLIN_TZ` and duplicate `MS_PER_DAY` constants, the verbose
`AdminSectionPage` docstring, the `useEffect` paraphrase comment in
`ai-section`, and rewrites the `_exhaustive: never` exhaustiveness
guard to the modern `slug satisfies never` form. `UserManagementSection`
now reads `currentUserId` via `useAuth()` instead of accepting it as a
prop.

Findings 1, 2, 3, 4, 6, 7, 8, 11, 15, 16, 17 from
.planning/phase-6-simplify-findings.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:41:02 +02:00
Marc-André BombeckandClaude Opus 4.7 65cfb2799a fix(a11y): label /admin overview quick-jump as a navigation landmark
Wrap the section quick-jump list under `/admin` in a `<nav>` with
`aria-labelledby` pointing at its heading. Without the landmark,
screen-reader users navigating by region heard "list of links" with no
contextual label distinguishing it from the sidebar nav (which already
carries its own `aria-label`).

The `<h2>` keeps its existing copy ("Sections") and now serves as the
nav's accessible name via `aria-labelledby`. Visual is identical.

Picked up from the v1.5 phase-5 friction audit (item #10) — small fix,
clear win, no scope creep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:23:04 +02:00
Marc-André BombeckandClaude Opus 4.7 e3a689954b fix(ui): disambiguate dashboard quick-add menu items
Phase-3 e2e suite documented an i18n collision: the dashboard's
quick-entry dropdown trigger says "Add" and both menu items underneath
also rendered the bare verb "Add" because `measurements.addMeasurement`
and `mood.addEntry` both translate to "Add". Screen-reader users heard
two indistinguishable rows; the e2e spec was forced to use
`getByRole("menuitem").first()` as a workaround.

The dropdown trigger already says "Add" — the menu items label what's
being added.

  - Add `dashboard.quickAddMeasurement` ("Measurement" / "Messung")
  - Add `dashboard.quickAddMood` ("Mood entry" / "Stimmungseintrag")
  - Wire the two `<DropdownMenuItem>`s on `/`
  - Mark the leading icons `aria-hidden` so the accessible name is
    just the noun
  - Update `e2e/measurement-flow.spec.ts` to target the menu item by
    name (`/measurement|messung/i`) instead of `.first()`

Dialog titles still use the existing `addMeasurement` / `addEntry`
keys — those work in the larger dialog context where "Add" reads fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:22:08 +02:00
Marc-André BombeckandClaude Opus 4.7 569300eaca style(ui): use semantic tokens for mood and feedback indicators
Closes the v1.4.6 deferred polish item — feedback / mood / medication
status indicators now ask for *meaning* (success / warning / info)
instead of hard-coding `text-dracula-green` / `bg-dracula-orange/15`.

Adds three CSS aliases to `globals.css`:
  --success → dracula-green   (positive outcome)
  --warning → dracula-orange  (caution / attention)
  --info    → dracula-cyan    (neutral informational)

Dark mode (Dracula): each alias resolves to the same hex as before, so
the visual is pixel-identical to v1.4.6 — what changes is the *intent*
of the class.

Light mode (Alucard): the raw Dracula greens and oranges fail AA
contrast on a white card. The new tokens get higher-contrast
counterparts (#14720a, #a34d14, #036a96) that match the existing
`--destructive` / `--chart-*` light-mode swap.

Migrated callers:
  - `src/components/admin/feedback-inbox-section.tsx`:
      - BUG category badge → `destructive` (semantic — bug *is* broken)
      - "published to GitHub" badge → `success`
  - `src/components/medications/medication-card.tsx`:
      - in-window status → `success`
      - very-overdue status → `warning`
      - "late" intermediate stays on `dracula-yellow` (yellow doesn't
        map to either semantic bucket — it's mid-warning)
  - `src/components/settings/ai-section.tsx`:
      - "ChatGPT connected" badge + success toast → `success`
      - "Connection expired" badge + raw-data warning → `warning`

Charts (`src/components/charts/**`) untouched per
`feedback_charts_visual_identity.md`. Other dracula-token callsites in
settings/* (telegram, ntfy, web-push, account, about, test-connection)
are deferred to v1.5.1 — broader sweep, not in tonight's scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:18:56 +02:00
Marc-André BombeckandClaude Opus 4.7 38f12df737 feat(ui): trend arrow color reflects metric-specific direction sentiment
Restore the colour on the small ↑/↓ trend-arrow next to each tile's
latest reading — but make it metric-aware. v1.4.6 P4 stripped the colour
because the original rule (up=red, down=green) was wrong for half the
metrics: mood up is good, weight up is bad, pulse up is neither. v1.5
phase-5 brings back the colour through a `directionSentiment` prop:

  - `up-good` → ↑ green, ↓ orange    (mood, sleep, steps, BP-in-target%)
  - `up-bad`  → ↑ orange, ↓ green    (weight, BP, body fat)
  - `neutral` → muted in both        (pulse, glucose — range matters
                                       more than direction)

Stable-slope arrows ("→") and "no slope yet" placeholders ("—") always
stay muted. The default is `neutral` so any TrendCard call site that
hasn't been updated keeps the v1.4.6 muted look — back-compat is the
visible intent of the prop.

Affects only the icon next to the latest reading. The chart axes,
lines, colours, and the avg7/avg30 range-colour classes are untouched
(per Marc's hard-rule on chart visual identity, see
`feedback_charts_visual_identity.md` in user memory).

Tests: `src/components/charts/__tests__/trend-card.test.tsx` covers all
three sentiments × all three slope directions plus the
prop-omitted-back-compat case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:14:14 +02:00
Marc-André BombeckandClaude Opus 4.7 5957e1827e feat(admin): users management with filters + force-logout action (T2.7 deferred)
Folds in the v1.4.6 deferred backlog item:

* `<UserManagementSection>` now exposes role filters (All / Admins /
  Users — "suspended" is documented as a no-op since the User model
  lacks a `suspended` field; mapped to passthrough for forward
  compatibility).
* New per-row "Force logout" action surfaces a confirmation dialog,
  then POSTs to the new endpoint below. Shows a toast with the
  number of sessions revoked.
* `POST /api/admin/users/[id]/force-logout` — admin-only kill switch
  that deletes every Session row for the target user. Audit-logged
  as `admin.user.force-logout`.

Existing role-toggle, edit, and reset-password actions are untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:59:48 +02:00
Marc-André BombeckandClaude Opus 4.7 69c5225cea feat(admin): backups view + manual backup trigger (T2.6 deferred)
Folds in the v1.4.6 deferred backlog item:

* `GET /api/admin/backups` — returns metadata-only listing of every
  DataBackup row (size in bytes, no payload). Encrypted blobs stay
  server-side.
* `POST /api/admin/backups/run` — enqueues an ad-hoc data-backup
  pg-boss job. Returns 503 if the worker isn't running; the existing
  weekly cron schedule is unchanged.
* `<BackupsSection>` — admin route at /admin/backups with the rows
  table + "Backup now" CTA.

No new dependencies; uses the global pg-boss instance already wired in
`src/lib/jobs/boss-instance.ts`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:59:38 +02:00
Marc-André BombeckandClaude Opus 4.7 12b280bb55 refactor(admin): move sections to dynamic-route pattern + reduce overview
Each admin section component now accepts an optional `id` prop (was
required) so it works under the new /admin/[section] dynamic route
without scroll-anchor wrapping. Adds <IntegrationsGroupSection> as the
composite for /admin/integrations (Umami + GlitchTip + WebPush + Bug
Report). Reduces /admin to a true overview: status-card grid + welcome
card + quick-jump menu of sub-sections; the long monolithic scroll is
gone.

Bundle-size win: each section now ships under its own route bundle, so
the overview no longer drags Umami/GlitchTip/Feedback/Users/etc. trees.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:59:25 +02:00
Marc-André BombeckandClaude Opus 4.7 d8b71b3848 feat(admin): scaffold per-section dynamic routes (admin-shell + [section]/page.tsx)
Mirrors the v1.4 settings split for the v1.5 admin refactor. Adds the
shell, the slug constants, the dynamic-route page, and the client-only
renderer that picks the right section component for each slug. The
existing admin sections continue to live in src/components/admin/*.tsx;
this scaffolding wires them into per-route bundles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:59:08 +02:00
Marc-André BombeckandClaude Opus 4.7 7af1f59bb9 fix(a11y): close violations the new e2e axe-core sweep surfaced
Five real WCAG 2.1 AA violations the extended axe-core spec caught:

- top-bar.tsx: mobile user-menu DropdownMenuTrigger had no
  accessible name on viewports < sm (the username text is
  `hidden sm:inline`, leaving an icon-only button). Added
  aria-label using the existing nav.userMenu key.
- password-input.tsx: show/hide eye toggle was icon-only, no
  aria-label. Added common.showPassword / common.hidePassword
  in en + de and bound to the visible state.
- auth/login/page.tsx: "Sign up" link relied on colour alone
  (link-in-text-block). Switched hover:underline → underline so
  the link is distinguishable without colour.
- page.tsx (dashboard): "Add" dropdown trigger was 32px tall
  (size="sm"); on Pixel 5 below WCAG 2.5.5 44px threshold.
  Added min-h-11 — desktop visual unchanged because the explicit
  height only kicks in if it exceeds the size variant's h-8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:49:34 +02:00
Marc-André BombeckandClaude Opus 4.7 1a85da0b05 fix(insights): preserve chart visual parity in deferred bundle
bb2b1de wrapped seven Recharts primitives (ScatterChart, Scatter, XAxis,
YAxis, CartesianGrid, Tooltip, ResponsiveContainer) in next/dynamic to
push the ~108 KiB Recharts chunk off the /insights initial bundle. That
seam is one level too low: ScatterChart discovers its children by
comparing child.type against the imported component identity
(findAllByType). Once each child is wrapped in a next/dynamic HOC the
identity check fails and the chart renders an empty plot area —
no axes, no scatter points, no tooltip, even after Recharts lands.

Fix: extract the inline <ResponsiveContainer><ScatterChart>…</ScatterChart>
block into src/components/charts/scatter-correlation-chart.tsx, a
self-contained wrapper that statically imports every Recharts symbol it
needs. The insights page now next/dynamics that wrapper instead, matching
the existing HealthChart / MoodChart pattern in the same file. Recharts
still ships only in the deferred chunk; child-type detection keeps
working because primitives stay statically related to each other.

All four scatter cards (BP-medication continuity, mood-vs-BP,
weight-vs-BP, mood-vs-weight) rewritten as declarative
<ScatterCorrelationChart> calls. Identical height={250}, margins, grid
colour (hsl(var(--border))), axis colours (var(--dracula-fg) /
var(--dracula-comment)), per-chart fills (--dracula-orange / --cyan /
--lavender), tooltip styling, units, domains, ticks, labels, and
formatters. No color tokens, animations, or chart options changed.

Also adds a loading skeleton (h-[250px] w-full bg-muted/40 animate-pulse
motion-reduce:animate-none) that matches the chart footprint exactly so
the first paint of each card no longer shifts layout while Recharts
hydrates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:41:49 +02:00
Marc-André BombeckandClaude Opus 4.7 bb2b1de742 perf(insights): defer Recharts ScatterChart imports via next/dynamic
The insights page imported seven Recharts symbols (ScatterChart, Scatter,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer) eagerly at
module top, which pulled the entire ~108 KiB Brotli Recharts chunk onto
the initial bundle even though every scatter card sits inside a
`length >= 5` gate below the fold.

Switching each symbol to next/dynamic with `ssr: false` matches the
existing HealthChart / MoodChart pattern in the same file. Recharts is
still loaded once via the shared chunk graph the first time any scatter
section mounts; until then, /insights ships the same JS payload as
chart-free pages.

Verified static-only — local pnpm build is blocked by the documented
Node-25 turbopack regression. Full deltas captured in
docs/audit/v15-performance.md (v1.5 phase-4 perf audit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:23:32 +02:00
Marc-André BombeckandClaude Opus 4.7 6b88e56aeb chore(format): prettier sweep over phase-2 touched files + stale planning docs
Picks up the prettier line-wraps the v1.5 phase-1 planning docs and
codex-protocol-spec.md missed, and brings the redact.ts + insights
generate route test into the project's standard form. No behaviour
change — pnpm format:check now passes clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:06:52 +02:00
Marc-André BombeckandClaude Opus 4.7 54038216e4 fix(insights): map AI provider errors to 422/503 instead of 500
v1.4.6 T5 fixed only the parse-error branch (502 → 422 to keep
Cloudflare's HTML-error rewrite from breaking `await res.json()` on
the dashboard). Provider errors — `OpenAI request failed (401)` for
an invalid admin key, or transient `OpenAI request failed (5xx)` —
still propagated to the apiHandler's generic 500. Cloudflare rewrites
the 5xx to its own HTML page, so the React Query mutation crashed
with "Unexpected token '<'" instead of surfacing a readable message.

Wraps `provider.generateCompletion(...)` in try/catch and mirrors the
v1.4.5 ai/test categorisation:

  - 401 / 403 from the provider → 422 with "AI provider rejected the
    request — check your API key in Settings > AI"
  - 5xx from the provider → 503 with "AI provider temporarily
    unavailable, try again in a moment"
  - 429 from the provider → 429 (rate-limit passthrough)
  - any other status → 422 generic provider-connection failure

Full error details (message, httpStatus, bodyExcerpt, providerType)
land in the Wide Event via `annotate()` so the operator can debug
without leaking provider URLs / partial keys to the client.

Adds unit tests covering 401/403/429/500/503/unknown mapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:04:15 +02:00
Marc-André BombeckandClaude Opus 4.7 512a6a619c fix(admin): wipe-all-data clears notification channels, push subs, telegram deletions
The "Wipe all data" admin transaction was scoped only to measurements,
medications, intakes, API tokens, Withings connections, and auth
challenges. Notification channels (encrypted Telegram bot tokens in
NotificationChannel.config), Web Push subscriptions, and queued
Telegram message deletions survived the wipe — a scope leak first
flagged in the v1.4.6 multi-agent QA pass.

Extends the transaction with `notificationChannel.deleteMany`,
`pushSubscription.deleteMany`, and `telegramScheduledDeletion.deleteMany`.
NotificationPreference rows cascade via the schema FK.

Feedback rows are intentionally preserved (T8 v1.4.6) and the i18n
copy now states this explicitly so admins know bug reports survive a
wipe. Counts for the three new tables are returned to the UI.

Adds an integration regression test that seeds all three tables,
invokes the route handler with a real admin session, and asserts the
wipe clears them while Feedback + AuditLog survive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:56:24 +02:00
Marc-André BombeckandClaude Opus 4.7 e4a07bc2e4 fix(ai): complete Codex backend protocol implementation per spec
Stop iterating on individual 400 errors and implement the full Codex
Responses-API protocol once, from the source. Wrote a 713-line
reference at docs/codex-protocol-spec.md from the official
openai/codex Rust client; this commit is the TS implementation
against that spec.

Required pieces that were missing in v1.4.11:

- `ChatGPT-Account-ID` header — extracted from the `chatgpt_account_id`
  claim of the OAuth id_token (also accepts the URI-namespaced
  variant under `https://api.openai.com/auth`). Without it: 401.
- `originator`, `User-Agent`, `Accept: text/event-stream`,
  `session_id`/`session-id` (both spellings), `thread_id`/`thread-id`.
- Body fields `reasoning: null` and `include: []` are required even
  when no reasoning is requested.
- Model slug is `gpt-5-codex`, not `gpt-5.3-codex` (the latter is a
  test placeholder upstream).
- Refresh endpoint takes a JSON body, not form-urlencoded.

Storage codec rewrite: `codexAccessTokenEncrypted` now holds an
encrypted JSON blob `{ accessToken, accountId, expiresAt }` instead
of just the raw access token, so the account id stays available
across requests. v1.4.7-v1.4.11 records that lack the field cannot
be revived — `decryptCodexCreds` returns null for those rows and
`resolveCodexProvider` flips the connection to `expired`, prompting
the user to re-link once.

Removed the dead browser-flow routes (`/api/auth/codex/authorize`
and `/callback`). They never worked on hosted domains because
Hydra's redirect-URI allow-list for the public Codex client ID
covers only localhost; device-code is the only viable path.

CodexClient SSE parsing extended to handle `response.failed` and
`response.incomplete` terminal events, and to read the actual
routed model from the `OpenAI-Model` response header.

Tests rebuilt: codex-oauth covers the full device-code flow with a
synthesised id_token JWT, the JSON-body refresh, and the
storage-codec round-trip. CodexClient tests carry an `accountId`
through the constructor. All 720 tests pass; typecheck clean.

Refs: docs/codex-protocol-spec.md §1-§6 for every claim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:36:12 +02:00
Marc-André BombeckandClaude Opus 4.7 3dbc8af627 fix(ai): device-code flow uses OAuth access token directly (v1.4.8 follow-up)
v1.4.8's device-code flow got the user as far as the chatgpt.com
approval, but the connect step at the end still failed with:

  Codex api-key exchange failed (401): "Invalid ID token: missing
  organization_id"

Root cause: the api-key exchange (RFC 8693, swap id_token for an
OpenAI API key) requires the id_token to carry an `organization_id`
claim. That claim only gets added when the authorize URL is hit with
`id_token_add_organizations=true` — which the browser/auth-code
flow does, but the device-code flow does NOT (the user types the
code on chatgpt.com's own page; we never control the authorize URL).

The official Codex CLI handles this by NOT calling the api-key
exchange in the device path: it persists the OAuth access_token /
refresh_token directly and uses them against
`https://chatgpt.com/backend-api/codex/responses` (the Codex backend
that bills against the ChatGPT subscription). v1.4.9 does the same.

`pollDeviceCode` no longer calls `obtainApiKey`; it returns the raw
OAuth access_token instead. New `refreshDeviceTokens()` mirrors that
on the refresh path. The provider rewires Codex back through the
existing `CodexClient` (bg backend-api), with proactive refresh
+ on-401 refresh both going through `refreshDeviceTokens`.

The browser/auth-code flow path is left in place — operators who
register a private OAuth app with OpenAI (e.g. for an enterprise
deployment) can still use the api-key exchange via the
`exchangeCodeForTokens` helper. For the public Codex client ID on
hosted instances, device-code is the only working path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:25:50 +02:00
Marc-André BombeckandClaude Opus 4.7 19eb1d95d6 fix(ai): switch ChatGPT-OAuth to device-code flow (v1.4.7-followup)
v1.4.7 corrected the issuer (`auth.openai.com`) and added the
RFC-8693 api-key exchange, but the Connect button still failed in
production with `unknown_error`. Root cause: OpenAI's Hydra
allow-lists only `http://localhost:1455/auth/callback` and
`:1457/auth/callback` for the public Codex CLI client ID. Any hosted
redirect URI gets silently rejected — the v1.4.7 user landed on the
"An error occurred during authentication (unknown_error)" page.

The official Codex CLI ships a parallel **device-code flow** for
exactly this case (`codex-rs/login/src/device_code_auth.rs`):

  1. POST `https://auth.openai.com/api/accounts/deviceauth/usercode`
     with the client id → `{ device_auth_id, user_code, interval }`.
  2. Show the user the code and a link to
     `https://auth.openai.com/codex/device`. They open it on any
     device, log in, and type the code.
  3. POST `https://auth.openai.com/api/accounts/deviceauth/token`
     repeatedly until Hydra returns `{ authorization_code,
     code_challenge, code_verifier }` (otherwise 403 / 404).
  4. Standard PKCE token exchange against `/oauth/token` — but with
     `redirect_uri = https://auth.openai.com/deviceauth/callback`,
     which IS allow-listed for the public client. Then the same
     id_token → API-key exchange that v1.4.7 already added.

This sidesteps the redirect-URI allow-list entirely; HealthLog's
domain never appears in any OAuth round-trip.

Implementation:

- `src/lib/ai/codex-oauth.ts` adds `requestDeviceCode()` and
  `pollDeviceCode()`.
- `POST /api/auth/codex/device-start` initialises the flow, persists
  the `device_auth_id` + `user_code` in an encrypted httpOnly cookie
  (15 min, scoped to the requesting user), and returns the user-
  facing pieces.
- `POST /api/auth/codex/device-poll` does one poll attempt; on
  `pending` it returns `{ status: "pending" }`, on success it
  exchanges + persists the api key + refresh token and returns
  `{ status: "connected" }`.
- The Settings → KI section replaces the "redirect to OAuth"
  button with an inline card showing the user code, the
  verification URL, and a copy button. The page polls every
  `intervalSeconds` (5s default) until status flips to "connected"
  or the user cancels. EN + DE strings under
  `settings.ai.deviceCode*` cover the new copy.
- The legacy authorize / callback routes stay on disk for now —
  they're harmless when the redirect-URI allow-list is updated for
  a private client ID, so operators with an OpenAI partner client
  can still use the original flow via `CODEX_OAUTH_CLIENT_ID`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:31:30 +02:00
Marc-André BombeckandClaude Opus 4.7 2f7f487228 fix(ai): Codex OAuth flow — correct issuer + api-key exchange
The "Connect with ChatGPT" button never actually completed an OAuth
flow. The implementation hit `chatgpt.com/authorize` and
`chatgpt.com/oauth/token`, which are not OAuth endpoints — ChatGPT
silently rendered its normal web app, dropped our params on the
floor, and the user landed on a regular logged-in chat session with
nothing else happening. There was no error and no callback, which is
why the regression survived multiple releases.

The canonical issuer for ChatGPT-OAuth is `auth.openai.com`. The
official `openai/codex` CLI confirms this in
`codex-rs/login/src/auth/manager.rs` and `codex-rs/login/src/server.rs`.
The flow is also slightly more involved than a vanilla PKCE
authorisation:

  1. PKCE authorize against `auth.openai.com/oauth/authorize` with the
     `openid profile email offline_access api.connectors.read
     api.connectors.invoke` scope set, plus `id_token_add_organizations`
     and `codex_cli_simplified_flow` flags so the resulting `id_token`
     carries the ChatGPT-subscription claims.
  2. Token exchange against `auth.openai.com/oauth/token` (form-
     urlencoded body, NOT JSON) yields `{ id_token, access_token,
     refresh_token }`.
  3. **Second token exchange** with grant
     `urn:ietf:params:oauth:grant-type:token-exchange`, swapping the
     `id_token` for an OpenAI API key (`requested_token=openai-api-key`).
     This key bills against the ChatGPT subscription rather than a
     separate API plan — exactly what the v1.2/v1.3 design intended.
  4. HealthLog stores the API key in `codexAccessTokenEncrypted`
     (re-purposing the existing column to avoid a migration) and the
     OAuth refresh token in `codexRefreshTokenEncrypted`. When the
     key is about to age out we refresh + re-exchange transparently
     before the next request.

The downstream provider is now a regular `OpenAIClient` tagged with
`providerType: "codex"` so logs and analytics still distinguish
ChatGPT-subscription billing from plain BYOK. The bespoke
`codex-client.ts` (which hit the chatgpt.com/backend-api/codex/responses
endpoint with the OAuth access token) is still on disk for now so the
prisma columns and the provider tag stay stable, but is unreferenced
by the active path.

The public Codex CLI client ID `app_EMoamEEZ73f0CkXaXp7hrann` is
shipped as a default; operators who registered a private OAuth app
with OpenAI can override via `CODEX_OAUTH_CLIENT_ID`. The
`isCodexOAuthConfigured()` gate is a no-op now because the default
always has a value, so the UI surface no longer silently hides the
"Connect with ChatGPT" button.

Tests cover the new shape end-to-end: PKCE generation, the
authorize-URL parameters, the form-encoded token exchange, the
api-key exchange, the refresh path, and the encrypt/decrypt
round-trip. The provider integration test is updated to expect a
`providerType: "codex"` OpenAIClient instead of the legacy
`CodexClient`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:31:13 +02:00
Marc-André BombeckandClaude Opus 4.7 02b99553b9 chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:27:17 +02:00
Marc-André BombeckandClaude Opus 4.7 dc4507a55f fix(admin/status-overview): one failed probe no longer blanks the grid
P20. Switch the 16-probe batch from `Promise.all` to
`Promise.allSettled`. Before: any single probe rejecting (a transient
DB timeout, a missing index after a schema migration, etc.) would
reject the whole route and the admin overview grid would render as
blank — costing the admin the other five healthy cards' info too.

Now each probe is labelled, the rejected ones map their card's severity
to "alert" + zero/null defaults so the StatusCardGrid still draws
something, and the failed probe names are surfaced in a Wide Event
annotation (`statusOverviewProbeFailures: [...]`) for ops triage.

Adds a vitest case that mocks a single rejection on the withings probe
and asserts the response still 200s, the integrations card flips to
alert with `withings: 0`, and untouched cards keep their normal
severity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:10:15 +02:00
Marc-André BombeckandClaude Opus 4.7 26543378b5 fix(admin): surface query errors instead of an infinite spinner
P19. `useSystemStatus()` and `useAdminSettings()` already throw on
non-OK responses so React Query sets `isError`, but neither caller
read it — the system-status panel just kept spinning forever and the
settings sections silently rendered defaults as if they were live.

Now system-status renders a destructive inline alert when isError, and
the admin page top renders a one-line "Failed to load admin settings"
banner when the shared settings query fails so the admin notices the
toggles below aren't reflecting reality. Both messages have new i18n
keys (`admin.systemStatusLoadError`, `admin.adminSettingsLoadError`)
in en.json (canonical) + de.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:10:05 +02:00
Marc-André BombeckandClaude Opus 4.7 4a159d2b6c fix(insights): cache hits no longer burn rate-limit tokens
P13. Move `checkRateLimit` below the 24h cache-return path so a request
that gets served from the cached `insightsCachedText` never consumes one
of the 2/hour LLM-generation tokens. A user reloading the dashboard a
few times no longer locks themselves out of a fresh refresh for an hour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:01:17 +02:00
Marc-André BombeckandClaude Opus 4.7 dc6db82a79 style(dashboard): visible muted welcome subtitle + medications card padding parity
- P6: drop hidden sm:block from the welcome subtitle and add text-muted-foreground so it stays visible on mobile but recedes from the H1.
- P10: medications card rounded-lg border p-4 -> rounded-xl border p-4 md:p-6 to match the other dashboard chart cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:58:30 +02:00
Marc-André BombeckandClaude Opus 4.7 1adda80944 feat(insights): widen per-card window from 30 days to 360+24 buckets
Every per-card KI insight generator was clipping each metric to the
last 30 daily means before sending the prompt. The model therefore
saw a one-month window per metric — far too narrow to detect drift
that plays out over a year, and the cause of repeated complaints that
a "Stable" trend assessment ignored an obvious 9-month decline.

New shared helper `applyPayloadBudget()` builds a `{ daily, monthly }`
view: the most recent 360 days as daily means (`dayOffset 0..359`)
plus 24 thirty-day windows for the older history (`monthOffset
12..35`). Empty buckets are dropped. If the canonical payload would
serialise above 50 KB the helper transparently re-buckets at 180
daily days so the prompt token budget stays bounded; a per-generator
`annotate({ payload_size_bytes })` makes the chosen window
observable in Wide Events.

All seven generators now consume the bucketed view as the metric
`series` field (general-status, blood-pressure-status, pulse-status,
weight-status, bmi-status, mood-status, medication-compliance-
status) — including their cross-metric joins (BP-pair, weight ↔ mean
BP correlation, medication-compliance per day) which are rebuilt on
top of the shared `dayOffset` instead of date strings. Mood and
medication queries widened to ~3 years so the older monthly window
can actually be filled.

One focused vitest per generator captures the prompt that goes to the
provider, JSON-parses the embedded snapshot, and asserts every
metric series carries both `daily` and `monthly` arrays with the
expected shape. The bucketing helper also has its own unit tests
covering offsets, leap-year-safe Berlin-day boundaries, and the
50 KB → 180-day fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:50:10 +02:00
Marc-André BombeckandClaude Opus 4.7 c3ca8614a6 fix(admin): preserve audit trail across the data wipe
Two related corrections to the "Wipe all data" admin action:

- Stop deleting the audit log inside the transaction. The whole point
  of the audit trail is to outlive the data it documents, and this is
  the single feature where that property matters most. AuditLog rows
  now survive the wipe.
- Audit-log the *intent* (`admin.data.clear.start`) before the
  transaction begins, so even if the wipe crashes mid-flight there is
  a record of who initiated it.
- The i18n copy and the success toast both undersold the scope: they
  only mentioned measurements/medications/intakes, but the wipe also
  clears API tokens, Withings connections, and any open auth
  challenges. EN + DE strings now enumerate every cleared category and
  call out that user accounts AND the audit log are preserved.
- Mutation result type widened so the toast can render the full
  count tuple.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:32:29 +02:00
Marc-André BombeckandClaude Opus 4.7 31959e436e fix(bugreport): admin toggle now actually blocks feedback submissions
The "Bug reports" admin toggle had no effect. The form on `/bugreport`
posts to `/api/feedback`, which was the wrong endpoint for the gate —
it lived only on the legacy `/api/bugreport` route the form does not
call. Users still submitted into the local feedback inbox after the
admin disabled the feature, and the prior commit message claimed
"the report button gracefully disappears" was untrue.

- `/api/bugreport/status` now also returns `enabled` (defaults true
  when the column has never been written) so the page can decide
  whether to render the form at all.
- `/api/feedback` POST checks `bugReportEnabled` and returns 503 with
  a readable error when the admin has switched the feature off, so a
  scripted client cannot bypass the UI by hitting the endpoint directly.
- `/bugreport` page now hides the form and shows the disabled-banner
  copy when `enabled === false`.
- New i18n keys for the disabled state in DE + EN.
- Vitest covers the gate: blocked when off, allowed when on, allowed
  when row absent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:31:11 +02:00
Marc-André BombeckandClaude Opus 4.7 c8ee28d2b0 fix(insights): map LLM-parse failure to 422 instead of 502
Same Cloudflare-rewrites-5xx footgun the v1.4.5 ai/test fix solved. A
malformed JSON response from the upstream model returned HTTP 502, which
Cloudflare swapped for its own HTML error page; the client's
`await res.json()` then crashed with `Unexpected token '<', "<!DOCTYPE "`
and the user — already throttled by the 2/h rate limit — saw nothing
useful. 4xx passes through Cloudflare untouched, so the React Query
mutation can read the JSON envelope and show a real error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:27:07 +02:00
Marc-André BombeckandClaude Opus 4.7 eba898f4c1 fix(ai): block aiBaseUrl from leaking across providers
The user row stores a single `aiBaseUrl` column that is meaningful only
for the LOCAL provider, but `buildUserProvider` and
`resolveProviderForTest` were forwarding that value to OpenAI and
Anthropic clients too. A user who had configured LOCAL pointing at e.g.
`http://192.168.x.x/v1` and later switched to OPENAI would have their
OpenAI API key sent to that LAN host on the next request.

Defence in three layers:

- PATCH /api/user/ai-provider — when the provider switches to anything
  other than LOCAL and the body does not include an explicit
  `aiBaseUrl` write, set the column back to NULL.
- `buildUserProvider` — for OPENAI hardcode `https://api.openai.com/v1`,
  for ANTHROPIC do not forward a base URL at all (the SDK default is
  correct and the UI never exposed an Anthropic base-URL input).
- `resolveProviderForTest` — same belt-and-braces: ignore the merged
  base URL for OPENAI/ANTHROPIC so a stored LOCAL value cannot bleed
  into a connection-test request.

Adds two regression tests covering the LOCAL → OPENAI and LOCAL →
ANTHROPIC paths with a stale `aiBaseUrl`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:26:48 +02:00
Marc-André BombeckandClaude Opus 4.7 8aae7d6dfe fix(dashboard): tile fill + muted-foreground hierarchy
Two related visual fixes for the dashboard tile strip and every
secondary string in the app:

- TrendCard now fills its grid cell. As a flex-child the card was
  taking content width, which made tiles in the same row 122-166 px
  wide on a 375 px viewport instead of even shares of the row.
  Adding `w-full` to the wrapping `flex` element gives every tile
  the same horizontal share that the parent grid hands out.
- `--muted-foreground` was identical to `--foreground` in both
  themes, so labels, "Ø7d:" prefixes, units, and inactive nav items
  all rendered at the same brightness as primary text. Dark theme
  now uses `#9aa3b3` (Dracula comment), light uses `#5b6273` —
  WCAG-AA contrast against `--background` and `--card` holds at
  ≥ 4.5:1 in both modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:24:22 +02:00
Marc-André BombeckandClaude Opus 4.7 e8f4820018 fix(dashboard): tile strip back to CSS Grid for true symmetric width
Marc's v1.4.4 feedback: the tiles look better when they're all
exactly the same size and the strip begins and ends at the same
x-coordinate as the charts — the v1.4.4 `flex-1 basis-0` distribution
was close but not quite symmetric, and at 6 tiles the right edge
of the strip didn't line up with the charts below.

Switched to the v1.3-era pattern but with the modern `auto-fit +
minmax(9rem, 1fr)` form so it doesn't need explicit breakpoints:

  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 0.75rem;

CSS Grid puts every tile in an identical 1fr track. Width is
literally identical, gaps are symmetric, and the strip is bounded
by the same parent box as the charts so left/right edges align by
construction. When the row no longer fits a 9 rem floor the grid
wraps to a new row — Marc explicitly tested both and prefers the
wrap over the v1.4.x horizontal-scroll for the symmetry it
preserves.

Tests + typecheck green (671/671).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:05:35 +02:00
Marc-André BombeckandClaude Opus 4.7 cbded7c39e fix(dashboard): symmetric tile widths, BP back to two tiles
Marc's v1.4.3 feedback:
  - Tiles end up the same height now but uneven widths — they should
    distribute symmetrically across the strip.
  - BP shouldn't be one combined tile; sys + dia should sit side by
    side as two distinct tiles like every other metric.

Two fixes:

1. **Strip wrapper switches from `shrink-0 grow basis-[10rem]` to
   `flex-1 basis-0 min-w-[9rem]`.** With `basis-[10rem]` the floor
   was 160 px, and tiles whose content rendered wider (the combined
   BP tile rendering "117.0/79.0 mmHg" plus a paired avg row) pushed
   past their share, breaking symmetry. `flex-1 basis-0` starts every
   tile from zero, so the row-width gets divided evenly regardless of
   how much text each card contains. `min-w-[9rem]` is the readable
   floor — if the row no longer fits at that width, the parent's
   existing `overflow-x-auto` lets the user swipe instead of wrap.

2. **BP renders as two tiles again.** Reverted the v1.4.3 combined
   `secondary` rendering: the dashboard now pushes a `bp-sys` and
   a `bp-dia` entry, ordered with a 0.001 sub-step so dia stays
   immediately after sys without colliding with the next widget's
   integer order. The `secondary` prop on TrendCard stays in the
   API for callers that *do* want a paired-value tile.

Tests + typecheck green (671/671).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:14:24 +02:00
Marc-André BombeckandClaude Opus 4.7 d438a8520d feat(admin): bug-report toggle, unify "Trennen" button styling
Bucket E+F of the v1.4.3 polish pass.

**Disconnect-button styling unified.** Three integrations had three
different "Trennen" buttons: Withings used `outline` + `text-
destructive`; moodLog used solid `destructive` (red bg, white text);
the AI/Codex card used `ghost` + `text-destructive`. Same action,
three readings — confusing. All three now use the Withings pattern
(`outline` + `text-destructive`). The actual confirmation button
inside the AlertDialog keeps its solid-red treatment so the final
"yes, do it" still reads as a strong commit.

**Bug-report explicit on/off toggle.** Until v1.4.3 the bug-report
feature was implicitly gated on "is the GitHub token + repo
configured". An admin who wanted to *temporarily* hide the report
button (e.g. during a freeze window) had to clear the encrypted
token, forcing re-entry on resume — destructive for a reversible
intent. Migration `0027_bug_report_enabled_toggle` adds
`app_settings.bug_report_enabled BOOLEAN NOT NULL DEFAULT TRUE`,
the admin settings schema accepts `bugReportEnabled`, and a Switch
in the admin → Bug Report card lets the operator flip it.
`POST /api/bugreport` now returns 503 ("Bug reports are disabled by
the administrator") when the flag is off — the client already
renders a "not configured" screen on that status, so the report
button hides gracefully without further client-side wiring.

i18n keys added: `admin.bugReportEnabledLabel` + `…Description` in
both DE and EN.

Tests + typecheck green (671/671).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:45:18 +02:00
Marc-André BombeckandClaude Opus 4.7 c05f48f520 feat(settings): KI-provider — OpenAI key field, model preset dropdown, gated OAuth
Bucket C of the v1.4.3 polish pass.

Three independent bugs in the KI-provider settings, fixed together
because they all live on the same panel:

1. **Selecting "OpenAI" left no way to provide a key.** The schema
   stored Anthropic and Local API keys, but OPENAI was a valid
   `aiProvider` value with no matching credential column — the
   resolver silently fell through to the admin-level OpenAI key. So
   a self-hosted user who picked OpenAI and typed nothing got the
   admin's key (might not exist), and a user who *wanted* their own
   OpenAI key had no UI surface to enter it.

   Migration `0026_user_ai_openai_key` adds `users.ai_openai_key_
   encrypted`. The resolver's `OPENAI` branch now picks up the
   user-level key first and only falls back to admin if the user
   slot is empty. The settings UI grew an OpenAI-key field gated on
   `provider === "OPENAI"` mirroring the Anthropic flow. The
   `/api/ai/test` override schema accepts `openaiKey` so the
   "Verbindung testen" button can validate an unsaved key without
   persisting plaintext.

2. **Model field was a freetext Input, easy to typo.** Replaced
   with a `<select>` populated from `MODEL_PRESETS` per provider:
   gpt-4o / gpt-4o-mini / gpt-4-turbo / gpt-5 / o3-mini for OPENAI;
   claude-sonnet-4-6 / claude-opus-4-7 / claude-haiku-4-5 / claude-
   3-5-sonnet-latest for ANTHROPIC; llama3.1:8b / llama3.1:70b /
   mistral / qwen2.5 for LOCAL. An "Eigenes…" sentinel still
   surfaces the freetext Input so power users can target preview
   models or fine-tunes. The dropdown disables when no provider is
   chosen — saves the user from filling a model that won't be used.

3. **"Mit ChatGPT verbinden" silently bounced to chatgpt.com login
   with no OAuth grant.** The authorize URL was built without a
   `client_id` parameter, so chatgpt.com couldn't recognise the
   request as an OAuth handshake — it just rendered the regular
   login form. The user came back, nothing connected, the button
   "did nothing".

   Added `CODEX_OAUTH_CLIENT_ID` env var. The URL builder now
   throws `CodexOAuthNotConfiguredError` if it's unset; the
   authorize route maps that to a 503 with an actionable message
   instead of dispatching the dead URL. The settings card reads
   `codexOauthConfigured` from `/api/insights/settings` and hides
   the Connect button when the flag is false, replacing it with a
   tiny italicised "ChatGPT-OAuth ist auf dieser Instanz nicht
   konfiguriert — nutze stattdessen einen eigenen API-Key unten."
   `CHATGPT_OAUTH` was also removed as a manually-selectable
   provider option in the dropdown — the top connect button is the
   path; having both confused the affordance.

Refresh + token-exchange both forward the client_id now too. Test
fixture seeds `process.env.CODEX_OAUTH_CLIENT_ID="test_client_id"`
in `beforeEach` so the existing URL-shape assertion still works,
plus a new test asserts the unset case throws.

Tests + typecheck green (670/670 — one new assertion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:36:36 +02:00
Marc-André BombeckandClaude Opus 4.7 622099c0fd fix(settings): About + API surfaces — restructure, fix update check, fit tables
Bucket B of the v1.4.3 polish pass.

Three issues, one commit because they're the same surface:

1. **API & Tokens tables overflowed the settings content column.**
   Each table set `min-w-[760px]` (or 860px) so the table never
   compressed below that width. On mobile this is intentional (it
   gives the inner cells breathing room while the outer wrapper
   horizontally scrolls). On md+ the parent column is smaller than
   the min-width on common laptop sizings (~800px content area when
   the browser isn't full-width), which forced an inline scrollbar
   for no benefit. Switched to `min-w-[760px] md:min-w-0` per table
   so the constraint releases at the breakpoint where the layout
   stops scrolling.

2. **"Auf Updates prüfen" button silently did nothing.** The
   client-side handler called `https://api.github.com/.../releases/
   latest` directly. Production CSP locks `connect-src` down to
   `'self'` + `https://wbsapi.withings.net`, so the fetch was
   blocked at the browser before it left the tab. Replaced with a
   server-side proxy at `/api/version/check-updates` that fetches
   GitHub for the user, compares the running `package.json` version
   against `tag_name`, and returns one of `up_to_date | newer_
   available | unknown`. The route is auth-gated (cookie OR bearer)
   so anonymous traffic can't burn through GitHub's 60-req/hr anon
   rate limit on the deployment's IP. The route also annotates the
   external call into the Wide Event so a future spike is visible
   in the logs.

3. **The About surface had no headings and a stray Badge box.** The
   v1.4.2 version put the License inside a `<Badge variant=
   "outline">`, which created a small framed pill that read as a
   separate widget rather than a peer of the version number. Below
   that, the Sources/Changelog/Docs and the update-check button
   each lived in their own card with no h2 — three nameless cards
   stacked on the page. Restructured into three titled cards:

     - **HealthLog** — identity, with version + license rendered
       inline as plain monospace pairs (no Badge, no nested grid).
       Build SHA + build timestamp ride along on the same row when
       set.
     - **Quellen & Dokumentation / Sources & docs** — links unchanged
       but now under their own h2.
     - **Updates** — manual `Check now` button + a passive "Last
       checked: …" line that pulls from localStorage so the user
       can see when the previous check ran. On mount, if the last
       successful check is >24h ago (or has never run), the page
       auto-checks once. The 24h window is a deliberate compromise
       between "always fresh" and "GitHub anon rate limit". The
       error path renders a non-blocking "Couldn't reach GitHub"
       message with the alert role so screen readers announce it.

i18n keys touched in both `de.json` + `en.json`:
  - `settings.about.linksHeading` (new)
  - `settings.about.updatesHeading` (new)
  - `settings.about.checkUpdates` (label shortened)
  - `settings.about.checking` (button busy state — new)
  - `settings.about.lastChecked` / `lastCheckedNever` (new)
  - `settings.about.checkFailed` (new — replaces the misleading
    `testConnection.errors.connection_failed` reuse)

Tests updated for the new heading + button strings; 669/669 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:03 +02:00
Marc-André BombeckandClaude Opus 4.7 8c0593fe79 feat(dashboard): single BP tile, equal-height tile strip, settings dashboard/thresholds split
Bucket A of the v1.4.3 polish pass.

Tile-strip width drifted past the chart-strip width by 16px on >=md
because the strip used `-mx-2 px-2` to bleed two pixels past the
parent for a snap-scroll feel. The bleed is real on mobile — the
horizontal-scroll affordance only reads as a scroll if the first/last
tiles touch the viewport edge — but on tablet/desktop where the row
fits without scrolling it just made the tile group visibly wider than
the charts below. Now `md:mx-0 md:px-0` drops the bleed once charts
fit inline, so the two strips share the same content box.

Tile heights varied because the blood-pressure tile rendered as two
stacked TrendCards (sys + dia) inside one tile slot while every
other tile was a single TrendCard. The cleaner fix is one TrendCard
that displays the paired value as `117/79` — TrendCard now accepts an
optional `secondary: { latest, avg7, avg30 }` and the dashboard pushes
ONE BP entry instead of a Fragment of two. Tile count drops from 6
to 5, the strip stops wrapping awkwardly, and the BP card carries
the systolic sparkline + traffic-light tooltip while the diastolic
value rides along under the same heading. Each tile wrapper got
`flex` and the card itself `flex h-full flex-col`, so flex-stretch
inside the strip can do its job and the avg7/avg30 row pins to the
bottom.

Settings nav split: the section formerly titled "Übersicht" was
renamed to "Dashboard" (the term every user already knows from the
main nav), and the embedded Persönliche Zielwerte panel moved out
into its own top-level settings section under `/settings/thresholds`.
Two reasons: the panel is heavy (one card per metric) and was
visually buried behind the layout customizer, and "Übersicht" leaked
internal vocabulary — neither the route slug nor the surrounding nav
entries used it. Tests guarding slug order + section count updated
in lockstep.

Touched:
  - messages/de.json, messages/en.json
  - src/app/page.tsx (dashboard tile strip)
  - src/app/settings/[section]/page.tsx (route wiring)
  - src/components/charts/trend-card.tsx (secondary-metric prop)
  - src/components/settings/section-slugs.ts
  - src/components/settings/settings-shell.tsx (new entry + icon)
  - src/components/settings/section-placeholder.tsx
  - src/components/settings/dashboard-section.tsx (drop ThresholdsSection)
  - src/components/settings/thresholds-settings-section.tsx (new)
  - src/components/settings/__tests__/{sections,settings-shell}.test.tsx

Tests + typecheck green (669/669).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:24:30 +02:00
Marc-André Bombeck 0810a859e1 fix(ui): defensive optional chains on all remaining var?.x.y sites
Marc reported many residual TypeError crashes after the targeted fix
in 680b001. A comprehensive perl regex sweep across src/app and
src/components turned up 19 more occurrences of the same anti-
pattern: `var?.x.y` only protects the outer object, so when the
inner field is undefined the access throws.

Touched files:
  - src/app/api/dashboard/summary/route.ts (server-side null guards)
  - src/app/api/health/route.ts (session check)
  - src/components/admin/feedback-inbox-section.tsx
  - src/components/medications/medication-card.tsx (compliance rates)
  - src/components/medications/medication-form.tsx
  - src/components/medications/intake-history-list.tsx
  - src/components/measurements/measurement-list.tsx
  - src/components/mood/mood-list.tsx
  - src/components/ui/password-strength.tsx

Pattern applied: `var?.outer.inner` → `var?.outer?.inner`. Tests
green, typecheck clean. Lint untouched (the rule we already enforce
catches set-state-in-effect, not optional-chain depth).
2026-05-08 18:47:00 +02:00
Marc-André Bombeck 680b0019c1 fix(ui): defensive optional chains across summaries/medications/preferences/entries/measurements
Same anti-pattern as the v1.4.2 hotfix on /dashboard and /insights —
the optional chain protected only the outer object, so any field on
the inner object that didn't exist threw at access time. Marc's
reports flagged dashboard, insights, medications,
notification-preferences, admin login overview, mood list, and
measurement list. Applied 'data?.X?.Y' to every spot the regex
caught.
2026-05-08 18:36:13 +02:00
Marc-André BombeckandMarc-André Bombeck 643e6ec3a0 release: v1.4.2 — resurrect v1.4.1 work + bug-fix triple (#152)
* Revert "revert: roll source tree back to v1.4.0 to restore production"

This reverts commit e444a90b10.

* fix(v1.4.2): summaries.WEIGHT + medications array guards + healthcheck IPv4

Three production bugs surfaced after the v1.4.0 deploy stabilized:

1. `TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
   on /dashboard and /insights for users without weight readings —
   the optional chain stopped at `data` when it needed to also cover
   `data.summaries`. Fixed: `data?.summaries?.WEIGHT` (one level deeper).

2. `TypeError: y?.filter is not a function` on /medications — the
   medications query value was non-array (likely a stale
   service-worker response from an older API shape). Replaced with
   `Array.isArray(medications) ? medications : []` so the page is
   immune to any future shape drift.

3. Healthcheck-induced 503 outage (already in 1.4.1 — included here
   so 1.4.2 is the single coherent release). The docker-compose
   healthcheck used `localhost` which busybox-wget resolves to IPv6
   ::1; Next.js standalone listens on IPv4 0.0.0.0 only. Pinned
   to 127.0.0.1.

This release also includes everything from v1.4.1 (which never
published a clean GHCR tag): the per-section admin extraction, the
testcontainers integration suite, the Playwright + axe-core E2E
foundation, the moodLog SSRF fix, the central error-redaction in
WideEventBuilder + Glitchtip, the medical-citation drift fixes
(ESH 2023, Saint-Maurice attribution, steps source), and the docs
sync.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:46:31 +02:00
Marc-André Bombeck e444a90b10 revert: roll source tree back to v1.4.0 to restore production
Production at healthlog.bombeck.io has been 503-ing since the v1.4.1
deploys started landing on apps-01 (Coolify). The container boots —
Next.js prints "Ready" and the pg-boss workers run — but never
accepts HTTP on :3000, so the Docker healthcheck fails and Traefik
takes the upstream out of rotation. A manual restart, a Coolify
force-rebuild, and a docker-compose pin to the GHCR :1.4.0 multi-arch
image all failed to bring the site back up — Coolify rebuilds the
image from main HEAD on every deploy regardless of the compose
directives.

This commit resets the working tree to commit 21bd46d (v1.4.0
release). Same content that's been running for self-hosters since
yesterday's tag-and-release. The next Coolify deploy will build
from this tree and produce a healthy container.

The v1.4.1 work is NOT lost:
  - PRs #144, #145, #137, #146, #147, #148, #149, #150 remain in
    git history.
  - Their commits are still tagged (`v1.4.1`), still on the GHCR
    multi-arch image (`ghcr.io/mbombeck/healthlog:1.4.1`), still in
    the GitHub Release notes.
  - Self-hosters who have already pulled the v1.4.1 image keep it.
  - Local development continues from main HEAD with the v1.4.1
    code — the regression only surfaced under the Coolify build
    flow.

Re-applying v1.4.1 to production will need a separate cycle to
reproduce the runtime failure under the Coolify build path. That
work is tracked in docs/ops/v141-followup-issues.md (added back
when the tree is reapplied) and the deploy gating in
.github/workflows/e2e.yml will catch this class of bug going
forward.

No DB migration. No env-var change. No API contract change.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:15:34 +02:00
Marc-André Bombeck 70353882eb fix(security): close moodLog SSRF + central error redaction; fix citation drift (#148)
The v1.4.1 audit pass turned up one CRITICAL, two HIGH, and four
medical-citation drift findings. This commit addresses everything
that's safe to land in a hardening release; deeper architectural
fixes (idempotency-race transactional rewrite, encryption-key
fallback gate, refresh-token reuse-detection serialisation, moodLog
secret HMAC lookup column) are tracked for v1.4.2 in
docs/ops/v141-followup-issues.md.

Security
--------

CRITICAL — moodLog SSRF
  src/lib/validations/moodlog.ts now refines the credentials URL
  with isPublicUrl() so an authenticated user cannot store
  http://169.254.169.254/ (cloud metadata) or RFC1918 hosts.
  src/lib/moodlog/sync.ts re-checks isPublicUrl(baseUrl) at the
  fetch site (so legacy rows stored before the credential guard are
  also refused) and switches to redirect: "manual" + 3xx → fail so a
  public host cannot 302 the request to an internal target with the
  user's apiKey on the redirect hop. New unit test
  src/lib/validations/__tests__/moodlog.test.ts asserts the guard
  rejects 169.254.x.x, 10.x, 172.16.x.x, 192.168.x.x, 127.0.0.1, and
  localhost.

HIGH — error-message secret leakage
  New src/lib/logging/redact.ts redacts Bearer tokens (incl. our
  hlk_/hlr_), Telegram bot URLs (bot<digits>:<token>), and
  query-string secrets (?secret=, ?code=, ?token=, ?api_key=). The
  redactor is called from WideEventBuilder.setError() — every error
  reaching Loki is scrubbed once, centrally — and from the
  reportToGlitchtip() call in api-handler.ts so the Glitchtip
  incident UI is also protected. New unit suite covers the four
  patterns plus over-redaction (Bearer's \\S+ greedily consumes
  anything up to the next whitespace, which is the safer side of
  the trade-off).

Truthfulness — citation drift fixes (audit found 0 hallucinations,
4 drift items)
  - messages/{en,de}.json bpClassificationTitle and avg30dEsc:
    "ESC/ESH 2018" → "ESH 2023". User-visible on the dashboard and
    doctor-report PDF. The numeric thresholds are unchanged from
    2018; the 2023 update is ESH-only since ESC withdrew from
    joint authoring.
  - src/lib/analytics/classifications.ts: section header,
    getBpTargetsByAge() docblock, and inline comment updated to
    cite ESH 2023 with the same "numerically unchanged" caveat.
  - src/app/api/insights/targets/route.ts:375: source label "WHO"
    → "Saint-Maurice JAMA 2020". The AI-prompt anti-pattern guard
    explicitly forbids citing WHO for a step number; this surface
    was the last "WHO" mislabel in the codebase.
  - Saint-Maurice "mortality plateau 8000-12000" attribution
    softened to "continued dose-response benefit through ~12,000
    steps/day, not a plateau" in src/lib/medical-citations.ts,
    src/lib/analytics/effective-range.ts,
    src/lib/analytics/classifications.ts, and both AI prompts. The
    original JAMA 2020 paper shows continuing benefit (HR 0.49 at
    8k vs 4k, HR 0.35 at 12k); the plateau-shaped finding belongs
    to Paluch 2022 Lancet PH (PMID 35247352), not Saint-Maurice.

Quality gates
  pnpm typecheck         clean
  pnpm test              669 / 669 (was 658; +11 from new suites)
  pnpm test:integration  10 / 10 (untouched)
  pnpm lint              0 errors
2026-05-08 16:30:37 +02:00
Marc-André Bombeck 6da94280b5 feat(admin): extract per-section components from admin page monolith (#145)
The admin page was a single 2,702-line client component with
nineteen inline functions covering everything from system status
to feedback inbox. The status-card grid foundation in #133 already
moved the at-a-glance dashboard out; this commit moves the inner
panels.

Split into one file per logical section in src/components/admin/:

  _shared.tsx                         276 LOC  shared types, hooks,
                                                primitives (PasswordInput,
                                                StatusItem, SettingsToggle,
                                                useAdminSettings,
                                                useUpdateSettings,
                                                useSystemStatus,
                                                getApiErrorMessage)
  system-status-section.tsx           155
  general-settings-section.tsx         54
  services-section.tsx                 68
  umami-section.tsx                   173
  glitchtip-section.tsx               172
  web-push-vapid-section.tsx          148
  bug-report-section.tsx              109
  reminders-section.tsx               318
  user-management-section.tsx         341
  api-token-overview-section.tsx      144
  login-overview-section.tsx          181
  danger-zone-section.tsx             118
  feedback-inbox-section.tsx          511 (inbox + category badge
                                            + detail dialog)

src/app/admin/page.tsx is now a 77-line shell that imports the
14 sections and the existing status-card-grid.tsx. No behavior
change — every section keeps the same DOM, the same query keys,
the same i18n keys, the same id="…" anchors so deep-links from
the sidebar still scroll into place. UserManagementSection no
longer takes a queryClient prop; it grabs its own via
useQueryClient() like every other section does.
2026-05-08 15:58:06 +02:00
Marc-André Bombeck 6bb00556d6 fix(medications): convert API-endpoint dialog status fetch to useQuery (#144)
The dialog's "load endpoint status" effect was triggering a
react-hooks/set-state-in-effect lint error: setLoadingStatus(true)
fired synchronously inside an effect, which the rule flags as a
cascading-render risk.

Convert the imperative useCallback + useEffect pair to a single
useQuery — same fetch shape, but loading/data state now lives in
TanStack Query and the effect goes away. The "Reload status" menu
item calls refetch(); the toggle path uses queryClient.setQueryData
to keep the displayed state in sync after a PUT, so users still see
the new active-token count without a second roundtrip. Errors from
the GET render through the same callout that toggle errors do.

No user-visible change. Restores lint to 0 errors on main.
2026-05-08 15:40:34 +02:00
Marc-André Bombeck 65e86e4a53 feat(settings): extract all six remaining sections — content lands at /settings/<slug> (#143)
Replace the SectionPlaceholder fallbacks for Account, AI, Integrations,
Notifications, Dashboard, API, Advanced, and About with real per-section
components. Every /settings/<slug> URL now renders working content; the
placeholder only fires for unknown slugs.

A2-content extraction
- Account: profile + passkeys + password change (account-section.tsx)
- AI: insights + per-user provider override (ai-section.tsx)
- Integrations: Withings + moodLog (integrations-section.tsx)
- Notifications: Telegram + ntfy + Web Push (split into telegram-card,
  ntfy-card, web-push-card to keep each file under ~600 LOC)
- Dashboard: layout + thresholds wrappers (dashboard-section.tsx)
- API: endpoints + tokens (api-section.tsx)
- Advanced: export + danger zone (advanced-section.tsx)
- About (greenfield): version, license, build SHA, repo/changelog/docs
  links + "Check for updates" against GitHub releases

A2-about
- Reads /api/version via TanStack Query, surfaces app version, AGPL-3.0
  badge, short build SHA, build timestamp, repo/changelog/docs links.
- "Check for updates" button compares against api.github.com latest
  release tag with up-to-date / newer-available / failure feedback.

A8-UI test buttons
- Shared TestConnectionButton primitive that POSTs to a test endpoint,
  surfaces ok-with-latency or translated meta.errorCode failures.
- Wired into Withings + moodLog (Integrations) and Web Push
  (Notifications). Glitchtip + Umami remain admin-only.

i18n
- New settings.about.* and settings.testConnection.* key groups in en.json
  and de.json.

Cleanup
- Delete src/app/settings/page.legacy.tsx (3230 LOC monolith).
- Convert sync-from-server patterns to the React-recommended
  prev-payload-id render-time pattern so the strict
  react-hooks/set-state-in-effect rule passes without a file-level disable.

Tests
- New src/components/settings/__tests__/sections.test.tsx with 9 SSR smoke
  tests (one per section) — total suite 514 (was 505).
2026-05-08 14:52:04 +02:00
Marc-André BombeckandMarc-André Bombeck d7778c1085 feat(v1.4): multi-tenant prep — off-host backup, encryption-key versioning, worker/web split, native-Bearer UA gating (#135)
* feat(crypto): G2 — encryption-key versioning + rotation CLI

Adds backwards-compatible support for ENCRYPTION_KEYS (JSON map of
{ "id": "<key>" }) plus ENCRYPTION_ACTIVE_KEY_ID. Ciphertext format is now
"<keyId>.<base64payload>"; legacy bare-base64 ciphertext still decrypts
under the synthetic "v1" id derived from the existing ENCRYPTION_KEY.

scripts/rotate-encryption-key.ts re-encrypts every encrypted column on
User / WithingsConnection / AppSettings with the active key. Idempotent —
rows already on the active id are skipped.

Why: enables zero-downtime rotation when a key is suspected leaked,
without forcing every encrypted row through a single big-bang rewrite.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(auth): G4 — native-Bearer UA gating + rotating refresh tokens

Web callers (UA contains 'Mozilla/') keep the legacy 90d Bearer when they
explicitly opt in via X-Client-Type: native. Native callers (HealthLog-iOS,
HealthLog-iPad, n8n, Health-Connect, unrecognised UAs) now receive a 24h
access token plus a 60d rotating refresh token in the login response.

New /api/auth/refresh endpoint atomically validates + rotates the refresh
token (one-time-use), revokes the previously paired access token, and
audit-logs every refresh + revoke. Reuse of an already-consumed refresh
token revokes the entire family for the user/device — defence against a
stolen refresh token.

Migration 0025_refresh_tokens adds the table; the only DDL is CREATE
TABLE so it is forward-compatible. Down migration documented at
docs/ops/migrations/0025-down.sql.

Why: the V3 audit (still-V2-A) flagged that 90d tokens are too long for
native callers when refresh-token rotation is now table-stakes for mobile
apps. Web flows are unchanged so existing browser sessions keep working.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(infra): G3 — web/worker container split via HEALTHLOG_PROCESS_TYPE

Adds a tiny startup gate (src/lib/process-type.ts) so the same Docker image
can run as web-only, worker-only, or 'all' (default — single-container,
backwards compatible). assertSubsystemEnabled() refuses to boot the
reminder worker when HEALTHLOG_PROCESS_TYPE=web, preventing accidental
double-cron when an operator splits the deployment.

docker-compose.yml ships the optional app-worker service block commented
out so 'docker compose up' keeps working out of the box; uncommenting it
+ flipping app to HEALTHLOG_PROCESS_TYPE=web is the only change to scale
HTTP horizontally. Healthchecks are isolated per service so neither
startup deadlocks the other; both wait on db: service_healthy.

docs/self-hosting/scaling.md walks through when + how to split.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(jobs): G1 — daily off-host encrypted backup to S3-compatible storage

New pg-boss queue 'data-backup-offhost' runs at 02:30 Europe/Berlin, dumps
each user's measurements + medications + intake events + mood entries,
encrypts the JSON under a SEPARATE key (BACKUP_ENCRYPTION_KEY, isolated
from the application ENCRYPTION_KEY), and uploads to any S3-compatible
target via @aws-sdk/client-s3.

Wire format documented in docs/ops/backup-restore.md:
  magic "HLBK" || version 0x01 || iv(12) || authTag(16) || ciphertext

The worker prunes objects older than BACKUP_RETENTION_DAYS (default 30);
operators are encouraged to also configure a bucket-level lifecycle rule.

Admin endpoint POST /api/admin/backup/test issues a 1-byte PUT+GET
roundtrip and returns endpoint + bucket + per-call latency. Credentials
are never returned.

scripts/restore-backup.ts downloads + decrypts a single object so
operators can verify the chain end-to-end without standing up a separate
restore harness.

Why: GDPR Art. 32 + V3 audit "data durability" — without an off-host copy
the only line of defence against a Postgres volume loss is the in-DB
DataBackup model on the same host. Separate-key isolation means a leak of
the application encryption key does not also expose every user's archive.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(multi-tenant): address review findings — rotation coverage, hard decrypt errors, web/worker gates

Multi-agent review on PR #135 caught several CRITICAL + HIGH issues that
would have shipped silent data corruption or weakened the threat model:

Encryption + rotation
- scripts/rotate-encryption-key.ts: stop selecting `bugReportTokenEncrypted`
  (column doesn't exist in the schema; the script crashed on the AppSettings
  step). Add the three encrypted columns it was missing —
  NotificationChannel.config, PushSubscription.p256dh, PushSubscription.auth
  — so rotating to a new key actually re-encrypts every encrypted row.
  Without this, dropping the old key id from ENCRYPTION_KEYS would leave
  notification channels and web-push subscriptions permanently
  undecryptable.
- src/lib/crypto.ts: refuse to silently fall back to the active key when a
  legacy bare-base64 row is decrypted but no v1 entry is configured. Throws
  a clear "Restore ENCRYPTION_KEY (or add a v1 entry to ENCRYPTION_KEYS)
  and run scripts/rotate-encryption-key.ts before removing it" so an
  operator who pulled the v1 too early notices immediately instead of
  hitting opaque GCM tag errors.
- docs/ops/encryption-key-rotation.md: full step-by-step + rollback note +
  troubleshooting section. Spells out that rotation is a forward-only
  operation and what to do if the rollback path is needed.

Web / worker split (G3)
- src/proxy.ts: refuse HTTP traffic with a 503 +
  X-HealthLog-Process-Type: worker header when HEALTHLOG_PROCESS_TYPE=worker.
  The worker container (which still runs `node server.js` for now) no
  longer accepts requests, so a misrouted client gets a clear hint instead
  of a partially-served response.
- src/instrumentation.ts: short-circuit before importing the reminder
  worker when HEALTHLOG_PROCESS_TYPE=web — avoids the noisy
  "[instrumentation] CRITICAL" stderr line we emitted on every web boot.
- /api/version added to PUBLIC_PATHS so the compose healthcheck doesn't
  silently 302-redirect through the auth gate (the prior wget treated the
  login page as a 200 success and would never notice an unhealthy backend).

Refresh-token rotation (G4)
- src/lib/auth/refresh-token.ts: reuse-detection now revokes ALL active
  refresh tokens for the user, not only those scoped to the same deviceId.
  An attacker who rotated without an X-Device-Id header would otherwise
  keep their family alive on the no-device branch. Defence-in-depth.
- revokeRefreshToken() also revokes the paired access token so a leaked
  access token cannot outlive the refresh-token sibling that the caller
  just killed (logout-on-device).

Off-host backup (G1)
- src/lib/jobs/offhost-backup.ts: drop the worker-side DeleteObject path.
  Retention is the bucket's lifecycle-policy job; the worker keeps a
  PutObject + GetObject grant only. A compromised worker can no longer
  wipe the backup history. Per-user errors are now surfaced as Wide
  Event warnings + a structured failures[] array on the report so an
  operator can tell which user failed and why.
- scripts/restore-backup.ts: schema-validate the decrypted payload before
  writing to disk, plus an optional --user-id=<id> flag that refuses to
  proceed unless the dump's userId matches. Prevents silent
  cross-tenant restores when an operator points the script at the wrong
  S3 key.

Idempotency
- src/lib/idempotency.ts: the leak-guard already blocked `hlk_`
  (access-token prefix); also block `hlr_` so a future caller wrapping
  the new /api/auth/refresh endpoint with withIdempotency() can't
  accidentally cache a freshly-issued refresh token.

Migration / schema
- prisma/migrations/0025_refresh_tokens/migration.sql: wrap in a single
  transaction; switch to CREATE { TABLE | INDEX } IF NOT EXISTS so a
  partial-deploy retry is a no-op instead of "relation already exists";
  guard the FK ADD with information_schema lookup for the same reason.
- Schema replaces the single-column `@@index([deviceId])` with a
  composite `@@index([userId, deviceId])` (every lookup also filters on
  userId; the old index would never have been used). Comment on
  accessTokenHash corrected to HMAC-SHA-256.

Container / ops
- Dockerfile: install tzdata + ENV TZ=Europe/Berlin so the daily
  pg-boss schedule (`30 2 * * *` Europe/Berlin) actually fires at 02:30
  Berlin instead of silently falling back to UTC on the alpine base.
- docker-compose.yml: tighten the healthcheck to `wget --spider` (matches
  the Dockerfile HEALTHCHECK style, doesn't pipe the body), pass through
  BACKUP_RETENTION_DAYS so a custom retention env reaches the container.
- .env.example: document every new env var added in this PR
  (HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS, ENCRYPTION_ACTIVE_KEY_ID,
  BACKUP_S3_*, BACKUP_ENCRYPTION_KEY, BACKUP_RETENTION_DAYS) with
  comments and the all-or-none semantics for the backup block.

491 tests still green, typecheck clean.

* chore: retrigger CI

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 14:33:39 +02:00
Marc-André Bombeck 6e74612efc test(coverage): real-behaviour tests for audit + webhook routes (~80% line coverage) (#142)
Adds vitest unit tests covering the four zero-coverage webhook /
monitoring surfaces external systems hit. Each test mocks at the module
boundary (per existing project pattern in src/app/api/auth/login/
__tests__/native-token.test.ts) and asserts on actual call arguments
— no test theatre.

Per-file line coverage:
- src/lib/auth/audit.ts                                    100.00%
- src/app/api/withings/webhook/route.ts                     98.11%
- src/app/api/integrations/moodlog/webhook/route.ts         95.23%
- src/app/api/telegram/webhook/route.ts                     95.12%

Test count: +61 (494 -> 555).
2026-05-08 14:30:03 +02:00
Marc-André BombeckandClaude Opus 4.7 382b4a0f9f test(coverage): real-behaviour tests for admin routes + glitchtip ingest (#141)
Adds vitest unit tests for five previously zero-coverage routes:

- /api/admin/users (GET) — admin gate, safe select shape regression, no
  password-hash leak.
- /api/admin/users/[id] (PUT) — Zod validation, 404 for unknown id,
  last-admin demotion guard, audit-log call shape.
- /api/admin/settings (GET / PUT) — singleton row regression, encrypted
  blob never reaches the client, AES encrypt() called for VAPID +
  GitHub-issue tokens, clear flags wipe encrypted columns, every
  schema-allowed field round-trips through upsert.
- /api/admin/status (GET) — integration health summary aggregates umami
  / glitchtip / web-push / bug-report flags, only non-revoked tokens
  count as active, encrypted blobs never appear in the envelope.
- /api/monitoring/glitchtip (POST) — IP rate-limit gate, short-circuit
  when DSN is null, message/stack size caps (2000 / 20 000 chars),
  upstream 5xx mapped to 502, environment fallback to "production".

All five files now land at >=80% line coverage:
- admin/users/route.ts: 100%
- admin/users/[id]/route.ts: 100%
- admin/settings/route.ts: 100%
- admin/status/route.ts: 84.21%
- monitoring/glitchtip/route.ts: 100%

Test count: 469 -> 523 (+54; my files contribute 46, the other 8 were
unblocked by re-running prisma generate in the worktree).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:29:48 +02:00