Commit Graph
1977 Commits
Author SHA1 Message Date
Marc-André Bombeck 76e62283fa test(integrations): align status tests with dropped legacy counter + lock in down-script recipe
Five test surfaces updated to match the v1.4.47 W1 drop:

  - `src/lib/integrations/__tests__/status.test.ts`
    * `recordSyncSuccess`, `markDisconnected`, `markReconnected`,
      `resumeIntegrationFromPark` upsert assertions drop the
      `consecutiveFailures: 0` field.
    * Alert-ladder tests now seed the existing-row mock with a bucket
      payload at N-1 so the in-memory increment lands at N — the
      writer no longer consults the upsert return's
      `consecutiveFailures` for the threshold check.
    * The "back-fills from legacy single counter" case is replaced
      with a "seeds a fresh zero envelope when the row has no bucket
      payload yet" case (the back-fill path is gone).
    * New `down-script restores legacy consecutiveFailures via
      Math.max` block locks in the migration 0076 rollback recipe at
      the JS level: an in-memory `projectLegacyCounter` mirrors the
      SQL `GREATEST(transient, reauth_required, persistent)`
      projection and is asserted against zero, null, the 28-deep
      production-shape persistent streak, and parity with the live
      alert ladder's `Math.max(...Object.values(buckets))`.
    * Stale `consecutiveFailures: N` properties on Prisma mock returns
      are stripped (the writer no longer reads them).

  - `src/lib/integrations/__tests__/admin-alert.test.ts`
    * `parkIntegrationAtReauth` "creates a fresh row at counter=0"
      now asserts the zero bucket envelope rather than the dropped
      `consecutiveFailures: 0` literal.
    * The "not.toHaveProperty consecutiveFailures" assertion gains
      a sibling for `consecutiveFailuresByKind` — the bucket is the
      live counter now, and `parkIntegrationAtReauth` must still leave
      it untouched on a re-park.

  - `src/components/settings/__tests__/integrations-section.test.tsx`
    * The six fixture envelopes drop the now-removed
      `consecutiveFailures: N` field (the view-model dropped the
      property in this release).

  - `tests/integration/integration-status.test.ts`
    * Five `row.consecutiveFailures` assertions are migrated to
      `row.consecutiveFailuresByKind` matchers carrying the same
      counter value bucketed by failure kind.
    * The parked-state fixture's `integrationStatus.create` call no
      longer sets the dropped column.
2026-05-22 09:16:30 +02:00
Marc-André Bombeck 0cdf974e07 refactor(integrations): drop legacy consecutiveFailures from status writer + readers
Now that migration 0076 has dropped the `consecutive_failures` column,
the status writer no longer needs to back-fill from the legacy
integer, increment two parallel counters, or read the column in the
alert-ladder reducer.

Concrete changes:
  - `IntegrationStatusSnapshot` drops the `consecutiveFailures` field
    (the per-kind bucket payload remains as the sole counter).
  - `recordSyncFailure` removes the back-fill branch and the
    `consecutiveFailures` field from both `create` and `update` upsert
    payloads. The audit row's `attemptNumber` now sums the buckets so
    operators see the same running total they saw before the drop.
  - The alert-ladder reader becomes
    `Math.max(...Object.values(buckets))` — no parallel read of the
    legacy column.
  - `markReauthRequired` switches its create-side seed from
    `consecutiveFailures: 1` to a bucketed
    `{ transient: 0, reauth_required: 1, persistent: 0 }` so first-time
    out-of-band reauth detections still seed the bucket the alert
    ladder reads.
  - `recordSyncSuccess`, `resumeIntegrationFromPark`, `markDisconnected`,
    `markReconnected`, `parkIntegrationAtReauth` drop the legacy column
    from their update payloads.
  - The `/api/integrations/status` view-model + the settings
    integrations-section view-model drop `consecutiveFailures: number`
    from their TypeScript shapes (it was declared but never read in
    the UI — only the bucket payload was consumed).
