4270 Commits
Author SHA1 Message Date
Marc-André Bombeck 4f95afa33c Merge pull request #607 from MBombeck/release/v1.32.12
release/v1.32.12 — mood rollups key by local day, not UTC day
v1.32.12
2026-07-24 04:04:18 +02:00
Marc-André Bombeck 1fca6c1d25 fix(mood): key mood rollups by the local day, not the UTC day
The persistent mood-rollup writer bucketed on the UTC truncation of
`mood_logged_at` and ignored the canonical per-row `MoodEntry.date`
label. Every coverage-miss fallback keys on `entry.date`, so the rollup
tier disagreed with the fallback and with the day the user logged the
mood: a late-evening or after-midnight local mood landed on the wrong
calendar day, and the label shifted the moment the boot backfill flipped
a user from the live fallback onto the rollup tier.

Key every bucket off the stored `date` label instead — DAY, WEEK, MONTH
and YEAR — with `bucket_start` set to that label's UTC-midnight instant.
The rollup is now byte-identical to the live `entry.date` fallback and
timezone-independent by construction: DST correctness stays where it
already lives, in `moodDateKey` at write time. No consumer changes, no
schema change; every rollup reader already formats `bucket_start` as a
UTC day label.

Migration 0269 empties `mood_entry_rollups` so the boot-time
full-backfill worker re-mints every user's buckets under the corrected
keying; the worker now deletes-then-refolds so it is self-cleaning
against any row left by an older instance. While the table is empty,
reads fall back to the live `date`-keyed walk, which was already
correct, so there is no window where a wrong day is shown.
2026-07-24 03:50:14 +02:00
Marc-André Bombeck 6cf9ad1273 Merge pull request #606 from MBombeck/release/v1.32.11
release/v1.32.11 — native web-handoff login for self-hosted iOS
v1.32.11
2026-07-24 02:44:44 +02:00
Marc-André Bombeck 0b3280c604 chore(release): v1.32.11 — native web-handoff login for self-hosted iOS
Self-hosters can sign in to the iOS app through their own domain, so the
password manager files credentials under the right site and passkeys run in
the correct origin. The app opens the instance's own web login inside an
ASWebAuthenticationSession; after an interactive login the server mints a
single-use, PKCE-locked handoff code on a compiled-in custom scheme, which the
app exchanges for the standard native token bundle. The token pair never rides
a URL — only the opaque code does.

Three endpoints mirror the OIDC native handoff and share its mint/consume core,
now extracted to native-handoff.ts (the OIDC leg keeps working unchanged, as
the default flow):

  - GET  /api/auth/native/login    validates the PKCE challenge, sets a
                                   short-lived encrypted state cookie, and
                                   redirects to /auth/login?flow=native.
  - GET  /api/auth/native/complete validates the web session, enforces the
                                   freshness binding, mints the code, and
                                   redirects to healthlog://login-callback.
  - POST /api/auth/native/token    exchanges the code under the cookie-less
                                   native transport gate for the native bundle.

The freshness binding refuses any session older than the flow start, so a
pre-existing browser session can never silently complete a handoff. The start
time is stamped from the database clock (SELECT now()) to stay comparable to
Session.createdAt with no app/DB skew, and createdAt is read through a
dedicated projection so the comparison cannot be silently dropped. A flow
discriminator on the handoff row (migration 0268) makes an OIDC code
non-redeemable at the web-login route and vice versa.

