Files
HealthLog/CLAUDE.md
T
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

10 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project

HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export.

Commands

# Development
pnpm dev              # Start dev server (http://localhost:3000)
pnpm build            # Production build
pnpm lint             # ESLint
pnpm format           # Prettier format
pnpm format:check     # Prettier check
pnpm typecheck        # TypeScript strict check

# Tests
pnpm test             # Run all tests (vitest)
pnpm test:watch       # Watch mode

# Database
pnpm db:generate      # Generate Prisma client
pnpm db:migrate       # Create & apply migration (dev)
pnpm db:migrate:deploy # Apply migrations (production)
pnpm db:push          # Push schema without migration (prototyping)
pnpm db:studio        # Prisma Studio GUI

# Docker
docker compose up -d          # Start app + postgres
docker compose logs -f app    # Tail app logs

Architecture

  • Next.js 16 App Router with TypeScript strict. Pages are RSC by default; "use client" only for interactivity.
  • Prisma 7 ORM with PostgreSQL (25 models). Uses PrismaPg adapter from @prisma/adapter-pg. Client singleton at src/lib/db.ts. Generated client at src/generated/prisma/client (note the /client suffix). Prisma config in prisma.config.ts (not in schema.prisma).
  • shadcn/ui components (new-york style) in src/components/ui/. Add new ones via pnpm dlx shadcn@latest add <component>.
  • Dracula theme via CSS variables in globals.css. Dark mode is default. Use --dracula-* tokens for chart colors.
  • TanStack Query for client-side data fetching. Provider in src/components/providers.tsx.
  • Zod v4 (zod/v4) for all input validation (API routes and forms).
  • API response envelope: { data, error, meta } via helpers in src/lib/api-response.ts.
  • apiHandler wraps ALL API route handlers (src/lib/api-handler.ts) — error handling, Wide Event logging, x-request-id propagation.
  • Structured Logging (Wide Events) in src/lib/logging/ — one JSON event per request/operation via WideEventBuilder + AsyncLocalStorage. Tail sampling, stdout JSON + optional Loki Push.
  • Vitest for unit testing. Config in vitest.config.mts.
  • pg-boss (PostgreSQL-native) for job queue: medication reminders, insight caching, weekly data backups.
  • jsPDF + jspdf-autotable for client-side doctor report PDF generation (src/lib/doctor-report-pdf.ts).
  • PWA offline via service worker caching strategies in public/sw.js (cache-first for static, network-first for pages, network-only for API).
  • Proxy route protection — src/proxy.ts checks session cookie, enforces auth on all non-public paths. Next.js 16 uses proxy.ts (not middleware.ts).

Key Conventions

  • Default locale is English. All code-level strings in English. UI text via i18n t() with German + English translations.
  • Timezone: Europe/Berlin for display, UTC in database.
  • Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM (src/lib/crypto.ts) before DB storage.
  • Passkeys are primary auth (SimpleWebAuthn v13). Sessions stored server-side in PostgreSQL.
  • All API mutations require authentication. External ingest uses Bearer token (hashed with HMAC-SHA256 keyed by API_TOKEN_HMAC_KEY).
  • Rate limiting (in-memory sliding window) on auth and external-facing endpoints.

File Layout

  • src/app/ — pages and API routes (App Router)
  • src/components/ui/ — shadcn/ui primitives
  • src/components/layout/ — shell (sidebar-nav, topbar, mobile-nav, bottom-nav)
  • src/components/medications/ — medication form, card, timeline
  • src/components/measurements/ — measurement form, list
  • src/components/mood/ — mood form, mood list
  • src/components/charts/ — Recharts wrappers
  • src/lib/logging/ — Wide Events structured logging (types, config, event-builder, context, sampler, transports, background)
  • src/lib/api-handler.ts — apiHandler wrapper, requireAuth(), requireAdmin(), HttpError
  • src/lib/ — server utilities (db, crypto, auth, analytics, export, rate-limit, gravatar)
  • src/lib/export.ts — CSV/JSON export formatting (measurements, medications, mood)
  • src/lib/doctor-report-pdf.ts — client-side PDF generation (jsPDF, European medical format)
  • src/lib/withings/ — Withings OAuth client and sync service
  • src/lib/notifications/ — dispatcher, types, senders (telegram, ntfy, web-push)
  • src/lib/jobs/ — pg-boss reminder worker (reminders, insights, data backups)
  • src/lib/analytics/ — trend calculations, compliance, correlations
  • src/lib/i18n/ — i18n context, config, locale detection
  • src/lib/validations/ — Zod schemas shared between API + client
  • src/hooks/ — React hooks (use-auth)
  • messages/de.json + messages/en.json — i18n translations
  • prisma/schema.prisma — database schema (25 models)
  • prisma.config.ts — Prisma config (DB URL here, not in schema)
  • public/sw.js — Service worker for Web Push notifications + offline caching
  • docs/ — long-form audit notes (docs/audit/); end-user docs live in the separate site at https://docs.healthlog.dev
  • AGENTS.md — AI agent instructions (Codex, Cursor, etc.)
  • CHANGELOG.md — release notes per semver tag