2026-05-22 09:16:30 +02:00
Marc-André Bombeck 55bb5e9432 chore(schema): drop legacy consecutive_failures column on integration_statuses
v1.4.43 W14 introduced consecutive_failures_by_kind as the bucketed
per-FailureKind counter and kept the single-column consecutive_failures
integer for one release as a fallback for v1.4.43 readers mid-deploy.
v1.4.45 W10 closed the back-fill write gap so the bucket and the
legacy column post-write at the same N+1 value, and the >24h release
gap since v1.4.43 means every active integration in production has
written at least one failure or success, so every row's
consecutive_failures_by_kind is populated.

The legacy integer is now redundant: the alert ladder reads
Math.max over the bucket values and no other call site consults the
column. Migration 0076 drops it with an IF EXISTS guard. The
reversibility recipe is documented inline so a rollback can rebuild
the column from GREATEST(transient, reauth_required, persistent) over
the existing JSON payload.
2026-05-22 09:16:30 +02:00
Marc-André Bombeck 185e626651 feat(withings): random-nonce OAuth state with single-use ledger row
The legacy OAuth state shape `${user.id}:${random16}` (v1.4.42-) put
the user id on both the URL state param and the `withings_state`
cookie. httpOnly + Secure + sameSite:lax shut the XSS exfiltration
surface, but the user id still leaked into any request-log entry
where the cookie is not redacted and into network-traffic captures
of the redirect. Cosmetic today; a future refactor flipping the
cookie to non-httpOnly would silently expose it.

The v1.4.43 security audit (L-1) recommended switching the state to
a fully-random nonce backed by a short-lived `(nonce -> userId)`
ledger row, with the callback resolving the user via the row rather
than parsing it out of the cookie. This change lands the migration
end-to-end:

- New `WithingsOAuthState` table (migration `0076`): nonce PK, userId
  FK with cascade, createdAt default-now, expiresAt + index for the
  cleanup sweep.
- `withings/connect` mints a 22-char base64url nonce
  (`randomBytes(16).toString("base64url")` — 128 bits, OAuth 2.0
  §10.10 recommended threshold), persists the row with a 10-minute
  TTL, and sets the cookie to JUST the nonce (no user id).
- `withings/callback` verifies the cookie/URL pair byte-for-byte
  (timing-safe), then looks up the row, asserts `expiresAt > now()`
  AND `row.userId === session.user.id` (defence-in-depth — the audit
  rec was to resolve user identity from the row; cross-checking it
  against the session covers a stolen-cookie + cross-user scenario),
  and deletes the row on every exit branch (single-use semantics, so
  a replay of the same nonce fails the second time).
- Daily cleanup cron at 03:20 Europe/Berlin slots between the
  audit-log cleanup (:15) and the mood-reminder cleanup (:25); the
  helper module mirrors the audit-log-cleanup shape so the worker
  contract is symmetric.

Tests:

- 3 unit cases pin the cleanup helper contract (deleteMany shape,
  default-now branch, idempotent re-run).
- 2 unit cases pin the mint helper + constants (22-char base64url,
  10-minute TTL, cookie name preserved across the deploy window).
- 5 integration cases pin the connect -> callback ledger lifecycle
  end-to-end against real Postgres: row mint, no-credentials short-
  circuit, single-use consumption + replay rejection, expired-row
  rejection, cross-user mismatch rejection.
- The existing `withings-oauth.test.ts` integration cases updated to
  reflect the new state shape; the legacy `${user.id}:${random32}`
  assertion was the only test relying on the old contract.

Files: 12. Tests: 5076 -> 5108 (+32 unit; integration adds 5 cases
plus 2 case updates on the existing file).
2026-05-22 09:16:30 +02:00
Marc-André Bombeck 887d448ecc feat(settings): drag-to-reorder dashboard widgets with @dnd-kit
The dashboard layout section already let users reorder widgets via ↑/↓
arrow buttons; the v1.4.43 QoL audit (M1) flagged that modern parity
with Apple Health and Withings web expects drag-to-reorder as the
mouse/touch surface. Adds a GripVertical drag handle on each row wired
to @dnd-kit/sortable. The keyboard fallback stays — arrow buttons are
unchanged and the new handle is also keyboard-operable via dnd-kit's
KeyboardSensor (Tab + Space + arrows).

