Commit Graph
19 Commits
Author SHA1 Message Date
Marc-André Bombeck e6c709876d fix(auth): the ingest and idempotency Bearer resolvers refuse a pending account
Both resolve a Bearer token outside resolveBearerToken and checked only
revoked and expiry. The deletion revokes every token in its own transaction,
so only a token issued in that same instant could get through, but the rule
should hold everywhere a token is resolved: both now refuse an account
pending deletion.
2026-10-25 17:33:16 +01:00
Marc-André Bombeck 78ac2c5aeb refactor(sharing): read and judge grants from a leaf module
lib/sharing/grants.ts held both the grant transitions and the predicates
every reader asks (state, level, scope, the live grant for a pair). The
transitions reach into the managed-profile lifecycle and the OAuth connect
target, and both of those read a live grant, so grants, lifecycle and
connect-target formed an import cycle.

The predicates, findActiveGrant and touchGrantUsage move verbatim to
lib/sharing/grant-access.ts, which imports only the database client and
the scope vocabulary. Importers and the mocks that replaced those
functions point at the new module. Runtime import cycles fall from 7 to 6.
2026-10-23 11:02:10 +02:00
Marc-André Bombeck 9cb06ec44d fix(idempotency): read a rebuilt multipart form as the same request
A client that rebuilds a form for a retry picks a fresh random boundary,
so the raw bytes differ and a repeated document upload ran a second time
instead of replaying. The boundary named in Content-Type is replaced by a
fixed token before hashing.
2026-10-04 10:43:00 +02:00
Marc-André Bombeck b55850967d fix(idempotency): replay a key only for the same request body
The replay cell was keyed on (user, key, method, path). A client that sends
two different writes under one key got the first write's cached response for
the second, and the second was never saved. Native clients up to 1.1.0 post
the two halves of a manual blood pressure exactly that way, so the diastolic
value was silently dropped.

Each cell now stores a SHA-256 fingerprint of the request body (JSON with
recursively sorted keys, otherwise the raw bytes; scoped by method and path;
at most the first MiB is read). Lookup replays, or refuses as in flight, only
when the fingerprint matches. A different body, or a row written before the
column existed, runs the handler without claiming or caching and leaves the
existing cell for its own retries; a lost claim race re-checks the winner's
fingerprint the same way. The body itself is never stored.

Migration 0371 adds the nullable idempotency_keys.request_fingerprint column.
The OpenAPI parameter description, the client notes and the Home Assistant
guide say what a replay now requires. docs/ops gains a read-only query that
lists systolic readings left without their diastolic half, held to the
schema by an integration test.
2026-10-04 10:14:22 +02:00
Marc-André Bombeck 076c2f9c5d fix(auth): sign out the other devices, lose a refresh to a revocation, key the replay cache by authority
"Sign out everywhere" revoked every other refresh token and nothing else.
A native login is two rows, and the Bearer check consults only the
paired access token, so the other phones kept working until that token
expired, up to a day on the default policy. The route also handed
destroyOtherSessions a session-kind credential for a Bearer caller, whose
session.id is the ApiToken row id; the exception matched nothing and the
calling phone revoked its own refresh token. destroyOtherSessions now
finds the paired access tokens through the refresh rows and revokes them
inside the same transaction, reporting the count, and the route names a
Bearer caller by its access-token hash. Programmatic tokens are untouched.

rotateRefreshToken marked the consumed row under usedAt: null alone, so a
revocation landing between the read and the write was overtaken and a
pair minted after the family ended stayed live. The guard now includes
revokedAt: null; on a lost race the minted pair is retired and the reason
distinguishes a revoked family from a double consume.

withIdempotency consulted the grant only at replay time, and only for its
existence; a narrow-scoped token was checked only on the delegated arm.
Both move into the cell key: a delegated cell carries the grant's id,
level and scope fingerprint, a narrow token's cells carry the token, and
a request with no live grant neither reads nor claims a cell. Cookie and
wildcard own-record cells are keyed byte-for-byte as before.

Tests: integration for the paired access tokens (cookie and Bearer
caller), a deterministic revoke-mid-rotation race against Postgres, unit
coverage for the new cell keys, and the structural guard moved to the
new invariant.
2026-09-08 20:23:30 +02:00
Marc-André Bombeck 3a99de312e fix(02-12.1): check record context before an idempotent replay
- evaluate the fence in the wrapper above findCached, so a stale request
  neither replays a body, learns a cell exists, nor inserts a claim