The callback scheme is a compile-time constant with no redirect_uri parameter,
requireAdmin and requireFreshMfa stay cookie-only, and the exchange mints no
wider scope than password login. The state cookie is AES-256-GCM with a fresh
IV. Adds the flow=native login-page banner in all six locales and the three
routes to the OpenAPI contract.
2026-07-24 02:28:51 +02:00
Marc-André Bombeck d2d79b70a2 Merge pull request #605 from MBombeck/release/v1.32.9
release/v1.32.9 — Coach Guard II: grounding ledger, action ladder, six-locale risk screen
v1.32.9
2026-07-23 23:20:16 +02:00
Marc-André Bombeck dece1f89d9 docs(changelog): restore the 1.32.8 heading above its entry 2026-07-23 22:52:53 +02:00
Marc-André Bombeck 6333d4d989 chore(release): v1.32.9 — Coach Guard II (grounding ledger + action ladder)
The Coach output guard now tracks the numbers it was shown across the whole
conversation, so a figure carried forward or grounded in a goal, a schedule,
or a reference band stops being marked unverified, while an invented one is
still caught and a fabricated risk number now blocks in six languages.
2026-07-23 22:50:03 +02:00
Marc-André Bombeck 9c95e713d2 feat(coach): typed grounding ledger + four-rung action ladder (Guard II)
Promote the per-turn magnitude bag to a per-conversation typed Grounding
Ledger built from every source the model was actually shown: this turn's
tool payloads, prior-turn tool figures, coach-memory goals, the medication
schedule, the reference-grounding block, and guided blocks. Assistant prose
is never a registration source, so a figure that slipped a rung cannot
launder into an authoritative value on the next turn.

Complete the action ladder: a reconciled number passes, a reference or
education sentence is exempt across all six locales, a positively-typed
benign number (date, time, year, ordinal, list marker) passes while an
untyped unreconciled one stays on the strip path, and an unreconciled
health figure is soft-stripped.

Gate the dose continuation exemption on the user's actual schedule: a
"keep taking your N mg" is trusted only when N is a dose the user is on;
an off-schedule maintenance dose is caught. Extend the risk and percent
classifiers to fr/es/it/pl to match the EN/DE depth.

No schema migration: the ledger is rebuilt per turn from persisted tool
traces and re-rendered prompt blocks.
2026-07-23 22:49:54 +02:00
Marc-André Bombeck 25393d93fe Merge pull request #604 from MBombeck/release/v1.32.8
release/v1.32.8 — Provenance: transport-derived intake source + diagnostic sync trigger
v1.32.8
2026-07-23 21:52:22 +02:00
Marc-André Bombeck 7b765c7659 chore(release): v1.32.8 — carry write provenance on records made through the app
Bumps the version, regenerates the OpenAPI contract and the service-worker
version fallback, and records the release notes for the intake-source and
sync-trigger provenance surface plus the shared hhmm util and migration-number
guard.
2026-07-23 21:36:10 +02:00
Marc-André Bombeck c1bb997de7 test(auth): assert the cookie path returns the derived authMethod
The cookie arm of requireAuth now carries authMethod: "cookie" alongside the
session payload, so the exact-shape assertion expects it.
2026-07-23 21:36:01 +02:00
Marc-André Bombeck 6e46afc1fc test(prisma): guard against duplicate migration numeric prefixes
Two short-lived branches can each grab the next free migration number and
both merge, leaving the tree with two directories sharing a four-digit
prefix. This structural test fails the build the moment a prefix repeats, so
the collision surfaces at PR CI instead of after merge. One historical
collision (0025) predates the guard and is grandfathered — a migration
directory name is the primary key in the applied-migrations ledger, so it
cannot be renamed without orphaning the record on every deployed database.
2026-07-23 21:26:54 +02:00
Marc-André Bombeck 9e6453c51d refactor(medications): consolidate the hhmm-to-minutes helpers into one shared util
Seven near-identical copies of the HH:mm to minutes-since-midnight parser
had accumulated across the scheduling surfaces in two flavours: a strict one
that trusts an already-validated literal and a null-returning one for
unvalidated input. Both now live in src/lib/medications/scheduling/hhmm.ts as
hhmmToMinutes and hhmmToMinutesOrNull, and every call site imports from there.
Behaviour is identical: the per-medication route keeps its malformed-to-zero
fallback as a thin adapter over the shared core, and dose-window re-exports
the strict variant so its consumers and unit suite keep their import site.
2026-07-23 21:25:15 +02:00
Marc-André Bombeck 61a1592665 test(medications,measurements): cover transport-derived intake source and the diagnostic syncTrigger
Route-level tests that run the real apiHandler + requireAuth so the
cookie-vs-Bearer derivation is exercised end to end, not papered over by a
unit mock:

