Withings sometimes replies HTTP 200 with a non-zero body status, empty
measuregrps, or a transient 503. Pre-v1.4.42 the client surfaced all of
those as identical `Error("Withings <verb> error: <status>")` throws,
and the sync layer used a message regex to bucket reauth vs transient.
That made rate-limit (601) and contract-mismatch (293/294) responses
silently retry forever without ever reaching an admin alert.
Adds `src/lib/withings/response-classifier.ts` — a pure
`(httpStatus, body) -> {success | transient | reauth_required |
persistent}` function plus a `WithingsApiError` subclass carrying the
verdict. The four client entrypoints (exchangeCode, refreshAccessToken,
fetchMeasurements, subscribeWebhook) and both v2-sync paths
(sync-activity, sync-sleep) now throw the typed error; sync.ts reads
err.classification directly via classifyError() and falls back to the
legacy regex when the prototype is lost on a pg-boss retry.
Extends FailureKind with `persistent`. Persistent failures audit with
kind="persistent" and surface a distinct admin-alert label
("persistent error" / "investigate the upstream contract") but keep
state=error_transient so the next sync still runs once the upstream
side fixes the mismatch.
Three test branches pinned (37 new tests, 103 -> 140 passing in
src/lib/withings + src/lib/integrations).
Creates the new src/lib/rollups/ aggregator directory and relocates
the main measurement rollup module:
- src/lib/measurements/rollups.ts → src/lib/rollups/measurement-rollups.ts
- src/lib/measurements/__tests__/rollups.test.ts →
src/lib/rollups/__tests__/measurement-rollups.test.ts
Every importer of @/lib/measurements/rollups is rewritten to
@/lib/rollups/measurement-rollups. The orphan sibling reference inside
src/lib/measurements/rollup-read.ts (still in its old home until the
next batch) is promoted to the @/lib alias so the chain keeps
typechecking through the in-flight move.
Pure relocation — no behaviour change, no exported-surface change. The
umbrella exists so the rollup contract is discoverable from a single
directory before the iOS sprint locks the boundary.
Three Withings ingest paths wrote measurements without ever firing the
rollup write hook: `syncUserMeasurements`, `syncUserActivity`, and
`syncUserSleep`. Once the dashboard chart's `source=rollup` fast-path
landed in v1.4.36 the under-counting started biting Withings-only
metrics on the 30 / 90-day range — Marc's BP / pulse charts painted
the empty-state at 30 even though the live `measurements` table held
the days. The 7-day window kept reading raw rows so the empty-state
only appeared above the chart's `< 3 daily points` threshold.
Each sync now collects every `(type, measuredAt)` it touched, collapses
to distinct `(type, dayStart)` via `collapseToTypeDayKeys`, and calls
`recomputeBucketsForMeasurement` once per pair after the row writes.
The per-day collapse keeps a 30-day catch-up bounded — at most ~30
DAY recomputes per sync rather than one per row. Best-effort: a
populator hiccup never fails the user's sync.
- Tests pin the hook call count on activity (6 calls for 2 days × 3
metrics, 3 calls when the dates collapse) and sleep (one per night
even when each night has multiple stage segments), plus a populator-
failure path that proves the sync still returns its imported count.
- Reproducer verified: stashing the sync-activity hook makes the new
test red on develop; restoring it greens.
WITHINGS_NOTIFY_APPLIS grows from [1, 2, 4] to [1, 2, 4, 16, 44]:
appli=16 (Activity) and appli=44 (Sleep v2) join the webhook-primary
set so the new sync routines fire in seconds rather than waiting for
the hourly cron fallback.
setupWebhook iterates the new list unchanged — Withings has no batch
subscribe endpoint, so every appli is its own POST. The two new
categories require the user.activity OAuth scope from W5d; legacy
connections that never reconnected return 503/293 on those subscribes,
which setupWebhook already logs as a per-appli warning while keeping
the remaining subscriptions live.
The Withings Sleep v2 sync writes one Measurement row per stage segment
(AWAKE | CORE | DEEP | REM) for the same night, all sharing the legacy
four-column composite (user_id, type=SLEEP_DURATION, measured_at,
source=WITHINGS). Migration 0055 adds sleep_stage as the fifth axis and
recreates the unique index with NULLS NOT DISTINCT so non-sleep rows
(sleep_stage IS NULL) still dedup on the first four columns — same
effective semantics as before for every meastype other than SLEEP_DURATION.
Prisma's generated compound input requires non-null fields, so the
Withings sync's upsert is reshaped as findFirst + create/update for the
non-sleep path (sleepStage is always NULL there). The new five-column
upsert key is the canonical path for the Sleep v2 ingest landing in W17c.
Move the Withings webhook shared secret from `?secret=…` to a path
segment (`/api/withings/webhook/<secret>`) so it never lands in the
reverse-proxy `query_string` access-log column or GlitchTip URL/
breadcrumb capture surface (W10 finding M-2).
Withings has no facility for custom HTTP headers on outgoing
notifications and never signs the body — every Notify.subscribe call
accepts only action/callbackurl/appli/client_id/nonce/signature, so
the callback URL is the only authenticity surface a subscriber
controls. Path-segment binding is the largest practical shift away
from loggable query strings that Withings supports end-to-end.
The legacy `/api/withings/webhook` route stays alive for one release
cycle (removal target: v1.4.27) so existing subscriptions keep
delivering. The query-string path now emits a deprecation breadcrumb
on every hit. Active users re-subscribe organically when they reconnect
for the v1.4.25 `user.activity` OAuth-scope upgrade.
Tests: 8 new for the path-segment route (valid / invalid / missing /
unset-env / prefix-match / rate-limited / HEAD / GET), 3 new for
`getWithingsWebhookCallbackUrl()`. Existing legacy-route tests
continue to pass over the refactored shared helper.
setupWebhook now subscribes to appli=1 (weight + composition),
appli=2 (temperature), and appli=4 (pressure family: BP, pulse, SpO2)
in one pass. The previous implementation only registered appli=1, so a
new BP reading from a BPM cuff sat for up to an hour waiting for the
hourly poll fallback instead of arriving via push within seconds.
A failure on one category is logged and we proceed with the rest —
losing one webhook is strictly better than rolling back all three.
The disconnect path mirrors the same iteration so cleanup unsubscribes
every category we ever subscribed to. Webhook handler already
dispatches `syncUserMeasurements` regardless of appli, so no change
needed on the receiver side.
Exports `WITHINGS_NOTIFY_APPLIS` as the single source of truth so the
unit test and the disconnect route stay in lockstep with setupWebhook.
v1.4.15 Phase B2, criterion 2. The Phase B2 status helper (commit
4db72a8) defined a markReauthRequired / recordSyncFailure API but
nothing called it. A Withings refresh token that had revoked got the
same "transient error" treatment as a 503 from the upstream API,
scheduled syncs kept hammering the OAuth endpoint, and the Settings
UI showed "Connected" until the user manually triggered a sync and
read the inline error.
This commit wires every sync / OAuth path:
- getValidToken() (Withings): refresh failures classified by
Withings status code. 100/101/102 (auth failed / invalid token /
user gone) and 200..299 (OAuth grant errors) -> error_reauth.
Anything else (5xx, network) -> error_transient. Same logic
inlined in /api/withings/status so the proactive refresh path
parks the row too.
- syncUserMeasurements() and syncMoodLogEntries() short-circuit
immediately when isReauthRequired() returns true. Scheduled
pg-boss jobs no longer burn API quota on a parked connection.
- moodLog 401/403 from the upstream -> error_reauth. 5xx / 3xx
redirect / network / invalid-JSON / bad-format -> error_transient.
SSRF refusal also writes a status row so it surfaces in the UI.
- /api/withings/callback calls markReconnected() after writing the
new tokens, the next sync runs.
- /api/withings/disconnect and DELETE /api/settings/moodlog call
markDisconnected() to clear any prior error state. moodLog
PUT /api/settings/moodlog (re-save credentials) markReconnected()s
for the same reason.
The helper writes AuditLog action=integrations.sync.failed on every
failure (criterion 4) and dispatches a SYSTEM_ALERT notification to
admins after consecutiveFailures >= 3 with a 24h re-alert window
(criterion 3). Both paths are covered by the unit + integration test
suites that landed in 4db72a8 — this commit makes them live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami
Five POST /test endpoints modelled on /api/ai/test (apiHandler +
requireAuth/requireAdmin + checkRateLimit 5/60s + annotate() +
sanitised error mapping):
- /api/integrations/withings/test — pings wbsapi.withings.net /measure
with the decrypted user access token
- /api/integrations/moodlog/test — HEAD against the user's decrypted
moodLog URL (5 s timeout, isPublicUrl
SSRF guard)
- /api/notifications/web-push/test — sends "Push test" via web-push to
every PushSubscription, returns only
endpoint host (never full URL)
- /api/monitoring/glitchtip/test — admin-only; sends a stub event via
sendGlitchtipEvent() using the saved
AppSettings.glitchtipDsn
- /api/monitoring/umami/test — admin-only; HTTPS-only GET against
AppSettings.umamiScriptUrl, checks
body for "umami" marker
Each route has a vitest suite covering: happy path, rate-limit denial,
missing config (422), and upstream-401 sanitisation (no sk-/Bearer/full
URL leak).
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
* fix(integration-tests): address review findings — singleton key, redirect SSRF, token refresh, scoped push
Multi-agent review caught a regression set on the five integration test
endpoints. Fixes here address every CRITICAL + HIGH finding:
- AppSettings is keyed on `id: "singleton"` everywhere else; the Glitchtip
and Umami test routes were querying `id: "default"` and would silently
return 422 in production. Tests now assert the where argument so the
typo can't sneak back.
- moodLog and Umami fetch with `redirect: "manual"` to neutralise the
redirect-follow SSRF (a public host could 302 to 169.254.169.254 or
RFC1918 ranges); 3xx is now treated as a configuration error.
- Withings test goes through the existing `getValidToken()` helper, so an
expired access token refreshes the same way the real sync does instead
of reporting "credentials rejected" until the next scheduled sync.
- Glitchtip DSN is validated as HTTPS + public host before any outbound
call, and the dispatch is wrapped in a 5 s `Promise.race` deadline.
- Withings dispatch picks up an `AbortController` 5 s deadline; the
Withings API-level `status` field (HTTP 200 + `status !== 0`) is now
categorised separately from the HTTP status (e.g. `status: 401` →
credentials_rejected, `status: 601` → rate_limited).
- moodLog test does an authenticated GET against the actual sync
endpoint instead of a bare HEAD on the configured URL — a HEAD on the
bare URL would return 200 from a generic landing page even when the
API key was invalid.
- Web Push test sends to the most recent subscription only (was: every
registered subscription, so a user with three devices got three buzzes
per click); tests assert findFirst+orderBy so this can't regress.
- Web Push response shape is the same shape on success and upstream
failure (errors land in the meta envelope, not in the visible
response body) — the prior dead-code in the 401 sanitisation test is
gone.
- Every error response now carries an `errorCode` in `meta` (e.g.
`credentials_rejected`, `rate_limited`, `dsn_host_not_public`) so the
upcoming UI can localise via `t("settings.testConnection.errors." + code)`
instead of rendering the server's English fallback.
- Outbound error annotations strip `Bearer <token>` and full URLs
before they reach Wide Events / Loki — push providers in particular
embed the subscription endpoint (which is the routing secret) in
410/404 error messages.
- Umami response read is capped at 256 KB so a hostile public host
cannot stream gigabytes through us; the marker check is tightened to
tracker shapes (`window.umami`, `umami.track`, `"umami"`) so a 404
page that says "Powered by Umami" no longer false-positives.
- `apiError` now optionally carries a `meta` object so error responses
can include the `errorCode` without breaking the existing
`{ data: null, error: <string> }` envelope.
477 tests green.
---------
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
HealthLog — personal health-tracking PWA with weight, blood pressure,
pulse, mood tracking, medication compliance, Withings integration,
doctor report PDF export, and Dracula-themed UI.