Commit Graph
681 Commits
Author SHA1 Message Date
Marc-André Bombeck 5a3b8b142d feat(onboarding): defer dashboard tour to second visit + add About replay button
WelcomeCarousel and TourLauncher both auto-launched when their gating
flags were null, stacking ~90 seconds of forced onboarding on top of a
brand-new user before they could touch the dashboard. The tour now
auto-launches only ≥ 24 h after the wizard finishes (new pure
`shouldAutoLaunchTour` helper); first-day visits belong to the welcome
carousel alone. Users who *want* the tour on day one can trigger it
from a new "Replay the tour" / "Tour ansehen" button in Settings →
About — same persistence + window-event flow as the existing Settings
→ Account restart button. A per-user `tour-force-launch` sessionStorage
marker carries the manual intent across the navigation back to the
dashboard so the 24 h gate stays out of the way of an explicit click.
2026-05-22 09:16:30 +02:00
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 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 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 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 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 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 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 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 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 2390438edc i18n: tighten 404 + global-error copy across all locales 2026-05-21 22:32:02 +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
Marc-André Bombeck 81b1eeea8d feat(coach): re-detect replayed injections from prior turns
v1.4.43 W13 M-3 — `detectRefusal` runs only on the inbound
`message` each turn. `priorTurns` are loaded straight from
`coach_messages.encryptedContent` and concatenated into the
`userPrompt` transcript every reply. An injection that slipped
past the regex bank on a previous turn was re-entering the prompt
verbatim on every subsequent turn, so the model saw it on every
reply — false-negative amplification.

Re-run `detectRefusal` against each user-turn re-loaded from DB
before building the transcript. On a hit, short-circuit the SSE
with a refusal AND emit an `audit.coach.replay-injection` row so
operators have a durable signal. The audit row carries the
conversation id, turn index, and matched reason — never the
message content, so the bypass payload doesn't survive into the
audit ledger.
2026-05-21 22:19:57 +02:00
Marc-André Bombeck 8767f81924 docs(security): pin plain-text invariant on admin-alert Telegram body
v1.4.43 W13 M-2 — `formatAdminAlertPayload` interpolates the
upstream-influenced `WithingsApiError.message` into the Telegram
body. Today the alert path uses no `parseMode` so an injection in
`json.error` renders literally and is inert.