- a cookie POST records WEB, a Bearer POST records API, and a body that
  forges `source` is ignored;
- the dose-history ledger selects and returns `source` per row, null on a
  legacy row;
- a batch carrying `syncTrigger:"background"` rides the ingest annotation,
  a batch without it annotates null, an invalid value is 422, and the field
  leaves the per-row insert/update outcomes byte-identical.
2026-07-23 21:21:21 +02:00
Marc-André Bombeck 27b548ab13 feat(medications,measurements): derive write provenance from transport and surface it
Root-cause the intake source attribution and give the HealthKit batch a
diagnostic sync-trigger, one attribution surface for both.

AuthContext now carries a read-only authMethod ("cookie" | "bearer"),
derived from the same branch that sets the wide event's auth_method. The
medication-intake route maps it to the stored IntakeSource: a Bearer
(native) call records API, a cookie (browser) call records WEB. The other
producer values are untouched — a converge onto a pending reminder row
updates through applyCanonicalSlotWrite, which never rewrites source, so
REMINDER / IMPORT / APPLE_HEALTH provenance is preserved. The value is
never client-asserted, the same posture as userId being narrowed from auth.

The dose-history ledger now selects and returns source per intake row so a
client can label how each dose was recorded. The endpoint gains its first
OpenAPI entry (path plus response schema) so the field lands in the
iOS-consumed contract.

The measurements batch accepts an optional per-request syncTrigger
(foreground | background | push). It is diagnostic-only: recorded on the
measurement.batch.ingest wide event and nothing else — never persisted,
never part of the dedup key, never an input to attribution or how a sample
is stored. Sending it or omitting it produces byte-identical row outcomes.

Regenerated docs/api/openapi.yaml.
2026-07-23 21:15:17 +02:00
Marc-André Bombeck d321391024 Merge pull request #603 from MBombeck/release/v1.32.7
release/v1.32.7 — Coach Guard I: typed grounding, narrowed risk and dose banks
v1.32.7
2026-07-23 20:40:32 +02:00
Marc-André Bombeck 9d584806c7 chore(release): v1.32.7 — Coach Guard I (typed grounding + narrowed risk/dose banks) 2026-07-23 20:25:08 +02:00
Marc-André Bombeck a69d0493f8 feat(coach): typed grounding tokenizer + normal-form reconciler + narrowed risk/dose banks
Replace the untyped magnitude bag in the Coach numeric verifier with a typed
tokenizer and a normal-form reconciler, and narrow the outbound risk and dose
banks so correct figures and correct refusals stop being blocked while every
fabrication shape still blocks.

Grounding verifier (coach-prose-grounding.ts):
- Classify each prose number before grading — ISO/written dates, clock times,
  bare years, ordinals, list markers, hyphenated time spans, ranges, percents,
  thousands-separated integers, unit-suffixed measurements. Benign types are
  never graded or stripped; ranges decompose into per-endpoint magnitudes.
- Grade against typed authoritative entries (value + kind + aggregation).
  Sign-insensitive, canonical rounding, ratio to percent in percent space, and
  minutes to hours widen the match only for an aggregate, kinded entry; kind
  scoping stops a weight value grounding a blood-pressure claim.
- Thousands/decimal separators follow the reply language.
- Boundary-safe strip so a flagged token never clips a larger grounded number.

Outbound screen (outbound-screen.ts):
- Risk bank requires an asserted figure or a categorical engine result; a bare
  engine or horizon mention (education/refusal) passes. Spelled-out percents
  count as figures. No refusal-context exemption. EN + DE.
- Dose bank keeps a maintenance-continuation restatement, voided by any change
  stem. Taper suffix fixed.

