The project licence changes from AGPL-3.0 to the PolyForm Noncommercial License 1.0.0 for this and future versions: free to use, modify and self-host for noncommercial purposes; commercial use needs a separate arrangement. Releases up to and including v1.15.18 were published under AGPL-3.0 and remain available under that licence. LICENSE carries the verbatim PolyForm text, and the SPDX identifier is updated in package.json, the version endpoint, the OpenAPI info block, the image labels and the README.
25 KiB
Contributor notes
Working notes for anyone (human or otherwise) editing this repository. Skim before the first PR; refer back when a convention question comes up. Pair with README.md (the public face) and CHANGELOG.md (the release history); this file is the maintainer's operating manual.
What HealthLog is
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single docker compose up, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.12.x.
Voice and privacy
Hard rules for every committed artefact — commit messages, CHANGELOG entries, PR bodies, code comments, GitHub release notes, docs site copy. Treat these as the project's public face.
- Voice. English. Maintainer prose: present-tense, imperative for commits, factual for prose. No marketing tone, no apology tone, no "wow what a cool feature" framing. The voice is one human writing notes for the next.
- No assistant / tooling references. Do not surface AI, agent, assistant, copilot, marathon, phase, wave, round, or session vocabulary into anything that lands on GitHub. The
Co-Authored-Bytrailer on commits is fine — it is git metadata and the project accepts that convention; anywhere else, no. - No personal data. No real names (the maintainer's, a reporter's, or anyone else's). No live health figures, no live tenant identifiers, no real-account anchors. Generalise to "a self-hoster", "a user", "an operator", "an authenticated session", etc. The internal
.planning/*files are gitignored and exempt; everywhere else in the repo, no.
If a draft commit message or release note would violate one of the three, rewrite it before committing. The git log is more durable than today's task.
Branch and release model
main is the single trunk and is always releasable. There is no long-lived develop branch — the former one was retired and archived as the tag archive/develop-pre-v1.5 (it carried no work absent from main; a long-lived integration branch has no role in a single-maintainer shop and only accrued back-merge debt). This is trunk-based development with release tags: short-lived branches off main, work always converges back onto main, and the immutable vX.Y.Z image built from a tag on main is the one source of truth — including for the OpenAPI contract the iOS client consumes.
- Trunk.
mainalways builds, passes CI, and is deployable. Every commit on it is a release commit (chore(release): vX.Y.Z), a release-branch merge, or a maintenance commit (docs, gitignore, audit findings) applied directly. - A release is built on a short-lived
release/vX.Y.Zbranch cut offmain— built wave-by-wave, then opened as a PRrelease/vX.Y.Z→mainwith green CI, merged (merge-commit), and taggedvX.Y.Z. The tag push triggersdocker-publish.yml(GHCR multi-arch +:latest). Deploy is manual (seedocs/ops/deploy.md); after a deploy, verify/api/version(it returnsversion+buildSha+builtAt) on every target, since the:latestpull is the source of truth, not the queued deploy status. Delete the release branch after merge. - Small standalone changes (a single fix, a docs touch) can go through a short-lived
fix/X/feat/Xbranch + PR, or directly ontomainwhen the maintainer chooses —mainis the target either way. - Hotfixes branch from
mainand merge straight back tomain. There is no second long-lived line to forward-port to, so the old back-merge debt cannot recur by construction. - Never force-push
mainor any release branch. Never skip hooks (--no-verify). Never--no-gpg-signunless explicitly asked.
Semver
Prefer patch bumps. A release dominated by bug fixes plus additive features without breaking changes ships as vX.Y.Z+1, not vX.Y+1.0. Bumping minor is reserved for breaking changes, removed flags, deprecated routes, or multi-feature milestones framed as such (e.g. v1.5 = native iOS client + Apple Health integration). Pre-existing example: a v1.5.0 cut was rerouted to v1.4.14 mid-flight when the diff turned out to be bugfix-dominated.
Tech stack at a glance
| Layer | Tech | Notes |
|---|---|---|
| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no .nvmrc or engines. |
| Framework | Next.js 16.2.6 — App Router, output: "standalone" |
SWC strips console.* in prod except error/warn. |
| Language | TypeScript 6, strict: true, moduleResolution: "bundler" |
Path alias @/* → ./src/*. scripts/ excluded from typecheck. |
| UI | React 19.2.5 (exact), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
| Persistence | PostgreSQL 16 + Prisma 7.8 — 61 models | Generated client at src/generated/prisma. Production image ships a separate /opt/prisma-cli install + /opt/pg-boss runtime install (see Dockerfile:74-83). |
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under src/lib/jobs/. |
| Auth | @simplewebauthn/server 13, @node-rs/argon2 2 |
Passkey + Argon2id password, server-side sessions in Postgres. |
| Notifications | @parse/node-apn 8 (APNs), web-push 3 (VAPID), raw fetch for Telegram + ntfy |
Per-channel recordPushAttempt row, hard-reject classification. |
| Forms / validation | react-hook-form 7 + zod 4 + @hookform/resolvers + zod-openapi 5 |
The Zod registry is the source of truth for docs/api/openapi.yaml. |
| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), @axe-core/playwright |
Integration runs against a testcontainers/postgresql Postgres 16. |
| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (amd64 + arm64) |
pull_policy: always in docker-compose.yml is load-bearing — without it Docker re-uses the stale :latest digest. |
More detail in .planning/codebase/tech.md, generated as part of the v1.5.2 codebase audit.
How a request flows
client
│ HTTP
▼
src/proxy.ts renamed from middleware.ts. Public-path allowlist,
│ demo-mode mutation block, server-side onboarding
│ redirect, worker-only refusal, request-id + nonce
│ generation, every security header.
▼
src/app/<route>/route.ts every API route wraps in `apiHandler(...)`
│
▼
src/lib/api-handler.ts WideEventBuilder + AsyncLocalStorage, auth resolver
│ (cookie OR Bearer), idempotency, error envelope,
│ GlitchTip forwarder. Cookie path can elevate to
│ admin via requireAdmin(); Bearer never can.
▼
handler body Zod parse (`safeParse` + `returnAllZodIssues`),
│ business logic, `annotate({ action, meta })`,
│ `auditLog()` for sensitive ops.
▼
src/lib/api-response.ts `apiSuccess(data, meta?)` / `apiError(msg, status,
│ meta?)` — every response is `{ data, error, meta? }`.
▼
src/proxy.ts security headers attach, request-id echoes.
▼
client
The architecture map in .planning/codebase/arch.md walks each layer with file:line citations.
Code conventions
- RSC by default. Server components are the default; add
"use client"only when a hook, state, or browser API actually needs it. - Every API route wraps in
apiHandler. Verified — zero exceptions in the current tree. Every body-accepting route runs ZodsafeParseand returns 422 viareturnAllZodIssues(multi-issue envelope sanitised against echoed input throughsanitiseZodIssues). userIdis always narrowed from session or Bearer. No route acceptsuserIdas a body field — it comes fromrequireAuth()and feeds the Prismawhere. The batch endpoints (/api/measurements/batch,/api/mood-entries/bulk,/api/medications/intake/bulk,/api/workouts/batch) don't even declare auserIdfield in their Zod schemas.- No mass assignment. Every
prisma.X.{create,update}({ data: ... })builds itsdataobject field-by-field fromparsed.data, never by spreading the parsed object whole.tokens.createhardcodes the narrow scope so the user-facing endpoint can never mint a wildcard token. requireAdmin()is cookie-only. Bearer tokens — even with["*"]scope — cannot reach admin endpoints. This is a structural boundary atsrc/lib/api-handler.ts:414-431, not a runtime check that future code can soften.- Raw SQL is parameter-bound or whitelist-spliced. Every
$queryRawuses tagged-template parameters. Every$queryRawUnsafe/$executeRawUnsafeeither passes positional$Nplaceholders with an argument array OR splices a value already asserted against a closed enum + regex (seesrc/lib/rollups/measurement-rollups.ts:330-343). Inline comments document the whitelist at the splice point. - TanStack Query keys live in the centralised factory.
src/lib/query-keys.tsis the only legal source ofqueryKey/mutationKeyarrays. The in-repo ESLint rulehealthlog/queryKey-factory(set toerror) flags any bare array. Same-key + differentqueryFnshape silently poisons the cache; the factory prevents it. Every read unwraps(await res.json()).datafrom the envelope. annotate()on every interesting code path. Wide-event observability. Action names follow<surface>.<noun>.<verb>(coach.budget.exceeded,measurement.batch.ingest). Don't free-text inmeta— pin the shape so dashboards stay stable.- i18n keys exist for every
t()call.i18n-call-site-coverage.test.tswalks every.ts(x)undersrc/, extracts everyt("ns.key")literal, and asserts each key resolves inmessages/en.json.i18n-locale-integrity.test.tspropagates the EN guarantee acrossde / en / es / fr / it / pl. If a guard fails, fix the bundle, don't suppress the test. - OpenAPI registry stays in sync. Zod schemas carry
.meta()annotations undersrc/lib/openapi/;pnpm openapi:generateemitsdocs/api/openapi.yaml. CI fails on drift. Re-run the generator after touching a request / response schema and commit the YAML alongside the Zod change. - File naming. Components and lib files: kebab-case (
daily-briefing.tsx,secure-cookie.ts). Hooks:use-prefix kebab-case (use-coach-prefs.ts). React component exports stay PascalCase regardless of filename. The 18 PascalCase outliers undersrc/components/medications/+src/components/onboarding/are pre-existing drift and worth cleaning up when the surrounding files come up for edit.
Tests and commands
pnpm typecheck—tsc --noEmit. Required before every commit.pnpm lint— ESLint flat config witheslint-config-next+ the project'shealthlog/queryKey-factoryrule. One harmless warning inwithings/resume/__tests__/route.test.tsis allowed; everything else should be clean.pnpm test— Vitest 4 unit + component suite. Required before pushing a fix; full suite is fast (~30 s).pnpm test:integration— Vitest against atestcontainers/postgresqlPostgres 16. Needs Docker / OrbStack running. Slower but pins the contracts the unit-mocks can't.pnpm e2e— Playwright againstpnpm exec next build+ Postgres. Heavy; CI runs it, locally only when touching UI flows.pnpm openapi:generate— regeneratesdocs/api/openapi.yamlfrom the Zod registry. Run after any request / response schema change. CI'sopenapi:checkfails the build on drift.pnpm check-env— validates that.env.production.examplecovers every required variable. Wired into CI asenv-check.yml.pnpm bundle-report/ANALYZE=1 pnpm build— bundle inspection. Use when a feature adds material weight.- One-shot scripts live in
scripts/. Run them viapnpm dlx tsx scripts/<file>.ts— the production standalone image stripstsxand the barepnpm tsx ...invocation fails inside the container. Recurring tasks belong on pg-boss, not on a CLI script.
Critical-files map
| Concern | File | What it owns |
|---|---|---|
| HTTP edge | src/proxy.ts |
Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
| API kit | src/lib/api-handler.ts (490 LOC) |
apiHandler wrapper, requireAuth / requireAdmin, idempotency plumbing, GlitchTip forwarder. |
| Response envelope | src/lib/api-response.ts |
apiSuccess / apiError, safeJson (now with opt-in maxBytes), trusted-proxy IP resolver, returnAllZodIssues + sanitiseZodIssues. |
| Crypto at rest | src/lib/crypto.ts |
AES-256-GCM with versioned key ids, extractKeyId, fail-closed loader, rotation primitives. Rotation CLI: scripts/rotate-encryption-key.ts. |
| Session | src/lib/auth/session.ts + src/lib/auth/secure-cookie.ts |
Postgres-backed sessions, sliding 30-day expiry, shouldEmitSecureCookie() the one source of truth for the Secure flag (every cookie setter routes through it). |
| Refresh tokens | src/lib/auth/refresh-token.ts |
Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
| Bearer tokens | src/lib/auth/hmac.ts + src/lib/auth/issue-token.ts |
HMAC-SHA256 hashing under API_TOKEN_HMAC_KEY; no plaintext path; lastUsedAt updated fire-and-forget. |
| Rollup tier | src/lib/rollups/ |
DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time rollup-full-backfill queue handles new accounts. |
| Compliance | src/lib/analytics/compliance.ts |
Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
| AI providers | src/lib/ai/provider-chain.ts + src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts |
Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is not registered in resolveProvider() — production cannot reach it. |
| Notification dispatcher | src/lib/notifications/dispatcher.ts + src/lib/notifications/senders/ |
APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, push_attempts ledger with 90-day retention. |
| Coach | src/app/api/insights/chat/route.ts + src/lib/ai/coach/ |
SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with encryptedContent Bytes column. |
| OpenAPI | src/lib/openapi/registry.ts + src/lib/openapi/routes.ts |
Source of truth for docs/api/openapi.yaml. |
| Schema | prisma/schema.prisma (61 models) |
cuid() PKs, snake_case columns via @map, encrypted columns mostly under *Encrypted (search the file). |
| Compose | docker-compose.yml |
app + db. Env-var whitelist under environment: — vars not listed never reach the container. pull_policy: always is load-bearing. |
.planning/codebase/arch.md carries the full annotated walk for every section.
Security-relevant patterns
The v1.5.2 audit produced .planning/security-audit-v1.5.2.md and a stack of per-domain findings under .planning/security-audit-findings/. The patterns worth preserving on future churn:
- Encryption: fail closed everywhere. Missing keys, malformed JSON, unknown key ids — every path throws rather than silently writing plaintext. Operators rotating keys must keep the legacy entry in
ENCRYPTION_KEYSuntil the rotation script reports zero remaining legacy rows. The script covers every*Encryptedcolumn +CoachMessage.encryptedContent(Bytes) +IntegrationStatus.lastError. - SSRF: input-time
isPublicUrlis the floor. The helper insrc/lib/validations/notifications.ts:64catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outboundfetch()that touches a user-controlled host must pinredirect: "manual"and anAbortSignal.timeout. - Outbound egress goes through
safeFetch.src/lib/safe-fetch.tsis the one documented egress entry (redirect: "manual"+AbortSignal.timeout(15_000)defaults; opt into the connect-time DNS-rebinding pin withrequirePublicHost: truefor any operator- or user-supplied host). The in-repo ESLint rulehealthlog/safe-fetch-required(set toerror) bans rawfetch(undersrc/lib/+src/app/outside the wrapper internals and test files; same-origin relative-path (/api/…) client fetches are exempt by construction. - Rate limits live in Postgres.
src/lib/rate-limit.tsuses a single atomic SQL upsert returning{count, reset_at}. Multi-instance correctness is structural. Bucket-key convention:<surface>:${identifier}where identifier isuserIdfor authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces usecheckAuthSurfaceRateLimit(...)so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket. - Webhooks: shared-secret +
timingSafeEqual+ per-source rate limit BEFORE secret verification. Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed fromhttp.path/http.routebyredactSecrets()via thePATH_SECRET_PATHSregistry insrc/lib/logging/redact.ts:40. - Idempotency keys are user-scoped, length-bounded, and refuse to cache secret-shaped responses.
src/lib/idempotency.ts. A client cannot replay another user's idempotency key (composite unique index byuserId_key_method_path). The cache helper refuses to persist a response body containinghlk_/hlr_/sk-patterns even if a future caller wraps an auth endpoint by mistake. - CSP: strict, per-request nonce, surgical
connect-src. Production CSP gates third-party hosts to the routes that need them — AI hosts only on/settings/ai/*,wbsapi.withings.netonly on/settings/integrations/withings/*+/api/withings/*. Exactly onedangerouslySetInnerHTMLin the tree (the theme bootstrap insrc/app/layout.tsx, nonce-bound, file-literal content). No markdown library installed anywhere; Coach / briefing / insights render as plain React text children. Don't add one without a security review of the renderer's HTML-allow defaults. - No CORS headers anywhere. Same-origin-only by construction. Adding
Access-Control-Allow-Origin: *anywhere would silently open every authenticated endpoint to cross-origin POST; webhook routes accept cross-origin POSTs but they authenticate by header secret, not cookie. - Redaction at the egress boundary, not at the call site.
redactSecrets()+redactSensitiveFields()+redactOptional()are applied centrally inWideEventBuilderandreportToGlitchtip. Don't add new redactor calls in handler bodies — extend the central denylists insrc/lib/observability/redact-payload.tsandsrc/lib/logging/redact.ts.
Self-hosting gotchas
These keep tripping operators; they're worth knowing before you push something that breaks them.
SESSION_COOKIE_SECUREcontrols theSecureflag on every cookie the app issues. Unset → flag tracksNODE_ENV === "production". Set tofalsefor LAN / Tailscale / VPN-only HTTP self-hosts where the operator deliberately serves plain HTTP. Set totrueto force the flag even in dev. The composeenvironment:block must list it explicitly — vars not on the whitelist don't reach the container even when.envhas them. v1.5.2 closed the missing-whitelist regression.pull_policy: alwaysindocker-compose.ymlis load-bearing. Without it Docker keeps a stale:latestdigest cached and silently skips the registry round-trip oncompose up.ENCRYPTION_KEYSmap vs legacyENCRYPTION_KEY. The map ({"v1":"<hex>","v2":"<hex>"}+ENCRYPTION_ACTIVE_KEY_ID=v2) is the modern path. The legacy single-key env var stays accepted asv1. Rotation playbook lives atdocs/ops/encryption-key-rotation.md; do NOT drop a key from the map before the rotation script reports zero remaining rows on that key.- GeoLite2 databases. Operators run
scripts/fetch-geolite2.shwith a MaxMind licence key beforedocker build; the image bakes the MMDBs into/opt/geolite2/. Without them the resolver falls back to onlineipwho.isand the admin login overview surfaces "unknown" for offline lookups. - APNs
.p8key. v1.4.47.x establishedAPNS_KEY_B64as the env-var contract — pasting the PEM body verbatim through Coolify'senv_filemangled the literal newlines. The key must be scoped "Sandbox & Production" in the Apple Developer Portal; a Sandbox-only key fails withBadEnvironmentKeyInTokenon production builds.
iOS handoff
The native SwiftUI client lives in a separate repository and rides public beta via TestFlight. Backend contract the iOS app speaks against is locked in docs/api/openapi.yaml and has been continuously validated since v1.4.23. Coordination notes worth knowing on the backend side:
POST /api/measurements/batchrecognisesexternalIdvalues starting withstats:(e.g.stats:HKQuantityTypeIdentifierStepCount:YYYY-MM-DD) as overwrites — a re-post replaces the row's value / unit / measuredAt / externalSourceVersion / deviceType / sleepStage. Every other externalId prefix (uuid-*, opaque HK identifiers) keeps the first-write-wins immutable contract because each sample is a canonical reading. Per-entry status field carries"updated"for an overwrite vs"inserted"for a fresh row.- Refresh-token rotation is per-device; reuse-detection revokes the family scoped to that device.
requireAdmin()is cookie-only by construction — a Bearer token, even with["*"]scope, cannot reach/api/admin/*. The iOS app is not an admin client.- The
clientManagedopt-in onPATCH /api/auth/me/notification-prefssuppresses server-sideMEDICATION_REMINDERAPNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag istrueand emits amedication_reminder.suppressed_client_managedwide-event annotation per skip. push_attemptstable holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at/api/admin/notifications/diagnosticsurfaces masked device tokens + channel state + last 20 attempts for the calling user.- Any request / response schema change must regen
docs/api/openapi.yamland commit the result. CI fails on drift.
DO-NOTs
- No
--no-verifyon commits. If a pre-commit hook fails, fix the cause; never bypass. - No
--no-gpg-signunless explicitly asked. - No force-push to
main(or to release branches, by extension). - No destructive git operations as shortcuts.
git reset --hard,git clean -fd,git checkout .etc. only when the maintainer asked or the state is unambiguously local-only. - No assistant / planning vocabulary in any committed artefact — see Voice and privacy above.
- No personal names — the maintainer's, a reporter's, or anyone else's — in any committed artefact, including code comments referring to "the user who reported issue #X".
- No secret-shaped strings in
.env.exampleor git history. Pre-existing scan shows clean. - No markdown library in dependencies. Coach + briefing + insights render as React text children for XSS reasons; adding one is a security decision, not a UX one.
- No
Access-Control-Allow-Originheaders anywhere except the documented webhook surfaces — and those authenticate via header secret, not cookie. - No
pnpm tsx scripts/foo.tsin production. The standalone image stripstsx. One-shot scripts run viapnpm dlx tsx; recurring tasks belong on pg-boss. - No backwards-compatibility shims for hypothetical callers. When a change is clean, make it cleanly; do not leave dead branches "in case" something we don't ship needs them.
Pointers
| What | Where |
|---|---|
| Public face | README.md |
| Release history | CHANGELOG.md |
| Codebase audit (v1.5.2) | .planning/codebase/{tech,arch,quality,concerns}.md |
| Security audit (v1.5.2) | .planning/security-audit-v1.5.2.md + .planning/security-audit-findings/* |
| Operator runbooks | docs/ops/ (deploy, encryption-key-rotation, env-check, backup-restore, migrations) |
| Self-hosting guide | docs/self-hosting/ |
| Public API contract | docs/api/openapi.yaml |
| Architecture diagrams | docs/diagrams/ (rendered through docs.healthlog.dev) |
| iOS coordination notes | .planning/ios-coord/ |
| Maintainer working notes | .planning/PROJECT.md, .planning/ROADMAP.md, .planning/STATE.md (gitignored release scratch lives alongside; the patterns are in .gitignore) |
| Personal preferences + collaboration memory | ~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md (local to the maintainer's working copy; not in the repo) |