After v1.4.9 the OAuth connect went green, but every real insight
call still failed with:
Codex request failed (400): { "detail": "Input must be a list" }
The `chatgpt.com/backend-api/codex/responses` endpoint speaks the
OpenAI Responses API shape (`ResponsesApiRequest` in
`codex-rs/codex-api/src/common.rs`), where `input` is a list of typed
message items rather than a single string. CodexClient was sending
the older Chat-Completions-style body (`input: <string>`) which the
upstream rejects with the 400 above.
The body now mirrors the canonical shape:
{
"model": "gpt-5.3-codex",
"instructions": "<system prompt>",
"input": [
{ "type": "message", "role": "user",
"content": [{ "type": "input_text", "text": "<user prompt>" }] }
],
"tools": [], "tool_choice": "auto",
"parallel_tool_calls": false, "store": false, "stream": false
}
The response shape (Output `ResponseItem[]` with `output_text`
content) is unchanged, so `parseResponse` still works without
modification. Both Settings → KI "Verbindung testen" and the insight
generator now complete end-to-end through the ChatGPT subscription.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
Three small follow-ups surfaced by the v1.4.6 QA pass:
- Settings → KI: the "raw data" warning still used raw Tailwind orange
tokens instead of the Dracula `dracula-orange` ones, breaking theme
consistency and tipping below the WCAG-AA contrast floor on the
card surface.
- Admin status grid: the Backups card cta read "View backups" but
jumped to the system-status section (the dedicated backups view
does not exist yet — that's a v1.5 feature). Re-labelling the cta
to "Open system status" and the Integrations cta to "Open
integrations" makes the destination honest. The href targets
themselves are unchanged.
- `withIdempotency` body-content guard previously did `text.includes("sk-")`
which silently broke caching for benign payloads containing common
English compounds like "task-id", "risk-management", "disk-io".
Tightened to a regex requiring the canonical secret prefix plus a
realistic suffix length, so only actual API keys trip the guard.
Three regression tests cover the false-positive cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
P18. Replace the brittle "wipeMsg.startsWith(localized prefix)" success
heuristic with React Query's `wipeAllData.isError` flag. The previous
heuristic (a) re-formatted the success copy with empty placeholders to
extract a prefix, (b) string-prefix-matched against the rendered
message, and (c) silently broke whenever the i18n copy changed colon
position or got translated. Now the colour follows the mutation state
directly: error → destructive, success → dracula-green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P17. The category badges (BUG / FEATURE_REQUEST / QUESTION / OTHER)
were using raw Tailwind palette values (bg-red-500/15 text-red-400 etc.)
which clash with the rest of the app's Dracula tokens. Switch to the
dracula-red / dracula-purple / dracula-cyan equivalents so the inbox
matches the site theme — and dark/light mode parity is consistent
with every other status badge in the admin grid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P15: drop `gpt-5` (unreleased) and `o3-mini` from `MODEL_PRESETS["OPENAI"]`.
gpt-5 would surface as model_not_found from OpenAI; o3-mini uses the
o-series param contract (max_completion_tokens, no temperature override)
which the current OpenAIClient body shape doesn't speak — so picking
either preset just produced a confusing 400. Keep gpt-4o, gpt-4o-mini,
gpt-4-turbo. Power users can still type any model via the Custom… option.
P16: pipe ~30 hardcoded German strings in ai-section.tsx through
`t("settings.ai.…")` and add the matching keys to both messages/en.json
(canonical English) and messages/de.json. Behaviour unchanged — the
Connect/Disconnect/Save/Test/Provider/Model labels and the connect-help
copy now respect the user's locale toggle. The provider-test flow keeps
its own DE strings localized too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P14. CodexClient previously threw a bare `Error("Codex request failed
(500)")` on upstream failure, which made it impossible to triage from
logs. Mirror the openai-client.ts:42-58 pattern: pull the body
(truncated 500 chars), mask any leaked `sk-` / Bearer token, and tag
the Error with `httpStatus`, `upstream`, `model`, and `bodyExcerpt`
fields. Glitchtip and Wide Events now have the same shape for both
providers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
P12. The body-content guard already rejected `hlk_` / `hlr_` (our access
and refresh tokens). Extend it to also reject `sk-` so any OpenAI or
Anthropic key that might be echoed by a misrouted handler never lands
in the IdempotencyKey table. The substring match also covers `sk-ant-`
so we don't need a second branch.
Adds a parametrised vitest case asserting all four prefixes
(hlk_, hlr_, sk-, sk-ant-) suppress caching, plus a
positive case to prove a normal payload still caches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P11. Adds a `sk-(ant-)?[A-Za-z0-9_-]+` rule so any sk- / sk-ant- token that
finds its way into an error message or stack trace gets redacted before
it reaches Loki or Glitchtip. We never log these on purpose, but a
mis-typed config or a dumped request body could carry one — defense in
depth on the egress path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- P7: shrink the bottom-nav clearance from 5rem to 4rem on mobile (md:pb-0 unchanged) so content sits closer to the nav without overlap.
- P8: TrendHint title gets text-muted-foreground so it stops competing with chart titles after the T2 muted-foreground hierarchy fix.
- P9: drop font-mono from the onboarding "X%" progress label; tabular-nums alone is enough.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
- P1: tabular-nums on latest, unit, avg7, avg30 spans so digits stop jiggling on refresh.
- P2: bump value text-2xl -> text-3xl tracking-tight; label text-sm font-medium -> text-xs uppercase tracking-wide.
- P3: tile padding p-3 -> p-4 md:p-6 to align with chart card padding.
- P4: drop direction-tinted arrow color (orange/cyan) — direction-as-good-or-bad is metric-specific, deferred to v1.5+. Both directions now muted-foreground.
- P5: always render avg7/avg30 chips, with em-dash placeholder when null, so vertical rhythm stays consistent across tiles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hitting "All" on a long-running account used to render thousands of
daily points — unreadable, and slow on mobile. The chart adapter now
picks an aggregation level based on the visible range:
- ≤ 90 days → daily (unchanged)
- 91-730 days → ISO weekly mean
- > 730 days → Berlin-calendar monthly mean
Empty buckets are dropped instead of zero-padding so a sparse user
keeps a real trend instead of a baseline floor. Each metric is
aggregated independently so blood-pressure SYS/DIA pairs stay in sync
without one channel pulling the other to 0 on missing days.
A small chip beside the chart title surfaces the active bucket
("Wochendurchschnitt" / "Monatsdurchschnitt" in DE, "Weekly avg" /
"Monthly avg" in EN) so the user always knows what they're looking at.
The bucketer (`src/lib/charts/bucket-time-series.ts`) is pure and
covered by 14 unit tests including ISO-week boundary handling, the
multi-metric independence guarantee, and the empty-bucket skip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
Every "Manage users" / "View backups" CTA reloaded `/admin` to the top
because the hrefs were pointing at sub-routes that never shipped
(`/admin/users`, `/admin/audit-log`) and at anchors whose IDs do not
exist (`/admin#integrations`, `#monitoring`, `#backups`,
`#maintenance`). The actual section IDs in `src/app/admin/page.tsx`
follow a `section-admin-*` / `section-user-management` pattern.
Six href fixes plus a tightened test: instead of `startsWith("/admin")`
the test now asserts each href is `/admin#section-<known-id>` and that
the anchor is a member of the source-of-truth ID set rendered by the
admin page. Adding a new section requires adding the ID here too —
which is exactly the visibility we want to keep CTAs honest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
The base-system prompt invites the model to embed `metric:WEIGHT` /
`metric:BP` etc. tokens in `primaryRecommendation` so the UI can drop
inline charts under the prose. The hero "Key Takeaway" card was the
only renderer printing the field raw — when the model followed the
instructions, the user saw the literal string `metric:WEIGHT` inside
the recommendation. Apply the same `stripChartTokens` + `<InlineCharts>`
pattern that the summary already uses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
Bucket D phase 1 of the v1.4.3 polish pass — the AI insights felt
"static" because (a) findings repeated phrasing with no concrete
numbers and (b) the inline-chart machinery existed but was prompted
permissively, so the model rarely used it.
Two concrete moves, scoped to ship in this hotfix:
1. **`metric:MOOD` is now an allowlisted chart token.** Renderer in
`<InsightAdvisorCard>` branches on `tokenKind(token)` — measurement
tokens still mount `<HealthChart>`, but `metric:MOOD` mounts the
dedicated, self-fetching `<MoodChart>` instead. The mood timeline
was previously the most under-served by the AI prose because it
couldn't be charted inline; now the model can illustrate adherence-
risk and mood-drift findings with the same chart users see on the
/mood page. Drift test was updated in lockstep — the v1.4.2 guard
that asserted MOOD was excluded now asserts it's present, plus a
new guard asserts COMPLIANCE is still excluded (no self-fetching
wrapper exists yet for `<ComplianceLineChart>`; ship that in v1.5).
2. **Prompt grew an EVIDENCE CONTRACT and a forbidden-phrase list.**
The base system prompt (DE + EN both) now requires every
`findings[]` entry to put a concrete number from the snapshot in
`value` ("138/85 mmHg", "+0.4 mmol/L vs. 30d-avg") instead of
adjective prose ("slightly elevated"). Findings without a snapshot
anchor MUST be omitted — variable cardinality 0..8 explicitly
stated, sorted by salience, NO padding. The forbidden-phrase list
blocks generic boilerplate that signals ungrounded output: "achte
auf ausreichend Schlaf", "drink enough water", "regular exercise",
"consult your doctor" (except in the disclaimer field which exists
precisely for that line). The chart-token instruction flipped from
passive ("Du DARFST") to active ("Bevorzuge Tokens immer dann,
wenn …") with three explicit triggers — temporal claims, threshold
references, window comparisons — that reliably surface chart-worthy
findings.
Carved out of v1.4.3 for v1.5: telescoping multi-resolution payload
(14d daily + 10w weekly + 21mo monthly + yearly summary per QA
research 1) and the evidence-ref output schema extension (QA 2).
Both touch the prompt's input shape and would change every cache
key + token bill — they want their own phase.
Tests + typecheck green (671/671 — one new chart-tokens drift
guard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
GettingStartedChecklist intentionally shares TanStack Query cache
with the dashboard via `queryKey: ["analytics"]` etc., but its
queryFns returned the raw `res.json()` (the full `{data, error}`
envelope) while the dashboard, medications page, notifications page
and withings integrations section all return `json.data` (the
unwrapped inner shape). When the checklist's queryFn populated the
cache first, the dashboard's `data?.summaries?.WEIGHT` ended up
reading a property of the envelope `{data, error}` instead of the
analytics body, came back undefined, and silently failed every
per-tile data gate (`hasWeight`, `hasBp`, `hasPulse`, `hasBodyFat`).
Marc saw only the mood tile because mood lives under a different
cache key (`["mood-analytics"]`).
Same envelope/shape mismatch existed for `["medications"]`,
`["withings", "status"]` and `["notifications", "preferences"]` —
fixed in one pass since the failure mode is identical (silent
undefined reads on shared keys, latent bug for any consumer that
mounts after the checklist).
Each queryFn now `await`s and returns `json.data`; the local
accessors collapse from `res?.data?.X` to `res?.X`. As a
side-effect this also fixes a long-standing checklist bug where
`medsRes.data.medications.length` was always 0 (the medications
API returns the array directly under `data:`, not nested under a
`medications` field — so the count silently coerced to zero and
the "log a medication" checklist item never auto-completed).
Tests + typecheck stay green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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).
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.
* 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>
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>
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
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.
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.
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).
* 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>
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).
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>
* feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account
Splits the 3218-LOC /settings monolith into a routing surface only — no
section content moves in this PR. Each of the eight slugs (account,
integrations, notifications, dashboard, ai, api, advanced, about) now lives
at its own /settings/[section] route and renders inside a new
<SettingsShell> sidebar+main layout. Every section currently shows a
placeholder ("Moving here with the next release update") so reviewers can
validate the URL structure in isolation. The historic monolith is preserved
verbatim as page.legacy.tsx for section-by-section extraction by the
follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations,
A2-notifications, A2-rest).
Behaviour change: existing entry points (sidebar nav, topbar dropdown,
onboarding hand-off, dashboard checklist, Withings callback, Codex
callback) now point at /settings/<slug> instead of /settings#anchor. The
/settings root is a 308 (permanentRedirect) to /settings/account so old
bookmarks and external links keep working.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(settings): route every Codex OAuth callback redirect to /settings/integrations
Review-pass H1: only the late success/failure paths in the Codex OAuth
callback were updated to point at the new section route. The four early
branches — rate-limited, provider-supplied `?error=...`, missing params,
invalid state — still redirected to `/settings?codex_error=...`.
After the section split that path 308-redirects to `/settings/account`,
where the Codex error toast/banner doesn't live. Users hitting any of
those edge cases would land on the wrong screen with the error code
silently lost.
All six branches now go to `/settings/integrations?codex_error=...`,
matching the success and token-exchange-failure paths and the existing
Withings callback shape.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(insights): inline charts in AI findings via allowlisted metric tokens
Lets the AI insight advisor inline a Recharts panel directly under the
prose paragraph it talks about — e.g. a finding centred on systolic BP
renders the BP chart immediately beneath itself, instead of forcing the
user to scroll back to the dashboard charts.
Tokens are routed through a server-side allowlist:
- 13 fixed strings (one per MeasurementType plus MOOD and COMPLIANCE).
- A drift test fails if a new MeasurementType is added without a token.
- The greedy `[A-Z_]+` regex caps the parse so trailing junk like
`metric:WEIGHT' onclick='alert(1)'` cleaves cleanly into the safe
metric and inert React text — there is no markdown / no eval path.
- Hallucinated tokens (`metric:NUKE`) are silently dropped.
- The visible prose is run through `stripChartTokens` so the literal
token never reaches the DOM.
Prompts (base + 7 domain prompts, EN + DE) tell the model it may embed
exactly one of the allowlisted tokens inside `summary` or a finding's
`label`/`guideline` — nothing else has any effect.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(insights): drop metric:MOOD and metric:COMPLIANCE until dedicated charts exist
Review-pass M1: those two tokens routed through HealthChart, which
validates `type` against the `MeasurementType` Zod enum. MOOD and
COMPLIANCE aren't in the enum, so the request 422'd and the chart
panel rendered "no data" — silently degrading the most-promoted UX
path under prose that explicitly talked about the user's mood or
adherence.
Allowlist + every prompt now lists only the 11 tokens that actually
render a real chart. The drift test inverts the previous "must
include MOOD/COMPLIANCE" assertion to "must NOT include them",
locking the contract until dedicated MoodChart / ComplianceChart
components ship.
494 tests green, typecheck clean.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami
Five POST /test endpoints modelled on /api/ai/test (apiHandler +
requireAuth/requireAdmin + checkRateLimit 5/60s + annotate() +
sanitised error mapping):
- /api/integrations/withings/test — pings wbsapi.withings.net /measure
with the decrypted user access token
- /api/integrations/moodlog/test — HEAD against the user's decrypted
moodLog URL (5 s timeout, isPublicUrl
SSRF guard)
- /api/notifications/web-push/test — sends "Push test" via web-push to
every PushSubscription, returns only
endpoint host (never full URL)
- /api/monitoring/glitchtip/test — admin-only; sends a stub event via
sendGlitchtipEvent() using the saved
AppSettings.glitchtipDsn
- /api/monitoring/umami/test — admin-only; HTTPS-only GET against
AppSettings.umamiScriptUrl, checks
body for "umami" marker
Each route has a vitest suite covering: happy path, rate-limit denial,
missing config (422), and upstream-401 sanitisation (no sk-/Bearer/full
URL leak).
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
* fix(integration-tests): address review findings — singleton key, redirect SSRF, token refresh, scoped push
Multi-agent review caught a regression set on the five integration test
endpoints. Fixes here address every CRITICAL + HIGH finding:
- AppSettings is keyed on `id: "singleton"` everywhere else; the Glitchtip
and Umami test routes were querying `id: "default"` and would silently
return 422 in production. Tests now assert the where argument so the
typo can't sneak back.
- moodLog and Umami fetch with `redirect: "manual"` to neutralise the
redirect-follow SSRF (a public host could 302 to 169.254.169.254 or
RFC1918 ranges); 3xx is now treated as a configuration error.
- Withings test goes through the existing `getValidToken()` helper, so an
expired access token refreshes the same way the real sync does instead
of reporting "credentials rejected" until the next scheduled sync.
- Glitchtip DSN is validated as HTTPS + public host before any outbound
call, and the dispatch is wrapped in a 5 s `Promise.race` deadline.
- Withings dispatch picks up an `AbortController` 5 s deadline; the
Withings API-level `status` field (HTTP 200 + `status !== 0`) is now
categorised separately from the HTTP status (e.g. `status: 401` →
credentials_rejected, `status: 601` → rate_limited).
- moodLog test does an authenticated GET against the actual sync
endpoint instead of a bare HEAD on the configured URL — a HEAD on the
bare URL would return 200 from a generic landing page even when the
API key was invalid.
- Web Push test sends to the most recent subscription only (was: every
registered subscription, so a user with three devices got three buzzes
per click); tests assert findFirst+orderBy so this can't regress.
- Web Push response shape is the same shape on success and upstream
failure (errors land in the meta envelope, not in the visible
response body) — the prior dead-code in the 401 sanitisation test is
gone.
- Every error response now carries an `errorCode` in `meta` (e.g.
`credentials_rejected`, `rate_limited`, `dsn_host_not_public`) so the
upcoming UI can localise via `t("settings.testConnection.errors." + code)`
instead of rendering the server's English fallback.
- Outbound error annotations strip `Bearer <token>` and full URLs
before they reach Wide Events / Loki — push providers in particular
embed the subscription endpoint (which is the routing secret) in
410/404 error messages.
- Umami response read is capped at 256 KB so a hostile public host
cannot stream gigabytes through us; the marker check is tightened to
tracker shapes (`window.umami`, `umami.track`, `"umami"`) so a 404
page that says "Powered by Umami" no longer false-positives.
- `apiError` now optionally carries a `meta` object so error responses
can include the `errorCode` without breaking the existing
`{ data: null, error: <string> }` envelope.
477 tests green.
---------
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
Three small follow-ups to the v1.3.3 audit:
- src/lib/auth/__tests__/hmac.test.ts now stubs API_TOKEN_HMAC_KEY via
vi.stubEnv per test instead of mutating process.env in beforeAll, so the
variable is restored cleanly after every case (and worker leaks under
--no-isolate are gone).
- The two native-token issuance tests (password login + passkey login) now
mock hashToken as (raw) => "hashed:" + raw and assert the persisted
tokenHash equals hashToken(plaintext) instead of accepting any string.
Catches a regression where the route would hash the wrong value.
- vitest.config.mts adopts coverage.include for the full src/ tree (the
vitest 4 replacement for coverage.all=true) so untouched files surface
with 0% rather than being silently omitted, and excludes the future
tests/integration/** suite so unit-test runs stay fast.
No production code touched; suite stays at 454 green.