Commit Graph
11 Commits
Author SHA1 Message Date
Marc-André Bombeck 5bb9b39dec feat(withings): classify off-responses into transient/reauth/persistent
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).
2026-05-21 19:01:15 +02:00
Marc-André Bombeck 29bfcc674b refactor(rollups): move measurement-rollups under src/lib/rollups umbrella
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.
2026-05-21 12:19:45 +02:00
Marc-André Bombeck ad22fa2e9d fix(withings-sync): fold persistent rollup tier on every touched (type, day)
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.
2026-05-21 09:01:35 +02:00
Marc-André Bombeck e504faa7b6 feat(withings): expand webhook subscriptions to activity + sleep v2
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.
2026-05-14 18:20:14 +02:00
Marc-André Bombeck df5a82bc6f feat(schema): extend Measurement composite with sleepStage (Migration 0055)
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.
2026-05-14 18:18:40 +02:00
Marc-André Bombeck a1ffa497f1 fix(security): Withings webhook secret as URL path segment, not query string
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.
2026-05-14 17:46:25 +02:00
Marc-André Bombeck a732b1926e feat(withings): subscribe to BP + temperature webhooks alongside weight
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.
2026-05-14 13:27:37 +02:00
Marc-André BombeckandClaude Opus 4.7 cd3b890067 refactor(v1.4.15): apply simplify-review safe suggestions
Drop dead exports + misleading comments + unreachable defaults flagged
by phase-D simplify review (autonomous-yes items only):

- channel-state: drop unused TEST_CONSTANTS re-export
- retry-policy: drop unused RejectKind type alias
- achievements: drop misleading "fallback" comment on exhaustive switch
- notification-status-card: drop unreachable default in stateBadgeFor
- withings: export isWithingsRefreshReauthFailure + dedupe inline
  classifier in /api/withings/status route
- codex-slug-cache: drop test-only inspectCodexSlugCache helper; tests
  read getCachedCodexSlug() instead

Findings 1, 2, 8, 9 deferred to v1.4.16 (require product call).
Finding 11 is an explicit anti-finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:14:43 +02:00
Marc-André BombeckandClaude Opus 4.7 b290a775d0 fix(integrations): refresh-token failure marks integration as needing re-auth
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>
2026-05-09 21:05:22 +02:00
Marc-André BombeckandMarc-André Bombeck 4a6be429ca feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami (#134)
* 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>
2026-05-08 13:40:22 +02:00
Marc-André Bombeck 38309932f3 Initial release v1.0.0
HealthLog — personal health-tracking PWA with weight, blood pressure,
pulse, mood tracking, medication compliance, Withings integration,
doctor report PDF export, and Dracula-themed UI.
2026-03-10 20:14:33 +01:00