Commit Graph
9 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 d25e50eaf3 fix(devices): wrap revoke cascade in a single transaction
Both DELETE /api/devices/[id] and DELETE /api/auth/me/devices/[id]
performed the same four-write cascade (refresh-token lookup, refresh
revoke, access-token revoke, device delete) as separate Prisma calls.
A partial failure between the third and fourth write left the device
row alive with all its tokens revoked - the user's iPad showed in the
device list as "still registered" but every request returned 401.

Extract the cascade into a shared revokeDeviceCascade() helper and
wrap the writes in prisma.$transaction so they commit atomically.
Both routes now call the helper; the audit-log via tag and the wide
event action name stay route-local.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:27:47 +02:00
Marc-André BombeckandClaude Opus 4.7 a2dfc5e256 fix(measurements): show source badge on mobile card too
The Apple Health pink chip rendered only inside the desktop `<Table>`
cell. iOS users — the audience that actually consumes Apple Health
data — land on the mobile card branch and saw no source indicator at
all, risking duplicate hand-entries on top of synced rows. Add a
compact badge inline with the timestamp on the mobile card so the
distinction lands at every viewport. Component test asserts the
`measurement-source-badge` testid appears twice (desktop + mobile)
so the next layout refactor can't silently drop one branch.

Also extends the `POST /api/devices` unit test mocks for the
NotificationChannel auto-upsert added in the previous commit, plus a
new test that pins the same-user apnsToken collision path returning
201 idempotently (the cross-user path keeps its 409).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:16:18 +02:00
Marc-André BombeckandClaude Opus 4.7 d5202e5dd8 fix(devices): enforce global apnsToken uniqueness via partial index
Two collision paths let the same APNs token attach to multiple Device
rows for one user, fanning a single push out to two rows and double-
charging the APNs quota:

  1. The cross-user-hijack guard filtered `NOT: { userId }` so it
     skipped same-user collisions entirely.
  2. The `apns_token` index was non-unique.

Drop the `NOT: { userId }` clause so the lookup catches both cases —
cross-user collisions still 409, same-user collisions fall through to
the existing legacy-token upsert path. Pair with migration 0041
introducing a partial UNIQUE index over `apns_token IS NOT NULL` and
dropping the redundant non-unique sibling. Apple's APNs contract
guarantees one token per (device, app), so global uniqueness matches
the upstream invariant.

Migration is strictly additive at the rolling-upgrade boundary; the
NULL-allowed partial-index shape preserves legacy iOS rows without an
APNs handshake. Schema-side comment documents why the partial index is
introduced via raw SQL (Prisma can't express it natively) and warns
against re-adding the non-unique sibling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:13:47 +02:00
Marc-André BombeckandClaude Opus 4.7 13977bcc70 fix(devices): auto-upsert APNS NotificationChannel on registration
The dispatcher's APNS branch only fires when a NotificationChannel of
type APNS exists for the user, but no production code path created one.
Every iOS install needed a manual settings toggle that doesn't exist,
making the v1.4.23 W3 APNs cascade unreachable in real traffic.

When `POST /api/devices` accepts an apnsToken, eagerly upsert the
channel row with an encrypted empty config — the per-device token and
environment live on the Device row by design, so the channel only
exists to flip the dispatcher's gate. Mirrors the legacy Telegram
on-first-dispatch auto-migration but eager so the very first reminder
after registration routes through APNs.

Adds an integration test that registers a device with apnsToken and
asserts the channel row exists, registers without one and asserts
absence, and re-registers the same apnsToken and asserts idempotency
(channel count stays at one).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:12:02 +02:00
Marc-André BombeckandClaude Opus 4.7 1bb5a45521 feat(api): device management endpoints for native + web settings
Adds GET /api/auth/me/devices to list a user's paired devices
(label, lastSeen, channels, isCurrent marker) plus matching DELETE
routes at /api/auth/me/devices/[id] and /api/devices/[id]. Revoke
cascades: every refresh token bound to the device is killed alongside
its paired access tokens, then the device row is deleted.

Cross-user attempts return 404 (no enumeration leak). The duplicated
/api/devices/[id] endpoint is the native-friendly URL the iOS client
calls on APNs token rotation, mirroring the W3 open question.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:11:30 +02:00
Marc-André BombeckandClaude Opus 4.7 a859661686 feat(api): POST /api/devices accepts apnsToken + apnsEnvironment
Extends the device-registration body schema with the paired APNs
identity fields the iOS client populates once it has an APNs token from
`application:didRegisterForRemoteNotificationsWithDeviceToken:`.
Validation rejects half-supplied requests (`apnsToken` without
`apnsEnvironment` or vice versa) with 422 and enforces hex format on
the token — catching the common iOS implementation bug where the token
gets serialised through Swift's `Data.description` (which renders
`<deadbeef cafebabe>` with spaces and angle brackets) instead of the
hex-join shape Apple's docs spec.

