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).
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
`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.
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.
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.
`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.
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.
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.
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.
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.
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.
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.
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.
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.
The v1.4.42 docker-publish workflow shipped a stale package.json version
baked into the bundle because BuildKit reused the pnpm build layer
across the release tag bump -- the COPY . . layer key was content-stable
relative to the prior build, the layer was warm, and the runtime
/api/version read the cached package.json content instead of the new
tag.
Thread github.ref_name through as a NEXT_PUBLIC_APP_VERSION build-arg
from docker-publish.yml. The Dockerfile picks it up as an ARG/ENV in
both the builder and runner stages and bakes it into the build context.
src/app/api/version/route.ts now reads the env var first and falls
through to package.json only when unset (local pnpm dev). The build-arg
becomes part of the layer cache key, so every release tag invalidates
every downstream layer and the runtime version can never drift from
the tag the workflow ran on.
A misbehaving iOS client retrying a 422 in a tight loop could write
thousands of dashboard.widgets.validation-failed audit rows per minute.
Gate the audit-ledger write behind an in-process (userId, action) memo
with a 60 s window so the breadcrumb stays useful for grep traffic
without flooding the ledger. The 422 response itself is unchanged --
every retry still receives the full multi-issue envelope. Cluster
deployments cap at one row per process per minute; a process restart
resets the map (one extra row worst case).
Add a regression suite that asserts both syncUserActivity and
syncUserSleep route their catch-block through the shared typed-
classification helper rather than the legacy regex chain.
Body-status 293 (invalid params) and 601 (rate-limited) are the
canonical regression targets: the legacy regex bucketed everything
outside 100/101/102 + 200-299 as `transient`, so the typed path is
the only way 293 surfaces as `persistent` and 601 as `transient`
with the correct error code. Pinning the verdict for both paths
catches any future refactor that accidentally falls back to the
regex helper.
Also pins the 100 / 102 reauth-required paths to round out the
taxonomy coverage; both sync paths must produce the same verdict
for the same body-status code.
Mirror the activity-sync migration on the sleep path. The sleep
catch-block now consumes the shared `recordWithingsSyncFailure`
helper instead of re-parsing the error message via the legacy regex
chain.
Drops the `extractWithingsStatus` + `isWithingsRefreshReauthFailure`
imports from sync-sleep.ts; the BL-P3-2 403 short-circuit stays
inline at the call site for the same reason it does on the activity
path — 403 isn't a documented Withings body-status code.
The two sync paths now share one classification surface and behave
identically for every documented Withings status taxonomy entry.
Export `recordWithingsSyncFailure` from sync.ts and migrate the
activity-sync catch-block to consume it. The shared helper routes
through `classifyError`, which reads `WithingsApiError.classification`
directly and falls back to the legacy regex only for un-prototyped
errors (pg-boss JSON round-trip).
Drops the call-site re-parse of `extractWithingsStatus` +
`isWithingsRefreshReauthFailure`, removing one duplicate of the
classification taxonomy. The body-status 293 / 601 cases now classify
as `persistent` / `transient` respectively instead of the legacy regex
default of `transient` for everything outside 100/101/102 + 200-299.
BL-P3-2 defence-in-depth — the body-status 403 short-circuit stays
inline as a call-site override because 403 is not a documented
Withings response code (only the HTTP layer emits it for scope-gated
resources), so it has no entry in the shared classifier table.
Programmatic `scrollIntoView` / `scrollTo` calls in the settings shell,
admin shell, and coach message thread ignored the user's reduced-motion
preference — a WCAG 2.3.3 violation. Add a single `scrollBehaviorForUser`
helper in `src/lib/motion.ts` that returns `"auto"` when SSR or when
`(prefers-reduced-motion: reduce)` matches, and `"smooth"` otherwise.
Wire it through the three call-sites; tests cover all three branches.
Both Sheet and Dialog primitives carried a 36 px floor on the close
affordance — under the WCAG 2.5.5 target. Gate the size by viewport:
`min-h-11 min-w-11 sm:min-h-9 sm:min-w-9` lifts the smallest viewport
to 44 px while sm+ keeps the dense 36 px the headers were laid out
around. Avoids the 320 px crowding regression the inline comments
flagged on previous attempts.
Both mood entry surfaces rendered the overflow trigger at `h-9 w-9`,
which fails WCAG 2.5.5 on touch. Lift to `h-11 w-11`, matching the
medication-form pattern that already shipped at parity.
The chart-overlay popover comparison row used `h-9 px-1`, which falls
below the WCAG 2.5.5 floor on touch. Switch to
`min-h-11 sm:min-h-9 px-2` so the mobile button reaches the 44 px
target while desktop keeps the dense 36 px row that fits the popover.
WCAG 2.5.5 calls for a 44 px minimum interactive target. The Switch
visual track stays at 18x32 px but a `::before` overlay with
`inset-[-12px]` floats a transparent hit zone over the full 44x44
region. Mirrors the v1.4.34 IW-G treatment already applied to Input
and auto-fixes every Switch call-site in one move.
formatRelativeTime always read the *Ago key with count=1, so a German user reading the Insights hero saw 'vor 1 Minuten' / 'vor 1 Stunden'. Splits each bucket into One / Other variants across all locales and branches on count === 1 in the helper, matching the dashboard.staleHintWeeksOne / Other pattern already in the bundle.
global-error.tsx renders before any provider, so an i18n lookup is impossible. Doubling the four user-facing strings as 'Deutsch · English' avoids leaving a German visitor on an all-English last-resort screen without paying for a runtime locale resolver. Footprint is small (one heading, one paragraph, two button labels, one copy-fallback prompt).
The branded 404 splash hard-coded its three strings in English, ignoring the cookie/Accept-Language locale resolution the rest of the app uses. Switches to getServerTranslator + resolveServerLocale and adds an errors.notFound.{title,body,backToDashboard} block to every locale bundle.
The pill previously collapsed both error_transient and error_reauth onto the same red 'reconnect' chip. The v1.4.42 persistent failure kind (Withings 293 / contract mismatch) maps to error_transient but reauth won't fix it. Adds a fourth pill state — warning — with an amber dracula-yellow chip and locale-aware copy ('Verbunden, aber Serverfehler' / 'Connected, server error'). Wiring at the call-site is deferred so the persistent kind can flow through the API payload first.
The per-day drill-down used measurements.saveError in its fetch-error branch, which surfaced 'Fehler beim Speichern' on a read failure. Adds a measurements.loadError key in every locale and points the GET error path at it.
Mirrors the health-chart and mood-chart fixes. The compliance payload
already carries `scheduled` per day; summing it yields the raw
scheduled-dose count for the window. When the chart aggregates to
< 3 daily buckets but the raw count is ≥ 3, paint the new
"More distinct days needed" copy so the next action matches reality.
Pin both branches with an SSR test that mocks the compliance fetch.
Mirrors the health-chart fix. The mood-analytics endpoint already
exposes `entries[].samples` per calendar day, so summing it yields
the true raw mood-entry count for the window. When chartData
collapses to < 3 daily buckets but the raw count is ≥ 3, paint the
new "More distinct days needed" copy instead of the misleading "log
at least 3 entries" hint.
Pin both branches with an SSR test that mocks the analytics fetch.