- return the refusal rather than throwing: one route composes the
  wrapper outside apiHandler, where a throw escapes the envelope
- read the claim and the session's own context on one session lookup
2026-08-06 18:54:20 +02:00
Marc-André Bombeck d404d8275a fix: preserve import job wipe ownership
Keep durable actor attribution from deleting record-owned jobs.

Align provider and idempotency coverage with fail-closed behavior.
2026-08-06 02:11:01 +02:00
Marc-André Bombeck c7960d726d Scope the idempotency replay cell to the record a request claims
A delegate retrying with one `Idempotency-Key` across two people's records met
itself in one cell. The cache tuple was `(caller, key, method, path)`, so the
second record's request replayed the first record's cached 201: the handler
never ran, no row was written for the second person, and the caller was told it
had been — with the other person's id in the body. Nothing errored and nothing
logged a conflict, and five of the write verbs about to become delegable are
wrapped here.

Fold the claimed record into the `key` component, centrally, once. Not into
`userId`: that column is a foreign key to `users` with a cascade behind it and
cannot hold a composite. It keeps naming the caller, which is what makes two
delegates on one record two cells for free. The separator is a byte the key
validator refuses, so a caller cannot hand-craft a key into a cell belonging to
a request they are not making.

A request with no acting account keys byte-for-byte as it did before, so a
client that never heard of sharing cannot be affected.

Accepted and pinned by test: the fold uses the CLAIM, before the grant check,
so within the 24h window a delegate whose access has ended can still replay a
key they had already completed. They receive bytes they already had, nothing
new is written, and a fresh key from them is refused because that request
reaches the handler.

Both frozen surfaces move in this diff. The claim reader gains a guard leg
freezing its consumers, since an unchecked claim is exactly the value a future
caller must not mistake for permission.
2026-08-03 10:18:27 +02:00
Marc-André Bombeck a8ac376cf7 fix(measurements): reconcile dual identities atomically 2026-07-20 11:55:09 +02:00
Marc-André Bombeck e31c66e959 fix(security): scrub wide events at the builder, encrypt the replay cache
Three leaks of the same class - data leaving the process unprotected.

addMeta and addExternalCall wrote their values verbatim while setError
and setHttp scrubbed, so the builder's redaction contract held for two
entry points out of four. The finished event is serialised whole to
stdout and the log store, which made meta a trusted sink by accident: a
Telegram request timeout put the bot token there, and the AI provider
chain fed upstream error bodies through it. Both now scrub, at any depth.

Scrubbing cannot help with the second half. A gateway that echoes the
request it rejected echoes the prompt, and on that path the prompt
carries the health snapshot; no pattern separates that from any other
text. Upstream bodies are therefore classified rather than quoted - the
diagnostic question is which rejection occurred and whether it repeats,
which a classification answers without the content.

The idempotency replay cache stored response bodies unencrypted. The
create paths echo their own decrypted payload, so cycle notes, mood text
and allergy reactions sat in cleartext for 24 hours in a column that
lands in every backup; the existing guard missed it because it matches
secret shapes and health data is not secret-shaped. Bodies are encrypted
now, and when encryption is unavailable the response is not cached at
all rather than written in the clear.

chore(release): v1.30.18
2026-07-19 01:28:20 +02:00
Marc-André Bombeck 4ca3de5ff3 fix(medications): make the nightly dedup and the pin path respect each other
A deliberately detached ad-hoc intake could be folded back into its slot
cluster by the nightly dedup, and a pinned row could lose its slot to a
sibling on the sync-version tiebreak — pins now outrank every other row
and detached rows never join a cluster, mirrored into the operator
repair script. Pinning onto an already-served slot no longer overwrites
the existing record silently; the API refuses with a clear error while
an identical re-post stays idempotent. The idempotency cache also
refuses to store invite-token-shaped responses.
2026-06-10 16:54:15 +02:00
Marc-André BombeckandClaude Opus 4.8 ae468edca2 fix(idempotency): claim the key before running the side-effect
findCached returned null and the response was only persisted after the
handler completed, so two concurrent requests carrying the same
Idempotency-Key both passed the lookup and both executed the side-effect
(duplicate writes for the very clients idempotency protects).