Both surfaces persist through the same draft → PUT path the arrow
buttons already used, so save / cancel / reset semantics carry over
without changes. Pure reorder logic is extracted into a shared
`reorderWidgets` helper so the mutation payload contract is pinned by
unit tests without spinning up a DndContext.

Bundle delta: ~29 KB gzipped (core 22 + sortable 5 + utilities 2),
well within the 50 KB budget called out in the audit.

All six locales gain `dragHandle` (button label) and `dragHandleHint`
(shared `aria-describedby` paragraph) keys.
2026-05-22 09:16:30 +02:00
Marc-André Bombeck 3a241dbe35 feat(ui): extract <Textarea> primitive with iOS-zoom + WCAG defences
Six textarea call-sites had been copy-pasting the same className with
the text-base sm:text-sm iOS-zoom defence; the audit flagged it as a
drift risk. New shadcn-style primitive in src/components/ui/textarea.tsx
mirrors <Input>'s shape and bakes in the defence, the WCAG 2.5.5 tap-
target floor (min-h-11 sm:min-h-9), autoCapitalize="sentences" /
spellCheck defaults for prose, and the autoComplete="off" + password-
manager ignore posture from <Input>.

Migrates four call-sites onto the primitive (bugreport, medications
JSON import, side-effect notes, admin feedback note) — saves ~16 LOC
of inline copy-paste and ~850 chars of repeated className strings.
The coach-input textarea stays as-is: it's an auto-grow composer with
a transparent bg wrapped in an outer "fake input" div, so migrating
would require nullifying nearly every primitive default.

11 new unit tests lock the contract (iOS zoom class, tap-target floor,
forwardRef sentinel, override semantics, attribute pass-through).
2026-05-22 08:54:49 +02:00
Marc-André Bombeck 8d0ae98afd fix(coach,withings): drop legacy_form_total counter + add navigator.onLine pre-check
v1.4.47 W7 — the in-memory legacy_form_total counter from v1.4.25 W21 Fix-K was per-process and useless on multi-container deploys; the access-log warning already carries the signal it surfaced. Drop the counter + the admin-status field that exposed it.

v1.4.47 W8 — Coach send-message path pre-checks navigator.onLine so an airplane-mode user gets the offline-specific 'coach.network' copy immediately instead of waiting for the fetch to fail with a generic network error. Surfaces W12 M6 properly.
2026-05-22 08:49:44 +02:00
Marc-André Bombeck 3e0eb77b4d test(intake): pin wall-clock on idempotency tests
Both 'is idempotent on a second pass' and 'does not double-mint when today's rows already exist' hard-coded the scheduledFor to 2026-05-21T05:00Z while the route's projection uses new Date(). On 2026-05-22 the projected day diverged from the existence-row date and createMany fired, breaking the regression guard. vi.setSystemTime pins both ends to the same calendar day.
2026-05-22 08:44:00 +02:00
Marc-André Bombeck 690cfaf0a2 Merge remote-tracking branch 'origin/main' into develop 2026-05-22 08:39:17 +02:00
Marc-André Bombeck ac319c6160 chore(release): v1.4.46 — server reconcile (PR worker + auto-skip + APNS admin test)
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).
v1.4.46
2026-05-22 01:06:55 +02:00
Marc-André Bombeck aced3f6f3f chore(release): v1.4.46 — server reconcile (PR worker + auto-skip + APNS admin test) 2026-05-22 00:59:05 +02:00
Marc-André Bombeck 12753d4531 feat(api/admin): wire APNS branch in notifications test endpoint
v1.4.46 — the admin "Send test notification" switch handled TELEGRAM,
NTFY, and WEB_PUSH but had no APNS arm. An iOS-paired admin's APNS
channel fell through to the "Unknown channel type" default branch, so
the Settings → Notifications "Test" button reported the channel as
broken even though the dispatcher routed real notifications through
APNs without issue.

Mirror the WEB_PUSH branch: `sendViaApns(userId, payload)` owns its
own per-device fan-out (`prisma.device.findMany` for the user's iOS
devices) and returns the same `SendOutcome` envelope the other senders
do. On failure the route surfaces the sender's `reason` in the
per-channel error (e.g. `apns_no_devices`, `apns_not_configured`) so
the admin UI shows the actionable cause rather than a generic "Send
failed".

