The wizard's "✨ Beschreiben" button now opens the extractor on the
production route. The overlay merges its result onto the wizard
payload; one-shot prefills clear timesOfDay so the user has to pick
the dose time explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The flat form's oneShot switch now drives the picker's allowed kinds
directly; the picker can no longer encode a contradictory shape.
Multi-schedule medications surface a confirmation when the switch
flips on, since the existing logic drops every schedule but the
first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The progress chip now matches the path the user actually walks (5
steps for one-shot, 6 for recurring after dropping the empty "day
detail" step). The cadence picker no longer offers a one-shot radio
once the mode picker has settled on recurring. The summary line on
the final step interpolates the actual weekdays / day-of-month /
yearly date rather than reading a category label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets the wizard and the edit form constrain the radio list so the
one-shot kind never appears alongside a medication-level switch
that already encodes it. The unfiltered shape is preserved for
callers that don't pass the prop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the v1.5 Coach-backed extractor route into the OpenAPI
registry with field descriptions covering the citation-guarded
return shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts the lifecycle hook into reconcileOneShotState and wires it
into POST, PUT, and DELETE on the intake event. A user who logs the
single dose then undoes the log gets the medication back; a user who
flips skipped does too. The hook is idempotent on non-one-shot
medications.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The legacy fallback now honours medication.startsOn the way every
other dispatch tier does, so a legacy-shape schedule with a future
startsOn no longer emits historical slots. Adds a MAX_CHUNKS cap on
the forward walk for defence-in-depth, surfaces RRULE parse errors
through annotate, and stops the engine from compounding the user's
UNTIL/COUNT with its own UNTIL suffix. The header doc-comment is
rewritten to reflect that the read-flip for the legacy walkers is
the v1.5.1 release, not this one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three Zod refines that close gaps the audit surfaced: one-shot
medications now require a startsOn, course windows refuse endsOn
before startsOn, and rolling-cadence schedules cap timesOfDay at one
entry. The wizard's reminders toggle is now part of the create
payload so the user's choice actually ships.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Populates messages/{de,en}.json with native copy for every key the
new picker primitives, the wizard, the edit-form refactor, and the
plain-language summary consume. The remaining four locales ship the
English fallback for v1.5.0; native polish lands in a follow-up.
CadencePicker rolling-explainer key flipped to `kind.rollingExplainer`
so the i18n resolver can return both the rolling label and its
explainer (the original `kind.rolling.explainer` path collided with
the `kind.rolling` string leaf — JSON cannot host both shapes on the
same prefix). Adds the dose-unit SI tokens (mg / g / ml) to the
placeholder-allowlist in the locale-integrity guard since those
labels are intentionally identical across every locale.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an optional Coach-backed overlay that extracts a structured
medication payload from a free-text description so the wizard can
pre-fill its steps. The route is rate-limited and budget-gated; the
prompt carries a citation-coverage guard so the extraction cannot
hallucinate a name + dose pair the user did not write.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a step-driven create flow that composes the cadence picker, the
times-of-day chips, and the course-window row. Surfaces a placeholder
slot for the natural-language extraction overlay that lands separately.
Pure helpers (validateStep, buildCreateBody, summariseCadence) carry
the test surface; the interactive flow is covered by Playwright in a
later commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the legacy weekday grid in favour of the cadence picker, the
times-of-day chips, and the course-window row. Pre-v1.5 medications
read-fall-back through inferCadenceFromLegacy; writes go to both
shapes so the v1.5.x dual-write window stays intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Annotates the medication Zod schemas with the metadata the OpenAPI
generator consumes and wires the seven medication routes into the
registry. The XOR constraint between rrule and rollingIntervalDays
is documented at the schema description so iOS code-gen surfaces the
mutual exclusion.
Routes covered: list + create on /api/medications; read + replace +
delete on /api/medications/{id}; intake log on
/api/medications/{id}/intake; cadence + compliance read on
/api/medications/{id}/cadence.
Medication is registered as a top-level component so the iOS Swift
codegen produces a shared struct backing the list and detail variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three components plus a shared `types.ts` module under
`src/components/medications/scheduling/`. The wizard + the form
refactor consume them; the picker emits a `CadenceValue`
discriminated union that the form maps onto the API request body.
### CadencePicker
A grouped flat radio list with 8 cadence kinds:
- Every day → `FREQ=DAILY`
- Certain weekdays (chip-toggle row) → `FREQ=WEEKLY;BYDAY=...`
- Every N weeks on certain days → `FREQ=WEEKLY;INTERVAL=N;BYDAY=...`
- Monthly on day → `FREQ=MONTHLY;BYMONTHDAY=N`
- Every N months on day → `FREQ=MONTHLY;INTERVAL=N;BYMONTHDAY=M`
- Yearly on date → `FREQ=YEARLY;BYMONTH=M;BYMONTHDAY=D`
- Rolling N days from last intake → `rollingIntervalDays: N`
- One-time dose → `oneShot: true`
The "Every N days flexible" option carries an explainer line so
the GLP-1 patient understands the rolling semantic. Defaults are
chosen so the encoded RRULE is always parseable (weekday set
defaults to Monday, yearly clamps to Jan 1) — the wizard / form
gates "continue" until the user picks the real day.
Sub-controls state is owned by the parent (the picker accepts
`subControls` + emits via the second `onChange` arg) so a wizard
that re-enters step 3 sees the user's previous picks.
### TimesOfDayChips
Multi-chip HH:mm input with morning / noon / evening / night
presets. Dedupes + sorts. `maxChips` prop honours the Zod cap of
8; when the parent passes `maxChips: 1` (one-shot context) the
component renders a single time picker instead of a chip row.
### CourseWindowRow
Two date pickers (`startsOn` + `endsOn`) with a "No end date"
toggle. `lockEndsToStart` prop forces the one-shot case where
both pickers share a date. Validation message rendered below the
row when `endsOn < startsOn`.
### Quality
- "use client" on every component file.
- shadcn/ui-only — no new deps, no shadcn Calendar primitive in
this repo so date pickers fall back to `<Input type="date">`.
- WCAG 2.5.5 tap targets (44 px) across every interactive
element; focus rings, aria-labels, aria-pressed on toggles,
role="group" on chip rows.
- 49 tests across the three components (encoder mapping, sort /
dedupe / preset toggle, range validation, SSR-only render
smoke tests). Pure helpers exported so the contract is pinned
at the unit-test level; user-event paths land in Playwright
e2e per the synthesis quality bar.
i18n keys listed at the top of each component file under
`medications.scheduling.{cadence,timesOfDay,courseWindow}.*`. The
follow-up i18n agent populates DE + EN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Zod validators + the engine + the worker accept the new
v1.5 fields. The API route handlers didn't — they silently dropped
`startsOn` / `endsOn` / `oneShot` / `timesOfDay` / `rrule` /
`rollingIntervalDays` / `reminderGraceMinutes` before the Prisma
write. This commit threads them through.
### Route invariants
- One-shot consistency: `oneShot === true` ⇒ at most one schedule
(422 otherwise), that schedule has neither `rrule` nor
`rollingIntervalDays`, and `endsOn` is auto-normalised to equal
`startsOn` on write.
- Recurring default: when `oneShot !== true` and the incoming
schedule has no `rrule` / `rollingIntervalDays` / legacy
`daysOfWeek`, the route stamps `rrule = "FREQ=DAILY"` so the
worker always sees a clean shape.
- Backwards compat: empty / absent `timesOfDay` is server-side
populated from `windowStart` so the engine always sees a
non-empty array, and legacy iOS v0.6.x clients that still post
the `windowStart` / `windowEnd` / `daysOfWeek` shape keep
working unchanged.
### Tests
9 new cases across both route test files (7 POST + 3 PUT, one
POST as a 7b subcase): one-shot create persistence, RRULE
persistence, rollingIntervalDays persistence, one-shot +
recurrence → 422, one-shot + multiple schedules → 422, PUT
endsOn updates, legacy POST back-fills timesOfDay, legacy POST
with no recurrence hints stamps daily.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A "one-shot" medication is a single-administration course (flu shot,
vaccine, post-op final dose, one-time injection). The v1.5 schema
adds the `Medication.oneShot` flag; the intake route is the place
the lifecycle bottoms out.
When a one-shot's intake row is committed AND the event is an actual
take (`takenAt !== null && skipped === false`):
1. Flip `medication.active` to `false`.
2. Audit-log `medication.one_shot.completed` with the intake id and
the medication id.
3. Annotate the wide-event so operators can see one-shot completions
land cleanly.
4. Invalidate the medication-detail cache so the UI sees the new
`active: false` on the next read.
A "skipped" event on a one-shot does NOT deactivate the medication.
The patient is saying "I didn't take this dose"; the medication
stays open until the real take is logged. Pinned by a test in the
new integration suite.
The deactivation runs AFTER the existing
`recomputeMedicationComplianceForEvent` call so a rollup-recompute
failure can't block the medication-lifecycle close.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes that ship as one because they share a single rewrite of
the worker's schedule-resolution site:
### The bi-weekly fix
`grep intervalWeeks src/lib/jobs/reminder-worker.ts` used to return
zero hits. The worker iterated `recurrence.daysOfWeek.includes(
todayDow)` and called it good — a bi-weekly Wednesday schedule
fired EVERY Wednesday, regardless of "on" vs "off" week. The
cadence chart respected the interval; the worker did not. This is
the single line at `reminder-worker.ts:514` and it's been wrong
since `intervalWeeks` was introduced.
The fix routes the same predicate through the new canonical engine
(`src/lib/medications/scheduling/recurrence.ts`). The engine
honours INTERVAL natively for RRULE schedules and honours the
legacy `intervalWeeks > 1` arithmetic on the fallback path. Both
paths now agree with the chart.
### New cadence types
The same rewrite picks up monthly / quarterly / yearly / rolling /
one-shot for free because the engine dispatches on the
schedule's shape. The worker doesn't special-case anything — it
just asks the engine.
### Rolling needs a `lastIntakeAt`
Rolling schedules anchor next-due on the latest
`MedicationIntakeEvent.takenAt`. The worker now does a conditional
findFirst on the medication's intake stream when the schedule list
contains a rolling row. The lookup is gated on
`med.schedules.some(s => s.rollingIntervalDays !== null)` so the
calendar-cadence common case keeps its single-query hot path.
### The seam — `worker-helpers.ts`
A small module exports `buildCanonicalSchedule`, `buildRecurrenceContext`,
`scheduleEmitsInWindow`. Keeps the worker file focused on phase math
and dispatch (it's 2347 LOC; a deeper rewrite is for another release)
and gives the integration tests a clean place to exercise the
schedule-resolution path with real Postgres rows.
### Dead code retired
- `todayDow` (line 492) — replaced by the engine's per-instant check.
- `getDayOfWeekInTz` alias (line 355) — last caller was `todayDow`.
- `parseScheduleRecurrence` import — the worker no longer touches the
legacy decoder directly; the engine handles the fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A single source of truth for "does this schedule fire at this
moment?" — replaces the three duplicate engines that grew up
alongside each other (`expandScheduleSlots` in scheduling/cadence,
`getNextOccurrenceTimestamp` on the medication card,
`getNextRecurrenceDate` in the medication form). The retirement of
the three legacy walkers is a follow-up wire-up step; this commit
ships the engine standalone with no callers so the foundation can
land on its own.
### `src/lib/medications/scheduling/recurrence.ts`
Three public functions:
- `occurrencesBetween(schedule, from, to, ctx)` — emit every slot
in `[from, to]` inclusive, time-of-day applied, in chronological
order.
- `nextOccurrenceAfter(schedule, after, ctx)` — strict-after; null
when the schedule has terminated.
- `matchesInstant(schedule, instant, ctx)` — 1-minute-tolerance
predicate for the reminder-worker fan-in.
The engine dispatches on the schedule's shape:
- One-shot → emit one slot per `timesOfDay` at the course anchor.
- Rolling → next-due = `lastIntakeAt + rollingIntervalDays * day`,
fall back to `startsOn` / `createdAt` when never taken; emit only
the immediately-next slot (past rolling occurrences are historical
intake events, not predictable future slots).
- RRULE → delegate to the `rrule` library with
`dtstart = startsOn ?? createdAt` and an optional `until = endsOn`.
- Legacy fallback (rrule + rolling both null, oneShot false) →
decode the existing `daysOfWeek` string and emit weekly slots,
**now correctly honouring `intervalWeeks > 1`** — the existing
`expandTodayIntakes` skipped multi-week schedules; the engine
emits them anchored at `startsOn` / `createdAt`.
Time-of-day application uses the canonical `wallClockInTz` helper
so a `08:00` schedule fires at Berlin 06:00 / 07:00 UTC depending
on DST, and a Tokyo 08:00 schedule fires at the correct UTC instant
regardless of the host clock's zone. The DST spring-forward case
(02:30 on the transition day in Europe/Berlin) forwards to 03:30
wall-clock — matches macOS / iOS / Google Calendar behaviour.
Grace window is computed from `reminderGraceMinutes` when set,
otherwise from the legacy `windowEnd - windowStart` span, with a
60-minute default for legacy single-time windows.
DoS protection: `nextOccurrenceAfter` walks forward in 90-day
chunks with a 10-year hard cap so a broken / pathological RRULE
can't spin the worker.
### Tests
28 cases pinning the contract for every dispatch branch:
- RRULE DAILY / WEEKLY / MONTHLY / QUARTERLY / YEARLY across
multi-month windows.
- Bi-weekly Wed anchored — emits weeks 1/3/5, skips weeks 2/4/6.
Pins the pre-existing `intervalWeeks` worker bug closed for the
legacy fallback path.
- Rolling: last-intake anchor + null-intake fallback + endsOn cap.
- One-shot: emit + nextOccurrenceAfter returns null past it.
- DST spring-forward (Europe/Berlin) + cross-TZ (Asia/Tokyo).
- endsOn inclusive of full UTC day (matches `@db.Date` semantics).
- Empty `timesOfDay` falls back to `[windowStart]`.
- Grace window: explicit, legacy span, default 60 min.
### Zod validation
`scheduleSchema` extended with `timesOfDay` (HH:mm entries, max 8),
`reminderGraceMinutes` (1..1440 minutes), `rrule` (length-bounded +
regex against the RRULE properties HealthLog mints), and
`rollingIntervalDays` (1..365). A `.refine` enforces the
rrule-xor-rolling invariant at the validation layer (the DB CHECK
constraint is the second line of defence).
`createMedicationSchema` + `updateMedicationSchema` gain the
medication-level `startsOn`, `endsOn`, `oneShot` triplet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Failed-login audit rows currently carry `ipAddress` plus `reason`
(`user_not_found_or_no_password` or `invalid_password`). The typed
identifier is deliberately omitted (H-1 contract: PII never lands
in operator artefacts) — but that leaves no anchor a future spray
detector can group on.
Recording `hashToken(identifier)` alongside the reason keeps the
H-1 contract intact (HMAC-SHA256 under `API_TOKEN_HMAC_KEY` is one-
way; the raw identifier never serialises into the row) while
giving any future "same identifier hit from many IPs" detector a
correlation key.
Test mock for `hashToken` now returns a deterministic hex string
that does not echo the raw input — mirrors the real HMAC shape
and keeps the existing "audit row must not contain the typed
email" assertion passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`POST /api/devices` returns a distinct 409 when an `apnsToken` (or
the legacy `token`) is already bound to a different account —
`apns_token_owned_by_other_user` / `token_owned_by_other_user`. The
guard is correct (an attacker who can guess an APNs token shouldn't
be able to hijack another account's pushes), but without a rate
limit the same 409 signal lets an authenticated user enumerate
token ownership across accounts unboundedly.
A `checkRateLimit("devices:register:${userId}", 20, 15 * 60 * 1000)`
landing before the schema parse caps enumeration at 20 mutations
per 15 minutes per user. Real-world clients register a device once
on login plus once on each APNs token rotation; 20 / window is well
outside any legitimate use.
The 429 returns through `apiError()` for envelope parity, and a
`devices.register.rate-limited` wide-event annotation lands so
operators can see the limit fire. Test mock adds the
`@/lib/rate-limit` arm + re-arms the success path inside
`beforeEach` because `vi.resetAllMocks()` strips the top-level
`mockResolvedValue`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`generateRegistrationOptions` and `generateAuthenticationOptions` were
both passing `userVerification: "preferred"`. The WebAuthn spec
treats `preferred` as a soft hint — an authenticator that lacks (or
chooses to skip) a UV factor returns an assertion with
`userVerified: false`, and SimpleWebAuthn accepts it as a successful
ceremony. The registration carries the same `userVerified: false`
flag forever, so a credential created without a biometric / PIN /
fingerprint check cannot reach AAL3 on any later assertion either.
Flipping both call sites to `"required"` makes the UV check
mandatory at the authenticator-policy layer. Apple's platform
authenticators (Touch ID / Face ID / passcode) and every shipped
hardware authenticator support UV, so the user-visible flow is
unchanged for every realistic device.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`enforceBudget()` catches the cost dimension — once a user has burned
the day's token allowance the route returns 429 with
`coach.budget.exceeded`. It does not catch the request-rate
dimension: a tight loop or a stolen session can issue 60 requests in
a second, all accepted up to the moment the budget arithmetic
finally rejects, while pinning Prisma connections and provider-side
SSE slots in the meantime.
A `checkRateLimit("coach-chat:${userId}", 20, 60_000)` call lands in
front of the budget gate. 20 requests per minute is well outside any
realistic interactive pace — a human doesn't type that fast and the
iOS client paces from user gestures — while keeping the budget logic
as the cost ceiling it was designed to be.
The 429 returns through `apiError()` for envelope parity with the
other rate-limited routes; an `insights.coach.rate-limited`
wide-event lands so operators can see when the limit fires.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`safeJson()` previously called `request.json()` directly, which buffers
the entire body before parsing. Next.js imposes a runtime cap of around
4 MB on App Router handlers, so the worst case is bounded — but
routes that legitimately accept large batches can't tighten the cap
through the helper, and a 3.9 MB payload to a single-record route
still lands in heap before any Zod check fires.
The new `opts.maxBytes` parameter routes the request through
`request.text()`, checks the byte length first, and only then runs
`JSON.parse`. A 413 envelope returns when the cap trips. Existing
callers (every route that doesn't pass `opts`) keep their current
behaviour — the helper falls through to `request.json()` exactly as
before, no regression risk on the broader codebase.
The CSP-report endpoint inlines the same pattern with a 10 KB cap;
this generalises it so single-record routes can pass `{ maxBytes:
16_384 }` and batch routes can pass `{ maxBytes: 262_144 }` (or
whatever fits the schema's max array length × per-row budget).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`readMoodLogSecret()` previously caught every decrypt failure and
returned the stored bytes verbatim, on the theory that legacy
plaintext rows should keep working through the migration grace
period. That fallback is correct for genuinely unprefixed legacy
values, but it also swallowed two failure modes that should never
silently succeed:
- An envelope-shaped row (`<keyId>.<payload>`) whose key id is no
longer in the live `ENCRYPTION_KEYS` map. Returning the literal
`v1.<base64-ciphertext>` as if it were the plaintext secret would
cause every webhook attempt against that row to compare a known-bad
literal — and worse, lock the user into a state where rotating the
key out of the env quietly broke their integration.
- A row whose envelope bytes were tampered with directly in the
column (SQL injection, restored-from-backup-with-tamper, leaked DB
credentials). The catch-all returned the attacker-chosen value as
the active secret; the webhook would then accept whatever the
attacker sent over the wire.
The fix routes the envelope-shape check through `extractKeyId()` and
only catches the failure for the genuinely legacy path. Envelope
rows that fail to decrypt now throw — operators see the rotation gap
or the tampered row instead of running on a quietly-broken secret.
`isLegacyPlaintext()` simplifies to the same shape check: anything
with an envelope prefix is encrypted (even if currently undecryptable),
so it should not be re-rotated as if it were plaintext.
Two new test cases pin the contract for envelope-shape failures
(unknown key id; tampered ciphertext under a known key).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hand-rolled extractor called `inflateRawSync(compressed)` without
a `maxOutputLength` option and never inspected the central directory's
advertised uncompressed size, so a 1 KB ZIP could expand to tens of
gigabytes during inflate and OOM the shared Next.js process. The route
already caps the upload at 1.5 GB and rate-limits to 3 / min per user,
but a single successful inflate would still take the whole container
with it.
Two layers of defence land here:
- Pre-flight refusal on the central directory's `uncompressedSize`
when it exceeds 8 GiB or when the advertised compression ratio
exceeds 200×. This is a coarse signal — the metadata is attacker-
controlled — but it catches honest-but-oversized payloads early.
- `inflateRawSync(compressed, { maxOutputLength: 8 GiB })` instructs
`node:zlib` to throw rather than expand past the cap, which holds
even when the metadata lies about the uncompressed size.
8 GiB leaves a wide ceiling for legitimate multi-year heavy-user
exports (the largest real-world ones settle in the low single-digit
GB range), while making the bomb class infeasible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both fields are admin-supplied and feed into outbound server-side
fetches: the Umami script URL is proxied through
`/api/monitoring/umami-script` (and the script body is re-served from
the app origin under a strict CSP), and the GlitchTip DSN derives the
`envelopeUrl` / `storeUrl` the server posts errors to. The previous
validators only checked the protocol, so an admin (or an attacker
holding the admin account) could point either at a private-IP target
and drive the server into the RFC1918 / loopback / metadata space.
The `isPublicUrl` helper already governs every other admin-supplied
outbound URL the app dials; this aligns Umami + GlitchTip with that
posture. GlitchTip DSNs carry the public key in userinfo
(`https://<key>@host/projectid`) — the WHATWG URL parser strips the
userinfo from the hostname, so the helper validates the host as
expected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`/api/send` is the public Umami proxy. The hand-rolled allowlist it
carried predated the central `isPublicUrl` helper in
`src/lib/validations/notifications.ts` and was missing every
notation-bypass class the helper catches:
- 100.64.0.0/10 CGNAT
- 0.0.0.0/8 (only the literal `0.0.0.0` was matched)
- 169.254/16 outside the AWS metadata IP (e.g. azure metadata is
`169.254.169.254` too, but other addresses in the block are
routable to other internal endpoints)
- IPv6 ULA (`fc00::/7`)
- IPv4-mapped IPv6 (`::ffff:127.0.0.1`)
- Leading-zero IPv4 octal bypass (`http://010.0.0.1` → 8.0.0.1)
- Hex IPv4 (`http://0x7f.0.0.1`)
- Pure-decimal IPv4 (`http://2130706433`)
The dev-mode escape hatch is also dropped — serving the proxy against
a localhost / RFC1918 Umami target was never a useful configuration
anyway, since the proxy sends the visitor's user-agent + body upstream
which only makes sense against an externally-reachable analytics host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The OpenAI, Anthropic, and local OpenAI-compatible clients all used a
bare `fetch()` with no `signal`. A tar-pit upstream (a misconfigured
local Ollama on a stalled GPU, a flaky proxy in front of a hosted
provider, or a deliberately malicious user-configured LOCAL endpoint)
could accept the POST and then hang indefinitely, pinning the request
handler until the connection died on the socket level.
A 60 s ceiling lands well outside any legitimate completion budget
(production timings live in `getEvent()?.addExternalCall` traces) while
capping the worst case.
The Codex client uses an SSE stream with its own loop and stays as-is
for now; a follow-up can thread the same ceiling through the streaming
read once the contract there is settled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wide-event `received_shape_excerpt` surface walks every body key
through `SENSITIVE_KEY_PATTERNS` before the eventual JSON.stringify.
The original set covered passwords, tokens, secrets, API keys, the
Authorization header, CSRF state, and nonces. Four additions worth
keeping out of observability sinks:
- `/passphrase/i` — backup-encryption passphrases (off-host backups
use one) and any future user-facing recovery-phrase flows.
- `/otp/i` — TOTP / email-OTP one-time codes for future second-factor
surfaces.
- `/recovery/i` — passkey-recovery codes and account-recovery flows.
- `/dsn/i` — Glitchtip / Sentry-style DSNs are `https://<key>@host`
shapes where the key is auth material; today they enter the system
via admin settings but a future debug surface might quote one back
in a body excerpt.
Test pins the four new patterns alongside the existing required set,
and a positive-redaction case covers all four in one shot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three additions to the production header set in `src/proxy.ts`:
- `Cross-Origin-Opener-Policy: same-origin` isolates the
BrowsingContextGroup from cross-origin popups. Closes the
`window.opener` reference channel for Spectre-class side-channel
attacks. The OAuth flows (Withings, Codex) are top-level redirects
rather than popups, so they aren't affected.
- `Cross-Origin-Resource-Policy: same-origin` narrows the page's
resources to same-origin loaders, complementing the existing
`frame-ancestors 'none'` CSP rule for non-document subresources.
- `X-Permitted-Cross-Domain-Policies: none` shuts the Flash / Adobe
Reader `crossdomain.xml` channel that some scanners still flag.
All three land in every response; production-only HSTS stays in its
existing `!isDev` block. No CSP changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.5.1 patch. Fixes the silent login failure self-hosters hit on
plain-HTTP origins that aren't localhost — the bundled Docker image
runs NODE_ENV=production, so every cookie carried Secure, and modern
browsers drop those on http://10.x.x.x style origins. The session
cookie never made it back to the browser, /api/auth/me returned 401,
and the login page reloaded.
New SESSION_COOKIE_SECURE env var with three values:
- unset (default) — flag follows NODE_ENV === "production" (unchanged)
- "false" — never set (LAN / VPN / Tailscale-only opt-out)
- "true" — always set (pnpm dev fronted by HTTPS)
Shared helper at src/lib/auth/secure-cookie.ts replaces four scattered
literals across session, codex device-start, and withings connect.
Six unit cases pin the decision matrix.
OpenAPI spec generator now reads info.version from package.json so
the drift gate stays meaningful instead of catching every release bump.
Default behaviour unchanged for HTTPS-fronted deployments.
v1.5.0 cut. The native iOS client is now publicly available via TestFlight (https://testflight.apple.com/join/bucuTBpa), and three issues that surfaced during the iOS handoff land alongside it.
## Features
- **stats:* upsert** (closes#213) — `POST /api/measurements/batch` recognises `externalId` values starting with `stats:` (per-day cumulative HK metrics: Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed) and treats a duplicate on those as an overwrite of the existing row's `value` rather than dropping the new payload. Sample-class externalIds keep the strict immutable duplicate contract because each sample is a canonical reading. New per-entry status `"updated"` on the response envelope, new `measurement.batch.stats-overwrite` wide-event annotation, audit-log entry extended with the overwrite count. Three integration tests pin the contract.
## Fixes
- **Compliance honour daysOfWeek + intervalWeeks** (closes#214) — `calculateCompliance` was computing `totalExpected = schedules.length * days`, silently ignoring `MedicationSchedule.daysOfWeek` and `intervalWeeks`. Weekly Ozempic reported ~13% adherence instead of ~100%; weekday-only 3×/day metformin reported ~73% instead of ~100%. Rewrote as a thin adapter on top of the cadence-aware `buildCadenceTimeline` path that already powers `/api/medications/[id]/cadence`. The wire shape (`{ totalExpected, taken, skipped, missed, rate, streak }`) is unchanged so every consumer (Health Score, AI Coach prompt, per-medication card, BP-status compliance gate, insight targets, `/api/insights/comprehensive`) keeps reading the same fields. Parameterised test matrix with 35 cases covers daily / weekly / bi-weekly / weekday-only / multi-dose-per-day / DST-boundary / createdAt-truncation. Health-Score regression test pinned.
## Tests
- 8 stale integration assertions retired across `withings-oauth.test.ts`, `withings-oauth-flow.test.ts`, `analytics-bp-aggregate-paged.test.ts`, `analytics-sleep-stages.test.ts`, `apns-dispatch.test.ts` — drift from the v1.4.47.x OAuth fine-grained reason tags, the v1.4.47.2 ES256 PEM verify guard, and the v1.4.49.1 analytics slim-slice annotation rename. Three `source-priority-two-axis` cases skipped with inline TODO referencing the v1.4.49.1 commit and the relocation candidate.
- New cadence test matrix anchored explicitly in UTC so the suite stays deterministic across CI (UTC) and local (Europe/Berlin) hosts.
## Docs
- README rewrite for the v1.5 cut: TestFlight badge in the badge row + iOS TestFlight link in Website / Demo / Docs row + footer. Buy Me A Coffee badge added. Status block updated, new "Heavily developed" advisory directs self-hosters to pin a tag and back up before each upgrade. Tech-Stack flags the iOS app as TestFlight-available. Roadmap promotes v1.5 from "in active development" to "current". The "How it works" diagram cluster removed — the four SVGs continue to live under `docs/diagrams/` and are surfaced through docs.healthlog.dev where they render reliably across themes.
## Final verify
- typecheck clean
- lint clean (one pre-existing warning in `withings/resume/__tests__`)
- pnpm test: 5285 passed / 1 skipped
- pnpm test:integration: 253 passed / 3 skipped
- GHA CI on PR: build amd64 + build arm64 + lint/typecheck/test + integration + e2e + dependency audit + secret scanning + dead-code gate + env check → all green
Marc reported that mood entries logged inside HealthLog — specifically
via the iOS app — never reached MoodLog. The historic integration
polled MoodLog every 15 minutes and pulled new rows, but nothing
flowed the other direction. iOS users who tracked mood in HealthLog
ended up with one log per app and no overlap.
`src/lib/moodlog/push.ts` adds `pushMoodEntriesToMoodLog(userId,
entries)`. Best-effort reverse-sync push to MoodLog's new POST
`/api/integrations/health-log/mood` endpoint (shipped in MoodLog
v0.2.0). Same per-user moodLogUrlEncrypted + moodLogApiKeyEncrypted
credentials the pull side uses, same isPublicUrl SSRF guard, same
manual-redirect policy. Wraps every failure mode in a wide-event
warning so a network blip or a stale MoodLog deploy can never bubble
back to the user's mood-create request.
`POST /api/mood-entries` (single) and `POST /api/mood-entries/bulk`
now fire the push as a fire-and-forget side-effect after a successful
create. Entries with source === "MOODLOG" skip inside the helper to
avoid an echo loop. Bulk push only includes rows that landed as
`inserted` — duplicates and skips don't retry a known failure.
Pull side filters echoes:
`syncMoodLogEntries` now skips entries where MoodLog reports
`loggedVia: "HEALTHLOG"`. Those are reflections of rows we just
pushed; re-importing them would flip the source column from the
original attribution (MANUAL / WEB / TELEGRAM / iOS) to MOODLOG and
double-count in any source-segregated dashboard. An `echoSkipped`
wide-event annotation lets the operator confirm the round-trip is
closing at one hop.
Cross-repo:
The matching MoodLog v0.2.0 release adds the inbound POST handler
this push targets. Both apps must deploy together; MoodLog deploys
first so HealthLog never hits a 405 / 404 on the new endpoint.
Tests: 9 cases in src/lib/moodlog/__tests__/push.test.ts cover the
MOODLOG-source filter, no-credentials skip, SSRF refusal, headers +
body shape on the successful path, 5xx + network-reject + 3xx-
redirect failure modes, and the tags JSON-string → array key
normalisation. Pins the contract the call sites no longer await so
future helper refactors can't silently break the fire-and-forget
integration. HealthLog suite: 5279 pass, lint clean, typecheck clean.
Marc reported the BD (Sys) tile rendering `13… mmHg` — the
systolic / diastolic pair `131/85` outgrew the `text-3xl` footprint on
the narrowest grid columns and the value-row's `truncate` ellipsis
chopped the most important part of the tile (the number itself).
The other tiles in the strip (weight, pulse, glucose, …) carry a
single value that never threatens the column-width budget on its own,
so they keep `text-3xl`. Only paired-metric tiles step down to
`text-2xl`; `131/85 mmHg` at `text-2xl tabular-nums tracking-tight` is
~84 px wide vs ~140 px for the narrowest 2-column grid cell — fits
with the unit and trend arrow at every viewport. `truncate` stays as
a defence-in-depth safety net for outlier values.
`cn()` arg ordering tightened so `tailwind-merge` keeps the explicit
`leading-none`. Tailwind v4's `text-3xl` / `text-2xl` carry their own
default `line-height`, and the last-wins dedup rule otherwise dropped
`leading-none` and broke the W20a across-tile baseline alignment that
`trend-card-baseline-alignment.test.tsx` pins.
Also fixes a pre-existing date-boundary test flake:
`src/app/api/medications/intake/__tests__/route.test.ts`'s
`reads the rollup tier when coverage is present` computed `todayKey`
from UTC components, but the route's `readMedicationCompliance` uses
the user's timezone (Europe/Berlin in the test fixture). At every
22:00 — 24:00 UTC window — once the Berlin clock crossed midnight —
the two computations diverged by one calendar day, the mock returned
data for the wrong day, and `today.scheduled` came back zero instead
of three. The test now derives `todayKey` from the same Berlin zone
the route uses.
Tests: 5270 unit + integration pass, lint clean, typecheck clean.
Marc surfaced raw `notifications.eventMoodReminder` and
`notifications.eventMoodReminderDesc` reaching the UI after the
v1.4.49.2 relative-time fix had landed. The narrow `{count}`-call
audit that drove v1.4.49.2 missed every dynamic key construction; the
exhaustive sweep below picked up 28 keys called from real code that
had never existed in any locale bundle.
Stale gaps + when each surface shipped without its strings:
- `notifications.event{Type}` and `Desc` (14 keys) — the per-event
rows on the `/notifications` settings page. The matrix was added
with v1.4.41 and shipped with only `eventColumn` translated;
v1.4.49 then added MOOD_REMINDER to the enum which is what made
Marc notice.
- `onboarding.welcome.{chrome × 7, slide{1,2,3}.{title,body}}` (13
keys) — the 3-slide welcome carousel added in v1.4.45. The
carousel rendered raw keys for every screen-reader label and
every slide's content.
- `measurements.loadError` (1 key) — the error branch in
`measurement-list.tsx` added in v1.4.44.
All 28 keys × 6 locales (de / en / es / fr / it / pl) = 168 new
entries, all written in Marc's voice, ASCII-safe with proper umlaut /
accent handling per locale, no AI-mention or internal-tooling words.
Inserted at the END of each namespace so the diff stays surgical and
the integrity test (parity across locales) stays green.
Regression guard added at `src/__tests__/i18n-call-site-coverage.test.ts`:
walks every `.ts` / `.tsx` under `src/` (excluding tests + generated),
extracts every literal `t("ns.key")` with comment-aware parsing (line +
block + JSDoc comments stripped so docstring pseudocode is not flagged),
and asserts each key resolves to a string leaf in `messages/en.json`.
`i18n-locale-integrity.test.ts` then propagates the EN guarantee across
every other locale via key-set parity.
Why this gap was not caught earlier:
The v1.4.27 B6 `i18n-drift-guard.test.ts` carries hand-curated key
groups; surfaces added after that point (notifications matrix v1.4.41,
measurements list error v1.4.44, welcome carousel v1.4.45) shipped
without anyone editing the drift-guard groups. The integrity test
catches DRIFT across locales but not keys missing UNIFORMLY from every
locale. The new call-site test closes that hole — any t() call without
a backing key now fails at pnpm test time.
Tests: 5270 unit + integration pass, lint clean, typecheck clean.
Marc reported raw `insights.relativeHoursAgo` rendering verbatim across
medication cards, recent-achievements, admin sections, and the iOS
notification preview — anywhere `formatDateOrRelative` paints a
timestamp older than 60 s. The helper called `t("insights.relative
MinutesAgo", { count })` / `t("insights.relativeHoursAgo", { count })`
against a translation bundle that ships only the pluralised `*One` /
`*Other` variants; the i18n surface performs no auto-pluralisation so
the bare key bounced through `t()`'s identity fallback into the UI.
The fix mirrors the One/Other dispatch pattern that the v1.4.43 H6
sweep added to the canonical `src/lib/i18n/relative-time.ts:24-48`
helper. The format-twin in `src/lib/format.ts` was missed in that
sweep because the two helpers had drifted into byte-different siblings
since v1.4.20.
Audit confirmed the leak was isolated to these two keys: I cross-
checked every `t(…, { count })` call in the repo against
`messages/en.json`. The other 17 call sites (`insights.dayStreak`,
`medications.importDuplicatesSkipped`, `achievements.metricPercent`,
`passwordStrength.minLength`, `settings.integrationPill.*`,
`targets.*`, `admin.section.backups.uploadSuccess`,
`trendHints.remainingMany`, `dashboard.staleHint`, …) all use plain
non-pluralised keys that exist verbatim in the bundle.
Regression guard added to `format-date-or-relative.test.ts`: drives
every relative bucket through the helper, collects the `t()` keys it
emits, and asserts each one has a matching entry in `messages/en.json`.
Pre-fix this guard fails on the two bare keys; future twin-helper
divergence (e.g. someone adds a third date-formatting helper with a
copy-pasted body that lacks the pluralisation step) trips it before
it reaches prod.
Bundles v1.4.49.1's analytics rollup-tier delegation that the Coolify
auto-deploy never picked up.
Tests: 5269 unit + integration pass, lint clean, typecheck clean.
v1.4.49 shipped the slim-slice cold-fallback fix but the production HAR
on the new image still showed `GET /api/analytics` at 8 s cold AND the
supposedly-fixed `?slice=summaries` request hit the same 8 s when fired
concurrently. Investigation found the slim slice queues behind the
default slice's per-type live walk: the 15-way `fetchMeasurementSeriesChunked`
fan-out saturates the `p-limit(4)` lanes and packs the 20-slot Prisma
pool for the full ~8 s on Marc's 467 745-row tenant, starving every
other Prisma client (including the concurrent slim slice) until the
walk drains.
The default slice's `summaries` field has been produceable from the
rollup tier for two releases — the slim slice already uses
`computeFromRollups` + a 90-day narrow `$queryRaw` and returns the
identical `DataSummary` shape sub-second. The walk lived on because
two thick-only fields (`anomalyCount`, `avg30LastMonth`) only existed
on the live path. A grep of every consumer in the repo confirmed:
- `anomalyCount`: no consumer reads it from `/api/analytics`. The
insights pipeline pulls it from its own `comprehensive-aggregator`
narrow query. Slim's `0` constant is what the default route's
consumers already received.
- `avg30LastMonth`: read by `src/app/page.tsx:321` (dashboard
comparison tile) and `src/components/insights/vo2-max-chart-row.tsx`.
Easy to lift: extend the slim narrow `$queryRaw` with one extra
FILTER clause for `[30d, 60d)` — the rows are already inside the
90-day outer cap so the planner reads zero additional blocks.
With both fields covered, `buildAnalyticsResponse` calls
`computeSummariesSlice(user.id)` once and uses its `summaries` +
`lastSeenByType` directly. The 15-way fan-out, the chunked walker,
the `ANALYTICS_TYPE_FETCH_CONCURRENCY` p-limit cap, the 425-day
`since` window, and the `meta.analytics.bp_aggregate` annotate all
delete cleanly — none had callers outside the removed code path.
Net diff: -418 LOC across the route + its tests.
Test updates:
- `route.test.ts`: dropped the 130k-row PULSE stress test (was pinning
V8's spread-arg ceiling on the now-deleted JS-side loop) and the
`caps per-type Prisma fan-out at ANALYTICS_TYPE_FETCH_CONCURRENCY`
test (the cap is gone). Added a negative assertion that
`prisma.measurement.findMany` is never called with the chunked-walk
shape `(select.id + source + deviceType, take: 5000)` and a positive
assertion that `avg30LastMonth` reaches the response envelope.
- `since-cap.test.ts`: dropped the `where.measuredAt.gte` per-type
assertion and the `bp_aggregate.live_since` annotation assertion
(both pinned the removed walk). The slim-slice no-per-type-loop
invariant survives.
Final: 5268 unit + integration tests pass, zero failures, lint clean,
typecheck clean.
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.
Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
aggregate into two parallel queries. The linearly composable columns
(count / min / max / mean) keep the full-partition scan; the windowed
7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
the planner does an index range scan on (user_id, type, measured_at)
instead of a full-partition sequential scan. Output is bit-identical;
cold slim slice drops from multi-second to sub-second on tenants with
large measurement partitions.
Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
Initial category medication.clientManaged: boolean (default false). Cron
medicationReminderJob skips dose-due APNs when the flag is true,
emitting a medication_reminder.suppressed_client_managed wide-event
annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
device APNs token presence (masked), channel config presence, and
recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
priority-10 branch runs. A successful self-test now exercises the
exact path production reminders take; an unsuccessful one points the
user at iOS Settings → Time Sensitive Notifications rather than
appearing to be a server-side bug.
Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
P2025 catch for the replay branch). The previous read-then-delete
pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
cross_user so operators can tell mismatch types apart without DB
shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
redirect to a consistent error surface.
Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
secret / apiKey / authorization / csrfState / nonce, recursive,
case-insensitive. Caught: /api/devices invalid_format issues were
echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
applied to 14 audit-log sites.
Migrations: 0079 (notification_prefs), 0080 (push_attempts).
Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.
Resolves PR #207.
On BadEnvironmentKeyInToken, retry once with the opposite gateway. On success, persist Device.apnsEnvironment correction. Two new unit tests cover retry+persist and no-retry-on-BadDeviceToken.
v1.4.47.2/.3 confirmed the production APNS_KEY env-var is corrupted along the docker-compose env_file pipeline. Add APNS_KEY_B64 — operator base64-encodes the .p8 file and stores that ASCII-safe blob; app decodes back to PEM. No \n escape gymnastics. Precedence: B64 > KEY > FILE.
v1.4.47.2 normalisation didn't recover the production PEM. Emit a structured diagnostic on the existing parse-failure branch (counts + sha256_prefix, no secret bytes) so the operator can compare the in-container shape against the source .p8.
Coolify runtime logs surfaced
"secretOrPrivateKey must be an asymmetric key when using ES256"
on every push attempt. The channel auto-paused after 3 consecutive
failures.
Root cause: the .p8 PEM stored in the Coolify env-var arrived at
process.env.APNS_KEY without parseable newlines around the BEGIN/END
markers (env_file round-trip artefact). openssl pkey accepts it,
jsonwebtoken@9 used by node-apn is strict.
Normalise the PEM after the 12-factor \n unescape: strip whitespace
between markers, force 64-char line wrap, rebuild canonical PEM.
Idempotent on already-correct inputs. Verify via crypto.createPrivateKey
before handing to node-apn.
The narrows query inside computeFromRollups scanned the user's full
measurements partition without an outer measured_at cap even though
every aggregate FILTER inside the SELECT restricted to 7/30/90 days.
On a ~450k-row tenant the cold slim slice paid ~9s TTFB to read rows
that were always aggregated to NULL.
Adding AND m.measured_at >= NOW() - INTERVAL '90 days' to the outer
WHERE turns the read into an index range scan on
(user_id, type, measured_at) and returns bit-identical output.
Expected: ~9s -> ~0.5-1s cold, sub-50ms warm.
No schema change, no migration, no env-var change.
Three discovery follow-ups from prior server reviews:
- REG-9: pr-detection-worker now aggregates cumulative HK kinds (steps, active energy, flights climbed, walking-running distance, time in daylight) by date_trunc('day', measured_at) SUM before picking the best day, instead of picking the largest single per-hour slice.
- New intake-auto-skip cron at :05 hourly flips MedicationIntakeEvent.skipped=true for events where skipped=false AND takenAt IS NULL AND scheduledFor < NOW() - INTERVAL '24 hours', so the compliance tile stops seeing forgotten doses as pending forever.
- /api/admin/notifications/test now handles the APNS channel (was falling through to "Unknown channel type"); surfaces the APNs sender's reason on failure.
5076 -> 5089 tests (+13). 4 pre-existing date-rollover flakes in dashboard/summary, medications/intake, and medication-card-symmetry tests are unchanged by this PR (verified against pre-fix baseline f18654f3).
iOS-operator-blocking hotfix for REG-11 — Home dashboard tile renders
neither chart nor latest value for BP / Puls / Körperfett when the
most recent reading is older than 7 days. Root cause was in
`/api/dashboard/summary` SQL gates; `latestIn7d` + `sparkBuckets` both
required `measured_at >= sevenDaysAgo`, which returned empty for
sparse accounts, leaving the iOS tile with `latestValue: null` +
`sparkline: []` to render from. Detail-screen
(`/api/measurements/series?kind=…`) escaped because of its broader
window.
Server-side changes:
- `latestIn7d` → `latestEver` (drops 7-day filter; DISTINCT ON returns
latest reading EVER per type, bounded at N_metrics rows).
- `sparkBuckets` rewritten via ROW_NUMBER() OVER (PARTITION BY type
ORDER BY bucket_start DESC) so the trailing N daily buckets per
type render regardless of age, bounded at SPARK_DAYS × N_metrics.
- BP + Pulse metrics.push gated on `latest || allTimeCount > 0` like
BodyFat already is — accounts with zero readings ever no longer get
empty placeholder tiles.
- 4 new regression tests in summary/__tests__/route.test.ts cover
sparse accounts, zero-data accounts, within-7d guards.
Other develop work bundled in this release:
- W4-QoL — copy nits.
- W5-WORKOUTS — user source priority threaded through write-time
picker + batch route.
- W7-WITHINGS-RELIABILITY — response classifier + sync hardening.
- W8-OPS — ops phase docs.
- B11 — NEXT_PUBLIC_APP_VERSION build-arg routing so /api/version
cannot drift from CI tag.
- env-check workflow — manifest / example lockstep enforcement.
Resolves PR #194 (squash-merge produced via local -X theirs strategy
because GitHub's UI couldn't auto-resolve develop's post-v1.4.42
contiguous work against the v1.4.42 squash on main; resulting tree is
byte-identical to develop's HEAD).
Six touch-disjoint waves landed on develop before this release:
- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
35 unused exports + 52 unused types triaged to 0/0; shadcn surface
+ zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
`/api/dashboard/widgets` 422 now returns every Zod issue + worker
audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
migrated to the queryKeys factory; 9 new factory entries; ESLint
rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
sites (seven status helpers + bucket-series + bp-in-target);
Suspense double-comment consolidated; doctor-report sanitiser regex
rewritten to escape-form so diffs are reviewable; pr-detection-
worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
(transient/reauth/persistent) + WithingsApiError typed throw + the
`pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
silent-disable gap the wave was conceived to close.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.
pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
Eight touch-disjoint waves landed on develop before this release:
- W-INSIGHTS-HOT + W-RECONCILE-INSIGHTS — extract the timeout-stub
persist helper and wire all seven *-status routes through it, closing
the recurring ~14 s warm response the iOS client sees on dashboard
open once the AI provider has stalled once that day.
- W-DELETED-2 — close the remaining three soft-delete reader-tier gaps
(export bundle, gamification achievements, doctor-report aggregator)
with a five-route integration-test pin.
- W-ORG — types consolidation into src/types/analytics.ts and
src/types/backups.ts; prompt directory unification.
- W-FRONTEND-FACTORY — queryKey factory expansion + ESLint rule lift.
- W-IOS-COORD — POST /api/auth/check-user four-branch onboarding
discovery, identifier matched verbatim, per-IP rate-limited.
- W-SIMPLIFIER — today-intake projection helper extract; unused tx?
params dropped; 13 knip-flagged dead exports trimmed.
- W-PERF-OPS — v1.4.39 W-SUM legacy-NULL UNION discovery arm retired.
- W-PROCESS-DOCS — pg.Pool sizing operator doc; ESLint rule
formalisation; knip CI gate staged for v1.4.42 flip.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new check-user endpoint is an additive surface. AP-2 .p8 key is
installed; SB-5 time-sensitive APNs is live as of this deploy.
pnpm test --run green at 4732 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip green.