* chore(release): prepare v1.4.0 — CHANGELOG, version bump, migration guide
Bumps `package.json` 1.3.3 → 1.4.0 and the OpenAPI spec to match.
Adds a comprehensive user-facing CHANGELOG entry for v1.4.0 covering
the foundation, medical safety, i18n, performance, chart-math,
dashboard one-row, /api/version, AI insights memory + hero card,
onboarding redesign, and the chart-math fixes that landed across the
v1.4 marathon. Bundles the migration guide
(`docs/migration/v1.3-to-v1.4.md`) covering env vars, behaviour-only
API changes, UI/UX deltas, the medical-default updates, and
rollback steps. Updates AGENTS.md status block.
Tests: 440/440 green. Typecheck clean.
Note: a few additional v1.4 buckets (admin redesign A4, settings split
A2, multi-tenant prep G, test coverage lift H, A8 test buttons) are
landing in parallel branches and may merge after this. The CHANGELOG
will be amended on the release branch as those PRs land.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* chore(release): amend v1.4.0 changelog with everything from the finish-marathon
Adds the user-facing bullets for the settings split, the five
"Test connection" buttons, AI inline charts, off-host backup,
encryption-key rotation, worker/web split, refresh-token rotation,
admin status grid, and the operational hardening fixes (tzdata,
healthcheck, idempotency hlr_, paired access-token revoke,
users.locale migration backfill).
Replaces the "tracked for v1.4.x or later" deferred block — the
items it listed have shipped or moved to v1.4.1.
---------
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
* 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>
* docs(ui): add UI guidelines as single source of truth + shadcn MCP server
- `docs/ui-guidelines.md` lays out Dracula tokens, spacing, typography,
component picks (button/input/dialog/sheet decision tree), layout
rules (settings split target, dashboard one-row), accessibility
baseline (WCAG 2.1 AA + axe-core in CI), the autofill / honeypot
pattern for measurement forms, and a reusable "Test button" UX for
every integration. New components in v1.4 reference this doc.
- `.mcp.json` wires the official `shadcn` MCP server so the design
system can be browsed and components installed without leaving the
editor.
- Adds `@vitest/coverage-v8` to dev dependencies — `pnpm test
--coverage` now reports real per-file coverage (the v1.4 cycle uses
this to lift critical-path coverage).
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* fix(settings/ai): connection-test honours the unsaved provider selection
Changing the AI provider in /settings, then clicking "Verbindung testen"
without saving first, used to silently run the test against the *saved*
provider instead — so the result felt unrelated to what the user had on
screen. This often surfaced as a confusing OK / failure depending on
whichever provider had previously been persisted.
The test endpoint now accepts an optional JSON body that mirrors the
form fields (provider, model, baseUrl, anthropicKey, localKey). When
present, the server resolves a transient provider from those values —
falling back to the persisted, encrypted credentials per field, so a
user with a stored Anthropic key can swap models in the dropdown
without re-typing the key. Plaintext keys never persist; the existing
SSRF guard for the Local provider's base URL is reused, the existing
rate limit (5/min) and the V3 error-leak shielding stay in place.
Empty body keeps the previous behaviour for any other caller.
Tests cover the new override path (provider forwarded, unknown
provider rejected with 422, empty body uses persisted config, config
errors surface the resolver's status). Whitespace in
`src/app/settings/page.tsx` is reformatted by Prettier as a
side-effect; logic touches only the AI section.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(ui): add Skeleton and EmptyState primitives
Two pieces every page in v1.4 needs and the codebase didn't have:
- `<Skeleton>` — a presentation `<div>` with the standard pulse
animation, `aria-hidden`, and `motion-reduce:animate-none`. Used to
shape the page while data loads instead of dropping a spinner. The
visible layout no longer jumps when the first measurement arrives.
- `<EmptyState>` — the shape every list, tile, and chart panel
reaches for when it has no data: optional icon, title, description,
primary action; `card` (default) and `plain` variants;
`default`/`compact` density. Lives inside an `aria-live="polite"`
region so screen readers announce the message and its action.
Tests cover screen-reader semantics, prettier-merged class names,
density/variant switches.
These two land first because the v1.4 pages — the split settings
sections, the new dashboard hero, the redesigned admin status grid —
all assume the primitives exist. The patterns are documented in
`docs/ui-guidelines.md` §4.1–4.2.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* fix(ui): block password-manager autofill on health-data inputs by default
Browsers and password managers were happily autofilling the user's
account credentials into measurement, medication, mood, and AI-token
fields whenever those forms didn't explicitly set `autoComplete`. The
codebase had zero `autoComplete` attributes across the data-entry
surfaces, so this happened often enough to be both a UX papercut
(account password landing in a "Notiz" field) and a real privacy issue
(secrets persisting through CSV/JSON export).
The base `<Input>` primitive now:
- Defaults `autoComplete` to `"off"` when the caller doesn't supply a
value.
- Adds `data-lpignore="true"` and `data-1p-ignore="true"` whenever
autoComplete resolves to `"off"`, so LastPass and 1Password skip
the field too.
- Keeps the password manager active for any form that already passes a
semantic `autoComplete` (`"username"`, `"email"`,
`"current-password"`, `"new-password"`) — the existing login,
register, and AI-key inputs continue to work unchanged.
Tests cover the three meaningful states (default off, explicit
semantic value, login-flow `username`).
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
---------
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds
User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in
the measurements filter, badge, mobile icon, and edit dialog (issue #109).
Server-rendered doctor report now includes body composition rows (audit
C-9). Body water + bone mass have proper effective-range thresholds so
severity logic doesn't silently return `nominal` (audit C-15).
Structural: extracted the per-type display maps from measurement-list.tsx
into measurement-list-meta.ts as a deliberate first step toward the
single-source manifest planned for phase P5. Coverage tests now fail-fast
if a future enum addition skips any of: list label/icon/color, doctor-PDF
label/unit/vital-types, chart label, threshold metric order/label/bounds.
Audit-2026-05-07 / phase P0:
- closes#109 (filter UI gap, root cause for the German raw-enum display)
- closes audit C-9 (server PDF used a stale type map)
- closes audit C-15 (no warn/critical bands for body composition)
Tests: 282 → 296 (14 new). Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: align OpenAPI spec + migration comment with body-composition reality
- OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at
v1.3.0 baseline; spec drifted past the v1.3 server release)
- Bump openapi version 1.3.0 → 1.3.2 to match package.json
- Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content),
not percent of body weight. Validators (VALUE_RANGES) and the Withings
client (type 77 → kg) treat it as kg already; the comment was the lone
outlier and would mislead the next reviewer.
Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum)
and M-B2 (migration unit comment).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip)
C-3 (Withings webhook secret in URL):
Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=…
fallback retained for legacy Withings configurations and emits a Wide
Event warning so operators can spot still-using-the-old-flow integrators.
The query-string secret was leaking into Coolify access logs and any
GlitchTip event whose request.url got captured.
C-4 (idempotency dead for Bearer-token clients):
The default userIdResolver only consulted getSession(). Bearer-authed
clients (the iOS / n8n / headless-API fleet that PR #117 was built for)
fell through, so every retry created duplicate measurements. The default
now tries cookie session first, then Bearer token via
hashToken+apiToken.findUnique with revoked/expiry checks. All three
existing call sites (measurements, mood-entries, medications/intake) get
the fix automatically.
H-B7 (request.url with secrets shipped to GlitchTip):
reportToGlitchtip now strips the query string before forwarding the URL,
so Withings legacy ?secret=… and any OAuth ?code=… that errors during
the callback don't end up in someone else's incident UI.
Audit-2026-05-07 / phase P2.
Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback +
revoked/expired/null cases).
Deferred to a follow-up (needs DB migration / lock primitive):
C-2 moodLog webhook → AES-GCM at rest + HMAC-over-body
C-5 idempotency TOCTOU advisory_lock or in-flight sentinel row
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3
Closes the OxygenSaturation feature request from #109.
- Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS)
- Zod validators: plausibility 50–100% (below 50 is incompatible with
sustained life; above 100 is physically impossible)
- effective-range thresholds: green 95–100, orange 92–94, red <92.
Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice
(≥95% at rest is healthy), AHA/clinical literature (<92% = call
provider, <88% = ER). Lower-only concern — upper orange wing
collapses to greenMax since saturation cannot exceed 100%.
- Personalisation: existing thresholds-override UI lets COPD /
chronic-respiratory users with a doctor-set 88–92 baseline tune the
bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in
thresholds-section.tsx; the exhaustive `Record<ThresholdMetric>`
forces TS to catch any missed surface.
- Withings ScanWatch type 54 mapping (best-effort — only fires for
pulse-ox-capable devices).
- Wired through measurement-form, measurement-list-meta (icon: Wind),
health-chart, both doctor-report-pdf renderers (core + browser-side
near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in
4 sections × 2 locales.
- iOS DTO already declared `OXYGEN_SATURATION` — server enum addition
closes the long-standing drift flagged in audit V2.
- 4 new tests: numeric green band, upper-wing collapse,
user-override (COPD baseline), METRIC_BOUNDS shape.
Tests: 301 → 305. Typecheck: clean.
Audit-2026-05-07 / v1.3.3 / closes#109 SpO2 feature request.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(analytics): truthfulness pass on medical citations + clamp override orangeMax
Doctor-agent + hallucination-hunter audit V3 found six medical citations
in effective-range.ts that the code did not actually implement. Plus a
latent bug in the user-override path that produced physically-impossible
orangeMax values (>100% saturation, <0% body-fat).
Citation rewrites (no numeric changes):
- ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020.
WHO publishes activity in min/week, not steps. Step-count was a
misattribution that also leaked into AI prompts.
- TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson
formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance,
it does not publish kg cut-offs.
- BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance-
estimated; not DEXA-comparable". Withings BIA values typically run
5–7% below DEXA — calling them DEXA-equivalent in a doctor-report
PDF was a real legal-exposure misattribution.
- OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS
clinical practice (≥95% at rest is healthy)" → "consumer pulse-
oximeter consensus (≥95% normal at rest); slightly tighter than
BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA
pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100
band — that's an ATS/consumer-consensus number, not BTS's literal
94-98 target. Soften the citation to match the code, not vice versa.
Bug fix (1 latent, 1 real-today):
- effective-range override path now clamps orangeMin/orangeMax to
METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting
SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible
saturation), and a BODY_FAT override of {2,80} emitted orangeMin
= -9.7 (negative percent). Default-range case 210 already
hard-coded orangeMax: 100 for the same reason — the override path
was the only branch missing the invariant.
Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%).
Typecheck: clean.
Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure
Two CRITICAL fixes from audit V3.
NEW-V3-1: Bearer-scope bypass.
apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent"
scope (the iOS app receives this on login). The previous check
permissions.includes(requiredPermission)
treated "*" as a literal string — never matched. Today many sensitive
routes call requireAuth() *without* a requiredPermission, so a leaked iOS
token can act as a full-scope token (account delete, settings wipe). Once
those routes adopt requireAuth("scope:name"), this wildcard handling keeps
the iOS app working while narrower-scoped tokens (e.g. user-issued
["medication:ingest"]) get correctly 403'd.
Same wildcard handling added to the standalone permission checks in
src/app/api/ingest/medication/route.ts:65, 119.
NEW-V3-2: GDPR Art. 17 account-delete erasure completeness.
Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which
preserved PII (free-text symptom descriptions, IP addresses, login-city
geolocation) after the user was deleted. We now explicitly
prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the
cascade-delete, making account deletion genuinely complete erasure.
Tests 306/306 still pass; typecheck clean. Audit V3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(pdf): real text-content assertions for body composition + SpO2 rows
Replaces bytes-only "renders body composition rows" theatre test with
pdf-parse-driven text extraction that asserts the actual rendered labels
+ values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN).
Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified
"%PDF-" header + byte-count, so a regression that drops a row would
still pass. Now a missing label fails the test.
- adds dev dep: pdf-parse@^2.4.5
- 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums
V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays
silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective
domains. Adding a new measurement type required hunting these down.
Now all aggregation paths derive from `measurementTypeEnum.options`:
- /api/insights/comprehensive (AI insights)
- /api/dashboard/summary (iOS dashboard adapter)
- /api/analytics (analytics aggregator)
- /lib/insights/general-status (AI general-status fetch)
- /api/import (Zod schema — round-trip now covers all 11 types)
External-contract enums extended additively (no breakage for old clients):
- /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass
- /api/dashboard/widgets widgetIdEnum: + oxygenSaturation
- DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row
- formatMeasurementsForExport: glucoseContext now round-trips
Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the
canonical enum stays the source of truth and documents PDF exclusions
(BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only).
313/313 tests pass (was 308, +5 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit)
- CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was
blanket connect-src on every page including /auth/login → DOM-XSS exfil
channel)
- web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl
refinement; was z.url() only → blind SSRF probe via Push subscription
- isPublicUrl: also fixes a regression where DNS labels starting with
"fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6
unique-local. Check is now gated on a colon being present in the host.
- /api/ai/test: stop returning provider err.message + bodyExcerpt to the
client (leaked provider URLs / partial keys / internal headers). Now
returns categorised generic message; full details land in the wide-event
via annotate() for the operator.
- /lib/geo (auditLog IP geo lookup): switch default provider to
ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes,
add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs.
Previously leaked IP+timestamp over plaintext HTTP every auth event
(GDPR Art. 32 + Art. 44).
- /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector)
Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF
guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider
shapes, geo opt-out env).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening
V3 audit follow-on bundle covering 4 HIGH findings.
(I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF
trust. Reads chain right-to-left so client-supplied leftmost entries
cannot rotate the per-IP rate-limit bucket. Default 1 matches typical
single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces
x-real-ip / null. New 7-test suite covers the rotation attack +
malformed entries + zero-hop fallback.
(F) Audit-log retention job — daily 03:15 cleanup of audit_logs older
than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e)
"storage limitation" gap; previously IPs + city + login events
accumulated forever. 6-test suite covers default + override +
misconfig guard (rejects <7 days).
(G) isCachableStatus — extracted the inline 4xx/5xx filter into a
named, exported, tested function. 7 cases cover the full do-not-cache
contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx
validation are still cached.
(H) Bearer-mock tightening — apiToken.findUnique mocks in
require-auth-bearer.test + idempotency.test now assert the
where.tokenHash shape uses the hashed value (not the raw bearer).
A regression that switched back to raw-token comparison would have
been silent without this assertion.
Tests: 345/345 pass (was 332, +13 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2)
The mood_log_webhook_secret column previously stored a plaintext secret
that the webhook handler compared timing-safe against every enabled
user. A DB dump or backup snapshot leaked active webhook credentials.
New contract — no schema migration required:
- Writes go through encryptMoodLogSecret() (AES-256-GCM via existing
crypto helper, ENCRYPTION_KEY).
- Reads go through readMoodLogSecret() which transparently decrypts
envelopes and tolerates legacy plaintext rows during the transition
(graceful upgrade path — no breaking change for self-hosted users).
- Webhook lookup decrypts each candidate before timing-safe compare.
- Status endpoint decrypts for the user's settings page.
One-shot startup migration in reminder-worker rotates any leftover
plaintext rows on next worker boot. Idempotent; encrypted rows are
skipped.
Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough,
isLegacyPlaintext semantics, rotateLegacy idempotence).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift)
V3 audit Doku Specialist findings — drift between code and meta-docs.
- README §Key Features now mentions pulse oximetry (SpO₂) + Withings
ScanWatch sync; Tech Stack model count corrected 23 → 25.
- AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected
`vitest.config.ts` → `vitest.config.mts`; migration range corrected
`0001–0022` → `0001–0024`; model count + list updated to 25 (adds
Device, IdempotencyKey).
- CLAUDE.md model count updated (2 occurrences).
- CHANGELOG.md v1.3.3 expanded with Security + Internal sections that
were missing the audit-fix-marathon work (Bearer-scope, account-delete
cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo
HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF,
audit-log retention, idempotency cachable filter, Bearer mock
tightening, enum-drift cousins, PDF text-content tests).
- OpenAPI Bearer description corrected `SHA-256 hashes` →
`HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs
match the actual server-side implementation. Wildcard scope contract
documented.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass
Multi-agent review of the audit-fix branch surfaced four HIGH issues
and a couple of MEDIUM hardenings. All addressed before merge.
HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk).
The dashboard-summary route now emits totalBodyWater/boneMass/
oxygenSaturation MetricCards, but the spec still listed only the
original 7 kinds. Generated iOS Codable would have crashed the dash
the moment a user logged SpO2. MetricKind extended with the 3 new
kinds + comment documenting unknown-value defensive decoding.
HIGH-2 — kindEnum naming consistency on /api/measurements/series.
The series adapter shipped "oxygen" while the dashboard summary used
"oxygenSaturation"; both routes now use "oxygenSaturation" so iOS
decoders can share a single MetricKind type across endpoints.
HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that
mock provider errors with sk-leaked-key + provider URLs in the err
fields and assert the response body never echoes them, plus the
3-status-categorised generic messages (401/429/5xx/other).
HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that
mock checkRateLimit allowed/disallowed and assert the 429 path uses
the right key shape (`import:<userId>`, 5, 1h) and that the within-
quota path proceeds.
MEDIUM-3 — getClientIp under-length XFF chain regression. With
TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp
returned the leftmost (attacker-controlled) entry, silently
re-introducing the rotation attack the helper was meant to close.
Now: when chain is shorter than configured hops, refuse XFF entirely
(fall back to x-real-ip / null). Added regression test.
LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of
silently degrading to hops=0. Added regression test.
Tests: 358/358 pass (was 350, +8 new). Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hotfix release closing #108 — the dashboard glucose tiles rendered the
raw i18n key, and the bug-report screenshot upload promised a feature
that never reached the published GitHub issue.
The ${VAR:?error} syntax in docker-compose.yml broke Coolify-style
deploys: that platform parses the compose file eagerly and stores the
fallback error string as the env-var value when the var is unset, which
then propagates into DATABASE_URL and breaks the running app with
P1000 auth failures.
Switched to plain ${VAR} interpolation. Validation moves into
docker-entrypoint.sh, which checks DATABASE_URL, SESSION_SECRET,
ENCRYPTION_KEY, API_TOKEN_HMAC_KEY before connecting to the database
and exits with a deploy-platform-agnostic error listing the unset vars.
Bumps version, drafts CHANGELOG body, and adds total-body-water /
bone-mass mention to README's Key Features list. Tag will be created
post-merge to trigger the GHCR multi-arch build.
package.json was still on 1.1.0 even though CHANGELOG and the released
features (Custom Thresholds, Glucose, Dashboard Customization,
Built-in Feedback, Multi-Provider AI) shipped as 1.2.0 on 2026-04-18.
CLAUDE.md and AGENTS.md said 21 / 22 models in different places; the
schema actually has 23. Feedback (added in v1.2 for the in-app
bug-report flow) was missing from the model list in AGENTS.md, and
AGENTS.md still claimed status "Feature-complete (v1.0.0)".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>