Claim the key by inserting a pending sentinel row under the existing
(userId, key, method, path) unique constraint before the handler runs.
A racing request either finds the pending row or loses the insert race
and is refused with 409. The claim is promoted to the completed response
on a cachable result, released on a non-cachable result or a
secret-shaped body, and released on a handler throw. The pending row
carries a short claim TTL so a crashed handler self-heals and the
existing expiry-sweep cleanup still applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:51:54 +02:00
Marc-André Bombeck 93694cacd0 fix(idempotency): scrub hls_ share-link tokens from cached bodies
The body-content secret guard matched hlk_ / hlr_ / sk- but not the
new hls_ clinician share-link prefix, so a response echoing a freshly
minted share token could be persisted to the idempotency cache. Add
hls_ to SECRET_PATTERN alongside the existing token prefixes.
2026-06-04 02:34:33 +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 67575180b8 fix(qa): apply pre-release multi-agent QA findings
Three small follow-ups surfaced by the v1.4.6 QA pass:

- Settings → KI: the "raw data" warning still used raw Tailwind orange
  tokens instead of the Dracula `dracula-orange` ones, breaking theme
  consistency and tipping below the WCAG-AA contrast floor on the
  card surface.
- Admin status grid: the Backups card cta read "View backups" but
  jumped to the system-status section (the dedicated backups view
  does not exist yet — that's a v1.5 feature). Re-labelling the cta
  to "Open system status" and the Integrations cta to "Open
  integrations" makes the destination honest. The href targets
  themselves are unchanged.
- `withIdempotency` body-content guard previously did `text.includes("sk-")`
  which silently broke caching for benign payloads containing common
  English compounds like "task-id", "risk-management", "disk-io".
  Tightened to a regex requiring the canonical secret prefix plus a
  realistic suffix length, so only actual API keys trip the guard.
  Three regression tests cover the false-positive cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:22:12 +02:00
Marc-André BombeckandClaude Opus 4.7 c7b600575e fix(idempotency): refuse to cache bodies containing third-party AI keys
P12. The body-content guard already rejected `hlk_` / `hlr_` (our access
and refresh tokens). Extend it to also reject `sk-` so any OpenAI or
Anthropic key that might be echoed by a misrouted handler never lands
in the IdempotencyKey table. The substring match also covers `sk-ant-`
so we don't need a second branch.

Adds a parametrised vitest case asserting all four prefixes
(hlk_, hlr_, sk-, sk-ant-) suppress caching, plus a
positive case to prove a normal payload still caches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:00:49 +02:00
Marc-André BombeckandClaude Opus 4.7 98a57eb786 audit(v1.3.3): SpO2 + body-composition + 14 CRITICAL/HIGH security/correctness fixes (#121)
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds

User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in
the measurements filter, badge, mobile icon, and edit dialog (issue #109).
Server-rendered doctor report now includes body composition rows (audit
C-9). Body water + bone mass have proper effective-range thresholds so
severity logic doesn't silently return `nominal` (audit C-15).

Structural: extracted the per-type display maps from measurement-list.tsx
into measurement-list-meta.ts as a deliberate first step toward the
single-source manifest planned for phase P5. Coverage tests now fail-fast
if a future enum addition skips any of: list label/icon/color, doctor-PDF
label/unit/vital-types, chart label, threshold metric order/label/bounds.

Audit-2026-05-07 / phase P0:
- closes #109 (filter UI gap, root cause for the German raw-enum display)
- closes audit C-9 (server PDF used a stale type map)
- closes audit C-15 (no warn/critical bands for body composition)

Tests: 282 → 296 (14 new). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: align OpenAPI spec + migration comment with body-composition reality

- OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at
  v1.3.0 baseline; spec drifted past the v1.3 server release)
- Bump openapi version 1.3.0 → 1.3.2 to match package.json
- Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content),
  not percent of body weight. Validators (VALUE_RANGES) and the Withings
  client (type 77 → kg) treat it as kg already; the comment was the lone
  outlier and would mislead the next reviewer.

Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum)
and M-B2 (migration unit comment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip)

