diff --git a/CONTRIBUTING-AI.md b/CONTRIBUTING-AI.md index 11d9aafb4..5cd662a4e 100644 --- a/CONTRIBUTING-AI.md +++ b/CONTRIBUTING-AI.md @@ -1,125 +1,16 @@ -# CLAUDE.md +# Contributing with AI tooling -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +HealthLog is built with AI assistance, and contributions made the same way are welcome. This file used to carry a copy of the internal conventions document; the copy aged badly and started contradicting the codebase, so it now points at the living sources instead. -## Project +## Where the real conventions live -HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export. +- `CLAUDE.md` at the repo root is the maintainer's operating manual for this codebase: architecture walk, code conventions, security patterns, test commands, the do-not list. It is checked in and kept current; whatever an AI agent (or a human) needs to work on the server is in there. +- `docs/CONTRIBUTING-CLIENTS.md` covers building a client app (Android, iOS, CLI) against the server: the API contract, auth from a client's point of view, sync semantics, and how coordination between repos works. +- `docs/api/openapi.yaml` is the wire contract, generated from the server's Zod schemas. CI fails on drift, so it is always accurate for the commit it sits in. -## Commands +## Ground rules, whatever tooling you use -```bash -# 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 (26 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 `. -- **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/components/settings/` — per-route Settings section components (one per `/settings/[section]`) -- `src/components/admin/` — per-route Admin section components (system-status, integrations, monitoring, users, audit, danger-zone, feedback) -- `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 (26 models) -- `tests/integration/` — Postgres testcontainers integration suite (rate-limit race, idempotency replay, GDPR cascade delete, session lifecycle); `pnpm test:integration` -- `e2e/` — Playwright + axe-core suite for public smoke checks (version, auth-redirect, login, locale-switch, a11y); `pnpm e2e` -- `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[]` 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 -- **Settings vs Admin scope rule (v1.4.16 phase B6 audit)**: `/settings/` is **per-user** — toggles, credentials, layout, target ranges that affect one account. `/admin/` is **system-wide** — defaults, feature gating, host metrics, audit log read-access. Two surfaces overlap by design: (a) notification channels — admin can disable a channel system-wide via `/api/settings/global-services`, the user sets their own credentials in `/settings/notifications`; (b) AI providers — admin sets the default Codex/OpenAI key, user can override with their own. **No user-facing toggle should appear in both surfaces.** Keys like `settings.adminAreaTitle` are dead code (planned admin-shortcut tile that never shipped) and stay in JSON marked deprecated until a hygiene PR removes them. See `docs/audit/v1416-settings-audit.md`. -- **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**: rendered from `src/lib/doctor-report-pdf-core.ts` (jsPDF), the same module in the browser and on the server; the server side is the clinician share link's `/c/[token]/report.pdf`. 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` -- **Multi-tenant prep (v1.4)**: `HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all`) splits HTTP and pg-boss workloads; the proxy refuses HTTP traffic with 503 + `X-HealthLog-Process-Type: worker` in worker mode. `ENCRYPTION_KEYS` is a JSON map of versioned keys (`{"v1": "...", "v2": "..."}`) plus `ENCRYPTION_ACTIVE_KEY_ID` for new writes; rotation via `pnpm tsx scripts/rotate-encryption-key.ts `. `BACKUP_S3_*` env block configures off-host weekly encrypted backups (PutObject + GetObject only — retention is the bucket's lifecycle policy, never the worker). -- **Native API clients (v1.4)**: `POST /api/auth/login` and `/api/auth/passkey/login-verify` issue a 24h access token (`hlk_<64hex>`) AND a refresh token (`hlr_<64hex>`) when `X-Client-Type: native` or the User-Agent starts with `HealthLog-iOS/`. The browser flow is unchanged. Refresh-token reuse-detection revokes every refresh token for the user. The idempotency replay-cache rejects bodies containing `hlk_` OR `hlr_` so cached responses can never echo a token back. - -## 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`. +- The human who commits a change has read it and stands behind it. The git history knows authors, not tools: no AI attribution trailers, no assistant vocabulary in commit messages, PR text, or release notes. +- No real names of users or reporters in committed text, and no live health figures anywhere, including fixtures and examples. Invent data. +- No secrets in code, fixtures, or docs, and nothing secret-shaped either. +- Run the gates before pushing (`pnpm typecheck`, `pnpm lint`, `pnpm test`) and fix what they find rather than suppressing it. A guard that fails is telling you something; the fix goes in the code, not in the guard. diff --git a/docs/CONTRIBUTING-CLIENTS.md b/docs/CONTRIBUTING-CLIENTS.md new file mode 100644 index 000000000..2f5660022 --- /dev/null +++ b/docs/CONTRIBUTING-CLIENTS.md @@ -0,0 +1,55 @@ +# Building a native client against HealthLog + +Working notes for anyone building a client app (Android, iOS, CLI, anything else) against the HealthLog server, with or without AI tooling. Everything in here is a rule we actually follow between the server and the iOS app, written down so a second client does not have to rediscover it. Pair it with `CLAUDE.md` (server-side conventions) and `docs/api/openapi.yaml` (the wire contract). + +## The contract, and where truth lives + +- `docs/api/openapi.yaml` is the single source of truth for the API. It is generated from the server's Zod schemas and CI fails on drift, so what the YAML says is what the server does. Build your models from it rather than from observed responses. +- The contract you target is the one in a tagged release (`vX.Y.Z`), not whatever is on `main` between releases. A running server tells you its version at `GET /api/version` (`version`, `buildSha`, `builtAt`). +- Contract changes are additive. Fields get added, routes get added; existing consumers keep reading what they read before. When a route is retired it does not vanish: it answers `410` with a machine-readable `meta.errorCode`, so a client can tell "removed on purpose" from "you have a bug". +- If you need something the API does not carry, ask for it in an issue before building a workaround. Adding a route or a field on the server is usually cheaper than a client-side reconstruction, and a workaround becomes a compatibility burden for both sides the moment it ships. + +## The envelope + +Every JSON route answers the same shape: `{ data, error }`, with `meta` on the error path when there is something machine-readable to say. Success means `data` is set and `error` is `null`. Validation failures answer `422` and carry every issue at once, not just the first, so a form can annotate all fields in one round trip. Parse the envelope first, then the payload; never assume a `200` body is the payload directly. + +## Auth, from a client's point of view + +- Password, passkey, and an OIDC handoff exist for sign-in. The native OIDC flow hands the app a one-time code via a fixed URL scheme; tokens never ride a URL. +- Refresh tokens rotate per device and are one-time-use. Reusing an old one revokes that device's whole token family, on purpose. Persist the newest pair atomically and never retry a refresh with a token that already succeeded once. +- Bearer token scopes are fail-closed. A token minted for one purpose does not open unrelated routes, and a route that does not declare a scope refuses narrow tokens outright. Expect `403` to be a normal, recoverable answer, not an anomaly. +- Admin surfaces are cookie-session only, by construction. A native client cannot be an admin client, whatever scopes its token carries. Do not build admin features. +- Mutations accept an `Idempotency-Key` header. Replays answer with the original result plus `X-Idempotent-Replay`, so retrying on a dropped connection is safe. Use it for anything a user would notice twice. + +## Data semantics worth knowing before you sync + +- Batch measurement ingest treats `externalId` as the dedupe handle. Ids with the `stats:` prefix are overwrites (a re-post replaces the row), every other id is first-write-wins immutable. The per-entry response says which happened (`inserted` vs `updated`). Design your sync around that difference rather than around client-side bookkeeping. +- Absence is explicit. A metric that answers "not present" means the record honestly has nothing there. It is not zero, not an error, and not a reason to fill the gap client-side. +- Modules can be switched off per account. Routes belonging to a disabled module answer `403`; render that as "this feature is off", not as a failure. +- Whatever the user typed stays verbatim. Names of custom metrics, notes, labels: the server returns the user's own text and does not translate or normalise it. Curated content, on the other hand, comes with stable keys a client can localise. + +## Server-authoritative values + +The strongest rule we have, learned the slow way: when the server computes something (a compliance rate, a health-score verdict, a reference-range judgement, an aggregation bucket), the client renders the resolved value from the payload. It does not re-derive the number from raw data, because two implementations of one formula will disagree eventually, and the user sees two apps arguing about their own health record. + +The corollary: if a resolved value you need is missing from a payload, the fix is to ask the server to carry it, never to reimplement the server's logic in the client. And when the server changes a resolved value's meaning, that is a contract change and gets announced, not slipped in. + +## How coordination works in practice + +- One issue per question or contract need, opened early, ideally before you build on an assumption. A wrong assumption caught in an issue costs a comment; caught after a release it costs a migration. +- Claims about behaviour are made against a running instance, with the route and status code, not from reading code. "The code looks like it should" has burned us; "I called it and got this" has not. +- Answers you get from the server side name file and line or route and measurement, so you can verify them instead of trusting them. Hold your own reports to the same bar and it stays pleasant. +- Release notes and the changelog say when something client-relevant shipped. Reconcile open questions against each release rather than assuming the answer is still pending. + +## Ground rules for the repo itself + +The voice-and-privacy rules from `CLAUDE.md` apply to any repo under the project, including a client repo: commit messages and public text carry no tooling attribution and no assistant vocabulary, no real names of users or reporters, and no live health figures, ever. Fixtures use invented data. If an AI tool wrote part of a change, the human who commits it has read it and stands behind it; the git history knows only authors. + +Secrets never appear in code, fixtures, or examples, not even placeholder-shaped real ones. Anything user-identifying stays out of logs on the client just as it does on the server. + +## What a client should not do + +- No scraping of web routes or HTML pages; the JSON API is the interface. +- No admin endpoints, see above. +- No CORS assumptions: the server serves same-origin browsers and header-authenticated APIs, nothing else. +- No silent behavioural drift from the web app. Where the web app and a native client both render the same concept, they should mean the same thing; when they cannot, that difference is documented in an issue, not discovered by users.