Six regression cases pin the contract:
  * APNS channel triggers sendViaApns with the canonical payload,
  * success → `{ channel: "APNS", success: true }`,
  * failure with reason → reason surfaces in the per-channel error,
  * failure without reason → falls back to `apns_send_failed`,
  * APNS + WEB_PUSH fan-out in the same response works,
  * APNS no longer hits the "Unknown channel type" default arm.
2026-05-22 00:56:39 +02:00
Marc-André Bombeck 7cf760325f feat(jobs): hourly cron auto-skips past-due unmarked intakes
v1.4.46 — a `MedicationIntakeEvent` that the user neither took nor
manually skipped used to stay in the pending state forever. The
dashboard compliance tile counted it as `pending` indefinitely,
inflating the missing column on the streak chart and crowding the
next day's intake out of the "today" window.

New `intake-auto-skip` cron runs hourly at :05 (off the :00
reminder-check and :30 moodlog-sync ticks) and flips `skipped = true`
for every event matching:

  * `skipped = false` AND
  * `takenAt IS NULL` AND
  * `scheduledFor < NOW() - INTERVAL '24 hours'`

The 24 h grace window is intentional: a late mark (morning dose at
noon) shouldn't auto-skip before the user has had a full day to
record it. Anything older is reliably forgotten — compliance needs a
terminal state to count it as a real miss.

The flip is the same state the manual "Skip" button writes, so the
compliance rollup recompute path picks new rows up via the next
ingest / dashboard read with no extra plumbing.

Wired through `reminder-worker.ts` (`createQueue` + `boss.schedule` +
`boss.work` registration) alongside the existing inventory-expire
cron. Helper lives in `@/lib/jobs/intake-auto-skip` so a unit test
can drive it with an in-memory fake; eight cases pin the cron + grace
contract, the spec'd `updateMany` shape, idempotency on re-run, and
the strict `<` boundary semantics.
2026-05-22 00:53:55 +02:00
Marc-André Bombeck ce369fac89 fix(personal-records): aggregate cumulative kinds by calendar day before ranking
REG-9 (v1.4.46): the PR-detection worker's `findBestMeasurement` picked
the largest single per-hour slice for cumulative HK kinds (ACTIVITY_STEPS,
ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE,
TIME_IN_DAYLIGHT) instead of the actual daily total. A 4 000-step hour
on a hike would lock in as the user's "best step count" while the real
24 000-step day was ignored.

Route every member of `CUMULATIVE_HK_TYPES` through a new
`findBestCumulativeDay` helper. The helper SUMs each `(user_id, type)`
partition by `date_trunc('day', measured_at)` via $queryRaw, picks the
winning day by `ORDER BY day_total {DESC|ASC} LIMIT 1`, then re-reads
the latest slice on that day to recover unit/source/externalId for the
PR row. The PR's `value` is the day-sum; `achievedAt` is the
`MAX(measured_at)` on the winning day so the unique-index contract
`(userId, metricType, metricSlot, achievedAt)` stays stable across
re-runs. Spot kinds (resting HR, VO2 max, HRV, body composition) keep
the existing per-row pick — each row already represents the day's
measurement.

Two regression cases pin the contract:
  * 24 000-step day (8x 3 000) beats 8 000-step single-hour fragment.
  * Drift guard: every PR-trackable cumulative HK type lands on the
    day-sum path.