Route + fenced-chat thread the reply locale into the verifier.
2026-07-23 20:07:03 +02:00
Marc-André Bombeck eed66ae350 Merge pull request #602 from MBombeck/work/audit-foldin-reconcile
release/v1.32.6 — fold in the outstanding hardening and data-integrity fixes
v1.32.6
2026-07-23 17:42:15 +02:00
Marc-André Bombeck e5cdd0d483 chore(release): v1.32.6 — fold in the outstanding hardening and data-integrity fixes 2026-07-23 17:24:53 +02:00
Marc-André Bombeck e986b788a2 Merge remote-tracking branch 'origin/main' into release/v1.32.6 2026-07-23 17:24:03 +02:00
Marc-André Bombeck b1e36d9fb0 reconcile: fold in the outstanding hardening and data-integrity fixes
Merge the eight reviewed audit fixes that were cut a week ago and never
landed. Main has moved on since, so each fix was re-checked against the
current tree and each of the five conflicts resolved by understanding
both sides.

Kept, still open on main:
- Proxy early exits now carry the transport security header subset via a
  shared helper. Reconciled with main's /api/health worker-only carve-out.
- Batch measurement ingest bounds the entry instant so a future-dated row
  can no longer win "latest reading" or poison the rollup buckets.
- Web Push dials through the pinned safeFetch dispatcher with
  requirePublicHost, closing the last user-controlled-host egress.
- Mood bulk upsert and its tag/factor links run in one transaction, so a
  rejected rating rolls the mood row back instead of leaving a tagless row.
- Full backup carries NutrientIntakeDay with its unit and source. Folded
  into main's portable-export / disaster-recovery split.
- Live daily-series fallback restores the min/max spread band the tiered
  read already emitted.
- Coach stream aborts its in-flight reader on unmount and resets send state
  on a conversation switch.
- Manual water writes share the day-bound helper, nutrient add clamps after
  the increment, and the thiamin cap drops to a real supplement ceiling.
- Compliance point window widens symmetrically instead of wrapping overnight.
- Oura, Polar and Fitbit token helpers classify an empty or non-JSON 2xx
  body as a handled transient error.
- WebAuthn relying-party fallback to localhost is gated to development.
- Chart-token strip list is derived from the allowlist and pinned by a test.
- Regression tests pin the snapshot-cache tenant key, the GlitchTip
  path-secret redaction and the Withings credentials delete.

Superseded by main, tests re-pointed at the current shape:
- The intake CSV import was rewritten on main to run through a background
  job with a single-active-job admission gate. The future-date bound is
  re-applied at request validation and the per-user limiter is kept for
  parity with the bulk twin; the old synchronous-write test is replaced.
2026-07-23 17:15:21 +02:00
Marc-André Bombeck 1c82dbc401 docs(changelog): plainer wording in the v1.32.1 to v1.32.5 notes 2026-07-23 16:41:15 +02:00
Marc-André Bombeck 4895726a48 docs(changelog): credit the reporter behind the v1.32.5 OIDC fix 2026-07-23 16:14:03 +02:00
Marc-André Bombeck 3b3df3a1ac Merge pull request #601 from MBombeck/fix/oidc-double-callback
release/v1.32.5 — OIDC sign-in survives a duplicated service-worker callback
v1.32.5
2026-07-23 15:36:02 +02:00
Marc-André Bombeck 56b3e66686 chore(release): v1.32.5 — OIDC sign-in survives a duplicated service-worker callback 2026-07-23 15:18:59 +02:00
Marc-André Bombeck 7779dabfed test(oidc): cover the session resolver behind the duplicate-callback path
Integration coverage for `validateSessionFromCookieValue` against real
Postgres: a live secret-backed session resolves to its user, an expired one
and an unknown/forged/empty value return null, and a legacy cuid cookie
resolves only while its row still carries no hashed secret. This is the
hashing + row lookup + expiry the callback's duplicate detection turns on,
which the unit suite stubs out.

Refs #600
2026-07-23 15:16:48 +02:00
Marc-André Bombeck 60d5416cf8 fix(oidc): sign-in no longer fails on a duplicated service-worker callback
A browser SSO sign-in landed on /auth/login?error=oidc_failed even though it
had succeeded. Two layers conspired:

- The service worker enables navigation preload, but its fetch handler returned
  early for /api/ paths without calling event.respondWith(). For the OIDC
  login/callback navigations that left the preload unconsumed, so the browser
  also ran its own default fetch and the callback was requested twice. The
  first request redeemed the single-use authorization code and set the session;
  the second tried to redeem the now-consumed code, failed at the IdP token
  endpoint, and that 307 to the error page is what the user saw.