Important Patterns

  • Import Prisma client from @/generated/prisma/client (not @/generated/prisma)
  • Prisma 7 does NOT support url = env("...") in schema.prisma — use prisma.config.ts
  • Next.js 16 uses proxy.ts (not middleware.ts) — having both causes build error
  • SimpleWebAuthn v13 has no @simplewebauthn/server/script/deps — define Transport type inline
  • zxcvbn-typescript uses default export only (no named zxcvbnAsync/zxcvbnOptions)
  • pg-boss v12: { PgBoss } named import, localConcurrency instead of teamSize, handler receives Job<T>[] array
  • i18n: All UI text uses useTranslations() hook with t("section.key"). Messages in messages/en.json (English, default) + messages/de.json (German, user-selectable). Keys missing in the active locale fall back to English; missing from both surfaces the raw key — the src/lib/__tests__/i18n*.test.ts suite guards against that. Numbers/dates go through useFormatters() — never hand-roll Intl.* with a fixed locale.
  • Notifications: 3 channels (Telegram, ntfy, Web Push). Dispatcher at src/lib/notifications/dispatcher.ts. Opt-out model (all events enabled by default)
  • Gravatar: Server-side SHA-256 hash via src/lib/gravatar.ts. URL returned in /api/auth/me response as gravatarUrl field
  • Sidebar: Collapsible (localStorage-persisted). Settings mode with section shortcuts. User section at bottom with three-dot dropdown menu
  • ESLint: Strict react-hooks/set-state-in-effect rule — avoid setState inside useEffect. Use lazy useState() initializer for localStorage reads
  • Proxy route protection: src/proxy.ts checks healthlog_session cookie; PUBLIC_PATHS array bypasses auth. Security headers (CSP, HSTS, X-Frame-Options) applied to all responses
  • PWA safe area: Root layout has viewportFit: "cover". Bottom-nav uses pb-[env(safe-area-inset-bottom)] for iOS safe area
  • Doctor report PDF: Client-side via src/lib/doctor-report-pdf.ts (jsPDF). Data from POST /api/doctor-report. European medical format, German terminology
  • Achievements: Persistent in UserAchievement table. Computed on API call, new unlocks written to DB with stable unlockedAt timestamps
  • Data backup: pg-boss data-backup queue runs weekly (Sundays 03:00), stores JSON in DataBackup model
  • Wide Events / Structured Logging: apiHandler() wraps all API routes. Use annotate() from @/lib/logging/context for business-action annotations. Use requireAuth() / requireAdmin() from @/lib/api-handler (auto-annotates auth). Background jobs use withBackgroundEvent(). External calls tracked via getEvent()?.addExternalCall(). No console.log in production code — use event annotations instead. Env vars: LOG_LEVEL, LOG_SAMPLE_RATE, LOG_SLOW_THRESHOLD_MS, LOG_INCLUDE_STACK, LOKI_ENDPOINT, LOKI_USERNAME, LOKI_PASSWORD

Headless-Client API Patterns

The iOS app talks to bearer-protected routes while the web PWA uses cookie sessions; both share the same handlers (requireAuth() accepts either credential).

  • Bearer-token issuance: POST /api/auth/login and POST /api/auth/passkey/login-verify return a token field for native clients. isNativeClientRequest() (src/lib/auth/issue-token.ts) opts in via X-Client-Type: native header or a User-Agent starting with HealthLog-iOS/. The raw hlk_<64-hex> value is returned exactly once; default lifetime 90 days, permissions ["*"].
  • Token storage: API tokens are stored as HMAC-SHA-256 hashes (hashToken() in src/lib/auth/hmac.ts, keyed by env API_TOKEN_HMAC_KEY) — not plain SHA-256. The keyed HMAC defends against precomputed rainbow tables if the DB leaks. Same mechanism is reused for external-ingest token verification.
  • Idempotency-Key: withIdempotency() (src/lib/idempotency.ts) wraps POST/PUT/PATCH/DELETE handlers so retries with the same Idempotency-Key (regex ^[A-Za-z0-9_\-:.]{8,128}$) for the same (userId, method, path) replay the original response with X-Idempotent-Replay: true for 24h. NOT cached: 401, 403, 408, 429, any 5xx, and any body containing hlk_ (defence against caching a token-issuing response). Validation 4xx (422 etc.) is cached so a broken retry doesn't re-hit the DB. userIdResolver defaults to the cookie session — pass a 2nd argument for Bearer-only routes.
  • Device-registration cross-user-hijack guard: POST /api/devices rejects re-registration of an existing APNs token under a different user with 409 (Device token already registered to another account) instead of silently transferring ownership. APNs tokens aren't a secret, so trusting wire input would let anyone who learns a token redirect another user's pushes. Audit trail: device.register.denied with reason token_owned_by_other_user.