The medication-reminder paths use `parseMode: "HTML"` because
their bodies are server-built from sanitised data; a future
contributor copying that pattern onto the admin alert would silently
turn the upstream string into an HTML injection vector. Pin the
constraint with a docblock so the next reader notices before
flipping the mode.
2026-05-21 22:19:57 +02:00
Marc-André Bombeck 7f84e95097 feat(audit): persist hashed-identifier row on check-user every branch
v1.4.43 W13 M-1 — `/api/auth/check-user` only emitted a wide-event
`annotate()` row. The wide-event stream is sampled and retained
shorter than the audit ledger, so an admin investigating account
enumeration had no durable evidence. Every other /api/auth/* route
already lands an `auditLog` row; this aligns check-user with that
pattern.

The identifier is hashed via the existing `hashToken` helper (same
shape v1.4.43 W3 H-1 fix lands on `auth.login.failed`), so the
literal user-typed string never reaches `AuditLog.details`. The
v1.4.20 retroactive PII directive bars raw user-typed strings from
operator-readable artefacts; this preserves the durable signal
without leaking the candidate.
2026-05-21 22:19:57 +02:00
Marc-André Bombeck 5a7484658e feat(withings): surface parked state + manual resume CTA
Pill UI gains a fifth state — 'parked' — for the v1.4.43 W14 24h
persistent-failure park. Copy reads 'Pausiert — manuell wieder
verbinden' (DE) / 'Paused — reconnect manually' (EN); chip uses the
dracula-orange tokens (heavier than warning yellow, lighter than the
red reconnect pill).

The Withings card renders a 'Wieder verbinden' / 'Reconnect' banner
under the pill when the state is parked. Clicking POSTs to the new
/api/integrations/withings/resume endpoint (rate-limited 5/min per
user, same envelope as the test-connection endpoint). The endpoint
calls resumeIntegrationFromPark which flips state back to connected
and zeroes every per-kind bucket; the per-card status query
invalidates so the pill flips back without a page refresh.

Locale parity across all six locales (en/de/es/fr/it/pl); end-to-end
integration test against real Postgres pins both the resume flow and
the per-kind bucket increment contract.
2026-05-21 22:19:29 +02:00
Marc-André Bombeck ee15aa05ea feat(integrations): park persistent failures after 24h + per-kind counters
B4 — parkIntegrationAtPersistent. A persistent failure streak (293/
294/contract-mismatch) that has been running for more than 24h now
flips the row state to 'parked'. A parked row stops retrying (the
sync entry-point short-circuits via isReauthRequired, now extended to
treat 'parked' the same as 'error_reauth'). The user/operator has to
call resumeIntegrationFromPark() — exposed for the new /resume API
endpoint — before the next cron tick will attempt anything.

B7 — consecutiveFailuresByKind. Failures now increment a per-kind
bucket instead of a single shared counter so a transient hiccup
followed by a persistent failure no longer masks the persistent
streak's true age. The alert-ladder reads Math.max across buckets for
backward compatibility; the legacy consecutiveFailures integer is
kept in sync for one release and removed in v1.4.44.

Back-fill: a row that predates the v1.4.43 W14 schema migration has
null consecutiveFailuresByKind. The status writer back-fills it on
the next failure by bucketing the legacy count into the CURRENT
failure's kind (so a row sitting at error_reauth with 4 failures
back-fills into reauth_required: 4).

An 'integrations.parked' audit row is written once on the
state-transition; resumeIntegrationFromPark writes an
'integrations.resumed' row but only when the input was actually
parked (idempotent for non-parked inputs).
2026-05-21 22:19:29 +02:00
Marc-André Bombeck 287eafc47e fix(ui): reserve loaded-card height on workouts + drug-level loading states
`RecentWorkoutsTile` loading branch and the GLP-1 `DrugLevelChart`
loading branch both rendered an unsized centred spinner / hint, so
the surrounding dashboard layout reflowed once the data resolved.
Add `min-h-[10rem]` to the workouts tile and align the drug-level
loading height with the loaded chart's 240 px so neither tile
shifts the rest of the page when data lands.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck ae4bfc646e a11y(charts): use responsive sizing on chart-overlay-controls trigger
The dropdown trigger combined `size="sm"` (32 px default) with a
forced `min-h-11 min-w-11` stretch. That was the v1.4.27 pattern;
v1.4.33 settled on the cleaner `size="default" + min-h-11 sm:min-h-9`
shape (so mobile hits 44 px and desktop drops to 36 px without the
stretched-button look the maintainer earlier called klobig). Adopt
the consistent pattern here.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck b828d86749 a11y(insights): bump tab-strip group-popover items to 44 px
The outer insights tab pills meet the 44 px WCAG 2.5.5 floor; the
inner group-popover items were 4 px short at `min-h-10`. Lift to
`min-h-11` so the popover hits the same target floor.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck 54fbbf8fc8 fix(charts): paint empty-window state instead of erasing the card
`health-chart.tsx` returned `null` when the loaded payload was
empty in the selected range, which stripped the entire widget
(header + tabs + chart). On the dashboard the user saw a missing
tile with no explanation. Keep the card chrome mounted and paint a
`<ChartEmptyState>` with distinct copy from the < 3-points
"Mehr Messtage erforderlich" branch so the two situations don't
blur. Adds `charts.noDataInRange*` strings across all six locales.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck d02deb1769 fix(dashboard): paint tile-strip skeleton during slow slim-analytics fetch
The tile strip only painted once `trendCards.length > 0`. When both
slim + thick `/api/analytics` slices lagged (cache eviction, cold
start) the user saw the page header + zero tiles + then a burst of
tiles seconds later. Reserve the strip's footprint by rendering N
silhouette cards (`auto-fit minmax(min(100%,11rem),1fr)` track,
matching the real strip) keyed off the user's configured tile count
while `analyticsSlimQuery.isLoading`. The placeholders swap to the
real tiles the moment slim resolves; the EmptyState branch is
suppressed for the loading window so the user no longer sees a
flash of "no data" when the network is just slow.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck 9c9a99aea4 fix(insights): mirror scatter-chart aspect ratio on correlation-card skeleton
The skeleton was a fixed `h-[180px]` while the loaded scatter chart
is `aspect-square min-h-[180px] sm:aspect-[3/2] sm:h-auto` — on
`sm+` the loaded chart paints ~60 px taller than the placeholder
reserved, which CLS-shifted insights cold mount. Mirror the quartet
on the skeleton so it reserves the right space at every breakpoint.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck 610cead87c fix(insights): match dynamic-skeleton heights to loaded content + motion-reduce
The three insights mother-page `next/dynamic` placeholders
(DailyBriefing / CorrelationRow / TrendsRow) reserved `h-48 / h-32 /
h-64` while the loaded content is ~24 rem / 20 rem / 20 rem tall, so
the page CLS-shifted noticeably on slow networks. They also lacked
`motion-reduce:animate-none` on the `animate-pulse`, so motion-
sensitive users saw a continuous pulse. Bump the reserved heights and
add the modifier; a textual guard test pins both.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck de2be19b64 fix(medications): bump phase-config-dialog Input + Button to 44 px on mobile
The duration Input and the MIN/% toggle Button were both `h-8` (32 px,
below WCAG 2.5.5) and the Input pinned `text-sm` which defeated the
`Input` primitive's iOS-zoom defence (`text-base md:text-sm`).
Drop the size override on the Input and bump both heights to
`h-11 sm:h-9` so phones get a 44 px target while desktop keeps the
dense row.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck 97d39fc3d5 a11y(ui): pair animate-spin with motion-reduce:animate-none across 21 sites
The audit found that 21 of 128 `animate-spin` call sites lacked the
`motion-reduce:animate-none` modifier, so motion-sensitive users
still saw continuous rotation in those spinners. Sweep all remaining
sites (settings, admin, charts, dialogs, mood-list) and add a textual
guard test that scans `src/**` and fails if a future spinner is added
without the modifier.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck 600aa3697c fix(api/dashboard): drop 7-day filter on latestEver + widen sparkline (REG-11)
Der `/api/dashboard/summary`-Endpoint hat für Blutdruck und Puls
`latestValue: null` plus `sparkline: []` zurückgegeben, sobald der
letzte Wert des Nutzers älter als sieben Tage war. Der iOS-Kachel
hatte dann weder Zahl noch Chart, obwohl die Daten in der DB lagen
und der Detail-Screen sie auch korrekt anzeigte. Fünf iOS-seitige
Anläufe blieben wirkungslos, weil der Bug im Server-SQL saß.

- `latestIn7d` → `latestEver`: WHERE-Filter `m.measured_at >=
  ${sevenDaysAgo}` entfernt. `DISTINCT ON (type)` über alle nicht-
  gelöschten Rows liefert weiterhin nur eine Zeile pro Typ, jetzt
  aber den all-time-letzten Wert. Zeilenanzahl bleibt durch
  `|measurementTypes|` beschränkt.
- `sparkBuckets`: Kalender-Filter `r.bucket_start >= ${sevenDaysAgo}`
  durch ein `ROW_NUMBER() OVER (PARTITION BY type ORDER BY
  bucket_start DESC) <= SPARK_DAYS` ersetzt. Liefert die letzten
  sieben Tages-Buckets pro Typ, unabhängig vom Alter. Eine BP-Messung
  von vor 60 Tagen bekommt damit wieder ein Chart.
- BP- und Puls-Karten zusätzlich um das gleiche `if (latest ||
  meta.allTimeCount > 0)`-Gate ergänzt, das BodyFat seit v1.4.33
  schon hatte. Accounts, die diese Werte noch nie geloggt haben,
  bekommen keine leeren Platzhalter-Kacheln mehr.

Tests in `src/app/api/dashboard/summary/__tests__/route.test.ts`
angepasst plus vier neue Cases für REG-11:
- BP mit 60 Tage altem Wert → Kachel emittiert mit historischem
  `latestValue` + `sparkline`.
- BP mit Null-Readings ever → Kachel NICHT emittiert.
- Weight innerhalb 7d → Verhalten unverändert (Regression-Guard).
- Sparkline-Window nimmt 7 Daily-Buckets auch wenn alle älter als
  7 Kalendertage.
2026-05-21 21:55:01 +02:00
Marc-André Bombeck 0fbe68a313 feat(workouts): wire batch route through user source priority
The batch handler now looks up `User.sourcePriorityJson` once at the
start of the request and threads it into the write-time canonical-row
picker. A user with a customised Settings → Sources ladder keeps
their preferred row across the ingest pipeline; the route picks up
the v1.4.43 helper's user-aware path with one indexed findUnique per
batch.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck d1be9f0177 feat(workouts): thread user source priority into write-time picker
The write-time canonical-row picker now accepts an optional
`userPriorityJson` argument and resolves the ladder through the same
`parseSourcePriority` + `getSourceLadder(..., "steps")` chain the
read-time picker walks. A user who promoted MANUAL above APPLE_HEALTH
in Settings keeps their preferred row at both ingest and read time;
omitting the argument falls back to the canonical default ladder so
existing callers keep their v1.4.42 behaviour. Header docstring
revised to mark the source-priority divergence closed.
2026-05-21 21:46:12 +02:00