- Even with the double request gone, a legitimate retry (proxy, double
  navigation) hit the same wall on the server.

Fix both layers:

- The worker settles /api/auth/* navigations itself, consuming the preload
  response (or fetching once when there is none), so exactly one request
  reaches the server. These responses are never cached.

- The callback is idempotent. When the single-use state cookie is already
  consumed but the request carries a database-validated, unexpired session, the
  callback treats it as a duplicate of a completed login and redirects into the
  app instead of surfacing an error. The authorization code is never exchanged
  on this branch, and a consumed/forged/expired state from a caller with no
  valid session still fails closed with the unchanged oidc_state error.

Refs #600
2026-07-23 15:08:31 +02:00
Marc-André Bombeck e39161ad14 Merge pull request #599 from MBombeck/release/v1.32.4
release/v1.32.4 — Coach numeric verifier stops flagging grounded figures
v1.32.4
2026-07-23 13:45:40 +02:00
Marc-André Bombeck 15f1d3a2b6 Merge remote-tracking branch 'origin/fix/coach-unverified-false-positives' into release/v1.32.4
# Conflicts:
#	CHANGELOG.md
2026-07-23 13:28:07 +02:00
Marc-André Bombeck 0908fac738 Merge pull request #598 from MBombeck/release/v1.32.3
release/v1.32.3 — native step-up elevations for iOS 2FA management
v1.32.3
2026-07-23 13:02:13 +02:00
Marc-André Bombeck 22c92a7e9d chore(release): v1.32.3 — native step-up elevations for iOS 2FA management 2026-07-23 12:48:42 +02:00
Marc-André Bombeck b948f56fec Merge remote-tracking branch 'origin/work/native-stepup-rebase' into release/v1.32.3 2026-07-23 12:42:13 +02:00
Marc-André Bombeck edfe5290c9 docs(changelog): credit the reporters behind the v1.32.1 fixes 2026-07-23 12:35:07 +02:00
Marc-André Bombeck 7bb5e1947c Merge pull request #596 from MBombeck/sec/next-16.2.11
release/v1.32.2 — Next.js 16.2.11 security patch (4 high-severity App Router advisories)
v1.32.2
2026-07-23 12:09:15 +02:00
Marc-André Bombeck 938e84a43a chore(release): v1.32.2 — Next.js 16.2.11, closing four high-severity App Router advisories 2026-07-23 11:41:19 +02:00
Marc-André Bombeck a9bb2f42ba Merge pull request #594 from MBombeck/release/v1.32.1
release/v1.32.1 — honest health score, Coach score questions, import & pulse fidelity, three data-loss races
v1.32.1
2026-07-23 11:07:41 +02:00
Marc-André Bombeck f48f496c39 chore(release): v1.32.1 — honest health score, Coach score questions, import and pulse fidelity, three silent data-loss races 2026-07-23 10:37:26 +02:00
Marc-André Bombeck 87e690bdcb fix(coach): keep the numeric verifier dormant when no tool returned figures
The previous commit spliced the DATA INVENTORY manifest into the tool-mode
authoritative payload set unconditionally. That made the payload set
non-empty on a turn where the model answered WITHOUT calling any tool
(empty tool results), which flipped the verifier from dormant to active —
grading the reply against an inventory that carries per-domain sample counts
only, never the snapshot's pre-computed averages. A figure the model
correctly narrated from the snapshot (a 30-day systolic average) was then
stripped to "[unverified]" — the exact false positive this work exists to
prevent, and a regression from main's "no tool figures this turn → skip,
prompt-level grounding is the backstop" contract.

The inventory must only WIDEN an already-active authoritative set, never
ACTIVATE it on its own. Gate activation on a real present figure block
(a pinned workout-evidence block or a present tool result) exactly as main
did; when active, the inventory counts still join the set so a plain count
restatement stays grounded.

Adds a route-level regression test (fast suite) proving the verifier stays
dormant when no tool returned figures, plus companion unit tests documenting
that a real tool section grounds the average while a counts-only inventory
does not.
2026-07-23 10:31:38 +02:00
Marc-André Bombeck 0375fadd85 Merge remote-tracking branch 'origin/fix/apple-health-pulse-cluster' into release/v1.32.1 2026-07-23 10:27:20 +02:00
Marc-André Bombeck 0598c90af2 Merge remote-tracking branch 'origin/fix/silent-data-loss-batch' into release/v1.32.1 2026-07-23 10:27:10 +02:00
Marc-André Bombeck ecf2d1a7d5 style: run prettier over the import-worker reconcile addition 2026-07-23 10:01:09 +02:00
Marc-André Bombeck 6715c4a69a observability(intraday-pulse): surface per-shape row counts and HR-bucket write outcomes
Related to #585. Investigated the intraday pulse chart's read path
end to end: the ZIP-import raw-sample fold, the live-sync uploaded
10-min bucket fold, their merge, and the chart's render logic. All of
it treats the two ingestion shapes consistently — the chart has no
shape awareness at all, it paints whatever bucket series it receives.
No read-side or display-side defect explains a systematic density
difference between the two ingestion paths.

What was missing is visibility into the write and read sides that
would let a future report actually be diagnosed instead of
re-investigated from scratch. The batch endpoint's wide-event trace
never broke a skip down by reason, and never distinguished an
HR-bucket write from any other entry in the same request. The
intraday route's own trace surfaced only the final bucket count, with
no visibility into which shape (raw sample rows vs uploaded bucket
rows vs folded hourly rows) a day's data actually came from. Both
traces now carry that breakdown, so the next report of "the chart
looks sparser after a given date" is answerable by grep instead of by
re-deriving the whole read path again.
2026-07-23 09:55:23 +02:00
Marc-André Bombeck fbdeec9204 fix(coach): stop the numeric verifier from flagging correctly-grounded figures
Three confirmed false-positive shapes in findUnverifiedCoachNumbers/
stripUnverifiedNumbers were replacing legitimate prose with the literal
"[unverified]" placeholder even though the underlying data was grounded:

- A correctly-computed delta, midpoint average, or percent change between
  two of a payload's own headline aggregate figures (e.g. "down 3.5 kg from
  your 7-day average") was never itself a raw leaf in any tool result, so it
  was flagged as invented. The authoritative set now also carries the
  pairwise delta/average/percent-change between a SINGLE payload's own
  named aggregate fields (latest, avg7, avg30, avgSys30, allTimeAvg, ...),
  scoped per payload so a weight delta cannot ground itself against an
  unrelated metric's figure.
- The always-sent DATA INVENTORY manifest (tool-mode base context) carries a
  sample count per domain, but that count was never added to the
  authoritative set the way the pinned workout-evidence block already is —
  so a plain restatement of a domain's count ("you've logged 42 BP
  readings") was flagged whenever the model didn't also call that domain's
  retrieval tool in the same turn. The inventory entries now splice into
  toolResultPayloads exactly like workoutEvidence.
- A calendar-date ordinal ("July 21st") or a numbered-list marker ("1. Cut
  back on sodium") tokenised as a bare number and, when ungrounded, got
  soft-stripped mid-word into visibly broken text ("July [unverified]st").
  Prose is now masked for these two narrow, high-precision shapes before
  numeric extraction; the original text used for stripping is untouched.

A genuinely fabricated figure (unrelated to any grounded point, inventory
count, date, or list marker) is still caught and stripped — covered by new
adversarial cases alongside each fix.

Investigated and ruled out: multi-round tool-loop result accumulation (all
present results across every round already reach the verifier); unit
conversion (the app resolves and serves one consistent unit, so there is no
independent conversion step for the model to get wrong). The Daily
Briefing's separate withhold-a-whole-section grounding gate is unrelated to
the reported symptom (a different failure mode, whole-line omission vs.
inline replacement) and was not touched.
2026-07-23 09:43:54 +02:00
Marc-André Bombeck 32edb033e8 docs: note the three silent-data-loss fixes in the changelog 2026-07-23 09:39:46 +02:00
Marc-André Bombeck 9ef3207cb6 style: run prettier on the dashboard-layout-section test file 2026-07-23 09:38:24 +02:00
Marc-André Bombeck fb50335459 fix(insights): stop the pulse page from silently swapping to resting heart rate
Closes #584. `/insights/pulse` computed hasRestingHr from
RESTING_HEART_RATE presence and used it to swap the ENTIRE primary
chart and the Coach read strip to that series whenever any resting
row existed, while the title, stat strip, capture action, and
"show all values" link kept saying PULSE. A user could see a
resting-only chart under a page that claimed to be pulse throughout.

The chart now always includes PULSE as its first series; when resting
data exists, RESTING_HEART_RATE renders as a second, clearly-labelled
series (the same multi-series pattern the blood-pressure page uses for
systolic/diastolic) instead of replacing it. The Coach read strip
stays pinned to PULSE. The page also drops the resting-calibrated
target band it used to paint behind the chart — that band is
calibrated to steady-state resting readings and would misreport an
expected workout spike in raw PULSE data as "out of target"; the
personal target belongs on the dedicated resting-pulse page against
the clean resting series.
2026-07-23 09:38:03 +02:00
Marc-André Bombeck d750c1827e fix(settings): stop ntfy PUT from clearing the stored auth token on unrelated edits
PUT /api/settings/ntfy reconstructed the entire stored config from the
request body. GET never returns the write-only authToken (only
hasAuthToken), so clients correctly omit it on any edit that doesn't
touch the credential — but the route treated an omitted or empty token
as "clear it", silently destroying a working notification credential on
routine edits like toggling enabled.

An omitted or empty authToken now preserves the currently stored value;
only a non-empty value replaces it. Mirrors the identical headerValue
preserve-on-empty contract already shipped on the webhook channel route.
2026-07-23 09:28:39 +02:00
Marc-André Bombeck 1371bb1cf9 fix(measurement-reminders): recompute nextDueAt from the field actually set, not a nullish fallback
PATCH /api/measurement-reminders/{id} persisted an explicit null cadence
clear correctly, but the immediate nextDueAt recomputation merged fields
with `updateValue ?? existingValue` — a JSON null and an omitted field
are both nullish, so the merge silently fell back to the OLD cadence for
one cycle. This hit interval-to-RRULE and RRULE-to-interval transitions
and an explicit anchorDate clear alike.

The merge now keys off Object.hasOwn(updateData, field): updateData
already encodes exactly which fields are being persisted, including the
mutual-exclusivity auto-clear (setting one cadence nulls the other), so
recomputation can never diverge from what the row ends up holding.
2026-07-23 09:25:16 +02:00
Marc-André Bombeck 79843f8770 fix(import): stream the Apple Health ZIP extraction and sweep orphaned import jobs
Closes #588. The ZIP extractor decompressed export.xml synchronously
into one in-memory buffer, then wrote it with a blocking writeFileSync
— on the same event loop the web server runs in the default
single-container deployment. A real multi-year export could pin the
Node main thread for minutes, freezing every other request, while
briefly holding roughly 2x the decompressed size in heap. A process
that died mid-extraction never reached the failure path, so the
ImportJob row stayed stuck in "unpacking" with "0 rows imported"
forever and no error ever surfaced.

Extraction now streams through zlib's async inflate into a file write
stream, moving the decompression work off the event loop and bounding
memory to the chunk size. The zip-bomb ceiling moves from
inflateRawSync's maxOutputLength to an equivalent byte-counting
transform on the streamed output.

Orphan-ImportJob reconciliation used to run once, at worker boot.
docker-compose's restart:unless-stopped does bring a crashed worker
back, but a fast restart can land before the stuck row's heartbeat
goes stale or pg-boss stops reporting the job as live — and because
the boot-time pass never re-runs on a healthy worker, that row was
never revisited again. A 15-minute cron now re-runs the same reconcile
so a genuinely abandoned import converges to a visible failure instead
of staying silently stuck.
2026-07-23 09:23:28 +02:00