2026-05-22 00:50:44 +02:00
Marc-André Bombeck f18654f3de Merge remote-tracking branch 'origin/main' into develop 2026-05-21 23:14:28 +02:00
Marc-André Bombeck 6c7fc7dc84 chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged
Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
v1.4.45
2026-05-21 23:13:21 +02:00
Marc-André Bombeck ad5a1586a1 test(withings): drop emptyRequest arg from resume POST calls
POST handler signature was tightened from (request: NextRequest) to () after the simplifier-M4 reconcile dropped the unused param. The companion test file still called POST(emptyRequest() as never).
2026-05-21 23:12:31 +02:00
Marc-André Bombeck ab7c491716 chore: merge v1.4.44 hotfix branch + retarget release to v1.4.45 2026-05-21 23:11:03 +02:00
Marc-André Bombeck 5e3c47c75f chore(release): v1.4.43 — analytics 9 s perf fix + audit-marathon closure + Zod multi-issue rollout + Withings parked-state automation
Fourteen touch-disjoint waves landed: nine close every audit Critical + High plus recurring polish (chart-gate raw-count, QoL copy + plural forms, Withings classifier wiring, ops hardening with the BuildKit version-pin lesson from v1.4.42), one rolls out returnAllZodIssues to the 41 sibling routes from the v1.4.42 W2 backlog, one closes the v1.4.42 W2-WITHINGS deferred B4 (24 h persistent-failure park) + B7 (per-kind counters), three companion waves close the audit Mediums + Lows for mobile-UI / QoL / security, and a six-axis W10 QA round (code-reviewer, security, design, senior-dev, product-lead, simplifier) drove the reconcile that gated this tag.

Tests: 4815 -> 5076 unit (+261). Quality gates: typecheck, lint, knip (enforcing) all green. Migration: 0075_v1443_integration_park — operator runs prisma migrate deploy post-deploy. iOS v0.5.4 contract preserved (Zod envelope additive; new endpoints additive).
2026-05-21 23:06:41 +02:00
Marc-André Bombeck 76a7cbcff8 docs(planning): v1.4.43 W10 QA round findings reports
Six axes — code-reviewer, security, design, senior-dev, product-lead, simplifier — output to .planning/round-v1443-QA-<axis>-findings.md. Verdicts:

- code-reviewer: APPROVE_WITH_FIXES (1C closed, 1H = version bump)
- security: APPROVE (0C/0H)
- design: APPROVE_WITH_FIXES (1C closed, 1H closed)
- senior-dev: APPROVE (0C/0H + 1M closed)
- product-lead: SHIP (0C/1H closed)
- simplifier: CHANGES_REQUESTED (1C + 2H all closed)
2026-05-21 23:06:20 +02:00
Marc-André Bombeck 468e38c3ef fix(reconcile): close W10 QA Mediums and Lows
Bundle of small reconcile fixes from the W10 QA round:

- Switch tap-target padding -12 px -> -13 px closes the 1.6 px vertical short-fall to spec-strict WCAG 2.5.5 (design M1).
- animate-pulse skeleton sites in daily-briefing, trend-annotation and doctor-report dialog now carry motion-reduce:animate-none (design M2).
- Doctor-report unavailable rows render with text-muted-foreground instead of line-through so the visual reads as 'unavailable' rather than 'deleted'; the italic hint line below still carries the semantic (design M4).
- WelcomeCarousel scrollIntoView now reads scrollBehaviorForUser from src/lib/motion so all four advertised call-sites share the helper (simplifier L-1).
- Coach replay-injection audit row renamed audit.coach.replay-injection -> insights.coach.replay_injection to match the <surface>.<verb> convention across the audit ledger (simplifier L-2).
- /api/integrations/withings/resume drops the unused request param (the handler reads neither body nor headers; requireAuth scopes the call) (simplifier M-4).
- verifyRegistration body gets the same Zod boundary narrow W13 L-3 added to verifyAuthentication; SimpleWebAuthn still owns cryptographic validation (senior-dev L-1).
2026-05-21 23:06:13 +02:00
Marc-André Bombeck f1e16bf713 fix(integrations): increment per-kind bucket on back-fill path
The legacy single-counter column writes { increment: 1 } so the post-write value lands at N+1, but the back-fill seed wrote buckets[kind] = legacyCount (= N) without a compensating +1. Post-write the bucket would read N while the legacy column read N+1; when v1.4.44 drops the legacy column every back-filled row would page exactly one failure too late.