C-3 (Withings webhook secret in URL):
Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=…
fallback retained for legacy Withings configurations and emits a Wide
Event warning so operators can spot still-using-the-old-flow integrators.
The query-string secret was leaking into Coolify access logs and any
GlitchTip event whose request.url got captured.

C-4 (idempotency dead for Bearer-token clients):
The default userIdResolver only consulted getSession(). Bearer-authed
clients (the iOS / n8n / headless-API fleet that PR #117 was built for)
fell through, so every retry created duplicate measurements. The default
now tries cookie session first, then Bearer token via
hashToken+apiToken.findUnique with revoked/expiry checks. All three
existing call sites (measurements, mood-entries, medications/intake) get
the fix automatically.

H-B7 (request.url with secrets shipped to GlitchTip):
reportToGlitchtip now strips the query string before forwarding the URL,
so Withings legacy ?secret=… and any OAuth ?code=… that errors during
the callback don't end up in someone else's incident UI.

Audit-2026-05-07 / phase P2.
Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback +
revoked/expired/null cases).
Deferred to a follow-up (needs DB migration / lock primitive):
  C-2  moodLog webhook → AES-GCM at rest + HMAC-over-body
  C-5  idempotency TOCTOU advisory_lock or in-flight sentinel row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3

Closes the OxygenSaturation feature request from #109.

- Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS)
- Zod validators: plausibility 50–100% (below 50 is incompatible with
  sustained life; above 100 is physically impossible)
- effective-range thresholds: green 95–100, orange 92–94, red <92.
  Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice
  (≥95% at rest is healthy), AHA/clinical literature (<92% = call
  provider, <88% = ER). Lower-only concern — upper orange wing
  collapses to greenMax since saturation cannot exceed 100%.
- Personalisation: existing thresholds-override UI lets COPD /
  chronic-respiratory users with a doctor-set 88–92 baseline tune the
  bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in
  thresholds-section.tsx; the exhaustive `Record<ThresholdMetric>`
  forces TS to catch any missed surface.
- Withings ScanWatch type 54 mapping (best-effort — only fires for
  pulse-ox-capable devices).
- Wired through measurement-form, measurement-list-meta (icon: Wind),
  health-chart, both doctor-report-pdf renderers (core + browser-side
  near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in
  4 sections × 2 locales.
- iOS DTO already declared `OXYGEN_SATURATION` — server enum addition
  closes the long-standing drift flagged in audit V2.
- 4 new tests: numeric green band, upper-wing collapse,
  user-override (COPD baseline), METRIC_BOUNDS shape.

Tests: 301 → 305. Typecheck: clean.

Audit-2026-05-07 / v1.3.3 / closes #109 SpO2 feature request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(analytics): truthfulness pass on medical citations + clamp override orangeMax

Doctor-agent + hallucination-hunter audit V3 found six medical citations
in effective-range.ts that the code did not actually implement. Plus a
latent bug in the user-override path that produced physically-impossible
orangeMax values (>100% saturation, <0% body-fat).

Citation rewrites (no numeric changes):

- ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020.
  WHO publishes activity in min/week, not steps. Step-count was a
  misattribution that also leaked into AI prompts.
- TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson
  formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance,
  it does not publish kg cut-offs.
- BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance-
  estimated; not DEXA-comparable". Withings BIA values typically run
  5–7% below DEXA — calling them DEXA-equivalent in a doctor-report
  PDF was a real legal-exposure misattribution.
- OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS
  clinical practice (≥95% at rest is healthy)" → "consumer pulse-
  oximeter consensus (≥95% normal at rest); slightly tighter than
  BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA
  pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100
  band — that's an ATS/consumer-consensus number, not BTS's literal
  94-98 target. Soften the citation to match the code, not vice versa.

Bug fix (1 latent, 1 real-today):

- effective-range override path now clamps orangeMin/orangeMax to
  METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting
  SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible
  saturation), and a BODY_FAT override of {2,80} emitted orangeMin
  = -9.7 (negative percent). Default-range case 210 already
  hard-coded orangeMax: 100 for the same reason — the override path
  was the only branch missing the invariant.

Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%).
Typecheck: clean.

Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure

Two CRITICAL fixes from audit V3.

