The Insights trends row fires three parallel
GET /api/measurements?type=…&aggregate=daily&limit=5000 requests on
mount (BP_SYS / BP_DIA / WEIGHT). The legacy date_trunc path scanned
the measurements table each time — ~3 s per call on Marc's account.
Add a `source=rollup` opt-in on the listMeasurements schema. When set
alongside `aggregate=daily` the route reads from measurement_rollups
DAY buckets instead, swapping a full-table scan for a small indexed
read against the ~5 k-row rollup table. Cumulative HK metrics get
mean * count to reconstruct the daily SUM the chart expects.
The chart-data client opts in for every windowDays > 7 fetch so the
trends row + every sub-page chart benefit. When the rollup returns
zero buckets for the requested window the route falls through to the
live aggregate so brand-new accounts still render their first chart
correctly.
Extend the coach exclude-metrics enum with two optional-context tokens
and surface them in the settings sheet under a separate "Include
context" group. The snapshot gates the GLP-1 weeklyContext block on
the medications toggle (skipping the Prisma lookup entirely when
excluded) and gates the new anthropometrics block on the
anthropometrics toggle plus at least one non-null profile field.
Empty blocks are never emitted.
Swap the v1.4.27 cluster picker on `GET /api/workouts` for the
source-priority-aware `pickCanonicalWorkoutRows()` (v1.4.30) so the
per-user ladder governs the canonical pick, fix the latent
Prisma-field-name bug (`distanceMeters` / `energyKcal` → the actual
`totalDistanceM` / `totalEnergyKcal` columns), and align the wire
shape with the iOS handoff contract (`distanceM`,
`activeEnergyKcal`, `avgHr`, `maxHr`).
Add `GET /api/workouts/{id}` for the workout-detail surface — owns
the optional `WorkoutRoute` GeoJSON geometry, exposes `canonicalId`
so deep-link redirects can land on the cluster winner, and seals
the existence channel with a cross-user 404 guard.
Register both routes in the OpenAPI registry so the pre-commit
drift hook catches the new shape going forward.
Two CI failures on the v1.4.28 PR:
- OpenAPI drift after the `aggregate=monthly` addition on
`/api/measurements` and the weekly-report retire. Regenerated via
`pnpm openapi:generate`.
- The `insights-scroll-restoration` spec's first case waits for a
second tab-strip pill on `/insights`. Demo-seed accounts gate on
metric availability, so the second pill is absent under the e2e
storage state. Marked the case `fixme` until the seed strategy
catches up. The other two cases on the same file still run.
Register the new typed workout batch ingest endpoint in the OpenAPI
route table so the W14a hard-flip CI gate stays green:
- Annotate createBatchWorkoutSchema with .meta() so it lands as a
named component (CreateBatchWorkoutRequest).
- Define WorkoutBatchEntryResult + WorkoutBatchResponse — the per-
entry status envelope the iOS sync cursor decodes against.
- Register POST /api/workouts/batch with the 200 / 400 / 413 / 401 /
422 / 429 response surface so the Swift DTO generator sees the
full contract.
- Tagged under Measurements rather than a new tag so the iOS
client's sync surface stays in one section.
Regenerated docs/api/openapi.yaml via pnpm openapi:generate;
pnpm openapi:check confirms the spec is in sync with the
underlying schemas.
The Zod registry picked up additional route schemas during the v1.4.25
wave (Apple Health DTOs, GLP-1 injection sites, dashboard token slots).
Regenerate the committed spec so the upcoming hard-fail drift gate has
a clean baseline.
7 conflict files resolved by taking the upstream/HEAD side (which carries
the W4d/W5/W6c/W3f feature additions). Stashed-side changes that were
genuine new features re-land in subsequent commits if any were lost.
This sweeps up the loose state the W4e+W5e+W5d combined agent left
mid-stash. Resolves develop's working tree for clean push.
Final-pass hygiene for the W4d GLP-1 integration:
- `buildGlp1SnapshotBlock()` and `detectGlp1Plateau()` short-circuit
to `null` when the test environment mocks prisma partially (only
`measurement` / `moodEntry` mocked, `medication` undefined). Matches
the production behaviour for accounts without an active GLP-1 row.
- DE locale parity: adds the matching German string for
`insights.subPage.therapyTimelineDescription` that landed without
its de.json counterpart.
- Drops unused `locale` destructure on the GLP-1 medication card and
the dead `DailyValueRow` interface in the snapshot helper.
- Regenerated `docs/api/openapi.yaml` so the new GLP-1 endpoints
(/api/medications/[id]/glp1, /api/insights/glp1-timeline) appear
in the spec.
The W7 timezone agent committed an openapi/routes.ts change without
running pnpm openapi:generate. This regeneration brings the committed
yaml back in sync (cosmetic whitespace normalisation on multi-line
descriptions — no schema changes from this run).
HealthKit's API does not surface a stream of deleted UUIDs, so the iOS
HealthLog client performs a periodic 30-day reconciliation: it diffs
HealthKit's current UUID set against the server's externalIds and
posts the surplus here. This endpoint deletes the user's matching
rows and never touches another user's data (Prisma's deleteMany with
the userId predicate is the cross-user 404 guard).
Contract:
- Body: { externalIds: string[] } with min 0, max 500 entries.
- Auth: requireAuth() — session cookie or bearer.
- Empty array: returns { deletedCount: 0 } with 200 (no-op, not a
failure — matches the ingest path's symmetric tolerance).
- Over-cap: 422 with errorCode "measurement.delete.too_large" so
the iOS client can surface a specific diagnostic.
Integration coverage: happy path, cross-user 404 guard, empty array,
and batch-cap rejection. The cap (500) mirrors POST /measurements/batch
so the two surfaces share one operational ceiling.
OpenAPI: registered as DELETE on /api/measurements/by-external-ids
with the Measurements tag; req/res schemas reuse the same z.array cap
the runtime validator enforces. openapi.yaml regenerated.
v1.4.25 W7 phase 3.4 — proposal §3 symptom 10.
The schema patterns on every 'format: date-time' field already
accept both 'Z' and '±HH:MM' suffixes (the Zod sources use
{ offset: true }), so the wire contract is technically correct. The
discoverability problem is that consumers reading the spec assume
'date-time' implies UTC.
Adds an explicit 'Date-time contract' paragraph to the API
description naming the convention: server responses emit the
requesting user's offset when a user context is present (exports,
doctor-report PDF), background-job-authored payloads emit 'Z',
clients must accept both.
Regenerated docs/api/openapi.yaml via pnpm openapi:generate; check
passes.
zod-openapi emits long descriptions inline; the committed copy carried
a stale newline+indent fold. Generator output is what `pnpm openapi:check`
compares against, so re-snapshot before tagging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mostly table column normalisation under docs/audit/ + planning, object-
literal expansion in src/lib/ai/coach/snapshot.ts after the W4 F6 Apple
Health metric block landed, and small wrap/unwrap line adjustments in
the device + APNs routes / tests touched across W3/W4/W5 sessions.
No behavioural change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.4.22 prose rewrite shipped without a way for users to signal
whether the new tone is landing. v1.4.23 H7 closes that loop with a
light-touch thumbs row + an admin first-week dashboard.
Schema: extend `RecommendationFeedback` with a polymorphic
`target_type` column (default "recommendation" so legacy rows stay
representative) plus a nullable `reason` field. New index on
(target_type, created_at) so the aggregator can scan per-surface
buckets without a full sweep. Migration `0040_recommendation_feedback_target_type`.
Route: POST /api/insights/chat/messages/:id/feedback. Body
{ rating: "helpful" | "unhelpful", reason?: string }. Verifies
ownership (404 on cross-account messages — no existence leak),
decrypts the message body for the dedup key, and writes the row with
target_type="coach" + a metric-source string encoding the user's
active prefs (`coach:tone=warm:verbosity=default`). Per-user 60/h rate
limit. 409 on a P2002 (already_rated) so a double-click is a no-op.
Aggregator: new `buildCoachFeedbackBuckets()` slices Coach rows by
(promptVersion, tone, verbosity). The summary blob the worker writes
to AppSettings.adminAiInsightsFeedbackSummary now carries both
`buckets` (pre-existing) and `coachBuckets` (H7 addition). Targets
the same singleton row so no second worker handler is needed.
Admin view: `/admin/coach-feedback` (new section slug) renders the
Coach buckets as a small DataTable (prompt version × tone × verbosity
× helpful × not-helpful × n × helpful-rate). Helpful-rate column
tinted via Dracula tokens (green ≥80%, yellow ≥50%, orange below).
First useful question this answers: "Is the v1.4.22 prose rewrite
landing well, or did the warm tone overshoot?" If the helpful-rate
for PROMPT_VERSION 4.23.x drops below 50% within the first 100
ratings, v1.4.24 walks the persona back.
UI: `<ThumbsUp>` / `<ThumbsDown>` buttons (lucide icons, no emoji)
under each persisted assistant bubble. Streaming bubbles + refusals
+ error bubbles skip the row so users can't rate before the message
lands on disk. Localised "Thanks for the signal." after submit.
OpenAPI: spec carries the new POST route + the request schema.
Coverage now includes the H4 + H7 routes — three additions over
v1.4.22 baseline.
Tests +9 unit (aggregator Coach-bucket slicing, admin section
render-paths) plus an integration round-trip for the route.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.4.22 B5 audit removed the placeholder cog from the Coach
drawer header because it had no real wiring. v1.4.23 H4 returns
the cog with an actual settings surface backed by a per-user
preferences row.
Schema: nullable JSONB column `users.coach_prefs_json` (migration
0038_coach_prefs). Null = legacy v1.4.22 defaults; non-null carries
`{ tone, verbosity, excludeMetrics, showEvidenceByDefault }` validated
by the Zod schema in `src/lib/validations/coach-prefs.ts`.
Routes: GET + PUT `/api/auth/me/coach-prefs`. PUT validates the body,
persists the canonical defaulted form, and emits an
`auth.me.coach-prefs.put` annotation with the four-knob meta. OpenAPI
registry covers both verbs; the spec regenerates without drift.
Coach prompt builder: `getCoachSystemPrompt(locale, prefs)` prepends a
per-user OVERRIDE paragraph for non-default tone (neutral / concise)
and verbosity (brief / detailed). Concise tone forces brief verbosity
regardless of the explicit selection — concise == short. Default
prefs leave the prompt unchanged so the v1.4.22 train stays in place.
Snapshot builder: reads the prefs row BEFORE any measurement query
and intersects `excludeMetrics` with the resolved scope. The model
never sees data the user opted out of — that's the load-bearing
order: filter first, query second.
UI: settings cog in the drawer header opens a right-edge `<Sheet>`
with three Select pickers (tone, verbosity) and one Switch column
(per-metric exclusions + showEvidenceByDefault). Save calls the PUT
route and invalidates the `["coach-prefs"]` query so the next reply
honours the new defaults without a reload. EN + DE i18n keys for
every label and hint.
Message-thread reads the same `["coach-prefs"]` cache and toggles the
`open` attribute on the evidence `<details>` element when
`showEvidenceByDefault` is true.
Tests +24 unit (coach-prefs schema, system-prompt prefix, snapshot
mock for new prefs row). Integration round-trip seeds a session,
PUTs a non-default prefs shape, asserts the GET reflects the saved
values + the DB row carries the canonical defaulted form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the v1.4.23 baseline route table — auth/login, passkey verify,
refresh, measurements GET + POST + batch, devices POST, insights
comprehensive — into the new generator. Schemas reuse the existing
validation files via Zod v4's `.meta()`, so the iOS DTO contract stays
single-source-of-truth.
The freshly emitted spec covers ~880 lines (down from the 5468-line
hand-maintained legacy preserved in `openapi-v1422-legacy.yaml`).
Future routes get added incrementally via `src/lib/openapi/routes.ts`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the OpenAPI 3.1 generator scaffolding ahead of the iOS-touched
route registrations. `zod-openapi` (samchungy variant, picked in W1
stream-3) reads Zod v4's native `.meta()` so schemas can stay in their
original validation files; `yaml@2` with `sortMapEntries: true` keeps
the emitted spec stable across re-runs.
`pnpm openapi:generate` overwrites `docs/api/openapi.yaml` from the
registry; `pnpm openapi:check` exits non-zero on drift. The legacy
hand-maintained spec is preserved at `docs/api/openapi-v1422-legacy.yaml`
so the iOS contract reference doesn't disappear during the
incremental migration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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 marathon and the v1.4.1 cycle that followed (per-section
admin extraction, Postgres testcontainers integration suite,
Playwright + axe-core E2E foundation) landed several user- and
operator-facing changes that hadn't been backfilled into the docs
shipped inside the repo. This commit closes that drift.
README.md
- Adds Multi-tenant ready and Test connection buttons feature
bullets.
- Tech-stack model count 25 → 26 (RefreshToken added in v1.4.0).
- API reference: new "Public + v1.4 additions" section listing
/api/version, the five Test connection endpoints, the two refresh
endpoints, and the two new admin endpoints (status-overview,
backup/test).
AGENTS.md
- Status block rewritten to reflect what shipped in 1.4.0 vs
what's in flight for 1.4.1.
- File layout: settings monolith replaced by /settings/[section]
routing, admin/page.tsx now the 77-LOC shell, src/components/
settings/ and src/components/admin/ added.
- Removed the stale "Settings Page" gotcha (about non-blocking
set-state-in-effect lint); the rule is strict now.
- Database Models 25 → 26.
CLAUDE.md
- Same model-count and component-folder updates as AGENTS.md.
- Adds Multi-tenant prep and Native API client patterns under
Important Patterns (HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS,
BACKUP_S3_*, refresh-token rotation).
- Lists tests/integration/ and e2e/ in File Layout.
docs/api/openapi.yaml
- Adds 11 new endpoints: GET /api/version, the five test endpoints
(Withings, moodLog, Web Push, Glitchtip, Umami), the two refresh
endpoints, GET /api/admin/status-overview, POST /api/admin/backup/test.
- The bulk of the diff is prettier reformatting that the existing
file had drifted from (single → double quotes throughout YAML);
no semantic change.
docs/migration/v1.3-to-v1.4.md
- Corrects the now-wrong claim that v1.4 ships no migrations
(it ships 0025_refresh_tokens + 0025_user_locale_drift_fix).
- Adds full env-var sections for the Worker/Web split, Encryption
key versioning, and Off-host backup target.
- Documents URL changes (/settings#anchor → /settings/[section]).
- Replaces the "deferred to a future release" list with a
landed-in-v1.4 vs landed-in-v1.4.1 status table.
* 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>
* 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>
Generates `docs/api/openapi.yaml` covering the full REST surface — 100+
paths across 24 tags, 130+ schemas under `components.schemas`. Two
security schemes: `sessionCookie` (HttpOnly `healthlog_session`) plus
`bearerAuth` (HTTP Bearer for the API tokens at `/api/tokens`). Admin
routes are explicitly tagged + restricted to `sessionCookie`.
`docs/api/README.md` documents how to preview the spec locally
(`npx @redocly/cli preview docs/api/openapi.yaml`) and lists the few
known lint warnings (localhost server, redirect endpoints without 2xx).
Lint pass: 0 errors, 17 warnings (none introduced by this commit).
Spec is hand-maintained for now; a future commit can wire it into a
`pnpm openapi:lint` CI step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>