v1.4.43 W10 medium-reconcile — senior-dev M-1.
2026-05-21 23:05:59 +02:00
Marc-André Bombeck 89071e03d9 refactor(audit): share dedup helper between widgets and chart-overlay-prefs
Extracted shouldEmitAuditRow + __resetAuditDedupMemoForTests to src/lib/audit-dedup.ts so the chart-overlay-prefs route inherits the same 60 s (userId, action) window the widgets route got in W8 B2. Prevents a misbehaving iOS client looping on the chart overlay popover from pumping thousands of dashboard.chart-overlay.validation-failed audit rows per minute.

v1.4.43 W10 high-reconcile — simplifier H-2.
2026-05-21 23:05:52 +02:00
Marc-André Bombeck 120e318cc7 feat(settings): wire warning pill state for persistent Withings failures
W4 added a 'warning' state to IntegrationStatusPill plus locale copy ('Verbunden, aber Serverfehler') for the v1.4.42 persistent FailureKind (Withings 601 / 293 / 294), but the wave's allow-list excluded integrations-section.tsx. The pill state existed as dead code. Wire pillStateFor to return 'warning' when the integration's state is error_transient AND the consecutive persistent-bucket count is > 0, so a user clicking reconnect ten times on an upstream contract mismatch finally sees a different signal.