NEW-V3-1: Bearer-scope bypass.
  apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent"
  scope (the iOS app receives this on login). The previous check
    permissions.includes(requiredPermission)
  treated "*" as a literal string — never matched. Today many sensitive
  routes call requireAuth() *without* a requiredPermission, so a leaked iOS
  token can act as a full-scope token (account delete, settings wipe). Once
  those routes adopt requireAuth("scope:name"), this wildcard handling keeps
  the iOS app working while narrower-scoped tokens (e.g. user-issued
  ["medication:ingest"]) get correctly 403'd.

  Same wildcard handling added to the standalone permission checks in
  src/app/api/ingest/medication/route.ts:65, 119.

NEW-V3-2: GDPR Art. 17 account-delete erasure completeness.
  Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which
  preserved PII (free-text symptom descriptions, IP addresses, login-city
  geolocation) after the user was deleted. We now explicitly
  prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the
  cascade-delete, making account deletion genuinely complete erasure.

Tests 306/306 still pass; typecheck clean. Audit V3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(pdf): real text-content assertions for body composition + SpO2 rows

Replaces bytes-only "renders body composition rows" theatre test with
pdf-parse-driven text extraction that asserts the actual rendered labels
+ values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN).

Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified
"%PDF-" header + byte-count, so a regression that drops a row would
still pass. Now a missing label fails the test.

- adds dev dep: pdf-parse@^2.4.5
- 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums

V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays
silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective
domains. Adding a new measurement type required hunting these down.

Now all aggregation paths derive from `measurementTypeEnum.options`:
- /api/insights/comprehensive (AI insights)
- /api/dashboard/summary (iOS dashboard adapter)
- /api/analytics (analytics aggregator)
- /lib/insights/general-status (AI general-status fetch)
- /api/import (Zod schema — round-trip now covers all 11 types)

External-contract enums extended additively (no breakage for old clients):
- /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass
- /api/dashboard/widgets widgetIdEnum: + oxygenSaturation
- DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row
- formatMeasurementsForExport: glucoseContext now round-trips

Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the
canonical enum stays the source of truth and documents PDF exclusions
(BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only).

313/313 tests pass (was 308, +5 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit)

- CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was
  blanket connect-src on every page including /auth/login → DOM-XSS exfil
  channel)
- web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl
  refinement; was z.url() only → blind SSRF probe via Push subscription
- isPublicUrl: also fixes a regression where DNS labels starting with
  "fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6
  unique-local. Check is now gated on a colon being present in the host.
- /api/ai/test: stop returning provider err.message + bodyExcerpt to the
  client (leaked provider URLs / partial keys / internal headers). Now
  returns categorised generic message; full details land in the wide-event
  via annotate() for the operator.
- /lib/geo (auditLog IP geo lookup): switch default provider to
  ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes,
  add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs.
  Previously leaked IP+timestamp over plaintext HTTP every auth event
  (GDPR Art. 32 + Art. 44).
- /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector)

Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF
guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider
shapes, geo opt-out env).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening

V3 audit follow-on bundle covering 4 HIGH findings.

(I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF
trust. Reads chain right-to-left so client-supplied leftmost entries
cannot rotate the per-IP rate-limit bucket. Default 1 matches typical
single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces
x-real-ip / null. New 7-test suite covers the rotation attack +
malformed entries + zero-hop fallback.

(F) Audit-log retention job — daily 03:15 cleanup of audit_logs older
than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e)
"storage limitation" gap; previously IPs + city + login events
accumulated forever. 6-test suite covers default + override +
misconfig guard (rejects <7 days).

(G) isCachableStatus — extracted the inline 4xx/5xx filter into a
named, exported, tested function. 7 cases cover the full do-not-cache
contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx
validation are still cached.

(H) Bearer-mock tightening — apiToken.findUnique mocks in
require-auth-bearer.test + idempotency.test now assert the
where.tokenHash shape uses the hashed value (not the raw bearer).
A regression that switched back to raw-token comparison would have
been silent without this assertion.

Tests: 345/345 pass (was 332, +13 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2)

The mood_log_webhook_secret column previously stored a plaintext secret
that the webhook handler compared timing-safe against every enabled
user. A DB dump or backup snapshot leaked active webhook credentials.

New contract — no schema migration required:
- Writes go through encryptMoodLogSecret() (AES-256-GCM via existing
  crypto helper, ENCRYPTION_KEY).
