`z.string().max()` counts JavaScript string units (UTF-16 code
units), not bytes. The audit-table guarantee for SB-10 consent
artefacts is byte-bounded — a UTF-8 artefact full of multi-byte code
points (CJK, emoji) would otherwise slip past the 64 KB row budget.
Replace the `.max()` with a `.refine()` that uses
`Buffer.byteLength(value, "utf8")`.
Closes security M1 from the v1.4.40 QA round.
Adds the persistence layer for the AI consent audit trail required by
App-Store Guideline 5.1.2(i) and GDPR Art. 7:
- src/lib/validations/consent.ts — Zod schemas with a three-kind
discriminator (ai_full / ai_insights_only / ai_coach) and a 64 KB cap
on the opaque artefact so a misbehaving client can't fill the audit
table with multi-megabyte rows.
- src/lib/consent/receipts.ts — append-only helpers: createReceipt,
latestActiveReceipt, latestActiveReceiptsByKind, revokeLatest.
Revoke flips a single field on the existing row; a re-grant always
mints a fresh row, keeping the historical chain intact for legal
review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /api/measurements per-day drill-down branch clamped `limit` to
`Math.min(limit, 1000)` after the request reached the route. Callers
asking for more saw no response signal — the schema-declared 5000
ceiling silently capped at 1000. Move the cap into the validator's
.refine on (limit, dayKey) so the boundary returns 422 with an
actionable message. The route now reads `limit` straight through
because the validator has already clamped it.
The dayKey validator regex matched any YYYY-MM-DD string — including
impossible dates like 2026-02-30 or 2026-13-01. `new Date()` silently
overflows those values into the following month, so the drill-down
returned rows from a different calendar day than the user asked for.
No 422 was emitted; the response was syntactically well-formed and
pointed at the wrong day. The same helper feeds the admin drain
route, so a malformed CLI invocation had the same blast radius.
Add a refine that round-trips the parsed Date back to ISO and
rejects when the slice no longer matches the input.
The groupBy=day collapsed branch runs the per-user-day bucketing AFTER
the per-sample findMany, so it can't honour a non-zero offset without
real Postgres-side date_trunc grouping plus a distinct count — both
v1.4.38 backlog items. The dayKey drill-down has the same shape: it
returns a single bounded page, not a cursor.
Both branches previously accepted offset silently and hard-coded
offset:0 in the response meta, so a consumer threading the v1
pagination contract (admin tools, any future iOS list view) would
paint "showing 1-25 of N" with the wrong N. Reject the combination
at the validator instead with a clear 422.
The doctor-report PDF is HealthLog's flagship "data out" path. The
v1.4.16 phase B7 layout treated it as one of five equal cards in the
grid; this change lifts it into a hero card at the top of
`/settings/export`. The remaining four export cards (Measurements CSV,
Medications CSV, Mood CSV, Full JSON backup) move under a dedicated
"Weitere Export-Optionen" / "Other export options" sub-heading.
The hero reuses the `hero-gradient + glow-purple` visual treatment from
the Insights hero strip, so the surface feels consistent across the
app. The CTA routes through the existing `<DoctorReportDialog>` +
`/api/doctor-report` flow — only the framing changed. Mobile-first: at
< sm the CTA falls onto its own row and clears the 44 px WCAG touch
floor (`h-11 sm:h-10`); the button carries an `aria-describedby` link
to the value statement so screen readers announce the framing
alongside the action label.
Run prettier --write on the four files the v1.4.37 W3 commits
touched (the schema, the route, the V2 component, and the two test
files). Drive-by also collapses two pre-existing whitespace drifts
in the InventoryItem type aliases and the glp1PostBodySchema refine
call so the file matches the formatter contract end-to-end. No
behavioural change.
Extend the list endpoint with an optional `status` query knob —
`all` (default), `taken`, `skipped`, `completed` — so the detail-page
intake history can hide unconfirmed rows server-side without changing
the byte-stable contract the iOS Swift client and the existing
dashboard tiles depend on. `completed` (taken OR skipped) covers the
detail-page surface; `taken` / `skipped` are there for any future
single-arm consumer. Co-located route test pins the Prisma where
fragment per branch and the 422 on unknown values.
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.
Operator-side kill matrix for every LLM-driven surface. Master flag
`assistantEnabled` plus five sub-flags carve the seven surfaces:
Coach + chat, Daily Briefing + advisor, per-metric status cards,
correlation narration, Health-Score delta explainer. Defaults are
`true` so existing installs upgrade without losing affordances.
New `src/lib/feature-flags/` module exposes `getAssistantFlags()`,
`resolveAssistantFlags()` and the `AssistantDisabledError` typed
error route handlers throw to surface the 403 +
`errorCode: "assistant.disabled.<surface>"` envelope. The resolver
forces every sub-flag false when the master is off so callers never
have to compose `master && sub`.
Locks per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5:
the same matrix gates server-routed AND iOS on-device surfaces.
Migration 0065 is additive only; `app_settings` gains six boolean
columns, all default `true`, idempotent under `IF NOT EXISTS`.
R-F T1.4 + T1.5 close-out. Two new MeasurementType enum values
unlock the iOS Tier-1 surfaces Apple's own Health app shows
prominently.
Schema (migration 0064, additive):
- measurement_type gains WALKING_STEADINESS + AUDIO_EXPOSURE_EVENT
WALKING_STEADINESS — iOS 15+ Mobility daily rollup. Apple ships a
0..1 fraction; HealthLog stores 0..100 percent (same convention as
oxygen saturation + body fat).
AUDIO_EXPOSURE_EVENT — iOS 13+ category-type that fires when the
rolling 7-day average crosses the WHO 80-dBA loud-listening
threshold. Both the environmental + headphone events map to the
same MeasurementType so chart-card consumers pick them up uniformly;
the notes field carries the source token at iOS write time.
Updates ride the same release:
- Zod measurementTypeEnum gains both values
- apple-health-mapping.ts maps three HK identifiers
(AppleWalkingSteadiness, EnvironmentalAudioExposureEvent,
HeadphoneAudioExposureEvent) and removes the same identifiers
from HK_QUANTITY_TYPE_DEFERRED
- categories.ts assigns WALKING_STEADINESS to activity and
AUDIO_EXPOSURE_EVENT to hearing
- pr-direction.ts pins WALKING_STEADINESS to MAX (higher steadiness
is the achievement) and AUDIO_EXPOSURE_EVENT to no-PR (threshold-
cross flag, not goal-axis)
iOS contract: the two new enum values are net-new — iOS clients that
predate the codegen pass will not encounter them in read paths
because no source writes them yet. New iOS code paths can post the
new HK identifiers via the existing batch endpoint without further
server changes.
R-E H-5. Up to v0.4.1.1 the iOS app stored mood-entry prose as
`tags: ["note:<text>"]` because the server had no dedicated column.
The workaround contaminated the tags axis with prose the Coach
evidence shelf misread as taxonomic labels.
Schema (migration 0063, additive):
- mood_entries.note (TEXT, nullable)
Validation + routes:
- createMoodEntrySchema gains optional note (max 500 chars)
- updateMoodEntrySchema accepts note + null to clear
- POST /api/mood-entries persists note alongside tags
- PUT /api/mood-entries/[id] honours partial note updates
- POST /api/mood-entries/bulk carries note through the bulk path
Backfill script at scripts/backfill-mood-note-column.ts lifts the
legacy note:<text> tag entries into the new column. Idempotent
dry-run mode is the default; --confirm commits. Helper logic lives
in src/lib/mood/note-backfill.ts for tight unit coverage.
iOS contract: additive. Old iOS payloads (no note field) round-trip
unchanged. New iOS code paths can read or write note without
touching tags.
Push range-aware aggregation into Postgres via `date_trunc` so the
bucket cap applies AFTER bucketising, not before. A 1-year
`aggregate=daily` request on a minute-by-minute pulse account now
walks every row in the window and returns up to 365 daily buckets
instead of silently truncating to the first N raw rows.
Gate the aggregation branch on an explicit `aggregate` query param.
Callers that pass `from`/`to` without `aggregate` (the iOS path)
keep the raw `MeasurementWireDTO` wire shape — the v1.5 locked
contract stays byte-stable. The new `monthly` grain bounds
multi-year "All time" charts at 24 buckets per type.
The chart-data query in health-chart.tsx walked the full measurement
history with a `while (true)` paginated loop on every visit. For
Apple-Health-rich accounts (pulse every minute, months of backfill)
this meant tens of thousands of rows per type per page navigation,
which is the root cause behind FB-D2's "pulse insights loads forever"
report.
Derive `from` / `to` from the active range tab (plus the
comparison-baseline shift when overlay is on), pass both to
/api/measurements, and drop the unbounded paginator. The query key
re-keys on the window so a range-tab change refetches the right
slice. staleTime 60s + gcTime 5min hold the bounded payload across
tab navigation between insights sub-pages.
New `aggregate` query param + range-aggregation helper let the GET
handler downsample server-side for ranges over 90 d (one row per
day) and over 365 d (one row per ISO week). The per-request `limit`
ceiling lifts from 500 to 5000 — still bounded, but enough headroom
for an explicit from/to window without re-introducing pagination.
iOS contract — GET /api/measurements: additive. The `from`, `to`,
`aggregate` params are all optional; callers that omit them (the
iOS client, the existing measurements page) see the unchanged
response shape and the historical 500-row default cap. The
aggregated branch is only entered when both from + to are present
AND the window is wide enough for the threshold ladder to engage.
Drop fourteen exported helpers and constants with zero call sites:
countMessages, buildGeneratePrompts, _resetSafetyContractsCacheForTests,
forEachLocaleAndRule, getPulseRange, trendLinePoints,
CHART_MINI_HEIGHT_PX, formatDateWithWeekday,
resolveGeneralStatusLocale, isSubPageSlug, recordHeartbeat,
EVENT_TYPE_LABELS, cleanupExpiredRateLimits, and sleepStageEnum.
Also retire the v1.4.20 legacy insights prompt scaffold —
getInsightsSystemPrompt, InsightsOutput, and the two dead
INSIGHTS_SYSTEM_PROMPT_EN/DE constants — plus the matching vi.mock
key in the generate route test that no longer maps to a live export.
Drop the empty __testables block in glp1-snapshot.ts, the legacy
helper reference in the insight-generator JSDoc, and the two stale
v1.4.16 InsightsPageHero comments in hero-strip + its test file.
Consume the MedicationComplianceChart compareBaseline prop via an
explicit void so the destructure documents the intentional accept-and-
defer pattern instead of looking like a missed wire-up.
Every symbol verified zero-caller via grep across src/, scripts/, and
__tests__/ before drop.
Drops the hand-maintained 14-key listing on `metricPriorityObjectSchema`
and the parallel 14-key block on the top-level `sourcePrioritySchema`
flat shape; both surfaces now derive their property set from the
`SOURCE_PRIORITY_METRIC_KEYS` constant via `Object.fromEntries`. The
two listings had already drifted twice during v1.4.25 polishing
passes when a new metric class was added. Adding a metric class is
now a single-line constant edit instead of three matching listings
the lint chain doesn't enforce.
A workout with `endedAt <= startedAt` produced a non-positive
`durationSec` that downstream consumers treated as a real zero —
worst case a fastest-5km PR of zero seconds, which would lock in
until manual cleanup. Add a `.superRefine` clause on
`createWorkoutSchema` so the ingest gate refuses the row.
The PR detector stays defensive against any pre-gate row or future
code path that bypasses the schema: MIN-direction slots now always
filter `durationSec > 0` regardless of which field carries the PR
value. A MIN slot sorts ascending, so the smallest value wins — a
zero-duration row would always be picked first.
Fix-M / code-M2.
The GLP-1 convenience route shipped in W4d skipped the Zod / audit /
rate-limit triple the sibling inventory + side-effect POSTs ship with.
Hand-rolled `typeof === "number"` guards let NaN, Infinity, negative
doses, multi-megabyte notes, and 1970-era timestamps reach Prisma.
This pass adds:
- `glp1DoseChangePostSchema` + `glp1InventoryPostSchema` + the XOR
`glp1PostBodySchema` in `src/lib/validations/medication.ts`.
- A 500-char cap on `note`, 200-char cap on `reason`, 100 mg cap on
`doseValue`, integer cap on `delta`, and a +/-5-year window on
`effectiveFrom`.
- The 30/min/user rate-limit copied from the sibling routes, keyed on
`medication-glp1:post:<userId>`.
- `auditLog("medication.glp1.update", ...)` for every mutation, tagged
with the affected row id and the dose / delta values.
- A test fixture mirroring the inventory + side-effect tests with
thirteen assertions covering the new 422 paths, the rate-limit
header, and both audit branches.
`@/lib/validations/source-priority` imported `annotate` from
`@/lib/logging/context`, which pulls `node:async_hooks` via
`AsyncLocalStorage`. Any client component touching a type-only export
(e.g. `DEFAULT_SOURCE_PRIORITY` in `components/settings/sources-section`)
dragged async_hooks into the browser bundle and broke the Turbopack
build.
The validations module now exposes `registerSourcePriorityParseObserver`
with a no-op default. `@/lib/logging/context` registers the real
`annotate` callback on first server-side import, so the breadcrumb
still surfaces in ops dashboards while the client bundle stays clean.
Lock the W16b ingest contract on the W8d-authored workout schema:
- Cap route geometries at 20 000 points (covers any normal run /
ride / hike; rejects pathological payloads that would otherwise
blow the 5 MB request-body ceiling on a single workout).
- Enforce the parallel-array invariant via .superRefine() — when
route.sampleTimestamps is present its length must equal
route.geometry.coordinates.length. A desynced pair silently
poisons downstream analytics (per-sample HR / speed against the
wrong coordinate), so we hard-fail at parse time.
- Add createBatchWorkoutSchema mirroring the createBatchMeasurement
shape: { workouts: createWorkoutSchema.array().min(1).max(100) }.
Cap is an order of magnitude tighter than the 500-entry
measurements batch because each workout may carry a 20 000-point
route geometry; 100 covers a cold-start HealthKit backfill on a
healthy multi-year history.
Schema tests grow from 16 → 20 cases. The /api/workouts/batch
endpoint and Swift DTO generator both consume this contract.
`Measurement.deviceType` exists on the row, was added by the W8c
batch ingest path, but the single-entry `createMeasurementSchema`
omitted it — every single-row POST silently dropped the device tag
even when the client supplied one. Manual rows tagged with watch /
scale / ring metadata lost their attribution at the schema boundary
and showed up as unsourced.
Declare `deviceType` on the schema (optional, nullable, 1–32 chars),
thread it through both single-entry and array-body POST handlers,
and add three RTL cases (present, null, omitted) so the parsed
shape stays observable.
Two reconcile fixes on parseSourcePriority(), one observability and one
invariant guard:
- Emit a wide-event tag when the persisted JSON fails the Zod schema
so a future schema-tightening regression that silently nukes a
user's saved ladder surfaces in ops dashboards. annotate() is a
no-op outside a request context, so static settings-page renders
and unit tests stay side-effect-free.
- Deep-freeze the resolved blob. The top-level flat keys share their
backing arrays with `metricPriority` via the spread, so a caller
mutating `resolved.metricPriority.weight = […]` would silently
desync the two views. Freezing every ladder + the containers + the
resolved root surfaces the mutation at runtime in strict mode.
Locks the workout-ingest contract before the v1.5 iOS-Swift session.
v1.4.25 ships no UI surface for workouts beyond the VO2-max tile
landing in W8d.5; the table layout is in place so the iOS DTO can
be generated against it and the v1.4.26 XML / Strava workers can
target the same shape.
Schema highlights (full rationale in
.planning/research/w8d-implementation-outline.md §4):
- Typed columns for every hot-path metric (sport, duration, energy,
distance, HR avg/max/min, steps, elevation, pause). HKWorkout-
shaped 1:1, so the iOS DTO needs zero glue. Withings v2/measure
getworkouts maps cleanly modulo `hr_zone_0..3` (-> metadata).
- Open `metadata Json?` blob for the long tail (HK metadata bag,
Withings HR-zone breakdown, device bundle id).
- `sportType` is TEXT, not a Postgres enum — Apple alone carries
70+ HKWorkoutActivityType values and the tail only grows. Adding
a new sport is a Zod refinement, never a Postgres migration.
- Composite unique `(userId, source, externalId)` for ingest dedup;
Postgres NULL-distinct semantics keep manual entries non-colliding.
- WorkoutRoute is 1:1 with Workout, FK on workoutId with cascade
delete. Geometry is GeoJSON LineString in JSONB (no PostGIS
dependency — v1.4.20 set the same precedent for correlations).
Per-sample timestamps + speed/HR ride in `sample_timestamps` and
stay parallel to the coordinate array.
New module src/lib/validations/workout.ts:
- workoutSportTypeEnum: 20-member roster covering ≥98% of typical
workouts in the open-source corpora referenced in the outline.
- geoJsonLineStringSchema: RFC 7946 §3.1.4 with optional altitude
component; ≥2 points, lon ∈ [-180, 180], lat ∈ [-90, 90].
- workoutRouteSamplesSchema: parallel-array timestamp + speed + HR.
- createWorkoutSchema: full HKWorkout-shaped payload Zod for the
eventual POST /api/measurements/batch?kind=workout endpoint.
13 unit tests cover the happy path (Apple Health full payload), the
minimal-payload defaults, sport-type rejection, GeoJSON validation
(Point rejected, single-point LineString rejected, OOB coordinates
rejected), and route-sample edge cases. No ingest route ships in
v1.4.25 — the schema is the contract.
Adds three new enum values that close the iOS-17/18 HealthKit coverage
gap before the v1.5 iOS-Swift session locks against Postgres:
- AUDIO_EXPOSURE_ENV (dBA, environmental noise)
- AUDIO_EXPOSURE_HEADPHONE (dBA, AirPods listening)
- TIME_IN_DAYLIGHT (minutes/day, iOS 17+)
Wire-up touches the canonical surfaces:
- prisma/schema.prisma: MeasurementType + comment block
- src/lib/validations/measurement.ts: enum + unitMap + VALUE_RANGES
- src/lib/__tests__/measurement-type-enum-coverage.test.ts: 25 -> 28
expected types; new entries added to PDF_VITAL_EXCLUSIONS under the
existing v1.5 clinical-layout gate
- src/components/measurements/measurement-list-meta.ts: i18n keys,
Volume2 / Headphones / Sun icons, palette assignments
- src/lib/insights/chart-tokens.ts: ALLOWED_CHART_TOKENS allow-list
plus orphan-enum stripper coverage so the LLM cannot leak raw
upper-snake names into rendered prose
- messages/{en,de,fr,es,it,pl}.json: typeAudioExposureEnv,
typeAudioExposureHeadphone, typeTimeInDaylight (en/de translated;
fr/es/it/pl carry English placeholders matching their existing
convention for the W5d additions)
The task brief originally listed a fourth value (WORKOUT_ROUTE) as a
sentinel slot. Dropped here because workouts are first-class entities
landing in Migration 0053 with their own table (Workout + WorkoutRoute);
HealthKit also separates HKWorkout from HKQuantitySample. A reserved
sentinel that no real Measurement row will ever take would be a dead
slot for analytics, doctor-PDF allow-lists, and chart registries.
Layer two new optional keys on top of the existing User.sourcePriorityJson
shape — metricPriority (nested per-metric ladder, canonical going forward)
and deviceTypePriority (default ladder plus per-metric overrides). The
W5e flat top-level metric keys stay accepted as a backward-compat alias
so existing rows keep working without a DB migration or forced reconfig.
parseSourcePriority() now returns a fully-resolved shape where every
metric ladder is defaulted and the device-type axis defaults to the
documented DEFAULT_DEVICE_TYPE_PRIORITY (watch first). Two helpers
(getSourceLadder, getDeviceTypeLadder) centralise the lookup so the
picker, the UI and downstream call sites share one path.
Tests: 30 unit tests pass (up from 19). Covers both axes' parse,
fallback chain (W8c nested > W5e flat > defaults), enum guards, and
the two helper functions.
Pre-existing typecheck failures unrelated to this change:
src/lib/i18n/* and a handful of consumers expect a 'de' | 'en' locale
union but i18n config declares the 6-locale superset.
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.
Seven new enum values for v1.4.25 W5d. Source: Withings developer
guide — Data API → All Available Health Data — cross-referenced
against `.planning/research/withings-api-coverage.md` §3.
- FAT_FREE_MASS (meastype 5) — Body+, Body Cardio, Body Comp, Body Scan
- FAT_MASS (meastype 8) — same devices; mass form of BODY_FAT
- MUSCLE_MASS (meastype 76) — Body+ family
- SKIN_TEMPERATURE (meastype 73) — ScanWatch dermal reading. DISTINCT
from BODY_TEMPERATURE: surface temps run ~32 °C, core ~37 °C, so
they cannot share a bucket without corrupting analytics.
- PULSE_WAVE_VELOCITY (meastype 91) — Body Cardio / Body Scan;
indicator of arterial stiffness
- VASCULAR_AGE (meastype 155) — Body Scan; composite of PWV + age
- VISCERAL_FAT (meastype 170) — Body Comp / Body Scan; Withings'
1–12 rating scale (not a percent)
Migration 0049 is additive only — every value is unconditional, no
backfill needed. The Withings client picks these values up once the
meastype → enum mapping lands (separate commit in this phase).
Unit map + plausibility ranges updated in `src/lib/validations/
measurement.ts` so the create-measurement validator accepts the new
types end-to-end.
Phase 3 of W4d GLP-1 full integration. The form gains a small
"Treatment class" selector (GENERIC | GLP-1 injection) sitting under
the existing clinical-category dropdown. Picking GLP-1 reveals two
helpers that stay hidden for every other medication:
- "Once weekly on …" preset — a single-tap weekday row that writes
daysOfWeek=[N] + intervalWeeks=1 into the first schedule and reveals
the advanced block so the user can still tune the window. The last
weekday picked persists in localStorage so a follow-up med starts on
the same day.
- "Doses per pen / vial" optional input that powers the inventory math
(Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose
pen = 1). Leaving it blank keeps inventory tracking off.
The wire schema gains optional `treatmentClass` and `dosesPerUnit`
fields. Both default sensibly (GENERIC + null) so the v1.4.24 client
keeps working untouched.
Marc directive 2026-05-14: GLP-1 must integrate "harmonisch", not
crowd the form. The new affordances live behind the treatment-class
toggle — for every other med the form is byte-identical to v1.4.24.
`coachPrefs.defaultWindow` joins the existing prefs blob (tone,
verbosity, excludeMetrics, showEvidenceByDefault). The picker lives in
the Coach settings sheet; the drawer header carries a small pill that
overrides the saved default for the current chat only. Closing the
drawer drops the override so the next session starts on the saved
preference. Backwards-compatible: legacy rows missing the key resolve
to "allTime", matching the v1.4.24 behaviour. The chat route folds the
saved window into snapshot scope when the client doesn't supply one.
v1.4.25 W7 phase 6 — proposal §4 Option C as default-value layer.
The registration form now reads
Intl.DateTimeFormat().resolvedOptions().timeZone and sends it with
the signup payload. The server:
1. Validates the value against Intl.supportedValuesOf via
isValidTimezone().
2. On any invalid value (or absence), resolves the admin-
configured server default (AppSettings.defaultUserTimezone) and
uses that.
3. On no admin default, the resolver falls back to the
'Europe/Berlin' string, which is also the User.timezone column
default — so the worst case still produces the legacy
pre-v1.4.25 behaviour.
The Zod registerSchema gains an optional 'timezone' string field
capped at 64 chars (matches the longest IANA canonical zone
identifier plus headroom). Audit-log entry now carries the resolved
zone so an operator can spot a Tokyo user mis-attributed to Berlin
if browser detection fails.
No new tests — the existing register-route integration coverage is
thin, and the v1.4.25 W7 phase 7 integration test in
Pacific/Auckland covers the full signup → measurement → export
round-trip end-to-end.
v1.4.25 W7 phase 5 — proposal §5 admin half.
GET /api/admin/settings now returns defaultUserTimezone (null = use
Europe/Berlin fallback). PUT accepts the field:
- empty string clears the column (resolver falls back)
- non-empty must pass isValidTimezone(); otherwise 422
- successful writes invalidate the server-default resolver cache
so the registration handler sees the new value on the next
signup without waiting 60s for the TTL
The admin form gets a third row in the General Settings section
between 'default language' and the toggles, mirroring the user
picker UX (native select over Intl.supportedValuesOf, fallback
'Europe/Berlin (default)' option clears the override). 3 new i18n
keys per locale (defaultUserTimezone +
defaultUserTimezoneDescription + defaultUserTimezoneFallback);
descriptions follow proposal §5: 'Used for new accounts that don't
carry a browser-detected zone. Does not affect existing users.'
Tests: 3 new cases in src/app/api/admin/settings/__tests__/route.test.ts
covering valid IANA write, empty-string clear, and invalid-zone
rejection.
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>
Extend MeasurementType with the seven HealthKit-shaped values the iOS
batch ingest needs (HRV, resting HR, active energy, flights climbed,
distance, VO2 max, body temperature) and add APPLE_HEALTH to the
MeasurementSource enum. Sleep duration unit shifts from hours to
minutes so per-stage HKCategorySample rows persist without precision
loss; the new SleepStage enum mirrors the existing GlucoseContext
column shape and a CHECK constraint scopes it to SLEEP_DURATION rows.
The composite unique index `(user_id, type, source, external_id)`
gives the new batch endpoint an idempotent dedup key while the legacy
`(user_id, type, measured_at, source)` index continues to back the
manual UI's no-duplicate-at-the-same-wall-clock guard. NULL externalId
rows stay distinct under Postgres semantics so manual entries never
collide.
Migration is strictly additive: new enum values append, new columns
are nullable, no rows mutate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.16 phase B5e: ship the POST endpoint that backs the per-rec
thumbs-up / thumbs-down UI. Body is the four rec-attributes the
client just rendered (id, text-snapshot, severity, metric-source +
time-range) plus the boolean verdict; server-fills providerType +
promptVersion via `resolveFeedbackAttribution()` so the client cannot
poison the daily aggregator's slice by claiming a thumbs-down was
attributed to a rival provider.
Idempotent via the existing `withIdempotency()` wrapper — a retry
with the same Idempotency-Key replays the cached response with
`X-Idempotent-Replay: true`. The dedup unique key on the model
(userId, recommendationId, recommendationText) returns 409 when the
same rec-text is re-rated — that's by design, the aggregator wants
per-text signal not per-id signal so a regeneration that rewrites
the same id with new text re-rates cleanly.
The attribution helper prefers `chainProviderType` over `providerType`
in the latest insights.generate audit row so when the B5b multi-
provider chain falls through to a secondary, the feedback row
reflects the working provider not the configured one. Falls back to
"unknown" + the current PROMPT_VERSION constant when no audit row
exists yet — the aggregator buckets that separately.
Audit row: action `insights.recommendation.feedback`, details carry
the feedback id, severity, helpful, providerType, promptVersion.
Tests:
- 7 unit tests for the Zod request schema (valid path, severity
poisoning, time-range poisoning, empty fields, stringified bool,
extra-field stripping)
- 5 unit tests for `pickProviderType()` priority + fallback
- 4 integration tests: 201 happy path with provider attribution,
409 dedup, 422 validation, fallback to unknown when no audit row
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`MoodEntry.tags` is stored as a JSON-array-as-string in
`mood_entries.tags`. The backup schema accepted any string here, so a
malformed blob (or a tampered backup) would persist into the DB and
crash downstream readers that blindly `JSON.parse` it. Restore-time
validation now refines the field to one of:
- null (or omitted) — no tags on this entry
- "" — legacy moodLog wire format (treated as null)
- a JSON string that parses to an array of strings
Anything else fails Zod with a 422 from the restore endpoint instead
of bubbling out of `prisma.createMany` with an opaque message. New
unit-test file covers the three accept cases plus three reject cases
(non-JSON, JSON object, JSON array of non-strings).
Phase A5 / B-mobile CRITICAL #2: at 393 CSS px the desktop user table
truncated the role badge ("ADMI", "USE") and pushed the action column
entirely off the right edge. The destructive force-logout action was
unreachable on mobile, plus the card-header filter pills collided
with the title's count badge.
Fix:
- Header switches from horizontal to vertical stack at `< sm` so the
filter pills get their own row instead of fighting the title for
space.
- Card padding `p-4 sm:p-6` matches the api-tokens responsive
template.
- Desktop table is gated behind `hidden md:block` (instead of just
letting it overflow).
- New mobile card-list at `< md` renders each user as a self-
contained card with the username + role badge + meta line + a
flex-wrapping action row carrying all four buttons (toggle role,
edit, reset password, force logout). Nothing hidden behind a
horizontal scroll, every action stays tap-targetable.
- Action buttons extracted into a single `renderUserActions()` helper
so desktop + mobile stay in lock-step on future admin actions.
Evidence: new `user-management-responsive.test.tsx` asserts the
desktop table is `hidden md:block`, the mobile list is `md:hidden`,
the role badge appears in both layouts (no column lost), all four
ARIA-labelled action buttons are present in the mobile card, the
header stacks on `< sm`, and the card uses `p-4 sm:p-6`. 6 / 6 tests
green.
Findings ID: A5/CRITICAL-#2 (top of `.planning/phase-A5-mobile-findings.md`).
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>
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>
* 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
Closes the v3 audit "Locale Mixing" CRIT plus three of the cousin
findings the v1.4 discovery audit added:
1. **`getPhaseMessage` and `getPhaseKeyboard` were German-only.** Every
medication reminder shipped via Telegram, ntfy, and Web Push read
"Erinnerung", "Bald fällig", etc. regardless of the recipient's
locale. This is the user-facing surface most affected by the v3
"Locale Mixing" finding because the strings hit users on a 15-minute
cron, not just an admin / settings page they might never open. The
reminder worker now passes `med.user.locale` through; messages and
button labels resolve from `messages/{de,en}.json` under
`medicationReminders.*`. `callback_data` keys stay stable English
identifiers so the bot dispatcher keeps matching them across locale
changes.
2. **`/api/dashboard/summary` ships a hard-coded "Tage in Folge" label
and "Hi, X" salutation.** Both now come from
`dashboard.streakLabel` / `dashboard.greetingSalutation` resolved
server-side against the user's locale.
3. **Mixed-locale Zod messages.** `validations/measurement.ts:69` had
"Wert muss zwischen … liegen" while line 86 had "Value out of
plausible range" — the response envelope leaked German into English
sessions and vice versa. `validations/admin.ts` had four German
messages (web-push subject, Umami URL, Glitchtip DSN, GitHub
bug-report repo). All now resolve to a single English wording (the
app is English-first; the German UI translates the field labels
client-side, the server-side validation message is treated as a
developer-facing string).
Adds 13 new tests in `reminder-phases.test.ts` covering English,
German, and unknown-locale fallbacks for messages and keyboards;
updates `validations/measurement.test.ts` to match the unified English
message. The audit's remaining items
(`classifications.ts` server-returned EN category strings,
`pulse-targets.ts`, `targets/route.ts` labels,
`/api/admin/notifications/reminder-check`) ride a follow-up i18n PR —
those are admin-only or client-renderable surfaces with lower
user-facing priority.
Tests: 358 → 371 (+13). Typecheck clean. Locale-integrity test still
green.
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>
Security review of #106 surfaced two HIGH-severity gaps in the SSRF
guard. Both are documented bypass classes with public CVE precedent
(github.com/projectdiscovery/nuclei-templates' SSRF templates list
hex and IPv4-mapped IPv6 explicitly).
- Hex-notation IPv4: 'http://0x7f.0.0.1' / 'http://0x7f000001' /
'http://0xa.0.0.1' all parse to internal addresses depending on
the runtime's URL semantics. Reject the raw form before parsing.
- Decimal-notation IPv4: 'http://2130706433' is 127.0.0.1, etc.
Reject any pure-numeric host of plausible IP length.
- IPv4-mapped IPv6: '[::ffff:127.0.0.1]' and the hex variant
'[::ffff:7f00:1]' both target loopback. Match either form and
apply the private-IPv4 check to the embedded address.
- '[::]' (IPv6 unspecified, alias for 0.0.0.0) blocked alongside
'[::1]'.
Refactored the per-octet private-range checks into isPrivateIpv4()
since both the IPv4 and the IPv4-mapped-IPv6 paths now use them.
Test suite extended with regression cases for hex, decimal, and
IPv4-mapped IPv6. Now 208/208 passing.
- New unit tests for isPublicUrl covering loopback, link-local, RFC1918,
CGNAT, IPv6 ranges, and the leading-zero IPv4 octal-bypass regression
(URL parser silently turns '010.0.0.1' into '8.0.0.1' — caught now).
- Strengthened isPublicUrl: pre-URL leading-zero check on the raw input,
bracket-aware IPv6, deny on malformed IPv4 quads instead of falling
through to 'public' (better safe than sorry on user-supplied URLs).
- New unit tests for measurement validation covering the new
TOTAL_BODY_WATER and BONE_MASS types: ranges, glucoseContext rejection
on non-glucose types, and unrecognised type rejection.
Security hardening from 2026-04-26 audit:
- SEC-002: isPublicUrl() now uses a strict IPv4 parser that rejects
leading-zero forms like 010.0.0.1, which historically slipped past
the h.startsWith('10.') check. Also covers CGNAT (100.64/10),
IPv6 unique-local (fc00::/7), and additional metadata-service edge
cases. Documented as input-time only (DNS rebinding still possible
at fetch time).
- SEC-004: GitHub PAT is now redacted from logged error bodies in
publish-github.ts before flowing to Wide Events / Loki. Adds an
escapeRegex helper for safe RegExp construction.
- SEC-006: PUT /api/user/thresholds now goes through checkRateLimit
(30 writes / 5 min, per-user) — audit-log was already there but
unbounded. Makes audit-log enumeration unattractive.
- PERF-001: insights/comprehensive route now batches medication-intake
events into a single 'medicationId IN (...)' query and groups in
memory instead of N+1 per medication. Latency was scaling poorly
for users with many active meds.
- REL-003: pg-boss now installs SIGTERM/SIGINT handlers that call
boss.stop({ graceful: true, timeout: 30s }) so in-flight handlers
drain on docker stop / coolify redeploy instead of being killed.
- LOG-001: Replaced console.log/error in reminder-worker with a
workerLog helper. Lifecycle errors still go to stderr (only audience
outside any handler context); routine init messages are silent
because pg-boss own events + Wide Events from handlers carry the
state.
- CI: ESLint and TypeScript checks are now blocking — continue-on-error
removed. Tests already were. Required pre-existing 'any' types in
api-handler.ts to be properly justified with eslint-disable comments
(Next.js variadic handler signature; the bound type still constrains
return to Promise<Response>).