v1.4.43 W10 high-reconcile — product-lead PL-H1, design H1, simplifier H-1.
2026-05-21 23:05:44 +02:00
Marc-André Bombeck 02c192c048 fix(i18n): restore relative-time plural keys swept by W12 tighten
W4's pluralised relativeMinutesAgoOne/Other + relativeHoursAgoOne/Other + relativeDaysAgoOne/Other keys were silently removed across all 6 locales when W12's i18n tighten (commit 2390438e) re-applied the legacy singular keys. The formatRelativeTime helper still reads the *One/*Other keys, so the Insights hero, Daily Briefing, and Coach history rail all paint raw key strings to users.

v1.4.43 W10 critical-reconcile — code-reviewer C-1, design C-1, simplifier C-1.
2026-05-21 23:05:36 +02:00
Marc-André Bombeck 154e6fa751 test(auth): mock checkAuthSurfaceRateLimit in login + register tests
W6's auth-route tests mocked checkRateLimit. W13's M-4 wrapped the auth surfaces with checkAuthSurfaceRateLimit so the un-mocked wrapper resolved as undefined at runtime and the routes 500'd instead of returning 401/422. Adding the wrapper to the mock factories + arming it in beforeEach restores green.
2026-05-21 22:43:00 +02:00
Marc-André Bombeck 32ca196e9d fix(i18n): restore chart keys W11 + W2 added across all locales
W11's noDataInRangeTitle/Description and W2's needMoreDistinctDaysTitle/Description were both keyed under 'charts' and were swept out when W12's i18n tighten on the same root branch was applied without preserving sibling-wave additions. Restoring the four keys with their translations across de/en/es/fr/it/pl.
2026-05-21 22:43:00 +02:00
Marc-André Bombeck a8e2310fb0 docs(planning): W6-ZOD-ROLLOUT v1.4.43 phase report 2026-05-21 22:37:10 +02:00
Marc-André Bombeck d556d7c339 refactor(api): roll out returnAllZodIssues to auth/settings/admin routes
Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
the remaining 12 sites: auth password + register, admin settings +
assistant-flags + feedback PATCH + users PUT, user thresholds, public
feedback + bugreport submissions, and the three consent endpoints.
Status codes preserved everywhere — consent routes keep 400, the rest
stay 422. No audit-ledger breadcrumb on this batch (low-traffic admin
and onboarding flows).

Also tops up the legacy `measurements/__tests__/group-by-day` mock with
the `prisma.auditLog.create` shim now that the route's validation-
failed path writes a breadcrumb.

Tests: 26 new unit cases (2-issue + 3-issue per site; cadence-style
narrow schemas pinned on ≥ 1).
2026-05-21 22:37:10 +02:00
Marc-André Bombeck 1592fcc52b refactor(api/medications): roll out returnAllZodIssues to CRUD routes
Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
8 medication-CRUD sites (top-level POST, by-id PUT, glp1, inventory +
per-item PATCH, cadence GET, side-effects GET + POST). No audit-ledger
breadcrumb on this batch — these are low-traffic admin / detail-page
flows where the 422 response itself is the diagnostic signal.

Tests: 17 new unit cases (2-issue + 3-issue per site; cadence pinned
on ≥ 1 because its schema only has a single `days` knob).
2026-05-21 22:36:27 +02:00
Marc-André Bombeck 3af26c5c17 refactor(api): roll out returnAllZodIssues to iOS-contract routes
Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
21 iOS-contract / high-traffic routes so a multi-field validation
failure surfaces every Zod issue in one round-trip under
`details.issues` rather than walking the client through one error per
re-submit. Adds a fire-and-forget `<route>.validation-failed`
audit-ledger breadcrumb on each path so the operator can grep
`/api/admin/audit` for the same trail.

Routes touched:
- measurements list/batch/single + series + edit
- medications intake (top-level GET/POST + bulk + per-med
  POST/GET/PATCH + per-event PUT + CSV import)
- mood-entries list/POST + bulk + by-id PUT
- dashboard chart-overlay-prefs
- integrations healthkit, ingest medication, devices, tokens

CSV-import keeps the legacy `Invalid format:` UI branch via a new
`meta: { errorCode: "medication.intake.import.invalid_format" }`
passthrough; mood/medications bulk preserve their existing
`mood.bulk.invalid` / `medications.intake.bulk.invalid` errorCodes.

Tests: 120 new unit cases (every route gets 2-issue + 3-issue +
audit-row write + audit-write-rejection survival proofs).
2026-05-21 22:36:20 +02:00
Marc-André Bombeck 67434987aa fix(i18n): restore parked/resume keys after W12 tighten swept them out
W14 added warningServerError, parkedReconnect, resumeCta, resumeSuccess, resumeError to the integrationPill block across all six locales. W12's i18n tighten on the same block reverted to a pre-W4/W14 baseline. Restoring the missing keys with their translations.
2026-05-21 22:35:30 +02:00
Marc-André Bombeck c8f15589b1 docs(planning): W12-QOL-RESIDUAL phase report 2026-05-21 22:32:02 +02:00
Marc-André Bombeck 14dca2df23 chore(layout): satisfy lint rules for offline-banner test and effect 2026-05-21 22:32:02 +02:00
Marc-André Bombeck 281e7a54d4 feat(measurements): unify timestamp display via formatDateOrRelative 2026-05-21 22:32:02 +02:00
Marc-André Bombeck e7a7e576a5 feat(layout): surface offline status banner across authenticated routes 2026-05-21 22:32:02 +02:00
Marc-André Bombeck 2b5d0e9c68 feat(doctor-report): show unavailable sections as disabled, not hidden 2026-05-21 22:32:02 +02:00
Marc-André Bombeck a911b335a0 feat(settings): add account-delete CTA and quieten danger-zone visuals 2026-05-21 22:32:02 +02:00
Marc-André Bombeck b2e2f78ac2 feat(charts): show 'still computing' caption on long chart loads 2026-05-21 22:32:02 +02:00
Marc-André Bombeck c0a2c8784e feat(pwa): re-anchor SW CACHE_VERSION on every build via prebuild step 2026-05-21 22:32:02 +02:00
Marc-André Bombeck 7c3d567430 i18n(format): respect the full Locale union in legacy formatter cookie reader 2026-05-21 22:32:02 +02:00
Marc-André Bombeck 373515c1f1 fix(coach): surface offline hint when network drops mid-stream 2026-05-21 22:32:02 +02:00
Marc-André Bombeck 13f4880f0c i18n: align measurement checklist copy with 5-reading trend threshold 2026-05-21 22:32:02 +02:00
Marc-André Bombeck 2390438edc i18n: tighten 404 + global-error copy across all locales 2026-05-21 22:32:02 +02:00
Marc-André Bombeck 71bbd094d7 i18n(de): align integration-pill daysAgo with 'd' shorthand 2026-05-21 22:30:09 +02:00
Marc-André Bombeck 2679894277 docs(planning): W13-SECURITY-RESIDUAL v1.4.43 phase report 2026-05-21 22:19:57 +02:00
Marc-André Bombeck 13d4c4f7d7 test(auth): wire check-user contract test to new rate-limit wrapper
v1.4.43 W13 M-4 follow-up — the contract test at
`src/__tests__/api/auth/check-user/route.test.ts` mocks
`checkRateLimit` but the route now invokes
`checkAuthSurfaceRateLimit`. Mock both, and rewire the
rate-limit-exhausted case to drive the wrapper (which is now the
gate). Also stub `hashToken` since the route hashes the identifier
for the audit row.
2026-05-21 22:19:57 +02:00
Marc-André Bombeck ef7e1f0321 feat(auth): Zod-narrow passkey authentication body at boundary
v1.4.43 W13 L-3 — `verifyAuthentication` parsed the inbound JSON
body via a raw `as AuthenticationResponseJSON` cast straight into
`verifyAuthenticationResponse`. The SimpleWebAuthn verifier owned
cryptographic validation downstream, but the cast bypassed TS's
type-narrowing — a future refactor reading `typedResponse.id`
before calling the verifier would have crashed on `undefined.id`.

Drop the cast, parse via a Zod schema that mirrors
SimpleWebAuthn's `AuthenticationResponseJSON` shape (id, rawId,
nested response with clientDataJSON / authenticatorData /
signature, type literal "public-key"). Permissive on unknown
top-level keys for forward-compatibility with future
authenticator-attachment / extension-result additions; the strict
cryptographic checks remain inside the verifier.

A malformed body now throws here with a structured
"Malformed passkey authentication response" error before any
deref, closing the boundary gap.
2026-05-21 22:19:57 +02:00
Marc-André Bombeck 58d3f5eaca test(coach): regression guard for free-text snapshot fields
v1.4.43 W13 L-2 — every snapshot builder is allowed to ship arbitrary
structured data into the SNAPSHOT JSON that prefixes the Coach
userPrompt; free-text fields (medication name, dose unit, note bodies)
MUST wrap through `sanitizeForPrompt` first. The audit verified this
holds today, but a new snapshot builder added without the import would
silently leak control sequences and could override the
patient-safety guardrails (e.g. dose-prescription refusal).

The guard walks the listed snapshot-builder source files, identifies
property-assignment lines whose key matches a free-text field
heuristic (`name`, `note`, `description`, `comment`, `drug`,
`doseUnit`, `dose`) and whose value expression reads from a
user-controlled identifier, then asserts the value path includes
`sanitizeForPrompt` — OR the file imports + uses the sanitiser
somewhere (consumer-side wrapping, GLP-1-plateau-context pattern).

Cheap, deterministic, fires on the canonical 'new builder forgot the
sanitiser' failure mode. Adds a sanity check that the heuristic
actually trips on a synthetic leak.
2026-05-21 22:19:57 +02:00
Marc-André Bombeck d324d5162b feat(rate-limit): tighten anonymous bucket on trust-chain misconfig
v1.4.43 W13 M-4 — every IP-keyed auth surface (login, register,
passkey/login-options, passkey/login-verify, refresh, check-user)
falls back to a literal `{prefix}:unknown` rate-limit bucket when
`getClientIp` returns null. A misconfigured `TRUST_PROXY_HOPS`
silently degrades precision for the rest of the process lifetime
(the warning fires once); one attacker exhausting the
`auth:login:unknown` bucket then locks every other anonymous
caller out of the surface.

The `checkAuthSurfaceRateLimit` wrapper consults
`getClientIpOrTrustWarning`: clean chain → existing per-IP
semantics, byte-equivalent; `trustViolation === true` → routes
to a single global `auth:anon:trust-violation` bucket capped at
100/15min across every auth surface. The blast radius caps while
the operator fixes the proxy chain.

Auth-surface routes converted to the wrapper:
- /api/auth/login (5/15min per IP, 100/15min global on violation)
- /api/auth/register (5/15min)
- /api/auth/passkey/login-options (10/15min)
- /api/auth/passkey/login-verify (10/15min)
- /api/auth/refresh (60/15min)
- /api/auth/check-user (30/15min)

/api/auth/password is keyed by `user.id` (already authenticated)
so it stays on `checkRateLimit` directly.
2026-05-21 22:19:57 +02:00