- Reads go through readMoodLogSecret() which transparently decrypts
  envelopes and tolerates legacy plaintext rows during the transition
  (graceful upgrade path — no breaking change for self-hosted users).
- Webhook lookup decrypts each candidate before timing-safe compare.
- Status endpoint decrypts for the user's settings page.

One-shot startup migration in reminder-worker rotates any leftover
plaintext rows on next worker boot. Idempotent; encrypted rows are
skipped.

Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough,
isLegacyPlaintext semantics, rotateLegacy idempotence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift)

V3 audit Doku Specialist findings — drift between code and meta-docs.

- README §Key Features now mentions pulse oximetry (SpO₂) + Withings
  ScanWatch sync; Tech Stack model count corrected 23 → 25.
- AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected
  `vitest.config.ts` → `vitest.config.mts`; migration range corrected
  `0001–0022` → `0001–0024`; model count + list updated to 25 (adds
  Device, IdempotencyKey).
- CLAUDE.md model count updated (2 occurrences).
- CHANGELOG.md v1.3.3 expanded with Security + Internal sections that
  were missing the audit-fix-marathon work (Bearer-scope, account-delete
  cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo
  HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF,
  audit-log retention, idempotency cachable filter, Bearer mock
  tightening, enum-drift cousins, PDF text-content tests).
- OpenAPI Bearer description corrected `SHA-256 hashes` →
  `HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs
  match the actual server-side implementation. Wildcard scope contract
  documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass

Multi-agent review of the audit-fix branch surfaced four HIGH issues
and a couple of MEDIUM hardenings. All addressed before merge.

HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk).
The dashboard-summary route now emits totalBodyWater/boneMass/
oxygenSaturation MetricCards, but the spec still listed only the
original 7 kinds. Generated iOS Codable would have crashed the dash
the moment a user logged SpO2. MetricKind extended with the 3 new
kinds + comment documenting unknown-value defensive decoding.

HIGH-2 — kindEnum naming consistency on /api/measurements/series.
The series adapter shipped "oxygen" while the dashboard summary used
"oxygenSaturation"; both routes now use "oxygenSaturation" so iOS
decoders can share a single MetricKind type across endpoints.

HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that
mock provider errors with sk-leaked-key + provider URLs in the err
fields and assert the response body never echoes them, plus the
3-status-categorised generic messages (401/429/5xx/other).

HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that
mock checkRateLimit allowed/disallowed and assert the 429 path uses
the right key shape (`import:<userId>`, 5, 1h) and that the within-
quota path proceeds.

MEDIUM-3 — getClientIp under-length XFF chain regression. With
TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp
returned the leftmost (attacker-controlled) entry, silently
re-introducing the rotation attack the helper was meant to close.
Now: when chain is shorter than configured hops, refuse XFF entirely
(fall back to x-real-ip / null). Added regression test.

LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of
silently degrading to hops=0. Added regression test.

Tests: 358/358 pass (was 350, +8 new). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:05:56 +02:00
Marc-André BombeckandClaude Opus 4.7 a8a7c19d0b chore(idempotency): default userIdResolver to cookie session (#118)
Drop the `resolveUserIdForIdempotency` helper that was duplicated across
three PWA write routes. `withIdempotency` now reads the userId from
`getSession()` by default; routes that authenticate via Bearer token can
still pass a custom resolver.

Net: −15 LOC across the route layer, +2 test cases for the new default.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:30:21 +02:00
Marc-André BombeckandClaude Opus 4.7 9766cedea4 feat(api): generic Idempotency-Key support for safe POST retries
Introduces `withIdempotency()` in `src/lib/idempotency.ts` — wraps a
mutation handler so that a POST/PUT/PATCH/DELETE carrying an
`Idempotency-Key` header is replayed from the cached response instead of
running twice. Cache row is keyed by `(userId, key, method, path)` with
24h TTL; replays carry `X-Idempotent-Replay: true` so callers can
distinguish them.

Applied to the three mutation endpoints most exposed to client retries:
  • POST `/api/measurements`
  • POST `/api/mood-entries`
  • POST `/api/medications/[id]/intake`

Without the header: zero behaviour change. Existing clients that don't
send the header simply skip the cache lookup.

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