The cross-user-hijack guard from CLAUDE.md is duplicated at the
APNs-token layer: a registration whose `apnsToken` is already owned by
another user returns 409 with reason `apns_token_owned_by_other_user`.
APNs tokens aren't secrets, so trusting wire input would let any
client who learns one redirect another user's pushes — same threat
model as the existing `token` guard, applied to the new field.

Audit-log details for `devices.register` now carry `hasApnsToken` and
`apnsEnvironment` so admin trails surface APNs pairing events without
leaking the raw token. Five new route tests cover the validation +
hijack paths (paired-or-omitted, hex regex, create-with-pair,
cross-user-409, the original 401/422/409/201 contracts stay green).

Ticks STATE.md F4 and lands `.planning/phase-W3-v1423-report.md`
covering the six W3 commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:48:27 +02:00
Marc-André BombeckandClaude Opus 4.7 02b99553b9 chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:27:17 +02:00
Marc-André BombeckandClaude Opus 4.7 b9ade5b519 fix(api): address review findings — N+1, idempotency cache safety, device hijack guard
Three fixes from the 5-way review on PR #117:

1. Insights cards: replace per-medication N+1 query with single batched
   findMany + JS bucketing. For users with many active medications this
   collapses 10+ sequential round-trips into one.

2. Idempotency cache: only persist 2xx/3xx and validation 4xx responses.
   401/403/408/429/5xx are now skipped — a stale 401 (token expired
   mid-flight) or transient 5xx must not be replayed for 24h. Adds a
   defence-in-depth check that refuses to cache any body containing
   "hlk_" so a future caller wrapping an auth route in withIdempotency
   cannot leak a freshly-issued bearer token through the cache.

3. Devices: reject cross-user token re-registration with 409. Prior
   behaviour silently transferred ownership on findUnique-by-token,
   which would let anyone who learns/guesses an APNs token redirect
   another user's pushes. Audit-logs the denial with reason.

Tests adjusted: device-hijack test now expects 409, plus a new test
for same-user re-registration. 279/279 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:08:53 +02:00
Marc-André BombeckandClaude Opus 4.7 1f5ad1729d feat(api): aggregator endpoints for headless API clients
Adds seven read/write endpoints that aggregate or adapt existing data
into shapes more practical for non-browser consumers. None of the
pre-existing endpoints are touched.

  • `GET /api/dashboard/summary` — greeting + streak + today compliance
    + per-metric latest value with 7-day sparkline. Single round-trip
    for a "home" view instead of fanning out across 6 endpoints.
  • `GET /api/measurements/series?kind=&days=` — pre-aggregated series +
    stats (mean, min, max, stdDev, count) reusing
    `src/lib/analytics/trends.ts`.
  • `GET/PATCH /api/integrations/healthkit` — opt-in per-metric sync
    config (entries[] of `{ id, kind, direction, enabled }`). Stored in
    the new `users.healthkit_config_json` column.
  • `POST /api/devices` — APNs/FCM token registration only; no push
    sender wired up here. Upsert on `token`.
  • `GET /api/medications/intake?scope=today|compliance` — top-level
    aggregator over all of a user's medications, plus a sibling
    `POST /api/medications/intake` accepting `{ intakeId, status, takenAt }`
    for clients that don't want to maintain medication-id state.
  • `GET /api/insights/cards` — adapter that maps the rule-based alerts
    from `/api/insights/comprehensive` into card-shaped `Insight[]`.
  • `GET /api/insights/correlations` — placeholder array today; flagged
    in audit log as `insights.correlations.empty` until a real engine
    lands.

All endpoints honour `requireAuth()` (cookie or bearer) and the existing
audit/Wide-Event pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:53:16 +02:00