Compare commits

..
Author SHA1 Message Date
dependabot[bot] f719c92062 build(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/upload-sarif` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-25 15:56:02 +00:00
3681 changed files with 78549 additions and 442897 deletions

No files matched your search

+6 -62
View File
@@ -38,13 +38,6 @@
# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
# Left commented here so that append does not create a duplicate key. Uncomment
# and set it directly instead if you prefer to edit the file by hand.
# The container runs the app as `healthlog_app_<database>_<hash>`, a database
# role it creates on every start that reads and writes rows but cannot change
# the schema. The
# migrations keep the DATABASE_URL user. "off" keeps the app on that user, if
# you manage database roles yourself (docs/self-hosting/database-role.md).
# DB_APP_ROLE="off"
# POSTGRES_PASSWORD="generate-with-openssl-rand-hex-32"
@@ -209,10 +202,7 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
#
# Set this to "1" ONLY when every public request to HealthLog terminates
# at Cloudflare (Tunnel, Workers, classic proxy) before it reaches your
# origin, and the origin accepts connections from Cloudflare alone (a
# Tunnel, or a firewall that admits only Cloudflare's address ranges): the
# header is believed from every connection, before any other check.
# Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any
# origin. Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any
# direct-to-Next.js exposure MUST leave this off.
#
# Security implications when the flag is mis-set:
@@ -234,19 +224,10 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Number of trusted reverse-proxy hops in front of the app. The client IP is
# read that many entries from the right of the X-Forwarded-For chain. Set 0
# for a directly-exposed host; 1 (the default when unset) behind a single
# reverse proxy. X-Real-IP is read only at 1. Forwarding headers are read only
# from a connection whose own address is not public (loopback, private, CGNAT,
# unique-local, link-local, reserved or multicast; the full list is in
# docs/self-hosting/reverse-proxy.md). A wrong value makes the app
# reverse proxy. X-Real-IP is read only at 1. A wrong value makes the app
# refuse the headers, log one warning, and share one rate-limit bucket across
# all anonymous callers.
# TRUST_PROXY_HOPS="1"
# The addresses the reverse proxy connects from (comma-separated CIDRs). Set,
# only those connections may report the client's address; unset, any private
# address may, which behind Docker Desktop, OrbStack or docker-proxy includes
# every caller of the published port. Same host: 127.0.0.1/32,::1/128; Docker
# network: the proxy's subnet. See docs/self-hosting/reverse-proxy.md.
# TRUSTED_PROXY_CIDRS=""
# TRUST_CF_CONNECTING_IP=""
@@ -296,33 +277,6 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY
# BACKUP_ENCRYPTION_PREVIOUS_KEYS="" # retired off-host keys, comma-separated, read-only during a rotation
# -----------------------------------------------------------------------------
# Database dumps (optional, on by default)
# -----------------------------------------------------------------------------
# The whole-database dump that rebuilds a lost server: nightly, and before
# every update that brings migrations. Written to DB_BACKUP_DIR with a
# manifest beside it (row counts, key fingerprints, versions); with the
# off-host bucket above set, each nightly dump is also uploaded under
# `database/`, sealed with BACKUP_ENCRYPTION_KEY. Local dumps are not
# encrypted: they sit beside the database volume, in the same trust zone.
# Restore: `docker compose run --rm db-restore latest`. See
# docs/ops/disaster-recovery.md.
#
# Set "false" to stop the nightly dump.
# DB_BACKUP_ENABLED="true"
# Where dumps are written. The bundled compose file mounts the `backups`
# volume here; change it only together with that mount. In a container a
# directory that is not on a mount of its own sits in the container's layer
# and is lost at the next update, so no dump is kept there.
# DB_BACKUP_DIR="/var/lib/healthlog/backups"
# Five-field cron, in Europe/Berlin time like every scheduled job.
# DB_BACKUP_SCHEDULE="30 1 * * *"
# Nightly dumps kept (at least 1). The last two pre-migration dumps are kept
# besides.
# DB_BACKUP_KEEP="7"
# Set "false" to start an update without the dump before its migrations.
# DB_BACKUP_BEFORE_MIGRATE="true"
# -----------------------------------------------------------------------------
# Stored backups in the database (optional)
# -----------------------------------------------------------------------------
@@ -374,21 +328,11 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Auto-deploy webhook (v1.4.15 — phase C2)
# -----------------------------------------------------------------------------
# Coolify (or any CI) pings POST /api/internal/deploy-webhook after a deploy
# attempt with success/failure. A request authenticates with this shared
# secret in the `X-Deploy-Webhook-Secret` header (timing-safe compare; what
# Coolify's stock sender does, with no replay protection), or by signing:
# `X-Deploy-Webhook-Signature: sha256=<hex>`, the HMAC-SHA256 under this
# secret of the `X-Deploy-Webhook-Timestamp` value, a dot and the raw body,
# the timestamp within 5 minutes. Generate with `openssl rand -hex 32`.
# Configure the SAME value in Coolify's Notifications → Webhook
# custom-headers panel. See docs/ops/deploy.md.
# attempt with success/failure. We verify the request via this shared secret
# carried in the `X-Deploy-Webhook-Secret` header (timing-safe compare).
# Generate with `openssl rand -hex 32`. Configure the SAME value in Coolify's
# Notifications → Webhook custom-headers panel. See docs/audit/v1415-auto-deploy.md.
# DEPLOY_WEBHOOK_SECRET=""
# "true" also requires a fresh X-Deploy-Webhook-Timestamp on a secret-header
# request; leave unset for Coolify's stock sender, which sends none.
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=""
# "true" accepts signed requests only (the setting that stops a replay);
# leave unset for Coolify's stock sender.
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=""
# -----------------------------------------------------------------------------
+24 -103
View File
@@ -295,17 +295,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. When unset the notification omits the link.
# DEPLOY_LOGS_URL=""
# Optional: require an X-Deploy-Webhook-Timestamp within +/- 5 minutes on a
# request that authenticates with the secret header. Keeps a stale capture
# out, not a fresh forgery by someone who saw the secret. Leave unset for
# Coolify's stock sender, which sends no timestamp.
# Optional replay hardening: require a fresh X-Deploy-Webhook-Timestamp
# header (unix seconds / ms or ISO-8601, +/- 5 minutes) on every webhook
# call. Leave unset for Coolify's stock sender (it attaches no
# timestamp); see docs/ops/deploy.md.
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
# Optional replay protection: accept only signed webhook calls
# (X-Deploy-Webhook-Signature, an HMAC of the timestamp and body under the
# secret, with a timestamp within +/- 5 minutes); the secret header alone is
# refused. Leave unset for Coolify's stock sender, which can only send the
# secret header; see docs/ops/deploy.md.
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE="false"
# -----------------------------------------------------------------------------
@@ -375,11 +369,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# per-user BYO keys keep the pinned public-host posture either way.
# ADMIN_AI_BASE_URL_ALLOWLIST=""
# Codex (ChatGPT sign-in) model slugs. Left unset, each sign-in picks from
# the models its account is offered (read from the provider the way the Codex
# CLI reads it), with a built-in order as the fallback. CODEX_MODEL pins one
# slug ahead of that; CODEX_MODEL_FALLBACK_CHAIN replaces the order outright
# (comma-separated, first match wins) and the account's list is then not read.
# Codex (ChatGPT sign-in) model slugs. CODEX_MODEL pins one slug ahead of the
# built-in chain; CODEX_MODEL_FALLBACK_CHAIN replaces the chain outright
# (comma-separated, first match wins). Reach for these when the provider
# rotates a slug and requests start failing with 503 -- without them a
# rotation needs a new release to fix.
# CODEX_MODEL=""
# CODEX_MODEL_FALLBACK_CHAIN=""
@@ -420,33 +414,6 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# key once the lifecycle rule has retired every object written under it.
# BACKUP_ENCRYPTION_PREVIOUS_KEYS=""
# -----------------------------------------------------------------------------
# Database dumps (optional, on by default)
# -----------------------------------------------------------------------------
# The whole-database dump that rebuilds a lost server: nightly, and before
# every update that brings migrations. Written to DB_BACKUP_DIR with a
# manifest beside it (row counts, key fingerprints, versions); with the
# off-host bucket above set, each nightly dump is also uploaded under
# `database/`, sealed with BACKUP_ENCRYPTION_KEY. Local dumps are not
# encrypted: they sit beside the database volume, in the same trust zone.
# Restore: `docker compose run --rm db-restore latest`. See
# docs/ops/disaster-recovery.md.
#
# Set "false" to stop the nightly dump.
# DB_BACKUP_ENABLED="true"
# Where dumps are written. The bundled compose file mounts the `backups`
# volume here; change it only together with that mount. In a container a
# directory that is not on a mount of its own sits in the container's layer
# and is lost at the next update, so no dump is kept there.
# DB_BACKUP_DIR="/var/lib/healthlog/backups"
# Five-field cron, in Europe/Berlin time like every scheduled job.
# DB_BACKUP_SCHEDULE="30 1 * * *"
# Nightly dumps kept (at least 1). The last two pre-migration dumps are kept
# besides.
# DB_BACKUP_KEEP="7"
# Set "false" to start an update without the dump before its migrations.
# DB_BACKUP_BEFORE_MIGRATE="true"
# -----------------------------------------------------------------------------
# Stored backups in the database (optional)
# -----------------------------------------------------------------------------
@@ -479,16 +446,6 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 90 rather than repeat the default.
# COACH_MESSAGE_RETENTION_DAYS="365"
# How long deleting an account, a managed profile or "all data" stays
# undoable, in days. Default 14, ceiling 30. The account is locked at once
# (signed out everywhere, sign-in refused, nothing runs for it) and an admin
# can undo the deletion from the users list until the window ends; the purge
# job erases it then, together with its stored and off-host copies. A wipe
# keeps a copy of the record an admin can restore for the same time. 0 erases
# immediately, as releases before 1.46 did. The Data & Privacy page states
# this number, so it has to reach the container.
# ACCOUNT_DELETION_GRACE_DAYS="14"
# How long the host CPU/memory samples behind the admin monitoring charts are
# kept, in days. Default 7. Raise it for a longer trend; lower it on a host
# where disk is the scarce resource.
@@ -526,14 +483,6 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Behind such a pooler, set this to 1, true or yes to send no session
# settings at all; the pooler's or the database's own defaults then apply.
# DATABASE_SESSION_OPTIONS_DISABLED="1"
#
# The app runs as `healthlog_app_<database>_<hash>`, a database role the
# container creates for this database on every start: it reads and writes
# rows but cannot alter or drop tables, own anything, or create roles. Migrations and restores still use the user in DATABASE_URL.
# Where that user may not create roles (a managed database), the start log
# says so and the app uses DATABASE_URL as given. Set "off" if you manage
# the database roles yourself. See docs/self-hosting/database-role.md.
# DB_APP_ROLE="off"
# -----------------------------------------------------------------------------
@@ -568,43 +517,19 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# -----------------------------------------------------------------------------
# Client-IP resolution -- optional (required behind Cloudflare)
# -----------------------------------------------------------------------------
# When the stack sits behind Cloudflare, the real visitor IP arrives in the
# `cf-connecting-ip` header; the X-Forwarded-For chain otherwise yields the
# Cloudflare edge IP, so the audit-log overview stores the CF datacentre
# address and the geo lookup resolves the wrong location. Set this to "1"
# ONLY when Cloudflare is in front -- a direct-exposed self-host must leave
# it unset, or an attacker could forge the header. Off by default.
# Number of trusted reverse-proxy hops in front of the app; the client IP is
# read that many entries from the right of the X-Forwarded-For chain. 0 for a
# directly-exposed host (the connection's own address is used), 1 (the
# default when unset) behind a single reverse proxy; X-Real-IP is read only
# at 1. Forwarding headers are read only from a connection whose own address
# is not public: loopback, private, CGNAT, unique-local, link-local, reserved
# or multicast (the full list is in docs/self-hosting/reverse-proxy.md). A
# wrong value makes the app refuse the headers, log one warning, and collapse
# anonymous callers into one rate-limit bucket.
# directly-exposed host, 1 (the default when unset) behind a single reverse
# proxy; X-Real-IP is read only at 1. A wrong value makes the app refuse the
# headers, log one warning, and collapse anonymous callers into one
# rate-limit bucket.
# TRUST_PROXY_HOPS="1"
#
# The addresses your reverse proxy connects from, as comma-separated CIDRs
# (IPv4 or IPv6; a bare address is one host). When set, only those
# connections may report the client's address; every other connection is
# its own client. When unset, any private address may, which is the old
# behaviour and is NOT safe behind Docker Desktop, OrbStack or docker-proxy:
# there every caller of the published port arrives from the gateway, a
# private address, and could name its own client address. Set it whenever a
# reverse proxy is in front:
# proxy on the same host (proxying to 127.0.0.1:3000): 127.0.0.1/32,::1/128
# proxy container on a Docker network: that network's subnet (docker
# network inspect <network>), e.g. 172.18.0.0/16
# proxy on another machine with a public address: that address, /32
# A proxy on a public address that is not listed is not believed: every
# request through it shares the proxy's address and one rate-limit bucket.
# The boot log names the rule in force in one [proxy-trust] line.
# TRUSTED_PROXY_CIDRS="127.0.0.1/32,::1/128"
#
# Behind Cloudflare the real visitor IP arrives in the `cf-connecting-ip`
# header; the X-Forwarded-For chain otherwise yields the Cloudflare edge IP,
# so the audit-log overview stores the CF datacentre address and the geo
# lookup resolves the wrong location. "1" believes that header from EVERY
# connection, before the checks above, so set it ONLY when the origin accepts
# connections from Cloudflare alone (a Cloudflare Tunnel, or a firewall that
# admits only Cloudflare's address ranges). An origin that also answers on
# its own public address lets any caller forge the address that rate limits
# and the audit log record. Off by default.
# TRUST_CF_CONNECTING_IP="1"
# Memory cap of the app container (read by docker-compose.yml, not by the app).
@@ -714,16 +639,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# ENVIRONMENT_AIR_QUALITY_DISABLED="1"
# ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ──
# On x86-64 the renderer is enabled only on CPUs with AVX2. On older chips
# (Celeron/Atom-class NAS CPUs) it is disabled automatically and those features
# degrade cleanly: no thumbnails, and an AI provider that cannot take PDF files
# reads a PDF by its text layer, so a scanned PDF needs a provider that takes
# PDFs directly (Anthropic) or photos of its pages. On arm64 the published image
# renders on every 64-bit core, the Raspberry Pi 4 included. Only a musl
# (Alpine) build of your own needs ARMv8.2 (LSE atomics, RCpc) and is gated the
# same way. Override only if auto-detection misfires:
# The bundled renderer's x64 build uses AVX2 CPU instructions. On x86-64 hosts
# WITHOUT AVX2 (Celeron/Atom-class NAS CPUs) it is disabled automatically and
# those features degrade cleanly (no thumbnails; scanned PDFs read as text
# only). Override only if auto-detection misfires:
# off — force-disable the renderer
# on — force-enable (crashes PDF processing if the CPU truly lacks them)
# on — force-enable (crashes the container if the CPU truly lacks AVX2)
# NATIVE_CANVAS="off"
+1 -18
View File
@@ -64,21 +64,6 @@ updates:
schedule:
interval: "weekly"
open-pull-requests-limit: 3
ignore:
# The Dockerfile's `pgclient` stage carries pg_dump and pg_restore of
# the bundled database's major. A major bump there is part of the same
# migration decision as the database itself, and the runner's paths and
# version checks name major 18, so it arrives by hand, not as a PR. Its
# minor moves with the database's in docker-compose.yml
# (runtime-package-pins.test.ts fails while the two differ).
- dependency-name: "postgres"
update-types: ["version-update:semver-major"]
# The Node major in the build and runner stages is the one `engines`,
# `.nvmrc` and `.node-version` name, and the native modules are built
# against it. Moving it is a release decision, so it arrives by hand.
# Digest and minor updates of the pinned major still come as PRs.
- dependency-name: "node"
update-types: ["version-update:semver-major"]
# The `docker` ecosystem above reads the Dockerfile only. The bundled
# database lives in `docker-compose.yml`, which GitHub treats as its own
# ecosystem, so without this block a new PostgreSQL minor release never
@@ -94,8 +79,6 @@ updates:
# The first run of this ecosystem proposed 16.15 -> 18.6, which would
# have handed self-hosters a database their data directory cannot be
# read by. Minor releases still arrive as PRs; a major is a deliberate
# project decision with a migration guide behind it. The ignore also
# keeps `db-upgrade` on 16: it exists to read Postgres 16 data, and a
# bump to 18 would leave it unable to start on the volume it moves.
# project decision with a migration guide behind it.
- dependency-name: "postgres"
update-types: ["version-update:semver-major"]
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
# minutes and can produce nothing actionable, because nobody edits
# it and the fix for anything found there is `pnpm prisma generate`.
- name: Initialise CodeQL
uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: javascript-typescript
build-mode: none
@@ -75,6 +75,6 @@ jobs:
- public
- name: Analyse
uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:javascript-typescript"
-51
View File
@@ -1,51 +0,0 @@
name: Compose database move
# Plays the Postgres 16 -> 18 move in docker-compose.yml against real
# containers: a v1.45 database with every migration applied, then the current
# compose file's `db-upgrade` on its first start, a second start, a move cut
# short, an emptied new volume and a new install. The service runs once on an
# operator's host and has no second chance there, so every change to the
# compose file or to the check runs it here first.
# `scripts/compose-db-upgrade-check.sh` is the same check, runnable locally.
on:
pull_request:
branches: [main]
paths:
- docker-compose.yml
- scripts/compose-db-upgrade-check.sh
- .github/workflows/compose-db-upgrade.yml
push:
branches: [main]
paths:
- docker-compose.yml
- scripts/compose-db-upgrade-check.sh
- .github/workflows/compose-db-upgrade.yml
workflow_dispatch:
permissions:
contents: read
jobs:
move:
name: Postgres 16 -> 18 move
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# The check reads the v1.45 compose file from its tag.
- name: Fetch the v1.45.0 tag
run: git fetch --no-tags --depth=1 origin tag v1.45.0
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Move check
run: bash scripts/compose-db-upgrade-check.sh
-70
View File
@@ -1,70 +0,0 @@
name: Disaster recovery drill
# Rebuilds a lost server from the nightly database dump and the keys alone,
# against real containers: the app image built from this checkout, the
# bundled compose file with an S3-compatible bucket outside it, the demo seed
# plus a Guardian with a managed profile, the dump taken and uploaded by the
# scheduled job, every volume of the stack destroyed, a new stack, the
# restore, and the app booted on the result (health, login, an encrypted
# note, a document, the Guardian's profile, every table's row count against
# the manifest). Two sources: from the bucket (`db-restore latest
# --from-bucket`, the disaster path) and from dump files copied off the host
# (`db-restore latest`).
# `scripts/compose-dr-drill.sh` is the same drill, runnable locally; its
# header lists what it checks and the mutations that must turn it red.
on:
pull_request:
branches: [main]
paths:
- docker-compose.yml
- Dockerfile
- docker-entrypoint.sh
- scripts/compose-dr-drill.sh
- scripts/dr-drill-seed.ts
- scripts/db-dump.mjs
- scripts/db-restore.mjs
- scripts/pending-migrations.mjs
- src/lib/database-backup/**
- src/lib/jobs/database-backup-queue.ts
- src/lib/jobs/reminder/backup-handlers.ts
- .github/workflows/compose-dr-drill.yml
schedule:
# Nightly, 02:41 UTC: off the hour, after the evening merges.
- cron: "41 2 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: compose-dr-drill-${{ github.ref }}
cancel-in-progress: true
jobs:
drill:
name: Restore a lost server from its dump (${{ matrix.source }})
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
source: [bucket, local]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
# The seed scripts run from the checkout against the drill's database.
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- name: Drill
env:
DRILL_SOURCE: ${{ matrix.source }}
run: bash scripts/compose-dr-drill.sh
+2 -2
View File
@@ -150,7 +150,7 @@ jobs:
# Image is built but not pushed in that case.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -350,7 +350,7 @@ jobs:
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Log in to GitHub Container Registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+6 -7
View File
@@ -40,7 +40,7 @@ jobs:
name: e2e shard ${{ matrix.shard }}/3
services:
postgres:
image: postgres:18-alpine
image: postgres:16-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
@@ -62,7 +62,7 @@ jobs:
AUTH_RP_ORIGIN: http://localhost:3000
SESSION_COOKIE_SECURE: "false"
NODE_ENV: production
# The WebKit specs run in the `webkit` job below.
# The WebKit spec runs in the `webkit` job below.
E2E_SKIP_WEBKIT: "1"
steps:
@@ -146,9 +146,8 @@ jobs:
if [ -z "$version" ]; then echo "playwright-core not found in pnpm-lock.yaml"; exit 1; fi
echo "version=$version" >> "$GITHUB_OUTPUT"
# The WebKit specs, in the same image as the shards: the bottom sheet on an
# iPhone (`bottom-sheet.spec.ts`, project `webkit-iphone`) and the app shell
# on an iPad (`tablet-shell.spec.ts`, project `webkit-tablet`).
# The WebKit spec (`bottom-sheet.spec.ts`, project `webkit-iphone`), in the
# same image as the shards.
webkit:
runs-on: ubuntu-24.04
needs: playwright-version
@@ -158,7 +157,7 @@ jobs:
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
services:
postgres:
image: postgres:18-alpine
image: postgres:16-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
@@ -203,7 +202,7 @@ jobs:
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
- run: pnpm exec playwright test --project=webkit-iphone --project=webkit-tablet
- run: pnpm exec playwright test --project=webkit-iphone
env:
E2E_BASE_URL: http://localhost:3000
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
services:
postgres:
image: postgres:18-alpine
image: postgres:16-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
@@ -71,7 +71,7 @@ jobs:
steps:
- name: Log in to GHCR
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
publish_results: true
- name: Upload results
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: scorecard-results.sarif
category: scorecard
+8 -19
View File
@@ -245,23 +245,12 @@ jobs:
rm trivy-install.sh
trivy --version
# Every HIGH/CRITICAL finding in the built image, fixed or not, as a
# table in the job log. Reporting only: a Debian base carries
# advisories the distribution has not fixed yet (or marked as not
# worth a fix), and nothing this repository changes can remove those.
# The list is here so they stay visible and move to the gate below
# the day a fix is published.
- name: Trivy container scan (all findings)
run: trivy image --format table --severity CRITICAL,HIGH --exit-code 0 healthlog:scan
# Blocking since v1.15.20 — a HIGH/CRITICAL finding with a published
# fix fails the job. `--ignore-unfixed` leaves out only the findings
# listed above that have no fixed version yet; a fix that reaches the
# Debian archive, the Node image or a dependency turns this red until
# it is taken (rebuild, bump the base digest, or bump the package).
# `.trivyignore` at the repository root (Trivy reads it automatically)
# is for a finding with a fix that cannot reach this image, one CVE
# per line with a comment naming what it covers and when to remove
# it. Never suppress a finding whose fix applies here; take it.
# Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built
# image fails the job. For a finding without an upstream fix
# (e.g. a base-image CVE Alpine has not patched yet), add the CVE
# id to a `.trivyignore` file at the repository root — Trivy reads
# it automatically — one CVE per line, each with a comment naming
# the tracking issue and the re-check condition. Never suppress a
# finding that has a fixed version available; bump instead.
- name: Trivy container scan
run: trivy image --format table --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 healthlog:scan
run: trivy image --format table --severity CRITICAL,HIGH --exit-code 1 healthlog:scan
+1 -5
View File
@@ -84,7 +84,7 @@ jobs:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
name: integration shard ${{ matrix.shard }}/4 (shuffled, Postgres 16)
name: integration shard ${{ matrix.shard }}/4 (shuffled)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -110,10 +110,6 @@ jobs:
shell: bash
env:
HEALTHLOG_INTEGRATION_WORKERS: "3"
# Pull requests run the suite on Postgres 18, the compose default.
# Installs that bring their own database may still be on 16, so the
# nightly run keeps that major covered without doubling every PR.
INTEGRATION_PG_IMAGE: "postgres:16-alpine"
# Four times each property's own case count; a property's timeout
# grows by the same factor (`propertyTimeout`).
FC_RUNS_FACTOR: "4"
+4 -14
View File
@@ -6,7 +6,7 @@ name: Trivy Scheduled Scan
# - `security.yml` → `dependency-audit` runs `pnpm audit --prod` on every
# push to `main` and once a day, blocking on HIGH/CRITICAL.
# - `security.yml` → `docker-security` builds the image on a push to `main`
# and runs `trivy image` against it, blocking on HIGH/CRITICAL with a fix.
# and runs `trivy image` against it, blocking on HIGH/CRITICAL.
#
# Both are change-triggered, and both answer "is this change safe". Neither
# answers "is what we shipped last week still safe", because an advisory
@@ -88,7 +88,7 @@ jobs:
skip-dirs: src/generated
- name: Upload results
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: trivy-filesystem.sarif
category: trivy-filesystem
@@ -113,7 +113,7 @@ jobs:
# this covers `linux/amd64`. The arm64 manifest shares every
# application layer and differs only in native binaries.
- name: Log in to GitHub Container Registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -142,17 +142,10 @@ jobs:
output: trivy-published-image.sarif
severity: "CRITICAL,HIGH,MEDIUM"
limit-severities-for-sarif: true
# Only findings with a published fix reach code scanning. Debian
# leaves many base-package advisories unfixed in stable (no-dsa,
# postponed), and nothing in this repository can act on those: the
# build's `apt-get upgrade` takes the fix the day Debian ships one,
# and that is when the alert should appear. Listing them all made
# the page a hundred-odd rows deep without one to act on.
ignore-unfixed: true
exit-code: "0"
- name: Upload results
uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: trivy-published-image.sarif
category: trivy-published-image
@@ -169,6 +162,3 @@ jobs:
format: table
severity: "CRITICAL"
exit-code: "1"
# Same rule as the blocking gate in security.yml and the SARIF
# report above: only a finding with a published fix counts.
ignore-unfixed: true
-5
View File
@@ -30,11 +30,6 @@ scripts/bench/results
# parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly.
tests/integration/__golden__
# A release's backup file exactly as its exporter wrote it; the golden-backup
# test checks the bytes against the manifest's SHA-256, so it is never
# reformatted. The manifests are written by the same test.
tests/fixtures/golden-backups/*.json
# inlang writes its working files (cache, metadata, README) next to the
# project settings when its CLI or SDK runs; only settings.json is tracked.
project.inlang/*
+19
View File
@@ -3,6 +3,25 @@
# can apply; never suppress one with a fixed version available — bump instead.
# Each entry names what it covers and the condition to re-check / remove it.
# CVE-2026-33671 (picomatch ReDoS, HIGH) — flagged only in the npm CLI's
# OWN bundled copy that ships inside the Node base image
# (usr/local/lib/node_modules/npm/node_modules/picomatch, v4.0.3), used at
# image-build time by npm, not by our runtime. Our application dependency
# tree pins picomatch >= 4.0.4 via a pnpm override, so the running app is
# not affected and the path is not reachable at runtime. Re-check when the
# Node base image ships an npm build whose bundled picomatch is >= 4.0.4,
# then drop this entry.
CVE-2026-33671
# CVE-2026-48815 (sigstore certificate-OID verification, HIGH) — flagged only
# in the npm CLI's OWN bundled copy inside the Node base image
# (usr/local/lib/node_modules/npm/node_modules/sigstore, v3.1.0). npm uses
# sigstore for publish provenance at image-build time only; it is not in our
# application dependency tree at all (`pnpm why sigstore` is empty) and is
# never reachable at runtime. Re-check when the Node base image ships an npm
# build whose bundled sigstore is >= 4.1.1, then drop this entry.
CVE-2026-48815
# CVE-2026-85393 (node-forge RSA PKCS#1 v1.5 signature forgery, HIGH; GitHub
# advisory GHSA-86w9-cpqp-85rv) — node-forge <= 1.4.0 arrives only through
# @parse/node-apn, which uses it in its certificate-based credential path
-986
View File
@@ -1,991 +1,5 @@
# Changelog
## [Unreleased]
## [1.47.1] — 2026-11-07
### Added
- `GET /api/integrations/healthkit` lists the HealthKit sample types the
server accepts as `acceptedTypes`, taken from the same mapping the batch
upload uses. The iOS app can then ask for and send only types the server
stores, instead of reading a type the server would skip. (#1340)
- `GET /api/sync/changes?start=now` answers an empty page with a cursor
that starts at the present. The iOS app can page forward from it to
write readings to Apple Health in the background, without first walking
years of history. (#1339)
- `GET /api/biomarkers/{id}/series` returns one lab marker's line, the same
points and window the timeline draws for it, under the Labs module. The
iOS app can show a marker's chart with the timeline switched off. (#1342)
- Resting energy from Apple Health is stored as a metric of its own,
`BASAL_ENERGY_BURNED` in kcal, next to active energy and never added to
it. It used to be skipped. It is summed per day, charted, listed with your
measurements, included in backups and exports, and readable over MCP. It
feeds no score and no insight. (#1340)
- When the Coach looks for readings older than the lookback limit you set
for it, its live trail now says so on the wire: the `step` and `activity`
frames carry `reason: "outside_reach"` instead of looking like an empty
window. The native client can then tell "nothing in this window" from
"further back than the Coach may read". The answer still names the limit,
and the web trail keeps its wording.
- Turning on workout routes no longer helps only workouts recorded from
then on. When the app sends a route again for a workout that is already
stored without one, the server now keeps it, sealed like any other
track, and fills the workout's climb if it had none; the entry reports
`enriched`, as it does for a heart-rate series. A workout that already
has a route keeps it, and with routes switched off nothing is stored.
- **Lab markers grouped by category.** Under Settings, Arrangement, Labs,
the list can now group markers by their panel (Lipids, Liver, Thyroid
and so on), each group with a header that says whether its latest values
are all in range or how many are outside it. A catalog panel is
recognised in every language, a panel you typed yourself becomes its own
group, and a marker without a panel falls back to the catalog panel its
name belongs to, or to Other. You choose which groups open on load: all,
only those with a value outside range, or none. Grouping is off by
default. The server resolves the groups once: `GET /api/labs/overview`
carries `categories` and `grouped`, the layout preferences gain
`groupBy` and `groupsOpen`, `GET /api/biomarkers` lists the panels you
already use as `customPanels` for the panel field's suggestions, and a
merge preview names the panel the kept marker takes over as
`adoptedPanel`. No migration. (#1349)
- **Your cycle forecast as a calendar subscription.** Cycle, Settings,
Calendar subscription publishes the next period, and once three cycles
are logged up to six after it, as an ICS feed for Google, Apple or
Outlook calendar. Each event spans the window the period may start in,
wider the further ahead it is, and the feed stops where a date would be
more guess than estimate. Past cycles (up to twelve) and the fertile
window of the next cycle (only with the goal of trying to conceive) are
opt-in. Titles are a dot by default, and events never carry symptoms,
notes or other log details. The link is shown once, can be rotated or
turned off, and is turned off by a password change or a deletion
request. Only the record's owner, or a managed profile's guardian, can
turn it on, from a signed-in browser after confirming it is them. The
feed answers with an `ETag`, and every unknown or revoked link gets the
same plain 404. `GET /api/cycle/calendar` adds `projection` and `projectionState`
so the web and iOS calendars draw the same cycles ahead, and the Coach
and MCP cycle context list them as `upcoming`. Migration
`0494_cycle_calendar_feed`. Self-hosters: the feed link sits in the URL
path, so keep `/api/cycle/feed/` out of your proxy's access log;
`docs/self-hosting/reverse-proxy.md` shows how. (#1350)
- **Your own symptoms as lines on the timeline.** The timeline's values
menu has a Symptoms group with every symptom you have logged at least
once, hidden ones included. A line sits on a fixed 0 to 10 scale and
follows the zoom: each point is the mean of the bucket's days, where a
day counts with the highest intensity logged on it, and it says how many
days that was. A day without an entry is left out rather than counted as
zero. Symptoms follow the illness module and its sharing domain, so the
lines are only offered where the symptom log itself is.
`GET /api/timeline` takes `symptom:<id>` in `values` (up to 24) and answers
with `symptomSeries` and `availableSymptoms`; with `zoom=all` the window
reaches back to the first entry of a chosen symptom. No migration. (#981)
### Fixed
- **The report offered for a visit is the report you get.** When the last
matching visit was more than two years ago, the next-visit card offered a
report from that day, but a report covers two years at most, and the
export quietly made a 90-day report instead. The window before a visit
now stops at two years and says "The last two years", and the visit list
marks it with `preparation.capped`. A report range longer than two years,
however it is asked for, now covers the last two years of it rather than
falling back to 90 days. (Two years across a clock change also no longer
count as one day too many.)
- **PDF previews show in Safari.** On macOS and iOS the preview of an
uploaded PDF stayed an empty box. The page's security policy told the
browser that nothing may frame it, and Safari applied that to the
preview the page itself opens. The policy now allows framing by the app
itself and still refuses every other site. (#1311)
- **Periods from Garmin and Oura open their cycles.** Period days imported
from a watch or a ring were stored as days only, so the cycle tracker saw
no cycle start, no lengths and no forecast. They now open and fill their
cycles like a period logged by hand, a finished Garmin period also marks
its last day, and periods imported earlier are repaired once on the next
start. A day you logged yourself is never changed. (#1333)
- **Garmin in the setup questions.** The onboarding question about where
your readings come from now offers Garmin, and can end on connecting it.
(#1333)
- **Fitbit in the setup questions leads to Google Health.** Fitbit shut its
classic connection down on 30 October, but answering "Fitbit" still ended
the setup on that card. Fitbit stays an answer, since the devices are still
in use; the last step now opens the Google Health card, reads that
connection, and says that Fitbit data comes through Google Health now. The
question shows the same note under the Fitbit choice.
- **Cycle days in the integration details.** The Garmin and Oura cards list
the newest imported cycle day and how many there are under "Data
received", next to the other data types. (#1333)
- **Typing into the smoking history.** "When you stopped" only took text
that was already a whole YYYY-MM month, so every keystroke was dropped
and only a paste got in. The start year clamped each keystroke into its
range, so the first digit turned into 1900. Both fields now keep what is
typed and commit it once it is a valid value, and leaving the month field
pads "2019-3" to "2019-03". (#1334)
- **Crisis lines for the country you are in.** A questionnaire answered
from a phone set to English in Belgium (`en-BE`), French in Switzerland,
or English in Austria got the international crisis set, because the
country was only read for some languages. The region in the language tag
now picks the country's lines whatever the language; a bare language
code keeps the combined sets as before. (#1338)
- **SSO sign-in in the app names the deletion date.** Signing in with SSO
on the phone to an account pending deletion now carries the date the
account is erased, as the web sign-in already did, so the app can say
until when an admin can still undo it. (#1341)
- **A marker started from a text result keeps its range.** When a report
printed a text result such as "SEE NOTE" with a numeric range beside it,
the new marker was saved without that range, so its first number later
had nothing to be judged against. The range now goes onto the new marker,
from a document, a lab scan, a CSV import or a typed reading, and the
scan review shows it for such a row. A range that is text itself, like
"NEGATIVE", is not turned into bounds, and a marker that already exists
keeps its own. (#1313)
- **Several files dropped at once all arrive.** The server takes two
uploads per person at a time and asks the next one to come back in a
second, but the documents page sent three at once and showed that
answer as an error telling you to wait a few minutes. The page now sends
two at a time, and a file the server turns away for a moment, say while
another tab is still uploading, waits and goes again on its own for up
to half a minute before it reports anything. Attaching a new file in the
Coach does the same. Reaching the hourly upload limit now says that
instead of "a few minutes".
- **Counts in Russian, French and Portuguese.** Russian uses its singular
form for 21, 31 and so on, and French and Brazilian Portuguese use it for
zero, but those strings had the digit 1 written in, so "21 days" read as
"1 day". They now carry the actual count, and a guard keeps new ones from
writing the digit.
### Changed
- Dependabot no longer proposes a new Node major for the image. The major
is tied to `engines`, `.nvmrc` and `.node-version` and moves by hand.
### Security
- Next.js moves to 16.4.5, which fixes seven moderate advisories. Most
concern `use cache` and Cache Components, which HealthLog does not use;
one concerns self-hosted apps that pass a fetched response on to the
client, and one the Edge runtime's internal headers.
- `@hono/node-server` moves to the 2.x line, which fixes a second decode of
the request path in `serveStatic`. It arrives under the MCP SDK; the app
uses the SDK's web-standard transport and never loads it, but the copy
was in the tree. `systeminformation`, under the shadcn CLI at dev time,
moves past a Windows command injection.
## [1.47.0] — 2026-11-04
### Added
- **Blood pressure by time of day.** The blood pressure page has a card
with the morning, midday and evening averages side by side, each with
its count and pulse, and the morning minus evening difference once both
have at least three readings. Morning runs 04:00 to 11:59, midday 12:00
to 17:59 and evening 18:00 to 03:59 in your time zone, wide on purpose,
because home measurement guidance ties the two readings to waking and to
bedtime rather than to a clock. A control under it narrows the chart to
one part of the day, kept in the URL, and the stat strip names the part
while it is narrowed. The server computes the split, so the app can show
the same numbers (`GET /api/measurements/blood-pressure/time-of-day`,
and `partOfDay` on the daily series). (#1331)
- **Russian.** HealthLog speaks Russian, its tenth language: the whole
interface, the AI output language, the safety screen's Russian bank,
Russian month names in the coach's fact check, and the questionnaires
with the "show original" switch, marked as not yet validated. (#1322)
- **A lab reading has its own id over MCP.** `fetch labresult:<id>`
returns one reading with the report and visits it came from, and the
links on documents and visits now point at the reading that report
holds instead of the analyte's latest one. `lab:<analyte>` still answers
with the latest reading. (#1323)
- **The intake history shows where an injection went.** A dose logged
with an injection site already stored it, but the per-medication
history only showed when it was taken and where the entry came from.
Each injected dose now carries a chip with the site next to its status,
in the table and on the mobile cards, so the rotation can be read back
from the history. Doses without a site look as before.
- **Garmin, directly.** Garmin Connect now offers a Personal API, and
HealthLog reads it: create a key in Garmin Connect (Settings, Personal
API), paste it into the new Garmin card, and steps, distance, floors,
active calories, resting heart rate, sleep stages and sleep score,
overnight SpO₂, breathing rate and HRV, weigh-ins with body composition,
activities and logged period days sync every hour, with the whole
history filled in behind. Body Battery and Garmin's all-day stress level
arrive as types of their own (`BODY_BATTERY`, `STRESS_LEVEL`), never mixed
into the computed scores. Garmin keys expire after 30 to 365 days; the
card warns a week ahead and asks for a new key when Garmin stops
accepting the old one. Accounts on Garmin's China service work too; the
key goes only to the service you pick. The key is stored encrypted and
only ever sent to Garmin. (#1333)
- **Training Readiness and acute training load.** The Personal API does
not carry them, so they are new types (`TRAINING_READINESS`,
`ACUTE_TRAINING_LOAD`) a Home Assistant bridge can push through an ingest
token, and they chart on the recovery page next to Body Battery. The Home
Assistant guide has the automation. (#1333)
### Changed
- **The image runs on the official Node 22 image (Debian slim) instead of
Alpine.** The runtime is glibc now, so the native page renderer,
password hashing and the Prisma engine run their glibc builds. The
published arm64 image renders PDF pages on a Raspberry Pi 4 again, where
the Alpine build of the renderer needs CPU features an ARMv8.0 core
lacks. Node security releases now come with the official Node image,
which HealthLog pins and moves forward with its releases. The image
carries no package manager: npm, npx, corepack and yarn are removed.
`pg_dump` and `pg_restore` are PostgreSQL 18.6, the bundled database's
version. A few tools the Alpine image happened to carry are gone too:
`psql`, `pg_isready`, `pg_dumpall`, `ps`, `top`, `nc`, `unzip` and the
full `wget`. HealthLog uses none of them in the app container; run
`psql` in the `db` container. A health check that
still calls `wget`, from a compose file copied before this release or a
platform template, keeps working: the image carries a small `wget` that
answers health checks and nothing else. The current `docker-compose.yml`
probes with Node directly, and that is the one to switch to. (#1311)
- **The app connects to the database with less power.** On Postgres 17 or
later, with a database user that may create roles (the bundled compose
file's user may), the app and the worker now connect as a role of their
own that reads and writes rows but cannot change or drop tables, create
functions, read server files or create roles. Migrations, the dump
before them and restores still run as the owner, and so does the queue
schema, which the role may only write rows into. Nothing needs setting:
the role is created at start, with a password derived from an existing
secret, one per database. Where the role cannot be set up (Postgres 16,
a managed database without the right, `DB_APP_ROLE=off`), the app runs
as before and says why at start. `docs/self-hosting/database-role.md`
has the details.
### Fixed
- **Medication adherence reads faster.** Every rate walked a schedule from
the day the medication was created, so it slowed down as a medication
aged, about 4 ms per rate after two years. It now starts just before the
window it reads, with the same result.
- **A PDF the server cannot render is read by its text.** On a host
without the page renderer (an x86 CPU without AVX2), an AI provider that
takes pages as images could not read a PDF at all, and the message asked
for a Claude provider without saying why. A PDF with a text layer is now
read as text there, and a scan says plainly that this server cannot turn
its pages into images. A PDF that fails to render on a capable server is
reported as that, not as a provider problem. (#1311)
- **A marker that started with a text result takes the unit of its first
number.** A marker created from "see note" had an empty unit that locked
once it held a reading, so the first number, a unitless ratio included,
was refused. While a marker holds only text readings, its first number
now sets the unit. (#1313)
- **The AI safety screen catches more medication instructions.** A
negated take ("don't take your metoprolol tonight"), a pause, an extra
amount without a target ("add 4 more units of insulin") and a liquid
dose of a syrup or solution are now held back in all ten languages,
while advice such as drinking water or taking a break from screens
passes. A Dutch reminder not to stop a medication without a doctor was
held back before and passes now.
- **Least-squares fits keep r² within 0 to 1.** Values far from zero with
little spread could give an r² a hair above 1.
### Thanks
- @FloMaetschke for the injection site in the intake history (#1330).
- @l1137l for the Russian translation, the Russian safety bank and the
patience through two reviews (#1322).
- @johnnyrepo026 for finding the text-first marker that refused its first
number (#1313).
- @mbreitkreuz for testing the MCP links down to two reports on one day
(#1323).
- @Oguz3003 for staying with the Raspberry Pi 4 until it reads PDFs
(#1311).
- @mills1975 for asking how blood pressure differs over the day (#1331).
- @kruemelro for asking for a direct Garmin connection on Garmin's new
Personal API (#1333).
## [1.46.6] — 2026-11-02
### Added
- **Lab values without a unit.** Ratios and indices such as INR,
cholesterol/HDL or albumin/globulin had nowhere to go: manual entry, the
CSV import and the document review all required a unit. Entry, the
scanned-report rows and the document review now offer an explicit "No
unit" choice, stored as the UCUM unity `"1"`, and the CSV import reads
`1` in the unit column the same way. An empty unit is still refused, so
a unit the reading lost is not mistaken for none, and a document read
that answers "no unit" by itself is held for review. Such a value shows
as the number alone, a marker kept in a real unit refuses it, and the
FHIR export codes it as UCUM `1`. (#1313)
- **MCP reads follow their links.** Lab readings in `get_labs` and
`fetch lab:` list the documents and visits they are linked to, with the
linked records once per answer instead of on every reading of the same
report. `fetch med:` and `get_medication_schedule` list theirs, and
`fetch document:` now also names the lab results and medications filed
against it. Rows of `get_visits`, `get_medication_schedule` and
`get_illness_recovery` carry an id `fetch` takes. An imported document
names its source system and id, never the address of the instance it
came from. (#1323)
### Fixed
- **A refused lab value can be corrected in the review.** A value that
Finish review refused, such as a ratio printed without a unit or a unit
that disagrees with the marker, showed the reason but could only be
discarded. The row now offers the same Check control as a low-confidence
value. (#1313, #1327)
- **The doctor report prints every language.** The PDF used a built-in
font that covers Western European letters only, so Polish letters such
as ą, ł or ś, the whole Korean report, and any medication name or note in
another script printed as empty boxes. The report now embeds an open
Unicode font family covering Latin, Greek, Cyrillic and, when the report
needs it, Korean, and arrows, ≤ and ² print as themselves instead of
ASCII stand-ins. The file is compressed, so a report stays around
65 KB. Chinese, Japanese and Arabic text are not covered yet.
- **PDFs read on a Raspberry Pi 4.** The native canvas build for arm64
Alpine needs CPU features an ARMv8.0 core lacks, and loading it ended
every PDF read with an illegal instruction. The CPU check now covers that
case, and PDF reading carries on without the canvas, so the text layer
and the index work; thumbnails keep their existing fallback. (#1311)
- **The display name greets you.** The dashboard and Insights greetings,
the top bar and the sidebar showed the username even when a display name
was set. They now use the display name and fall back to the username.
(#1309)
- **Summaries count every reading right after an import.** A type whose
history was not folded yet counted as covered as soon as one day was, so
a summary could show one day of weight readings out of three. A type now
counts as covered only when its folded days reach back to its oldest
reading.
- **The repeated hour of a clock change stays two hours.** On the night
the clocks go back, a dense pulse or glucose chart folded both runs of
the repeated hour into one point. Each hour is its own point now.
- **Google Health heals a connection that lost its account id.** The
1.46.4 update cleared a placeholder id on existing connections. After a
clean sync such a connection now reads its real id back, so the
one-account-per-profile check covers it again.
- **WHOOP refuses a sign-in without a user id** instead of storing the
text "undefined" as the account.
### Changed
- Next.js 16.4.3 and the weekly dependency updates. node-forge, reached
through the APNs client, is raised to 1.4.2, which closes five high
advisories.
### Thanks
- @johnnyrepo026 for the precise report on unitless lab ratios (#1313),
with the cause already traced to the right two files, and for the pull
request that fixed the review half of it (#1327).
- @mbreitkreuz for the MCP linked-records request (#1323), a careful spec
with the real case that made date matching fail.
- @Oguz3003 for tracking the Raspberry Pi 4 crash down to the CPU and
confirming the workaround (#1311).
- @angusmaul for noticing the greeting ignored the display name (#1309).
- @mills1975 for the heart-rate sync report and the diagnostics that
pinned it to the iPhone app (#1326); the fix follows in an app update.
## [1.46.5] — 2026-10-30
### Fixed
- **Azure OpenAI reasoning deployments answer through the gateway.** An
OpenAI-compatible gateway pointed at an Azure OpenAI v1 endpoint sent
`max_tokens` and `temperature`, and a reasoning deployment (gpt-5 on,
the o-series) refused the request with a 400. A deployment named after
its model now gets `max_completion_tokens` and no sampling controls
from the first request, as on `api.openai.com`. A deployment with a
name of its own, or any other gateway that refuses `max_tokens` the
same way, is asked once more with `max_completion_tokens` and keeps
that for the endpoint and model. GPT-4 deployments and other gateways
keep the wire they had. Reported in #1304.
## [1.46.4] — 2026-10-30
### Fixed
- **Google Health connects on every profile of an instance again.** The
connect stored the same placeholder as the Google account's id for every
account, so since 1.44.0 a second profile on the same instance was
turned away with "This Google account is already connected to another
HealthLog profile", also when it only reconnected. The connect now
stores the account's real id. An update clears the stored placeholder,
and a profile that was turned away connects with its next attempt.
Instances with one Google Health connection were not affected.
## [1.46.3] — 2026-10-29
### Fixed
- **Codex picks from what the account is offered.** OpenAI retires Codex
models for ChatGPT sign-in in steps, and every retirement left Codex
refusing every request until a release changed the built-in list. Each
sign-in now reads its account's model list, the way the Codex CLI does,
and chooses from it in HealthLog's order of preference. Whether a model
reads images comes from the same list. If the list can't be read, the
built-in order applies, and `CODEX_MODEL` and
`CODEX_MODEL_FALLBACK_CHAIN` keep their meaning. The working model is
remembered per sign-in instead of in one slot for the whole server, so
two accounts on different plans, or the shared central connection, no
longer overwrite each other, and a sign-in reconnected to another
account starts fresh. (#1287)
- **A first Google Health sync no longer stalls on the sleep history.** Each
night's clean-up of re-scored sleep carried every segment id of the whole
fetch, so a backfill of a few years sent one query per night with tens of
thousands of values, over the database's limit, and kept the server busy
building them for many minutes before each one failed with a warning. Each
night now protects only the segments inside it, which is all the clean-up
can reach. (#1195)
- **A gateway that refuses the reasoning field is asked without it for a
day.** The lesson held for an hour, and every expiry cost one refused
request, a few a day on a gateway that runs the briefings unattended.
Lessons read from a single reply keep the hour. (#1279)
### Added
- **Lab values on the timeline.** The values menu lists the lab markers
the record has, and a chosen marker draws its readings as their own
points, never averaged, with the reference range behind them. A reading
outside the range is drawn as a triangle and named in words. A tap shows
the day, the value, the range and the report it came from. Readings in a
unit that cannot be converted are left out and the timeline says how
many. Up to 24 markers at once; the choice is remembered like the other
value lines. (#1299)
- **Restoring the backup of an erased account.** An upload whose account
no longer exists used to be refused with nowhere to go. Admin, Backups
now offers to restore it into a new account created for it, or into an
existing account that holds no records beyond what sign-up and setup
leave, confirmed by typing its username. Admin accounts are not offered.
Nothing names the erased account afterwards. A new account has no
password until an admin sets one with Reset password. Restoring the same
file into a second account is refused with an explanation instead of a
generic failure. The runbook in `docs/ops/backup-restore.md` has the
details.
### Thanks
@haritowa for taking the Codex retirements apart in #1287 and proposing
the live list. @mills1975 for the timeline idea in #1299. @Tio61 for three
days of request counts that showed what the hour still cost (#1279).
## [1.46.2] — 2026-10-29
### Fixed
- **Large accounts get their summaries.** On an account with tens of
millions of heart-rate readings the job that rebuilds the summary tier
after an upgrade ran one aggregate over five years under the database's
60 second limit, never finished, and left the dashboard cards reading the
raw table until they timed out too. It now works through the history one
calendar year at a time, every step gets five minutes of its own, and a
run that is out of time hands the remaining years to a follow-up instead
of starting over. Finding the accounts that need the rebuild no longer
reads every reading. If you raised `DATABASE_STATEMENT_TIMEOUT_MS` as a
workaround, you can set it back. (#1298)
- **Lab values from a document land on the day of the report.** A report
usually prints the collection date once, in its header, so confirming it
filed every undated value under the day you confirmed it. An undated
value now takes the report date, or the filing date when the report
names none, and an impossible date such as 30 February counts as no date.
Values filed under the wrong day before are moved once after the update:
only those that came from a confirmed document, had no date of their own,
still sit at the moment they were confirmed, and whose document names a
different day. The server log says how many moved. (#1293)
- **Deleting a blood pressure reading deletes all of it.** Deleting the
systolic or the diastolic value used to leave the other half and the
pulse behind. It now removes the whole reading, Undo brings the whole
reading back, and deleting only the pulse still removes only the pulse.
- **Daily briefings work with models that always reason.** Background work
asks for medium reasoning at most, and a model that cannot be told to
stop thinking used all of that room and returned nothing. Once HealthLog
has seen a model answer with nothing but its reasoning, background work
gives it the room the Coach gets on High. (#1276)
- **Single sign-on accounts can confirm it's you.** Creating a token,
connecting an assistant or resetting a password asks for a fresh
confirmation, and an account with no password, passkey or second factor
had nothing to offer. It now confirms through its identity provider with
a fresh login, accepted only for the linked identity and a sign-in from
the last few minutes. The same confirmation can add a passkey. (#1296)
- **A personal baseline no longer depends on the wall clock.** Reads that
ask for a baseline as of a given moment saw a different window through
the summary tier than through the raw readings.
- Signed out, the language switch keeps your choice in the browser instead
of asking the server to store it.
- A new account is greeted with a welcome instead of "welcome back".
- The Coach chat, the document chat and the profile settings accept every
language the app ships.
- The French translation says "vous" throughout.
### Added
- **Admins hear about a background job that keeps failing.** After three
failed runs in a row every admin gets a push on their system alert
channels, a reminder at most once a day while it keeps failing, and one
message when it works again. Retries of one run count once, and a job
that runs per account counts per account, so one account in trouble is
reported once and not every hour. (#1298)
- **Heart rate from Google Health and Oura is folded like Apple Health's.**
Readings older than 90 days become one value per hour, and a later sync
does not bring the folded readings back. The first fold of a large
history works through the backlog over a few nights and waits until that
account's summaries are rebuilt; afterwards the index rebuild under
Admin, System status gives the space back. (#1297)
- **Admins can delete another account** from the users list, through the
same grace window as deleting your own. Your own account, the last admin
and managed profiles are excluded.
- Crisis lines for the Netherlands and Belgium on the support card.
- A section on large accounts in the self-hosting guide.
- Tests that restore every release's own backup file, restore the backup
of an erased account, and hold the header height in every app language.
### Thanks
@Cnote43 for #1297 and #1298, written up so precisely that the fix mostly
followed the report. @haritowa for finding, fixing and live-testing the lab
date bug (#1293) and for the Codex model checks and tests (#1288). @Tio61
for testing every step of #1276. @sposschip for #1296.
## [1.46.1] — 2026-10-26
### Fixed
- **Charts on a phone are their old size again.** 1.46 moved the labels
for your usual level and for a target zone out of the chart and beside
it. On a phone that room took about a third of the chart's width, and
the legend took more of its height. On a narrow screen the labels sit
inside the chart again, as they did before; a tablet or a wide window
keeps them beside it.
- **The date axis shows more than one date on a phone.** A 90-day chart
is labelled with the first of each month again, and the All range with
the years.
- **The value axis writes whole numbers when its steps are whole**: 57,
58, 59 bpm rather than 57.0, 58.0, 59.0.
- **The All range shows your whole history.** Long ranges are read from
summaries the server keeps for the last five years, and readings older
than that did not appear: a weight history going back to 2015 started
in late 2021. The older readings are now read directly and lead the
chart.
## [1.46.0] — 2026-10-25
### Added
- **A database dump every night, and a restore that rebuilds a server.**
Until now every copy the app made was per account and could only roll an
account back on the same server; a lost server needed a dump you had to
make yourself. The app now takes a whole-database dump every night (01:30
Europe/Berlin) and before every update that brings migrations, keeps the
last seven on the new `backups` volume, and also puts it in your off-host
bucket when one is set up. Each dump has a manifest beside it: app
version, migration, Postgres version, key fingerprints and the row count
of every table. `docker compose run --rm db-restore latest` (or
`latest --from-bucket` after losing the host) restores it into an empty
database only, checks the keys and the checksum first and every table's
row count after, and refuses anything else. A drill in CI does exactly
that every night and on every change to the backup path: it builds a
server, fills it, destroys every
volume, rebuilds it from the bucket and the keys alone, and checks that
people can sign in and read their notes and documents.
- **Recovery readiness, pushed to the admins.** One card on the admin
dashboard and the backups page says whether a server could be recovered
today: the nightly dump, the weekly account copies, the off-host copies,
the restore drill, whether off-host copies are set up, and the recovery
kit. When one of them goes wrong, every admin gets a notification, once,
and again daily while it stays broken, and once when it recovers. A
stopped backup used to show only if someone opened the page.
- **A recovery kit instead of "save the active key".** The kit lists every
key a copy still needs, retired ones included, with their fingerprints:
the encryption keys, the backup key and its predecessors, and the token
key. It becomes due again whenever that set changes, for example after a
key rotation. An upgraded server asks once, because the kit now also
lists the token key.
- **Fourteen days to change your mind.** Deleting an account or a managed
profile now locks it at once (every session, token, share link and
connection is revoked) and erases it after 14 days
(`ACCOUNT_DELETION_GRACE_DAYS`, 0 keeps the old immediate deletion). An
admin can undo it in the users list until then. "Delete all data" first
keeps a copy of the account for the same 14 days, and the copy restores
like any other. Nothing in the background touches an account while it
waits.
- **Apple Health write-back for measurement tokens.** A measurement token,
the narrow kind a scale or Home Assistant uses to add readings, now has a
"Write readings to Apple Health" switch, when you create it and in the
token list. With it on, the HealthLog app on your iPhone adds that
token's readings to Apple Health. The readings still show that they came
from an external device, and the token still cannot read or change
anything. The server decides which readings go to Apple Health and tells
the app, so switching it on later also covers what the token already
sent. Readings from before this release are matched to a token only
where the account has exactly one measurement token; the card says so
when older readings could not be matched. (#1272)
- **Recently deleted for documents.** While it holds anything, the
Documents page links to Recently deleted. It lists each deleted document
with its type, its date, where it came from (Paperless-ngx, for
example) and how many of its 30 days are left, and each one can be
restored or deleted for good right away. The import picker now lets you
tick a document you deleted: one still in Recently deleted comes back
with everything that belonged to it, one already removed is imported
again. Automatic imports, such as a Paperless workflow or the import
script, still never bring back a document you deleted. (discussion
#1275)
- **Questionnaires in every app language.** PHQ-9, GAD-7, WHO-5 and the
sleep questionnaire no longer fall back to English where no validated
translation exists. New validated versions: PHQ-9 in French, Dutch,
Korean and Brazilian Portuguese, GAD-7 in Dutch, Korean and Brazilian
Portuguese, WHO-5 in Dutch and Korean, and the sleep questionnaire in
Polish. Elsewhere HealthLog shows its own faithful translation with a
note on every step that the result is for orientation, and one tap
switches to the English original. Each check-in remembers which wording
it was answered in, and the doctor report marks a total from an
unvalidated translation with an asterisk.
- **Your own files import in full.** The JSON import reads every field
the full backup and the JSON export write: a night's sleep stages, range
readings, an ECG's verdict, the recording device, and a mood entry's
note, detailed ratings, time zone, tags, rated factors and day context.
A full backup also brings your own mood tags, factors and groups along.
Missing ones are created within the usual limits, a tag you already
have is never overwritten, and anything that cannot be matched is named
on the import card.
- **GPT-6 models.** gpt-6-sol, gpt-6-luna and gpt-6.1-sol get reasoning
control with a real "off", the request shape reasoning models expect,
and image input, through an OpenAI key and the ChatGPT sign-in alike.
The OpenAI presets offer gpt-6.1-sol and gpt-6-luna.
- **A queued restore says what it is waiting for.** A restore never runs
beside a backup or another large job. The admin console now says which
of the two it waits for and since when, instead of reading like a stuck
worker.
### Changed
- **Postgres 18.** The compose file now runs Postgres 18 instead of 16. An
install from 1.45 or earlier moves its database across by itself on the
first start: a short-lived `db-upgrade` step copies it into a new
`postgres-data` volume, compares the row count of every table, gathers
the planner statistics, and only then lets the app start. If anything
fails, the app does not start and the log says why; a move cut short is
run again from scratch on the next start. The old `pgdata` volume is
left as it was, so going back is the previous image and two lines of the
compose file. On a real 600 MB instance the move took 29 seconds.
HealthLog keeps running on Postgres 16 for installs that bring their own
database, and the tests run on both. (discussion #1282)
- **A quicker, lighter start.** On an account with ten years of data the
app answers its first request about a tenth sooner after a start, holds
about 55 MB less memory right after it, and the background worker is ready
after loading about a quarter of the code it used to need, with the rest
loaded in the background. Within a minute it has tried every job once and
reports any that cannot load, so a broken image shows up at deploy time
instead of when a job fails hours later. Pages and charts answer as fast
as in 1.45, with the same number of database reads; a ten-year chart takes
a few milliseconds longer because it now checks that a trend has enough
days behind it.
- **Chart axes that read cleanly.** Value ticks fall on round steps (60,
80, 100 rather than 57, 83, 109), date ticks fall on a calendar grid
(days, Mondays, months, quarters, years) and keep their place as data
arrives, and the "your average" and target zone labels sit beside the
plot instead of over the readings or clipped to one letter. The dots
under a chart mark only the days the chart itself shows.
- **Tidier code underneath.** Large parts of the code were split up so
the next changes are smaller and safer to make: files over 800 lines
went from 123 to 84, functions over 200 lines from 411 to 346. Nothing
you use should look different. Signing in with a password or a passkey,
adding a weight, logging a mood, importing lab results, changing a
setting and restoring a full backup are now tested end to end against a
real server.
- **TrueNAS guide.** It covers the app in the TrueNAS Community train and
its backups, and the encryption key backup step points at the app's
Encryption Key field.
### Fixed
- **Slow local models get the time you allow them.** The response timeout
reached each model call, but not the work around it: a daily briefing is
up to five calls in a row and had one fixed bound of the timeout plus 30
seconds, so a model needing several minutes per answer was cut off in
its second call. The briefing now gets your timeout for every call, the
Insights page keeps waiting as long as that allows, and the status cards
and the other AI background jobs follow the same setting instead of
fixed limits. (#1274)
- **The Coach no longer shows a model's reasoning as its answer.** With a
model that always reasons, served through an OpenAI-compatible gateway
or a local server, the Coach could show the thought instead of the
reply, cut off, and sometimes skip the tools it meant to call. The token
limit now keeps room for reasoning whatever the model is asked,
reasoning is taken out of the answer in every shape servers send it,
tool calls run whenever the reply carries them, and a model that still
produces nothing but reasoning gets its own message suggesting another
model. A model that does not reason keeps its own answer even when it
is cut off. (#1276)
- **A model that never answers can no longer run up requests.** One idle
hour against a model that reasoned into its token limit could cost well
over a thousand requests. One call now makes two requests at most. A
reply with reasoning and no answer is asked once more without
reasoning, and if that does not help either, that model is not asked
that way again for an hour. Background work leaves a provider that
keeps failing alone: after three failures in a row it waits five
minutes, from five on it pauses for half an hour, doubling while the
failures continue, up to a day. The Coach and "Re-run analysis" still
reach it and show its error, and saving your AI settings, the admin AI
settings or a fresh ChatGPT sign-in ends the pause. A status card whose
generation failed waits from five minutes up to two hours before the
next try, even when new data arrives. Tokens spent on replies without
an answer count toward the daily AI budget, "Index all documents" stops
after three failed calls in a row, and a gateway that refuses the
reasoning setting is noticed once instead of failing every call.
(#1279)
- **"Extract lab values" reads long documents.** On a stored document it
still sent the whole report in one call, which a long report outgrew,
and the answer came back as "Couldn't read the document. Try a clearer
copy." It now reads the way the lab scan does: a PDF page by page and
text in parts, up to 400 readings, with the same timeout and budget. If
the provider stops early, the readings already found are kept and the
review warns that some values may be missing, every time it is opened;
a read that stopped before the first value says so instead of blaming
the copy. An extraction the iPhone app waits for reads in at most three
parts and marks the document incomplete when that was not enough.
(#1257)
- **PDF previews, vision reads and search work together.** The server
loaded two versions of pdf.js, and whichever ran first broke the other:
after a local text read every preview failed and a vision read of a PDF
was refused, after a preview the local text read failed and the document
stayed out of search. The server now ships one pdf.js. Previews and
search entries the clash broke are repaired once on the first start
after the update. (#1277, PR #1278)
- **"End" on a medication works, and ends it now.** The button had failed
with an error on every press since it was added. It now ends the
running course at that moment: a dose taken earlier today still counts,
and a later one is no longer due, reminded or counted as missed. End is
offered only while a course is running, someone who manages your record
can use it too, undoing the end brings the later doses back, and a new
course can start from the next day. (#1280, PR #1281)
- **Memory stays flat under bursts of requests.** Every page the server
rendered stayed in memory for ten minutes after it was sent, about
250 KB each, so a run of automated requests could push the container
towards its memory limit. A render is now released with its request.
- **Fast right after an import, not only once the database caught up.**
Postgres learns what a table holds in the background, some time after
rows arrive. Until it had, on a new install or right after a large
import, several reads chose a plan that grew with the square of the
data: the analytics page over a few thousand blood pressure readings
took seconds, and the long-range charts and all-time highs and lows of
a ten-year history could run past the database's time limit. Those
reads now take one pass over the readings whatever Postgres knows, with
the same results, and take a fraction of a second.
- **Trends need readings on enough days.** Three blood pressure readings
taken in one sitting, the only ones that week, produced a 7-day trend of
thousands of mmHg. A trend is now shown only when the readings fall on
at least three days spread over at least half the window (4 days of 7,
15 of 30), wherever a trend appears.
- **iPad and tablets.** The app fills the window after a rotation and
under Safari's floating toolbars, without a strip of page colour at the
bottom, a cut-off last card or a page that scrolls under the app. In
landscape every sidebar destination is reachable, with a cue when more
are below. A vertical swipe over a row of tabs scrolls the page, and the
area over the home indicator matches the page in Edge, Chrome and
Firefox.
- **The app's own backup and export import again.** The JSON import
refused both, because they write an empty field as `null` or `""`. It
reads them as the app wrote them now, converts display units such as lb
or mmol/L, and skips deletion records. The portable backup also carries
a reading's glucose context, sleep stage, range, rhythm verdict and
device, which it used to drop.
- **Deleted imports stay deleted after a restore.** A backup restored onto
a fresh server let a Paperless workflow or the import script store again
every document you had deleted. The backup now carries which imports
were deleted.
- **ChatGPT sign-in works without a model override.** The ChatGPT backend
rejects every model from gpt-5.5 down, so an install without its own
model list ended in "all configured Codex slugs were rejected". The
default list now starts at gpt-5.6-terra.
- **Workout splits and GPX times.** Splits were missing for every synced
route, and an exported GPX track had no times. Both read the
stored route points correctly now.
- **A hung backup no longer blocks restores.** A backup that hung held the
slot for large jobs until the container restarted, so a restore waited
forever. A job that outlives its time limit now gives the slot up.
- **API answers match the published contract.** Creating or editing a
medication answers with the same fields as reading it, a dose answers
the full entry, medications and doses come back in the same order on
every request, readings opened by day carry their note, and the
contract now declares several answers the server already gave.
- Every page view saved your language to the account again and counted
against your write limit; it is saved once per tab now.
- A page reloaded right after you entered a reading shows that reading
instead of the dashboard from before.
- The daily briefing's chart notes and storyline were dropped from replies
that passed validation; they are kept now.
- A stored AI key that can no longer be decrypted counts as no key instead
of failing the briefing with a server error.
- After a Fitbit or Google Health history import, the dashboard and
analytics show the new data right away instead of after their cache ran
out.
- Averages and streaks take one clock per request, so a reading at the
edge of a window no longer moves in and out, and today's reading counts
for the streak whatever time zone the database runs in.
- A refused request by someone you share your record with shows in your
activity feed with its own wording.
- A stat's value and unit stay on one line beside the docked day view;
the neutral score label is readable in the dark theme; axis labels are
sized correctly when reduced motion is on.
- **Large backups upload without filling the memory.** Uploading a copy
with many documents or ECG recordings held them whole while it was
checked, and a big vault could crash the server. Every reader of a
backup now streams the large parts; a 271 MB file needs about 2 MB.
- **Backup texts say what each copy can do.** The weekly copy is an
account rollback on this server, the personal export is for the person,
the JSON import reads measurements and mood within its stated limits, and
only the database dump rebuilds a server. Nothing claims more anymore.
- **Long Coach answers keep all their steps.** After a reload an answer
with more than 48 steps lost the rest.
- **The hint for reading scans on the device points to the right switch**,
Settings, Modules, Labs, in every language.
- **Help links open the right page** instead of going through redirects.
- **A directory setting below `/proc` no longer freezes the server.** Node's
way of creating a folder with its parents loops forever there on Linux,
so a `DB_BACKUP_DIR` or `GEOLITE2_DIR` pointing below it stopped the
process. Such a directory now reads as not writable.
- **The last hour of a long DST day keeps its doses.** On the day the
clocks go back, the list of today's doses that the apps offer from ended
an hour early, so a late evening dose was missing from it; on the day
they go forward it reached an hour into tomorrow. It now spans the real
local day, as the dashboard already did.
- **A dose logged for a two-day plan lands on its slot in the week the
clocks go back.** A plan with doses on two neighbouring weekdays, started
shortly before the change, could file a later dose as unscheduled, and
logging it again gave back the previous one. Whether a plan counts as
daily or weekly no longer depends on the stretch of days it is read
through.
### Security
- **A crafted PDF or image can no longer take the server down.** A small
PDF with one huge compressed image, or a JPEG with a padded header, made
the server reserve gigabytes and get killed, for every account on it.
Everything pdf.js does now runs in its own process with a memory and a
time limit, image sizes are read by the JPEG rules and an unreadable size
is refused, and a document over the limits is marked as such instead of
being tried again.
- **Rate limits count the real caller.** Forwarding headers are believed
only from a connection that comes from the server's own network or a
proxy on it; a caller reaching the port directly no longer picks its own
address, and the sign-in throttle no longer exempts an address an account
used before.
- **Writes from other sites are refused.** A request that changes something
and comes from another site, including a sibling subdomain, is refused
before it is read. The iPhone app and other token clients are not
affected.
- **The MCP consent page names where the access goes**, and warns when a
self-registered application sends it to a remote host.
- **Switching Labs off keeps lab values out of every AI feature.** The
Coach, the daily briefing, status notes and the MCP lab prompt read no
lab values while the module is off. Before, the Coach still sent them.
- Smaller fixes: share links show no documents while the documents module
is off, a narrow medication token can no longer probe medication names,
MCP tools fence medication and lab text like every other user text,
reading documents takes turns between accounts, so one account's heavy
files cannot hold up everyone else's, the session cookie cannot be
planted from a sibling subdomain over HTTPS, file-like paths under `/api`
go through the request checks, the deploy
webhook can be signed, the error relay to GlitchTip needs a signed-in
session, and database error details are redacted before they leave.
- Raised the minimum versions of body-parser and fast-uri, which the MCP
server runs on (CVE-2026-111181, high; CVE-2026-110180;
CVE-2026-110306), of ip-address, which took exponential time on a
crafted IPv6 zone identifier, and of a development-only copy of undici.
### Upgrade notes
- Migrations 0475 to 0485 (0475, 0476, 0477, 0478, 0479, 0480, 0482, 0483
and 0485; 0481 and 0484 are not used). Most add a column under a
bounded lock wait and retry on a lock timeout. These do more:
- 0476 links existing readings to the measurement token that sent them
where the account has exactly one such token. It sets only that link,
so the readings do not count as changed.
- 0477 builds an index on `measurements` concurrently; writes carry on
during the build.
- 0478 clears the recorded preview failures and 0479 re-queues the PDFs
whose search entry failed, so the first start renders those previews
once more and indexes those PDFs once more. The indexing uses your AI
provider where one is set up and you have allowed it, as an upload
would.
- 0485 drops the unused preview-features column from `users`.
On a copy of a real instance all of them together took under a second.
- If a concurrent index build fails half-way, for example because the
container was stopped, mend the cause and mark the migration rolled
back; the next start drops the invalid index and builds it again. See
`docs/ops/deploy.md`.
- After a deploy, the worker reports any job it cannot load within a
minute; `docs/ops/deploy.md` says what to look for and when to roll
back.
- `/api/settings/preview-features` is retired and answers 410. It served
a switch for pages that never shipped, and no client reads it. A backup
from 1.44 or 1.45 that carries the field still restores.
- The Apple Health switch takes effect with an iPhone app build that
reads it; until then the app keeps mirroring Withings, imported and
hand-entered readings as before.
- If you set `CODEX_MODEL_FALLBACK_CHAIN` only to get past the rejected
ChatGPT models, you can remove it.
- Migrations 0486 to 0489 add the dump ledger and its key column, the
readiness and kit tables and the deletion columns. 0487 also drops the four old key-backup
columns from `app_settings` and carries an existing confirmation over;
as with 0485, do not run the previous image beside the new schema.
- **A new `backups` volume** holds the local dumps. The compose file
mounts it; a stack built without it keeps dumps off-host only and says
so. In the bucket, dumps go under `database/<your host>/`, so two
servers sharing a bucket never mix them up; add `database/` to the
bucket's lifecycle rule, and the backups page warns when no rule covers
it. Dumps keep a deleted account until they
age out, so state that period as your backup retention.
- **New settings:** `DB_BACKUP_ENABLED`, `DB_BACKUP_DIR`,
`DB_BACKUP_SCHEDULE`, `DB_BACKUP_KEEP`, `DB_BACKUP_BEFORE_MIGRATE` and
`ACCOUNT_DELETION_GRACE_DAYS`, all on sensible defaults. If the dump
before a migration fails on a writable directory, the start stops and
names the switch to skip it.
- **Behind a reverse proxy,** forwarding headers are believed only from a
private or loopback address. If your proxy reaches HealthLog from a
public address, set `TRUSTED_PROXY_CIDRS` to that address; otherwise
every user shares one sign-in rate limit. `TRUSTED_PROXY_CIDRS` also
narrows the default when you know your proxy's network
(`docs/self-hosting/reverse-proxy.md`). Behind a proxy that rewrites the
Host header, make sure `APP_URL` is right, or writes from the browser
are refused as cross-site.
- The deploy webhook can now be signed (`X-Deploy-Webhook-Signature`);
`DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true` accepts signed requests only.
`DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP` keeps its meaning, and the stock
Coolify setup needs no change.
- Over HTTPS the session cookie is now `__Host-healthlog_session`. The old
name is still read and swapped on the next request, so nobody is signed
out.
- The app image is about 6 MB larger: it now carries the Postgres 18
client for the dumps. The supported memory floor stays at 1536 MB for
the app container.
- The first start with the new compose file moves the bundled database to
Postgres 18 (see Changed). Use `docker compose up -d` with the new
compose file; pulling only the new image keeps Postgres 16, which this
release still runs on. The move needs free disk space about the size of
the database. Rollback, the steps for a database you run yourself, and
how to remove the old volume later: `docs/ops/postgres-major-upgrade.md`.
### Thanks
Thanks to @haritowa for two reports that came with their fixes: the clash
between two pdf.js versions that broke previews and search (#1277), fixed
in PR #1278, and the "End" button that never worked (#1280), fixed in PR
#1281. Both pull requests are part of this release as written.
Thanks to @Tio61 for the precise wire captures behind the Coach showing a
model's reasoning as its answer (#1276) and for the exact request counts
that exposed the runaway requests against a model that never answers
(#1279).
Thanks to @gesus14 for noticing that the daily briefing gave up long
before the response timeout allowed (#1274).
Thanks to @asmala for coming back to report that "Extract lab values" on
a stored document still failed on long reports (#1257).
Thanks to @cnrd for the careful proposal that became the Apple Health
switch for measurement tokens (#1272).
Thanks to @frubino for asking how to get a deleted document back, which
led to Recently deleted (discussion #1275).
Thanks to @muhdusama for noticing that the MCP labs tool declared a
narrower answer than it gives, and for the fix with its tests (PR #1284).
Thanks to @sttrebo for asking about Postgres 18, which turned into the
move itself, not just a guide for it (discussion #1282).
## [1.45.0] — 2026-10-23
### Added
+3 -12
View File
@@ -151,27 +151,18 @@ What a reviewer checks on a translation pull request:
- Medical terms are the ones the language's clinical guidelines use, and the register matches the rest of the locale (`du` in German, `você` in Brazilian Portuguese).
- Words the app or the Telegram bot matches literally stay as they are: slash commands, `taken <name>`, and the unit symbols.
- Machine translation is fine as a first draft, but someone who reads the language has gone through every string before the pull request is opened, and the description says so.
- No questionnaire text. The bundles hold the app's own copy around the questionnaires (titles, notes, buttons), never an item, an answer option or a stem; see the next section.
- Validated questionnaire items (the PHQ-9, GAD-7, WHO-5 and SCI items, their answer options and their stems under `mentalHealth.items`, `mentalHealth.options`, `mentalHealth.gad7Options`, `mentalHealth.who5Options`, `mentalHealth.sciOptions`, `mentalHealth.stems`, `mentalHealth.functional*`) are never translated freely, not even as a draft. A language gets them only from a published, licensed validated version, copied verbatim and named in `src/lib/mental-health/instruments.ts`; until then the app shows the English original and the bundle carries none of those keys, which the i18n tests enforce.
To run the checks Fink shows on a local checkout: `npx @inlang/cli@3.4.4 check --project ./project.inlang`. It reads the files and changes nothing.
### Questionnaire wording
The PHQ-9, GAD-7, WHO-5 and SCI are not translated through the bundles, and Fink never shows them. Their text lives in code, one file per instrument and language under `src/lib/mental-health/wording/<instrument>/<locale>.ts`, because changing a word in a validated questionnaire changes what it measures. Each file declares what it is:
- `validated: true` with a `source`: a published validated translation, reproduced word for word (stem, items, answer options and, for the PHQ-9, the follow-up question, in the form of address the source uses). The source names who made it, the document it was read from with its link, and the terms that allow reproducing it in software. The file's header comment names anything that differs from the printed form, such as a corrected misprint.
- `validated: false`: no validated version exists that may be reproduced, so the file carries a translation written for HealthLog, faithful to the English and on the same scale. The app shows a note beside it that the result is orientation, offers the English original in one tap, and records on the result that it was answered in an unvalidated translation. The header comment names any validated version that was found and why it is not used (paywalled, terms that allow private study only).
To propose a validated version for a language that has none, or a better source for one that does, open an issue or a pull request with the publication, a link to the PDF it can be read from, and its terms of use. A pull request replaces the language's file: the text copied exactly from the PDF, `validated: true`, and the `source` filled in. The tests in `src/lib/mental-health/__tests__/` check that every instrument has text in every language, that every validated entry names its source, and that each answer is bound to the score the instrument gives it; the spot checks in `instrument-wording.test.ts` are the place to pin a line that is easy to transcribe wrongly. A correction to HealthLog's own translation of an unvalidated entry is welcome too; it stays `validated: false`.
### Adding a new locale
If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier.
2. Copy `messages/en.json` to `messages/<locale>.json`. Questionnaire text is not in it; the new language needs a wording file per instrument instead (see Questionnaire wording above), which step 4 makes impossible to forget.
2. Copy `messages/en.json` to `messages/<locale>.json`, then delete the questionnaire wording the new language has no validated version of (the test output lists the keys).
3. Translate. AI assistance is welcome as a starting point but the PR description should note whether it's an AI-initial pass or a fully human-reviewed one — that information drives the banner state.
4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns, the no-key fallback copy and the questionnaire wording registry. Include those in the same PR.
4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns and the no-key fallback copy. Include those in the same PR.
5. Run `pnpm test` before submitting. The i18n guards hold the new bundle to the English keys and placeholders.
### Style guide for translators
+77 -241
View File
@@ -1,23 +1,5 @@
# ── Base images ────────────────────────────────────────────
# glibc, not musl, in every stage. The native modules the runtime loads (the
# @napi-rs/canvas Skia renderer, @node-rs/argon2, the Prisma schema engine)
# then run their glibc builds. The arm64 musl Skia build needs ARMv8.2 (LSE
# atomics, RCpc loads) and dies with SIGILL on an ARMv8.0 core such as the
# Raspberry Pi 4's Cortex-A72; the glibc arm64 build does not use those
# instructions (#1311).
#
# Every Node stage, the runner included, is the official node:22-trixie-slim
# image, pinned by its multi-arch index digest. Node security releases reach
# that image within days of their publication, which no distribution package
# of an older Node stream matched. The runner removes the package managers
# the image ships; the one npm install the runtime needs happens in the
# `prisma-cli` stage and is copied over. pg_dump and pg_restore come from the
# official PostgreSQL image in the `pgclient` stage. The runner still checks
# the Node module ABI and the glibc against the builder at build time, so a
# future change to one of the stages cannot pair them wrongly.
# ── Stage 1: Dependencies ──────────────────────────────────
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS deps
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
@@ -29,7 +11,7 @@ COPY scripts/prepare.mjs scripts/prepare.mjs
RUN pnpm install --frozen-lockfile --prod=false
# ── Stage 2: Build ─────────────────────────────────────────
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS builder
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
@@ -75,10 +57,6 @@ RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# name; see the note there for why guessing was wrong.
RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \
> /app/.pdfjs-version
# The Node module ABI and the glibc the native packages were installed for;
# the runner refuses to build on an older glibc or another module ABI.
RUN node -p "process.versions.modules + ' ' + process.report.getReport().header.glibcVersionRuntime + ' ' + process.version" \
> /app/.native-abi
# The same for @napi-rs/canvas, whose native binary must match its JS loader.
RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \
> /app/.canvas-version
@@ -88,143 +66,18 @@ RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version"
RUN node scripts/verify-message-catalogs.mjs --dir messages \
--config src/lib/i18n/config.ts --write /app/.message-locales
# ── Stage 3: Migration CLI ─────────────────────────────────
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. This is the
# only npm install the runtime needs; it runs here, on the runner's own base,
# and the runner copies `/opt/prisma-cli` over, so npm never reaches the
# runtime image.
#
# The `allowScripts` field is for npm 12 and later, which runs no dependency
# install scripts unless package.json lists the package there. Three packages
# here have one: `@prisma/engines` downloads the schema engine `migrate
# deploy` runs, `prisma` checks its environment, and `esbuild` (under tsx)
# checks its binary. The npm in this image still runs them without the field;
# keeping it means a later npm major cannot drop the schema engine quietly.
# Either way the runner asks for the engine file after the copy.
#
# This install belongs to npm, not pnpm, so the `overrides` block in
# `pnpm-workspace.yaml` does not reach it: a package pinned there for the app
# tree still arrives here at whatever version Prisma asks for. That is not
# hypothetical. `prisma@7.8.0` resolves `@prisma/config@7.8.0`, which resolves
# `deepmerge-ts@7.1.5`, and the image scan reported CVE-2026-40345 (HIGH,
# stack exhaustion on recursive merges) against `/opt/prisma-cli` on a commit
# whose dependency audit over the app tree was green. Two installs, two
# override mechanisms, one of them set. `overrides` is npm's own mechanism and
# `npm pkg set` writes it into the package.json that `npm init -y` just
# created, before anything resolves.
#
# Measured by rebuilding this exact install both ways: without the field
# `deepmerge-ts@7.1.5` lands, with it `8.0.1` lands and `prisma --version`
# still reports 7.8.0. Keep the range in step with the entry in
# `pnpm-workspace.yaml` — they are one decision applied to two package
# managers, and a fix that lands in only one of them is the defect above.
#
# The same class recurred on 2026-09-02: the image scan reported
# `@hono/node-server@1.19.11` (GHSA-frvp-7c67-39w9, CVE-2026-39406) and
# `valibot@1.2.0` (CVE-2026-59952) under this path, both reached through
# `prisma -> @prisma/dev`, while the app tree carried 1.19.17 and a pin the
# npm install never reads. Rebuilding this install both ways confirms it:
# without the two fields below npm resolves 1.19.11 and 1.2.0, with them it
# resolves 1.19.17 and 1.4.2, and `prisma --version` still reports 7.8.0.
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS prisma-cli
RUN mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \
npm init -y && \
npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^2.1.3' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm pkg set 'allowScripts.@prisma/engines=true' 'allowScripts.prisma=true' 'allowScripts.esbuild=true' --json && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.9 --no-fund --no-audit
# ── Stage 4: PostgreSQL client ─────────────────────────────
# pg_dump and pg_restore of the bundled database's major, taken from the
# official PostgreSQL image (Debian trixie, the runner's own release; its
# binaries are the PGDG builds) together with the libpq they link. Debian
# trixie itself ships major 17. The PGDG apt package was the other route: it
# brings Perl and Debian's version-picking wrapper scripts for two binaries
# the app calls by name, and its repository drops a minor once the next one
# is out, so a pinned package version stops installing. A digest-pinned
# image stays buildable, and Dependabot proposes its next minor the way it
# does for the bundled database. The libpq file is found through the loader
# rather than by an architecture path, so the step is the same on amd64 and
# arm64.
FROM postgres:18.6-trixie@sha256:3a4264520e121176619f54ff867d12774bc81e95e7b84db58d63bccdb6144b81 AS pgclient
RUN set -eu; \
mkdir -p /out/bin /out/lib; \
cp /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore /out/bin/; \
LIBPQ="$(ldd /usr/lib/postgresql/18/bin/pg_dump | awk '$1 == "libpq.so.5" { print $3 }')"; \
[ -n "$LIBPQ" ] || { echo "ERROR: pg_dump links no libpq.so.5"; exit 1; }; \
cp -L "$LIBPQ" /out/lib/libpq.so.5
# ── Stage 5: Production runner ─────────────────────────────
FROM node:22-trixie-slim@sha256:2117f713a417f3aff682ac8f5bc2ebbdf1beed51f36e67ab1e0fd3866b6d2d8f AS runner
# The package managers the base ships (npm, npx, corepack, yarn) go first.
# Nothing at runtime installs a package, and the bundled npm carried the only
# HIGH findings with a fix the image scan reported. Removed in a later layer,
# their files stay in the base layer's bytes but leave the image's file
# system, which is what runs and what the scan reads.
#
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
# timestamp formatting) resolve to the actual offset instead of silently
# falling back to UTC; the slim base ships without it. tzdata stays unpinned
# on purpose: a zone rule change is a fix to take.
# falling back to UTC on Alpine images that ship without it.
#
# `apt-get upgrade` pulls in OS-package security fixes the pinned base digest
# `apk upgrade` pulls in OS-package security fixes the pinned base digest
# has not absorbed yet (the digest pin stays for build reproducibility;
# security patches from the Debian archive override it deliberately —
# CVE-2026-14456 in openssl was the first instance the container scan caught
# between base-image rebuilds).
#
# `pg_dump` and `pg_restore` serve the nightly database dump, the
# pre-migration dump in the entrypoint and the `db-restore` service
# (docs/ops/disaster-recovery.md). Major 18 matches the bundled database; a
# client dumps servers of its own major and older, so it also serves an
# install still on its own Postgres 16. The two binaries and their libpq come
# from the `pgclient` stage above; the Debian libraries libpq links
# (Kerberos, LDAP, SASL) come from apt below, so the build-time upgrade and
# the image scan see them. The copied binaries are outside dpkg: the scan
# does not see them and `apt-get upgrade` does not patch them, so a
# PostgreSQL client fix arrives by moving the `pgclient` pin.
# `src/__tests__/runtime-package-pins.test.ts` holds that pin to the bundled
# database's minor in docker-compose.yml.
#
# `ca-certificates` is the system trust store the OpenSSL-linked tools use
# (the Prisma schema engine, libpq) for a TLS connection to the database.
# `tar` and `gzip` for the GeoLite2 extraction and `sh` (dash) for the
# entrypoint are part of the Debian base.
RUN set -eux; \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
/usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-v*; \
sh -c '! command -v npm && ! command -v npx && ! command -v corepack && ! command -v yarn && ! command -v pnpm'; \
export DEBIAN_FRONTEND=noninteractive; \
apt-get update; \
apt-get upgrade -y --no-install-recommends; \
apt-get install -y --no-install-recommends \
ca-certificates tzdata libgssapi-krb5-2 libldap2 libsasl2-2; \
apt-get clean; \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /root/.npm /root/.cache
COPY --from=pgclient /out/bin/ /usr/lib/postgresql/18/bin/
COPY --from=pgclient /out/lib/libpq.so.5 /usr/lib/postgresql/18/lib/libpq.so.5
RUN echo /usr/lib/postgresql/18/lib > /etc/ld.so.conf.d/postgresql-18.conf && \
ldconfig && \
! ldd /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore | grep -q "not found"
ENV PATH=/usr/lib/postgresql/18/bin:$PATH
# The builder and the runner start from the same image, so the two agree
# today. The check stays: it fails the build the day one of them moves to
# another base or another Node major without the other.
COPY --from=builder /app/.native-abi /tmp/.native-abi
RUN set -e; \
read -r BUILD_ABI BUILD_GLIBC BUILD_NODE < /tmp/.native-abi; \
rm -f /tmp/.native-abi; \
RUN_ABI="$(node -p process.versions.modules)"; \
RUN_GLIBC="$(node -p 'process.report.getReport().header.glibcVersionRuntime')"; \
[ "$RUN_ABI" = "$BUILD_ABI" ] || { echo "ERROR: runner Node module ABI $RUN_ABI, builder $BUILD_ABI"; exit 1; }; \
[ "$(printf '%s\n%s\n' "$BUILD_GLIBC" "$RUN_GLIBC" | sort -V | head -1)" = "$BUILD_GLIBC" ] || \
{ echo "ERROR: runner glibc $RUN_GLIBC is older than the builder's $BUILD_GLIBC"; exit 1; }; \
echo "native ABI $RUN_ABI (builder $BUILD_NODE, runner $(node -v)); glibc $BUILD_GLIBC -> $RUN_GLIBC"
# security patches from the Alpine repo override it deliberately —
# CVE-2026-14456 in openssl was the first instance the container scan
# caught between base-image rebuilds).
RUN apk add --no-cache tzdata && apk upgrade --no-cache
WORKDIR /app
ENV NODE_ENV=production
@@ -255,18 +108,8 @@ ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ARG NEXT_PUBLIC_APP_BUILD_SHA
ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
RUN groupadd --system --gid 1001 nodejs && \
useradd --system --uid 1001 --gid nodejs --create-home --home-dir /home/nextjs \
--shell /usr/sbin/nologin nextjs
# Where the database dumps go (`DB_BACKUP_DIR`). Owned by the app user, so a
# fresh named volume mounted here starts with that owner: Docker fills an
# empty named volume from the image directory it covers, ownership included.
# Without a mount the directory stays in the container's layer; the app reads
# its mount table, sees that, and keeps no dump here (dump-core.mjs).
RUN mkdir -p /var/lib/healthlog/backups && \
chown nextjs:nodejs /var/lib/healthlog/backups && \
chmod 700 /var/lib/healthlog/backups
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy built assets
COPY --from=builder /app/public ./public
@@ -305,13 +148,13 @@ RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
# HOISTED to the top level there (verified: without it pdfjs logs "Cannot load
# @napi-rs/canvas" → "Cannot polyfill DOMMatrix" → rasterize ReferenceError);
# (2) @napi-rs/canvas's own loader then resolves its platform binary. Hoist BOTH
# the canvas package and the glibc (`-gnu`) binary package to
# /app/node_modules/@napi-rs via symlink into the .pnpm store, and drop the prebuilt .node into the canvas
# the canvas package and the musl binary package to /app/node_modules/@napi-rs
# via symlink into the .pnpm store, and drop the prebuilt .node into the canvas
# dir: the loader tries that local file (`require('./skia.<triple>.node')`)
# before the platform package, so it decides which binary runs.
#
# Hoist the version the app pins, by name, exactly like pdfjs-dist below. The
# store once held a second, older canvas that a second pdfjs-dist pulled in,
# store holds a second, older canvas that pdf-parse's own pdfjs-dist pulls in,
# and this step used to take whichever `find | head -1` returned first. It
# returned the old binary, which then sat next to the new loader: rendering
# still worked, but `loadImage` called an `Image.decode` the old binary does
@@ -323,39 +166,37 @@ RUN set -e; \
CANVAS_VERSION="$(cat /tmp/.canvas-version)"; \
rm -f /tmp/.canvas-version; \
CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/node_modules/@napi-rs/canvas"; \
GNU_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-gnu@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-gnu" 2>/dev/null | head -1)"; \
if [ ! -d "$CANVAS_DIR" ] || [ -z "$GNU_DIR" ]; then \
echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its glibc binary did not reach the traced image."; \
MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-musl@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-musl" 2>/dev/null | head -1)"; \
if [ ! -d "$CANVAS_DIR" ] || [ -z "$MUSL_DIR" ]; then \
echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its musl binary did not reach the traced image."; \
echo "Present instead:"; \
find /app/node_modules/.pnpm -maxdepth 1 -name '@napi-rs+canvas*' 2>/dev/null; \
exit 1; \
fi; \
NODE_BIN="$(find "$GNU_DIR" -maxdepth 1 -name 'skia.linux-*-gnu.node' | head -1)"; \
[ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $GNU_DIR"; exit 1; }; \
NODE_BIN="$(find "$MUSL_DIR" -maxdepth 1 -name 'skia.linux-*-musl.node' | head -1)"; \
[ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $MUSL_DIR"; exit 1; }; \
mkdir -p /app/node_modules/@napi-rs; \
ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \
ln -sfn "$GNU_DIR" "/app/node_modules/@napi-rs/$(basename "$GNU_DIR")"; \
ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \
cp "$NODE_BIN" "$CANVAS_DIR/"; \
chown -R nextjs:nodejs /app/node_modules/@napi-rs; \
BIN_VERSION="$(node -p "require('$GNU_DIR/package.json').version")"; \
BIN_VERSION="$(node -p "require('$MUSL_DIR/package.json').version")"; \
[ "$BIN_VERSION" = "$CANVAS_VERSION" ] || { echo "ERROR: canvas binary is $BIN_VERSION, loader is $CANVAS_VERSION"; exit 1; }; \
cmp -s "$NODE_BIN" "$CANVAS_DIR/$(basename "$NODE_BIN")" || { echo "ERROR: the canvas dir holds a different binary"; exit 1; }; \
echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $GNU_DIR)"
echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $MUSL_DIR)"
# pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render
# path breaks in the standalone image), so the server does a runtime bare
# `import('pdfjs-dist/legacy/build/pdf.mjs')`. Node resolves that up to
# /app/node_modules, so hoist the real pnpm-store copy to the top level too.
#
# Hoist the version the app pins, by name. More than one pdfjs-dist once reached
# the store (a text-extraction dependency carried its own, older copy), and this
# step used to take whatever `find | head -1` returned first. That is readdir
# order, so it could hoist the OLDER copy and the version named in package.json
# would never run in the image: the renderer resolving one version while every
# local check exercised another. Name the version and fail the build when it is
# absent, instead of degrading to whichever copy comes back first. Two copies in
# one process also break each other (#1277); the lockfile now resolves one, and
# `src/__tests__/single-pdfjs-guard.test.ts` keeps it that way.
# Hoist the version the app pins, by name. More than one pdfjs-dist reaches the
# store — `pdf-parse` carries its own, older copy for the text-extraction path —
# and this step used to take whatever `find | head -1` returned first. That is
# readdir order, so it could hoist the OLDER copy and the version named in
# package.json would never run in the image: the renderer resolving one version
# while every local check exercised another. Name the version and fail the build
# when it is absent, instead of degrading to whichever copy comes back first.
COPY --from=builder /app/.pdfjs-version /tmp/.pdfjs-version
RUN set -e; \
PDFJS_VERSION="$(cat /tmp/.pdfjs-version)"; \
@@ -390,19 +231,11 @@ COPY --from=builder /app/src/generated ./src/generated
# the runbooks and the entrypoint against this list. `pending-migrations.mjs`
# and `migrate-deploy.mjs` are the entrypoint's own: the first logs the
# pending migration count, the second runs `migrate deploy` with its
# lock-timeout retry. `db-dump.mjs` takes the dump before migrating, and
# `db-restore.mjs` is the `db-restore` service; both share
# `src/lib/database-backup/dump-core.mjs` with the app. The files are
# root-owned, so the app user can run them but not edit them in place. The
# directory around them stays the app user's.
# lock-timeout retry. Root-owned on purpose: the app user can run them but not
# rewrite them.
COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs
COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs
COPY --from=builder /app/scripts/migrate-deploy.mjs ./scripts/migrate-deploy.mjs
COPY --from=builder /app/scripts/db-dump.mjs ./scripts/db-dump.mjs
COPY --from=builder /app/scripts/db-restore.mjs ./scripts/db-restore.mjs
COPY --from=builder /app/scripts/db-app-role.mjs ./scripts/db-app-role.mjs
COPY --from=builder /app/src/lib/jobs/retired-measurement-indexes.mjs ./src/lib/jobs/retired-measurement-indexes.mjs
COPY --from=builder /app/src/lib/database-backup/dump-core.mjs ./src/lib/database-backup/dump-core.mjs
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
# Prove the reset CLI's imports resolve in this tree: with every import found
@@ -412,29 +245,49 @@ RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \
node --input-type=module -e "await import('pg')" && \
node --input-type=module -e "await import('./scripts/migrate-deploy.mjs')"
# The dump and restore scripts load, the client tools they run are the
# major the bundled database runs, and the S3 client `db-restore --from-bucket`
# loads resolves from the shared module's place in the tree.
RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \
node --input-type=module -e "import { createRequire } from 'node:module'; createRequire('/app/src/lib/database-backup/dump-core.mjs').resolve('@aws-sdk/client-s3')" && \
node scripts/db-restore.mjs 2>&1 | grep -q "db-restore: usage:" && \
pg_dump --version | grep -q "(PostgreSQL) 18\." && \
pg_restore --version | grep -q "(PostgreSQL) 18\."
# The migration CLI from the `prisma-cli` stage. The Prisma config loads
# dotenv from /app, so expose the isolated copy there. /opt/prisma-cli is
# root-owned: the app user can run it but not change it.
COPY --from=prisma-cli /opt/prisma-cli /opt/prisma-cli
RUN ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
# config loads dotenv from /app, so expose the isolated copy there before
# stripping npm/Corepack and their caches from the runtime surface.
#
# This install belongs to npm, not pnpm, so the `overrides` block in
# `pnpm-workspace.yaml` does not reach it: a package pinned there for the app
# tree still arrives here at whatever version Prisma asks for. That is not
# hypothetical. `prisma@7.8.0` resolves `@prisma/config@7.8.0`, which resolves
# `deepmerge-ts@7.1.5`, and the image scan reported CVE-2026-40345 (HIGH,
# stack exhaustion on recursive merges) against `/opt/prisma-cli` on a commit
# whose dependency audit over the app tree was green. Two installs, two
# override mechanisms, one of them set. `overrides` is npm's own mechanism and
# `npm pkg set` writes it into the package.json that `npm init -y` just
# created, before anything resolves.
#
# Measured by rebuilding this exact install both ways: without the field
# `deepmerge-ts@7.1.5` lands, with it `8.0.1` lands and `prisma --version`
# still reports 7.8.0. Keep the range in step with the entry in
# `pnpm-workspace.yaml` — they are one decision applied to two package
# managers, and a fix that lands in only one of them is the defect above.
#
# The same class recurred on 2026-09-02: the image scan reported
# `@hono/node-server@1.19.11` (GHSA-frvp-7c67-39w9, CVE-2026-39406) and
# `valibot@1.2.0` (CVE-2026-59952) under this path, both reached through
# `prisma -> @prisma/dev`, while the app tree carried 1.19.17 and a pin the
# npm install never reads. Rebuilding this install both ways confirms it:
# without the two fields below npm resolves 1.19.11 and 1.2.0, with them it
# resolves 1.19.17 and 1.4.2, and `prisma --version` still reports 7.8.0.
RUN mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \
npm init -y && \
npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.8 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma
# The schema engine the entrypoint's `migrate deploy` runs is in place.
# Look for the file before asking the CLI: a CLI that finds none downloads
# one, which would hide the missing install script here and fail offline.
RUN ls /opt/prisma-cli/node_modules/@prisma/engines/schema-engine-* >/dev/null && \
node /opt/prisma-cli/node_modules/prisma/build/index.js --version 2>&1 | \
grep -q "Schema Engine *: schema-engine-cli [0-9a-f]* (at "
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \
rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx
# Same proof for the intake-repair script under the launcher it is documented
# with: its `pg` import resolves, so it reaches its own DATABASE_URL check.
@@ -457,31 +310,17 @@ RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 |
# user so the runtime worker can WRITE it: a self-hoster who sets
# MAXMIND_LICENSE_KEY on the published image has the databases fetched into
# this directory at runtime (src/lib/geo/geolite2-fetch.ts) with no rebuild or
# manual mount. `tar`/`gzip` for the extraction come with the base. A
# manual mount. `tar`/`gzip` for the extraction ship in the busybox base. A
# read-only bind mount at this path (the bring-your-own-database route) still
# works — the runtime fetch just fails soft and the mounted files stand.
RUN mkdir -p /opt/geolite2 && chown nextjs:nodejs /opt/geolite2
COPY --chown=nextjs:nodejs assets/geolite2/ /opt/geolite2/
# `wget` for health checks written outside the image. A compose file copied
# from this repository up to v1.46.6 and the TrueNAS catalog app both call
# `wget --spider` against /api/health, and their check replaces the
# HEALTHCHECK below; the slim base has no wget, so without this every such
# container stayed unhealthy. `scripts/wget-shim.mjs` understands the flags
# those checks use, exits like wget, and writes nowhere but stdout. The
# image's own HEALTHCHECK and the bundled compose file use the Node probe.
# Root-owned: the app user can run it but not replace it.
COPY --from=builder /app/scripts/wget-shim.mjs /usr/local/lib/healthlog/wget-shim.mjs
RUN printf '#!/bin/sh\nexec node /usr/local/lib/healthlog/wget-shim.mjs "$@"\n' > /usr/local/bin/wget && \
chmod 755 /usr/local/bin/wget && \
wget 2>&1 | grep -q "health check shim" && \
! wget --no-verbose --tries=1 --spider http://127.0.0.1:9/api/health
# ISO-8601 build timestamp — /api/version returns it as `builtAt`.
# Declared this late in the stage on purpose: the value differs on
# every CI run, and every instruction after an ARG shares its cache
# fate. Down here the only layers it invalidates are the cheap
# entrypoint COPY/chmod below; the layers above stay
# entrypoint COPY/chmod below; the npm-install layers above stay
# cache-warm across rebuilds of the same release.
ARG NEXT_PUBLIC_APP_BUILT_AT
ENV NEXT_PUBLIC_APP_BUILT_AT=$NEXT_PUBLIC_APP_BUILT_AT
@@ -500,12 +339,9 @@ ENV HOSTNAME="0.0.0.0"
# migration waiting out a held lock retries for up to about eight minutes.
# Five retries: a pool saturated for a minute during a boot storm makes the
# probe's 2 s database check miss once or twice without marking the container
# unhealthy (docker-compose.yml explains the numbers). The probe is Node's own
# fetch: it needs nothing beyond the runtime, the same probe works on any
# base, and no HTTP client has to be installed for one line. A non-2xx
# answer, a refused connection and the 4 s abort all exit 1.
# unhealthy (docker-compose.yml explains the numbers).
HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=5 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
CMD wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "server.js"]
+5 -5
View File
@@ -50,7 +50,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
- **Clinical signals, opt-in and additive.** Validated self-checks for mental wellbeing and sleep (WHO-5, PHQ-9, GAD-7, Sleep Condition Indicator — official questionnaire wording, calm crisis-resource handling), grip strength, a 0–10 pain score, waist and body measurements, and a longevity lab panel — each added through one signal registry, so a new signal wires into charts, correlations, the coach, the FHIR map, and the API at once.
- **Records, structured.** Allergies and family history as structured records that flow into the doctor report (reactions and notes are encrypted, the substance and condition names are not), plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
- **A document vault for the paper trail.** An opt-in module stores every letter, report, and scan encrypted at rest — PDFs and photos preview inline, Office and text files stay download-only, and nothing user-uploaded can ever execute in the app. One searchable timeline with type, condition, and year filters, drag-and-drop and paste-to-upload, bulk filing, undo-able delete with a 30-day grace, and per-file/per-account limits the operator tunes.
- **Context that explains the numbers.** An environmental module folds weather, daylight, temperature, air quality, and pollen in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point. It is on by default, and nothing leaves the host until someone sets that location.
- **Context that explains the numbers.** An opt-in environmental module folds weather, daylight, and temperature in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point.
- **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
@@ -85,11 +85,11 @@ Verify the running version:
curl -s http://localhost:3000/api/version # returns version + buildSha + builtAt
```
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/getting-started/self-host-in-10-minutes/)).
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/)).
Persistent state lives in the database volume (`postgres-data`) and in `backups`, which holds the nightly database dumps; the app container is stateless. An install from v1.45 or earlier moves its Postgres 16 database from `pgdata` into the new volume on the first start ([details](docs/ops/postgres-major-upgrade.md)); recovering a lost server is in [disaster-recovery.md](docs/ops/disaster-recovery.md).
Persistent state lives in exactly one named volume (`pgdata`, the Postgres data dir); the app container is stateless, so backing up the database backs up everything.
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md), [Portainer app templates](docs/self-hosting/portainer/README.md) and [TrueNAS](docs/self-hosting/truenas.md) (the Community train app is pending catalog review).
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md) and [Portainer app templates](docs/self-hosting/portainer/README.md).
## Self-hosting
@@ -105,7 +105,7 @@ The App Store listing carries a small price, which exists to cover Apple's yearl
## Tech stack
Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 18 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Docker image on the official Node 22 Debian slim base. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 16 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
## Documentation
+2 -3
View File
@@ -1,11 +1,11 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file. v1.47.0 raised maxCatalogKbGz from 210 to 250 for the Russian catalog: Cyrillic is two bytes per letter in UTF-8, so the same key set is 1.12 MB raw against about 0.8 MB for the Latin-script locales, 240 KB gz as an emitted chunk where the largest Latin one is under 210. A reader downloads one catalog, once; nothing else in the budget moved.",
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file.",
"routeDriftAllowanceKbGz": 20,
"firstLoadP95KbGz": 447,
"firstLoadMaxKbGz": 461,
"uniqueClientKbGz": 1930,
"maxRechartsChunks": 1,
"maxCatalogKbGz": 250,
"maxCatalogKbGz": 210,
"routeBaselineKbGz": {
"/_global-error/page": 133,
"/_not-found/page": 282,
@@ -27,7 +27,6 @@
"/custom-metrics/[id]/page": 303,
"/custom-metrics/[id]/values/page": 311,
"/cycle/page": 412,
"/documents/deleted/page": 291,
"/documents/page": 378,
"/enroll-mfa/page": 305,
"/illness/[id]/page": 447,
+31 -282
View File
@@ -1,4 +1,4 @@
# HealthLog — two-container stack (app + Postgres 18).
# HealthLog — two-container stack (app + Postgres 16).
#
# First run (clean host):
# cp .env.example .env
@@ -14,13 +14,9 @@
# and login silently fails. This is the single most common self-host snag.
# Leave it unset (or true) only when a TLS reverse proxy serves HTTPS.
#
# Persistent state lives in the named volume `postgres-data` (Postgres' data).
# The app container is stateless, so a backup of the database is a backup of
# everything. `backups` holds the database dumps the app takes every night and
# before every migration (docs/ops/disaster-recovery.md); keep a copy of them
# off this host, or set the off-host bucket below. `pgdata` is where Postgres 16 kept the data up to
# v1.45; on an install that has it, `db-upgrade` moves it into Postgres 18 once
# and then leaves it alone as the way back (docs/ops/postgres-major-upgrade.md). The optional GeoLite2 databases are either baked
# Persistent state lives in exactly one named volume (`pgdata`, Postgres'
# data dir). The app container is stateless, so a backup of the database is
# a backup of everything. The optional GeoLite2 databases are either baked
# into the image at build time or mounted read-only from the host and
# pointed at with GEOLITE2_DIR — either way they are replaceable, not state.
@@ -81,12 +77,6 @@ x-healthlog-env: &healthlog_env
# 1 sends neither of the two settings above (they ride the libpq `options`
# startup parameter, which PgBouncer refuses by default).
DATABASE_SESSION_OPTIONS_DISABLED: "${DATABASE_SESSION_OPTIONS_DISABLED:-}"
# The app connects as `healthlog_app_<database>_<hash>`, a role the
# entrypoint creates for this database that reads and writes rows but
# cannot change the schema; the migrations and the queue's install keep
# the owner above. off keeps the app on the owner (for an operator who
# manages database roles). See docs/self-hosting/database-role.md.
DB_APP_ROLE: "${DB_APP_ROLE:-}"
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
# Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`;
# set it when the URL registered with Withings differs from the app's own
@@ -98,10 +88,8 @@ x-healthlog-env: &healthlog_env
TELEGRAM_WEBHOOK_SECRET: "${TELEGRAM_WEBHOOK_SECRET:-}"
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
# the real visitor IP is in `cf-connecting-ip`; set
# TRUST_CF_CONNECTING_IP=1 ONLY when the origin accepts connections from
# Cloudflare alone (a Tunnel, or a firewall admitting only Cloudflare's
# ranges): the header is believed from every connection (off by default;
# see docs/self-hosting/reverse-proxy.md).
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
# default — a direct-exposed host must not trust a forgeable header).
# IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by
# default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
# IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the
@@ -114,14 +102,7 @@ x-healthlog-env: &healthlog_env
# collapses every anonymous caller into one rate-limit bucket. Listed here
# so an operator value in .env reaches the container (the `environment:`
# block is a whitelist).
# TRUSTED_PROXY_CIDRS names the addresses the reverse proxy connects from
# (comma-separated CIDRs). Set, only those connections may report a client
# address; unset, any private address may, which behind Docker Desktop,
# OrbStack or docker-proxy includes every caller of the published port.
# Same host: 127.0.0.1/32,::1/128. Docker network: the proxy's subnet.
# See docs/self-hosting/reverse-proxy.md.
TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"
TRUSTED_PROXY_CIDRS: "${TRUSTED_PROXY_CIDRS:-}"
TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}"
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
@@ -164,20 +145,6 @@ x-healthlog-env: &healthlog_env
# serves anyway. A last resort for a refusal you are certain is false: it
# disables a safety check. See docs/self-hosting/getting-started.md.
ENCRYPTION_KEY_CHECK: "${ENCRYPTION_KEY_CHECK:-}"
# The whole-database dump (docs/ops/disaster-recovery.md). Nightly at
# DB_BACKUP_SCHEDULE (five-field cron in Europe/Berlin time, default
# "30 1 * * *") into DB_BACKUP_DIR (default /var/lib/healthlog/backups, the
# `backups` volume below), keeping DB_BACKUP_KEEP daily dumps (default 7)
# and the last two taken before a migration. With the off-host bucket set,
# each nightly dump is also uploaded under `database/`, sealed with
# BACKUP_ENCRYPTION_KEY. DB_BACKUP_ENABLED=false stops the nightly dump;
# DB_BACKUP_BEFORE_MIGRATE=false skips the dump before migrations. Change
# DB_BACKUP_DIR only together with the volume mount.
DB_BACKUP_ENABLED: "${DB_BACKUP_ENABLED:-}"
DB_BACKUP_DIR: "${DB_BACKUP_DIR:-}"
DB_BACKUP_SCHEDULE: "${DB_BACKUP_SCHEDULE:-}"
DB_BACKUP_KEEP: "${DB_BACKUP_KEEP:-}"
DB_BACKUP_BEFORE_MIGRATE: "${DB_BACKUP_BEFORE_MIGRATE:-}"
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}"
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
@@ -199,7 +166,6 @@ x-healthlog-env: &healthlog_env
# the block exists: `environment:` is a whitelist, and a value in .env that
# is not named here never arrives.
AUDIT_LOG_RETENTION_DAYS: "${AUDIT_LOG_RETENTION_DAYS:-}"
ACCOUNT_DELETION_GRACE_DAYS: "${ACCOUNT_DELETION_GRACE_DAYS:-}"
# How long Coach conversations are kept, in days (default 365, floor 30).
# Like the audit window this number is PUBLISHED to the user on the Data &
# Privacy page, so an instance running 90 has to be able to say 90.
@@ -312,23 +278,17 @@ x-healthlog-env: &healthlog_env
OIDC_SCOPES: "${OIDC_SCOPES:-}"
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
OIDC_ONLY: "${OIDC_ONLY:-}"
# Deploy-result webhook (optional). POST /api/internal/deploy-webhook
# authenticates either with DEPLOY_WEBHOOK_SECRET in the
# X-Deploy-Webhook-Secret header (what Coolify's stock sender can do; no
# replay protection) or with X-Deploy-Webhook-Signature: sha256=<HMAC of
# "<X-Deploy-Webhook-Timestamp>.<body>" under the secret>, the timestamp
# within 5 minutes. DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true also requires a
# fresh X-Deploy-Webhook-Timestamp on a secret-header request;
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true accepts signed requests only, the
# one setting that stops a replay. DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in deploy-failure
# notifications. Listed here so operator values in .env reach the
# container (the `environment:` block is a whitelist). See
# docs/ops/deploy.md.
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. Listed here so operator values in
# .env reach the container (the `environment:` block is a whitelist).
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
# Optional replay hardening: when "true", the deploy webhook
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
# See docs/ops/deploy.md.
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
DEPLOY_WEBHOOK_REQUIRE_SIGNATURE: "${DEPLOY_WEBHOOK_REQUIRE_SIGNATURE:-}"
# TLS leaf SPKI-change alarm baseline (optional). The native client
# pins the served TLS leaf; this is the known-good pin set the monitor
# alarms against on a rotation. Comma-separated for the dual-pin
@@ -353,11 +313,11 @@ x-healthlog-env: &healthlog_env
# you run a gateway this instance should trust; it widens only the admin
# setting, never the per-user BYO-key posture.
ADMIN_AI_BASE_URL_ALLOWLIST: "${ADMIN_AI_BASE_URL_ALLOWLIST:-}"
# Codex (ChatGPT sign-in) model slugs. Unset, each sign-in picks from the
# models its account is offered, with a built-in order as the fallback.
# Set CODEX_MODEL to pin one slug ahead of that, or
# CODEX_MODEL_FALLBACK_CHAIN (comma-separated) to replace the order
# outright; the account's list is then not read.
# Codex (ChatGPT sign-in) model slugs. Set CODEX_MODEL to pin one slug
# ahead of the built-in chain, or CODEX_MODEL_FALLBACK_CHAIN (comma-
# separated) to replace the chain outright. Reach for these when the
# provider rotates a slug and every request starts coming back 503 —
# without them a rotation needs a new release to fix.
CODEX_MODEL: "${CODEX_MODEL:-}"
CODEX_MODEL_FALLBACK_CHAIN: "${CODEX_MODEL_FALLBACK_CHAIN:-}"
# Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot.
@@ -513,11 +473,6 @@ services:
- "3000:3000"
environment:
<<: *healthlog_env
# The database dumps (DB_BACKUP_DIR). Without this mount the app still
# copies its nightly dump off-host when the bucket is set, but keeps none
# here and takes none before a migration; the boot log says so.
volumes:
- backups:/var/lib/healthlog/backups
# Bring your own GeoLite2 databases. The published image only carries
# MMDBs if the build had a MaxMind licence key, so on `:latest` the
# offline tier is usually absent and every login IP goes to the online
@@ -527,15 +482,13 @@ services:
# matches GEOLITE2_DIR's default (/opt/geolite2), so this works as-is
# once uncommented. Mounting at a different container path also works,
# just also set GEOLITE2_DIR in .env to match it.
# Steps: docs/self-hosting/geolite2.md. The line goes under `volumes:`
# above, beside the backups line:
# Steps: docs/self-hosting/geolite2.md.
#
# volumes:
# - ./geolite2:/opt/geolite2:ro
depends_on:
db:
condition: service_healthy
db-upgrade:
condition: service_completed_successfully
restart: unless-stopped
# Bounded log rotation: the default json-file driver keeps every line
# forever, which is exactly what fills the disk on the small NAS /
@@ -563,10 +516,8 @@ services:
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))",
"CMD-SHELL",
"wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1",
]
interval: 30s
timeout: 5s
@@ -575,7 +526,7 @@ services:
# past that, so a boot storm that saturates the pool for a minute after
# the start period (backfills, a restore, another tenant's import) costs
# a probe or two, not the container's health and the proxy's routing.
# The probe's own 4 s abort ends the request inside Docker's 5 s, so a
# wget's own 4 s timeout ends the request inside Docker's 5 s, so a
# stalled connection reads as one clean miss rather than a killed probe.
# A database that stays away for 2.5 minutes is a real outage and shows.
retries: 5
@@ -608,14 +559,9 @@ services:
# environment:
# <<: *healthlog_env
# HEALTHLOG_PROCESS_TYPE: "worker"
# # The worker takes the nightly database dump, so it needs the volume.
# volumes:
# - backups:/var/lib/healthlog/backups
# depends_on:
# db:
# condition: service_healthy
# db-upgrade:
# condition: service_completed_successfully
# restart: unless-stopped
# logging:
# driver: json-file
@@ -626,11 +572,12 @@ services:
# stop_grace_period: 45s
db:
# Pinned to the concrete minor instead of the floating `18-alpine`
# Pinned to the concrete minor instead of the floating `16-alpine`
# alias so a database update is always a deliberate, reviewable
# change. Minor releases share the on-disk format, so bumping this pin
# is safe without a dump / restore; a major version is not, which is
# what `db-upgrade` below and docs/ops/postgres-major-upgrade.md are for.
# change (at pin time `16-alpine` resolved to this same digest, so
# existing deployments see no image change). Minor releases share
# the on-disk format — bumping this pin is safe without a dump /
# restore; a major-version bump (17+) is not.
#
# The pin only works as long as something watches it: PostgreSQL
# publishes minor releases with security and data-corruption fixes and
@@ -640,7 +587,7 @@ services:
# `.github/dependabot.yml` now raises a PR for each new minor;
# `src/__tests__/database-pin-is-watched.test.ts` keeps both halves
# (concrete pin, active watch) from being dropped.
image: postgres:18.6-alpine
image: postgres:16.15-alpine
container_name: healthlog-db
# Loads pg_stat_statements so slow queries can be found from measurements
# instead of guesses. Loading the library only collects statistics; the
@@ -670,12 +617,8 @@ services:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
POSTGRES_DB: healthlog
# From 18 on, the image keeps its data under /var/lib/postgresql/18/docker
# and expects the volume one level up, so the next major can sit beside
# this one in the same volume. Mounting it at .../data, as 16 had it,
# makes the container refuse to start.
volumes:
- postgres-data:/var/lib/postgresql
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U healthlog"]
interval: 5s
@@ -696,199 +639,5 @@ services:
max-size: "10m"
max-file: "3"
# Moves a Postgres 16 database from v1.45 or earlier into Postgres 18, once,
# on the first start after the update. A major version cannot read the data
# directory of an older one, so changing the image line alone would leave
# the database refusing to start.
#
# On every start it decides in a second and exits:
# - no `pgdata` data (a new install): nothing to do;
# - the database service runs Postgres 16 (after going back): nothing to
# do, the live 16 volume is left alone;
# - 16 was used again after an earlier move: refuses, so the app does not
# start on the move-day copy;
# - Postgres 18 already holds the database: nothing to do;
# - otherwise it starts Postgres 16 inside this container on the old
# volume, streams a dump into the new database in one transaction,
# compares the row count of every table, gathers planner statistics and
# marks the old volume as moved. `app` starts only after it succeeded.
# A move cut short (a restart, a full disk) leaves a "moving" marker, and
# the next start empties the new database and runs it again from scratch.
#
# The old volume is read, never changed, apart from that marker file, so
# going back is the previous image and volume lines. If anything fails, the
# new database stays empty, `app` does not start, and the log says why.
# It costs one small image pull; a later release removes the service.
db-upgrade:
image: postgres:16.15-alpine
container_name: healthlog-db-upgrade
# Runs once per `compose up` and exits. "no" also keeps hosts such as
# Coolify from reporting the exited container as a failed service.
restart: "no"
security_opt:
- no-new-privileges:true
environment:
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
volumes:
- pgdata:/var/lib/postgresql/data
# Read-only, only to check there is room for the copy before starting.
- postgres-data:/var/lib/postgresql/next:ro
depends_on:
db:
condition: service_healthy
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# `$$` is how compose spells a literal `$`.
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -eu
set -o pipefail
log() { echo "db-upgrade: $$*"; }
OLD=/var/lib/postgresql/data
MARK="$$OLD/HEALTHLOG_MOVED_TO_POSTGRES_18"
MOVING="$$OLD/HEALTHLOG_MOVING_TO_POSTGRES_18"
export PGPASSWORD="$$POSTGRES_PASSWORD"
new() { psql -h db -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
old() { psql -h /tmp -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$$1"; }
empty_new() {
for S in $$(new "SELECT quote_ident(nspname) FROM pg_namespace WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'"); do
new "DROP SCHEMA $$S CASCADE"
done
new "CREATE SCHEMA public"
}
if [ ! -s "$$OLD/PG_VERSION" ]; then
log "no Postgres 16 database to move, nothing to do"
exit 0
fi
# The database service must be the new major. After going back to 16 the
# service runs on this very volume: touching it then would wipe the live data.
i=0
until NEWVER=$$(new "SHOW server_version_num" 2>/dev/null); do
i=$$((i + 1))
[ "$$i" -lt 60 ] || { log "the database service did not answer for a minute"; exit 1; }
sleep 1
done
if [ "$$NEWVER" -lt 180000 ]; then
log "the database service runs Postgres $$((NEWVER / 10000)), not 18: nothing to move, the old volume is left alone"
exit 0
fi
checkpoint() { pg_controldata "$$OLD" | awk -F': *' '/^Latest checkpoint location/ { print $$2 }'; }
USER_TABLES="FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')"
TABLES=$$(new "SELECT count(*) $$USER_TABLES")
if [ -e "$$MARK" ]; then
MOVED_ON=$$(sed -n 1p "$$MARK")
MOVED_CHECKPOINT=$$(sed -n 2p "$$MARK")
if [ -n "$$MOVED_CHECKPOINT" ] && [ "$$(checkpoint)" != "$$MOVED_CHECKPOINT" ]; then
log "the Postgres 16 database changed after it was moved on $$MOVED_ON: it was used again after going back to 16."
log "Postgres 18 still holds the state of that day, so it is not started on. To move the current 16 database,"
log "see docs/ops/postgres-major-upgrade.md, \"Updating again after going back\"."
exit 1
fi
if [ "$$TABLES" != 0 ]; then
log "the Postgres 16 database was moved on $$MOVED_ON, nothing to do"
exit 0
fi
log "the Postgres 16 database was moved on $$MOVED_ON, but the Postgres 18 database is empty now."
log "Not importing the old state again by itself. See docs/ops/postgres-major-upgrade.md, \"Starting over\"."
exit 1
fi
if [ -e "$$MOVING" ]; then
log "an earlier move did not finish; emptying the Postgres 18 database and starting it again"
empty_new
elif [ "$$TABLES" != 0 ]; then
log "the Postgres 18 database already holds data, leaving both as they are"
exit 0
fi
VERSION=$$(cat "$$OLD/PG_VERSION")
if [ "$$VERSION" != 16 ]; then
log "the old data directory is Postgres $$VERSION, this step moves Postgres 16 only."
log "See docs/ops/postgres-major-upgrade.md to move it by hand."
exit 1
fi
NEED=$$(du -sk "$$OLD" | cut -f1)
FREE=$$(df -Pk /var/lib/postgresql/next | awk 'NR == 2 { print $$4 }')
# Room for the copy, and for the dump the app takes before its first migration.
if [ "$$FREE" -le $$((NEED * 3 / 2)) ]; then
log "the move needs about $$((NEED * 3 / 2 / 1024)) MiB free for the new database and its first dump, the disk has $$((FREE / 1024)) MiB."
log "Nothing was changed. Free some space and start the stack again."
exit 1
fi
trap 'CODE=$$?; [ "$$CODE" = 0 ] || log "the move stopped (exit $$CODE). The old volume is untouched; the next start begins it again."' EXIT
date -u +%Y-%m-%dT%H:%M:%SZ > "$$MOVING"
log "moving the Postgres 16 database ($$((NEED / 1024)) MiB on disk) into Postgres 18"
su-exec postgres postgres -D "$$OLD" -c listen_addresses= -c unix_socket_directories=/tmp &
PID=$$!
i=0
until pg_isready -h /tmp -U healthlog -q; do
kill -0 "$$PID" 2>/dev/null || { log "the Postgres 16 server did not start, see the lines above"; exit 1; }
i=$$((i + 1))
[ "$$i" -lt 600 ] || { log "the Postgres 16 server did not get ready in ten minutes"; exit 1; }
sleep 1
done
pg_dump -h /tmp -U healthlog -Fc healthlog |
pg_restore -h db -U healthlog -d healthlog --no-owner --single-transaction --exit-on-error
COUNTED=0
for T in $$(old "SELECT quote_ident(schemaname) || '.' || quote_ident(tablename) $$USER_TABLES ORDER BY 1"); do
A=$$(old "SELECT count(*) FROM $$T")
B=$$(new "SELECT count(*) FROM $$T")
if [ "$$A" != "$$B" ]; then
log "table $$T has $$A rows in Postgres 16 but $$B in Postgres 18. Emptying the new database again."
log "Please report this with the lines above."
empty_new
exit 1
fi
COUNTED=$$((COUNTED + 1))
done
log "all $$COUNTED tables arrived with the same row counts"
vacuumdb -h db -U healthlog -d healthlog --analyze-in-stages --quiet
kill -INT "$$PID"
wait "$$PID" || true
# The marker keeps the old database's last checkpoint: a later start can tell
# whether 16 was used again after the move.
{ date -u +%Y-%m-%dT%H:%M:%SZ; checkpoint; } > "$$MOVING"
mv "$$MOVING" "$$MARK"
log "done. The old volume stays as it was, as a way back; see docs/ops/postgres-major-upgrade.md"
# Restores a database dump into an EMPTY database (docs/ops/disaster-recovery.md).
# Never started by `docker compose up`; run it by name:
#
# docker compose run --rm db-restore list
# docker compose run --rm db-restore latest
# docker compose run --rm db-restore healthlog-<time>-daily.dump
# docker compose run --rm db-restore latest --from-bucket
#
# It checks the dump against its manifest and the keys in .env against the
# keys the dump was written with, refuses a database that already holds
# tables, restores in one transaction and compares every table's row count
# with the manifest. Start the app only after it says "Restored".
# `--from-bucket` downloads the dump and its manifest from the off-host bucket
# (`database/`, the BACKUP_S3_* variables) and opens them with
# BACKUP_ENCRYPTION_KEY: the restore after the host and its local dumps are
# gone. Files you downloaded yourself restore from a directory you mount:
# `docker compose run --rm -v ./restore:/restore:ro db-restore
# /restore/healthlog-<time>-daily.dump.enc`.
db-restore:
image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
profiles: ["restore"]
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
environment:
<<: *healthlog_env
volumes:
- backups:/var/lib/healthlog/backups
depends_on:
db:
condition: service_healthy
entrypoint: ["node", "scripts/db-restore.mjs"]
command: ["list"]
volumes:
postgres-data:
pgdata:
backups:
-27
View File
@@ -88,17 +88,6 @@ echo "HealthLog: Database is reachable."
# Say how many migrations this image is about to apply, with a reminder to
# back up first. Reporting only: it never stops the boot.
node scripts/pending-migrations.mjs || true
# When migrations are pending, dump the database into DB_BACKUP_DIR first, so
# the state before them can be restored (docs/ops/disaster-recovery.md). No
# directory that survives the container (none writable, or the image's own
# without the backups volume): a warning, and the boot goes on. A kept
# directory and a failed dump: the boot stops here, before anything is
# migrated.
if ! node scripts/db-dump.mjs pre-migration; then
echo "HealthLog: ERROR — not migrating without the dump. Free disk space or fix the error above and start again." >&2
echo "HealthLog: to migrate without a dump this once, set DB_BACKUP_BEFORE_MIGRATE=false." >&2
exit 1
fi
# `migrate-deploy.mjs` runs `prisma migrate deploy` and retries only a
# migration that timed out on a lock and is marked safe to re-run; any other
# failure stops the boot with Prisma's exit code (docs/ops/deploy.md).
@@ -107,22 +96,6 @@ NODE_PATH=/opt/prisma-cli/node_modules \
node scripts/migrate-deploy.mjs
echo "HealthLog: Migrations complete."
# ── The app's database role ──────────────────────────────────
# Everything above ran as the owner DATABASE_URL names. The app itself
# connects as `healthlog_app_<database>_<hash>`, this database's own role,
# which reads and writes rows and runs the queue but cannot change the schema (scripts/db-app-role.mjs says what it may and
# may not do, and docs/self-hosting/database-role.md how to opt out). The
# script hands the role's URL over on descriptor 3 only, never on stdout or
# stderr; nothing handed over means it said why and the app stays on the
# owner, as before. The owner's URL is replaced, not kept beside it, so it
# does not reach the app's environment.
app_database_url="$(node scripts/db-app-role.mjs 3>&1 1>&2)" || app_database_url=""
if [ -n "$app_database_url" ]; then
DATABASE_URL="$app_database_url"
export DATABASE_URL
fi
unset app_database_url
# ── Start application ────────────────────────────────────────
echo "HealthLog: Starting application..."
exec "$@"
+7 -15
View File
@@ -17,13 +17,9 @@ notes, and machine-readable specs that ship alongside the source:
encryption-key rotation, password reset and account recovery, env
checks, TLS pinning, data repair. These are what an operator reaches
for during an incident.
- [`self-hosting/`](./self-hosting/) — operator notes next to the code:
[`getting-started.md`](./self-hosting/getting-started.md) (install and
updates, the page the boot log points at), the TrueNAS, Unraid and
Portainer installs with their catalog templates, Tailscale, the reverse
proxy, horizontal scaling (`HEALTHLOG_PROCESS_TYPE=web|worker|all`),
notification channels, the environment module, document sources and
reproductive-health data. Includes
- [`self-hosting/`](./self-hosting/) — horizontal-scaling notes
(`HEALTHLOG_PROCESS_TYPE=web|worker|all`) and the deploy-pipeline
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
[`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
@@ -45,16 +41,12 @@ notes, and machine-readable specs that ship alongside the source:
code. [`using/search.md`](./using/search.md) covers the command palette's
record search: opening it, what it finds, how matching works, and its
limits on very large and shared records.
[`using/cycle-calendar-feed.md`](./using/cycle-calendar-feed.md) covers
subscribing to the cycle forecast from Google, Apple or Outlook.
- [`migration/`](./migration/) — migration notes. Currently only
v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and
<https://docs.healthlog.dev>.
- [`integrations/`](./integrations/) — per-integration reference:
Withings, WHOOP, Fitbit, Google Health, Strava, Nightscout, Dawarich and
Garmin; the Apple Health, Health Connect, document and data
imports; AI providers; and Home Assistant with its blueprint. Oura and
Polar are covered on the docs site only.
- [`integrations/`](./integrations/) — per-integration reference
(Withings, Fitbit, Google Health, WHOOP, Apple Health, AI providers,
data import).
- [`adr/`](./adr/) — architecture decision records.
- [`security/`](./security/) — threat-model notes.
- [`diagrams/`](./diagrams/) — architecture diagrams rendered through
@@ -78,4 +70,4 @@ notes, and machine-readable specs that ship alongside the source:
If you're a self-hoster looking to spin up an instance, start at
<https://docs.healthlog.dev> or jump to the [Quick Start in the project
README](../README.md#quick-start-docker-compose).
README](../README.md#quick-start).
+4 -11
View File
@@ -66,11 +66,7 @@ before treating a warning as a regression.
`health:write`), audience-bound to `/mcp` and refused on REST writes;
- `POST /api/tokens/measurements` mints `measurements:write`, accepted by
`POST /api/measurements` and `POST /api/measurements/batch` only, on the
holder's own record only. The readings it writes carry `source: EXTERNAL`
and are linked to the token; whether the HealthLog app writes them into
Apple Health is the token's `mirrorToAppleHealth` setting (off by
default, changed with `PATCH /api/tokens/{id}`), resolved onto every
measurement row as `appleHealthMirror`;
holder's own record only;
- `POST /api/tokens/workouts` mints `workouts:write`, accepted by
`POST /api/workouts/batch` only, on the holder's own record only; the
workouts it writes carry `source: EXTERNAL`;
@@ -82,12 +78,9 @@ before treating a warning as a regression.
v1.30.17. The path still lists (`GET`) and revokes (`DELETE /api/tokens/{id}`)
every token above, whichever surface issued it.
- `sessionCookie` — HttpOnly session cookie set by the password and
passkey login flows. Used by the web app and any iOS client running
inside a web view. It is named `__Host-healthlog_session` wherever it is
`Secure` (every HTTPS install), so no other host under the same domain
can set it; a plain-HTTP install keeps `healthlog_session`. A cookie
under the old name is still read and is re-issued under the new one.
- `sessionCookie` — HttpOnly `healthlog_session` cookie set by the
password and passkey login flows. Used by the web app and any iOS
client running inside a web view.
- **`components.schemas`** — DTOs for the documented routes plus the
underlying Prisma models (e.g. `Measurement`, `Medication`,
`MedicationIntakeEvent`, `MoodEntry`, `User`, `DashboardLayout`,
-8
View File
@@ -110,14 +110,6 @@ What `search` matches for the clinical records:
reading deferred is matched on its title and details only.
- `vaccination:<id>`: vaccine name, catalogue name, practitioner.
A lab has two ids. `lab:<analyte>` is the analyte view and answers with that
marker's latest reading. `labresult:<id>` is one reading: its value or text
result, unit, reference range, status, date, and its own links (the report
it was read from, the visits it is filed against). Every link to a lab result
(from a document, a visit, a condition or a medication) uses the reading id,
so following a report's link lands on the reading that report holds rather
than on a later draw. Each reading in `get_labs` carries the same id.
Condition and vaccination notes are never searched or returned, the same
rule the Coach and the doctor report follow. A deleted practitioner is not
named or matched. Query words shorter than four letters match whole words or
+1013 -4906
View File
File diff suppressed because it is too large. Load diff
+17 -42
View File
@@ -630,11 +630,9 @@ Confirmed-rejected slugs (despite the source-tree references in the table above)
Confirmed-accepted slugs on a ChatGPT Plus subscription (per `available_in_plans` in `models.json`):
- `gpt-5.6-terra` — HealthLog's default (first chain entry) as of 2026-10-24; the backend rejected `gpt-5.5` and everything below it that day. `gpt-5.6-luna` and `gpt-5.6-sol` are its siblings on the same line
- `gpt-6-sol`, `gpt-6.1-sol`, `gpt-6-luna`, `gpt-6-astra` — the gpt-6 line, listed in `models.json` since 2026-09-22 (`gpt-6.1-sol` the catalog default since 2026-09-29). The catalog names `gpt-6-sol` as the upgrade of `gpt-5.6-terra` and `gpt-5.6-sol`, and `gpt-6-luna` as that of `gpt-5.6-luna`. Every gpt-6 slug lists `input_modalities: ["text", "image"]` and the same reasoning presets as the gpt-5.6 line (`low` to `max`, sol and astra also `ultra`). The presets omit `none`, as they do for `gpt-5.5`, which a live probe on 2026-10-06 showed accepts it; HealthLog sends `none` for "off" on the gpt-6 line too and learns a refusal per slug. A live check on 2026-10-24 found the account's own list (`GET /backend-api/codex/models?client_version=…`) offering `gpt-6.1-sol`, `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna` and the `gpt-5.6` line, all with `input_modalities: ["text", "image"]`. From a HealthLog container with its stored sign-in, `gpt-6.1-sol`, `gpt-6-sol` and `gpt-6-luna` each answered text and read a lab-report image correctly. `gpt-6.1-sol` refuses `reasoning.effort: "none"` and names `low` as its lowest effort, which the client learns and steps up to on the same slug; the other two accept `none`
- `gpt-5.5` — the default from 2026-06 until 2026-10-24, kept as the last chain rung while plans differ
Retired for ChatGPT sign-in (OpenAI's Codex models page): `gpt-5.2` and `gpt-5.3-codex` on 2026-05-26, `gpt-5.4` and `gpt-5.4-mini` on 2026-08-31, `gpt-5.5` on 2026-10-14 at 19:00 UTC. The backend answers each of them with 400 `model_not_found`, "The '<slug>' model is not supported when using Codex with a ChatGPT account.", which the client reads as a reason to try the next slug.
- `gpt-5.5` — HealthLog's default (first chain entry) as of 2026-06
- `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2` — accepted per plan
- `gpt-5.3-codex` — was the default (verified 2026-05-09); the backend rotated off the `-codex` line on 2026-06-02 and this slug is now only accepted intermittently, so it sits on a late chain rung
The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _migration prompts_ shown to existing users — they are NOT the slugs sent on the wire after migration. The wire slugs come from `models.json`. Always cross-check `models.json` (`gh api repos/openai/codex/contents/codex-rs/models-manager/models.json`) before picking a default.
@@ -642,43 +640,23 @@ The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _
The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-codex` → `gpt-5.3-codex` happened over 48 h in May 2026 and bricked HealthLog twice while the v1.4.7..v1.4.13 saga unrolled). The cause: a single hard-coded slug in `CODEX_MODEL` flips invalid the moment the upstream rotates it. Operator brief, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht sein."
To eliminate that risk class, HealthLog's `CodexClient` tries its sign-in's working slug first and otherwise walks an ordered chain built from the models the account is offered.
To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series.
**The account's model list.** Before it walks, the client reads the list the Codex CLI reads, on the same sign-in as the responses call:
```http
GET /backend-api/codex/models?client_version=0.165.0 HTTP/1.1
Host: chatgpt.com
Authorization: Bearer <oauth-access-token>
ChatGPT-Account-ID: <chatgpt_account_id>
originator: healthlog
```
The answer is `ModelsResponse` from `codex-rs/protocol/src/openai_models.rs`: `{ "models": [ModelInfo, ...] }`, where the client reads `slug`, `visibility` (`list` / `hide` / `none`), `priority` (lower first), `input_modalities` (`text`, `image`, `audio`) and `model_specialty`. `codex-rs/codex-api/src/endpoint/models.rs` appends `client_version`; the backend leaves out models whose `minimal_client_version` is above it, so the version sent tracks a current Codex CLI release (`CODEX_MODELS_CLIENT_VERSION` in `src/lib/ai/codex-model-catalog.ts`). The read is bounded like the CLI's (5 s, `codex-rs/model-provider/src/models_endpoint.rs`) and to 4 MiB, cached per sign-in for an hour, and a failed read waits ten minutes before the next attempt. The list is a hint, never a gate: a read that fails, times out, does not parse or comes back empty leaves the static chain below, and one `ai.codex.models_unreadable` warning per sign-in reports the stretch.
**Order.** `CODEX_MODEL` first when set. `CODEX_MODEL_FALLBACK_CHAIN`, when set, replaces everything after it and the list is not read. Otherwise: the default chain below kept to what the account is offered; then every other offered model in the list's `priority` order, leaving out what the list hides (`visibility` other than `list`), the specialist lines (`model_specialty`) and the `-astra` frontier tier; then the rest of the default chain, so the walk never holds fewer models than before the list existed. `supported_in_api` is not a filter: it describes API-key access, and the CLI ignores it for a ChatGPT sign-in (`ModelPreset::filter_by_auth`). A slug this sign-in was refused in the last six hours goes to the very end, even while its list still offers it. With the list's `input_modalities` for the slug in hand, the Codex vision gate reads them; without, it falls back to the model family.
**Default chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default:
**Chain** (top = first try; default order is the empirically-safest most-current-first ordering as of 2026-06, after the backend rotated off the `-codex` line on 2026-06-02):
```ts
const DEFAULT_SLUG_FALLBACK_CHAIN = [
"gpt-5.6-terra", // verified against the backend 2026-10-24
"gpt-6-sol", // terra's upgrade in the upstream catalog
"gpt-6.1-sol", // the catalog default, on every plan
"gpt-6-luna", // lighter gpt-6 sibling
"gpt-5.6-luna", // lighter sibling on terra's line
"gpt-5.6-sol", // heavier sibling on terra's line
"gpt-5.5", // previous default, kept as the last rung while plans differ
"gpt-5.5", // current default across paid tiers as of 2026-06-02
"gpt-5.4", // documented fallback
"gpt-5.4-mini", // documented lighter-weight fallback
"gpt-5.3-codex", // late rung — still accepted intermittently
"gpt-5.2", // legacy floor
];
```
Every default rung must be a model family the reasoning, sampling and vision gates recognise (`src/lib/ai/openai-model-family.ts`); `codex-slug-fallback.test.ts` holds the chain to that.
The shipped constant in `src/lib/ai/codex-client.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
The shipped constant in `src/lib/ai/codex-slug-chain.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. That is why the account's live list now comes first and the default chain only orders and backs it up.
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated), which replaces it outright; if `CODEX_MODEL` is also set, it is folded into position 0 and de-duplicated.
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated.
**Trigger condition** (when to walk to the next slug):
@@ -692,15 +670,15 @@ The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-sep
| `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) |
| `200` with SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong, slug is fine) |
**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` for that sign-in. On its next request, jump straight to the cached slug; the model list is read only when there is none or it stops working. TTL: 1 hour. Scope: per stored credential (`user:<id>` for a person's connection, `central` for the operator-shared one), process-local, no DB write, at most 1000 sign-ins held. Each entry carries a digest of the ChatGPT account id it was learned under, and a client reads it only under that account, so a credential reconnected to another account starts empty: its first request tries no slug, skips no refusal and trusts no list the old account left, in any process. The vision gate holds no account id; the connect and disconnect routes rebind or drop the entry, so in the process that handled the change it answers from the new sign-in at once, and elsewhere from that process's first request on it. Two sign-ins on different plans no longer overwrite each other's working slug.
**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` in a process-local Map. On the next request, jump straight to the cached slug. TTL: 1 hour. Cache scope: per-process (no DB write).
**Cache invalidation**: on a 400/404 walk the refused slug leaves the working slot and goes to the back of that sign-in's line for six hours. The next-best slug is promoted into the cache only after it succeeds.
**Cache invalidation**: on first 400/404 walk, the cached entry is dropped. The next-best slug is promoted into the cache only after it succeeds.
**All-failed case**: if every slug in the chain rejects with the walk-trigger, the client throws a structured error with `httpStatus: 503` and message `"AI provider unreachable — all configured Codex slugs were rejected"`. The route layer surfaces this to the user with "AI provider unreachable — check Settings".
**Observability**: a request that builds the chain annotates `codex.models.chain` (`operator` / `live` / `static`), `codex.models.read` (`live` / `cache` / `skipped`), `codex.models.failure` (`timeout`, `network`, `auth`, `http_error`, `too_large`, `unparsable`, `empty`, or null) and `codex.models.offered` (a count). `CodexClient.getLastDiagnostics()` holds the attempted slugs, the cache state and the working slug.
**Observability**: each attempted slug is recorded in the Wide-Event annotation `codex.slug.attempts: ["gpt-5.3-codex","gpt-5-codex"]` so Loki can answer "which slug carried this request" and "how often did the cache hit". Cache hits emit `codex.slug.cache: "hit" | "miss" | "expired"`.
The walk lives in `src/lib/ai/codex-client.ts`, the order in `src/lib/ai/codex-slug-chain.ts`, the list read in `src/lib/ai/codex-model-catalog.ts`, the per-sign-in memory in `src/lib/ai/codex-slug-cache.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-live-models.test.ts`, `codex-slug-chain.test.ts`, `codex-model-catalog.test.ts` and `codex-slug-fallback.test.ts`.
The implementation lives in `src/lib/ai/codex-client.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-client.test.ts`.
---
@@ -795,10 +773,7 @@ All paths relative to `openai/codex` repo:
- `codex-rs/codex-api/src/rate_limits.rs` — rate-limit headers
- `codex-rs/codex-api/src/auth.rs` — `AuthProvider` trait
- `codex-rs/protocol/src/models.rs` — `ResponseItem`, `ContentItem`, `MessagePhase`, `ImageDetail`, etc.
- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum; `ModelsResponse`, `ModelInfo`, `ModelVisibility`, `InputModality`, `ModelPreset::filter_by_auth` (the `/models` wire)
- `codex-rs/codex-api/src/endpoint/models.rs` — `ModelsClient`: `GET models?client_version=…`
- `codex-rs/model-provider/src/models_endpoint.rs` — the 5 s model-list bound; `models_identity.rs` — the per-account cache identity
- `codex-rs/models-manager/src/manager.rs` — priority sort, picker visibility, cache TTL
- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum
- `codex-rs/protocol/src/config_types.rs` — `ReasoningSummary`, `Verbosity` enums
- `codex-rs/model-provider/src/bearer_auth_provider.rs` — `Authorization` + `ChatGPT-Account-ID` headers
- `codex-rs/model-provider/src/auth.rs` — auth-provider selection
+1 -1
View File
@@ -14,7 +14,7 @@ Never point the seed or the benchmark at a database that holds real data. They w
```sh
docker run -d --name hl-bench-db --shm-size=256m \
-e POSTGRES_USER=bench -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=healthlog_bench \
-p 55432:5432 postgres:18.6-alpine \
-p 55432:5432 postgres:16.15-alpine \
postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=top
```
+1 -73
View File
@@ -11,7 +11,6 @@ is about choosing and writing tests.
| Pure logic: statistics, time zones, cadence, parsers, unit conversion, formatting | `src/**/__tests__/*.test.ts` (unit) | `pnpm test` |
| Laws of that logic over random inputs | `*.property.test.ts` next to the unit tests | `pnpm test` |
| What a component renders for a given state | `src/**/__tests__/*.test.tsx` (unit) | `pnpm test` |
| How a component behaves when someone types, clicks or presses keys, and what it sends | `src/**/__tests__/*.browser.test.tsx` | `pnpm test` |
| A structural rule about the tree (a guard) | `src/__tests__/` with a stated floor | `pnpm test` |
| A route: auth, scope, validation, what it stores and what it answers | `tests/integration/routes/` with the kit | `pnpm test:integration` |
| Anything whose truth lives in Postgres: constraints, `where` semantics, transactions, locks, cascades, time zones in SQL, backups, jobs | `tests/integration/` | `pnpm test:integration` |
@@ -28,28 +27,6 @@ ones: `src/__tests__/test-realism-ratchet.test.ts` counts route tests that mock
those numbers may only fall. Mock at the process boundary you do not own instead: provider HTTP
(`safeFetch`), APNs, SMTP, the model.
## Critical journeys
A few e2e journeys carry `@critical` in their test titles: signing in with a
password and a passkey and signing out, a reading reaching the dashboard, a
dose moving compliance, a mood entry, a lab CSV import, a questionnaire in
German and Brazilian Portuguese, a setting that persists, a backup coming
back, sharing a record, and the offline page. `pnpm e2e:critical` runs them.
They run against the real server and a seeded Postgres. A spec file that
carries the tag may not intercept anything under `/api`, directly or through
a helper it imports; `src/__tests__/e2e-critical-no-api-stub-guard.test.ts`
refuses it and keeps a floor on the number of tagged tests. Stubbing an
unreachable third-party host that is not the journey's subject is allowed,
with the reason next to the call.
A journey that writes gets an account nobody else writes. The accounts in
`e2e/setup/critical-fixture.ts` are seeded by global setup, one per journey
and per Chromium project, so a journey runs at both widths without the two
projects meeting. Reset what a previous run left with `resetCriticalAccount`
in a `beforeEach`, and read results through the app's own routes rather than
through the database.
## The route test kit
`tests/kit/` holds two modules for tests under `tests/integration/`.
@@ -137,43 +114,6 @@ into a fresh account and reads it back there. A new setting column without a
table entry fails the file. A column with no write route says why in its
entry.
## Browser tests
Interaction belongs in the `browser` project: `*.browser.test.tsx` files run
in headless Chromium through Vitest's Playwright provider, as part of
`pnpm test`. Run them alone with `pnpm test --project browser`, or one file
with `pnpm exec vitest run --project browser <path>`. The Node projects never
collect these files and this project collects nothing else. CI installs the
browser it needs (`playwright install --only-shell chromium`); locally, run
that once.
Reach for it when the claim is about behaviour a markup test cannot see:
focus, keys, an open menu, a pending state, the body a form posts, the URL a
filter writes. A `renderToStaticMarkup` grep stays the tool for what a given
state renders, and bottom-sheet gestures stay in `e2e/`.
The helper is `tests/browser/`:
- `renderWithApp(ui, { locale, user })` mounts the app's own providers: a real
TanStack `QueryClient` (no retries) and `I18nProvider` with the real
catalog. The signed-in account is seeded where `useAuth` reads it, with
`authUser()` defaults under `user`. The app stylesheet and the default
theme class are loaded, so layout, visibility and colour tokens resolve.
- `stubFetch()` replaces `fetch` and nothing above it. Answer a route with
`.on(method, path, { status, data, error, meta })`, hold one with
`.deferred(...)` to assert a pending state, and read the parsed bodies from
`.calls(method, path)`. An unmatched request gets a 404 envelope and is
recorded. Do not mock `@tanstack/react-query` or `@/lib/api/api-fetch`.
- `next/navigation` is aliased to a shim over the page's real `location` and
`history`; `url.ts` sets the starting query, records pushes and replaces,
and simulates Back or an edited address.
- `pinDate(iso)` fakes `Date` only, so timers and animation frames still run.
Every test gets the real `fetch`, clock, address and an empty `localStorage`
back afterwards. Locate by role and accessible name, the way a person finds a
control; a `Label` adds a colon to its name, so match a labelled combobox with
`/^Type/` rather than `"Type"`.
## The integration database
Global setup boots one Postgres container and migrates one template database.
@@ -212,8 +152,7 @@ node scripts/test-durations.mjs unit unit.json
The nightly workflow (`.github/workflows/test-nightly.yml`) uploads a file
regenerated from CI timings, which is the better source.
The unit suite is three Node projects and the `browser` project above. The
Node ones are decided per file from its source
The unit suite is three projects, decided per file from its source
(`tests/unit-projects.ts`): `guards` and `unit` share a module graph within a
worker, `isolated` gives each file a fresh one. A file lands in `isolated` when
it mocks, spies, stubs env or globals, fakes timers or resets modules. If a
@@ -284,14 +223,3 @@ earns its place; do not extend the date.
Order dependence is looked for on purpose: the nightly workflow runs both
suites with `--sequence.shuffle` and prints the seed. Replay a failure with
`--sequence.shuffle --sequence.seed=<seed>`.
A case resets or states what it reads: `vi.resetAllMocks()` (or
`mockReset()`) where a case swaps in its own implementation, since
`clearAllMocks` keeps it and a `mockResolvedValueOnce` the code never consumed
answers the next case; a module cache dropped through its own invalidator; a
row the case needs written by the case or its own `beforeAll`. A file that is
one walk by design (a parity sequence, a migration's before and after, a
dry run followed by the import and the same file again) says so in a comment
and passes `{ shuffle: false }` to its `describe`, which keeps that block in
order while the rest of the suite still shuffles. Wrap the whole file in one
such block when its top-level blocks build on each other.
+4 -6
View File
@@ -32,10 +32,10 @@ The gaps this section used to list are closed: `TileHeader` carries a `size="sm"
## 2. Spacing scale
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper, `pb-24` in the desktop shell, + the phone shell's bottom-nav clearance on `<main>`). The shell itself takes the window's edges (`absolute inset-0`), never `h-dvh`, and `<main>` is the one vertical scroller: the document neither scrolls nor bounces under it. Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + `<md` bottom-nav clearance on `<main>`). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13).
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`MAIN_CONTENT_WRAPPER_CLASS` in `layout/shell-metrics.ts`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6 shell-desktop:pb-24`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`.
- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding.
- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`.
@@ -101,7 +101,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented-single-implementation.test.ts` holds the definition to one module) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
| A grade beside a reading (confidence, score gain, band, lab range flag) | `ScorePill` (`ui/score-pill.tsx`): tone `success` / `info` / `warning` / `destructive` / `neutral`, `numeric` for figures; `CONFIDENCE_TONE` maps both confidence scales | a hand-rolled `rounded-full px-2 py-0.5 text-xs bg-*/15` chip (`score-pill-guard.test.ts`); `TagChip` (neutral label) and `Badge` (connection/account state) stay what they are |
| "This vital on these days vs those days" board | `MetricCrosstab` (`insights/metric-crosstab.tsx`): caller formats the numbers, the board owns row, neutral signed delta, confidence chip, dismissal | a fourth copy of the crosstab list |
| A trend / stat tile in a grid (KPI strip, derived tiles, their skeletons and building states) | `TrendTile` (`ui/trend-tile.tsx`) | the hand-spelled `bg-card border-border … p-4 md:p-6` frame |
@@ -148,7 +148,7 @@ Forbidden on any Insights card: a hand-rolled icon+title row, a card title centr
## 9. One scroll floor; sub-shells never re-declare bottom-space
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`), or the home indicator in the desktop shell, and its wrapper clears the Coach FAB (`pb-20`, `pb-24` in the desktop shell). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`) and its wrapper clears the Coach FAB (`pb-20`). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source**: `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`); applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget.
@@ -156,8 +156,6 @@ A two-column sub-shell that needs a "don't jump while loading" floor derives it
Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …), never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen.
The rail's destination list is the one part of the rail that gives up height: the logo band, the footer group and the account row stay pinned, and the list scrolls between them (`overscroll-contain`, no native scrollbar, fades at an edge with more). A mouse gets the height-stepped row density; a finger keeps the 44 px rows, and at an edge with more destinations the rail shows a scroll cue (`sidebar-nav-scroll-cue`) that a tap follows. Guard: `e2e/tablet-shell.spec.ts`.
## 11. Inline action row; the PageHeader pattern
A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape; reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`).
+1 -1
View File
@@ -81,7 +81,7 @@
<text x="600" y="360" text-anchor="middle" fill="#6272a4" font-size="11">pre-aggregated buckets</text>
<rect x="510" y="392" width="180" height="56" rx="10" fill="#44475a" stroke="#ff79c6" stroke-width="1.5" />
<text x="600" y="417" text-anchor="middle" font-weight="600">PostgreSQL 18</text>
<text x="600" y="417" text-anchor="middle" font-weight="600">PostgreSQL 16</text>
<text x="600" y="435" text-anchor="middle" fill="#bd93f9" font-size="11">encrypted at rest</text>
</g>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+2 -2
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">HealthLog self-hosting topology</title>
<desc id="desc">Internet traffic terminates at a reverse proxy (Caddy, Traefik or Nginx) for TLS, then reaches the Next.js app on port 3000. The app shares a Postgres 18 database with the pg-boss worker process for reminders, insights, backups and the Apple Health import. GHCR ships the multi-arch image; Coolify watches the digest and pulls; off-host S3-compatible storage is optional for backups.</desc>
<desc id="desc">Internet traffic terminates at a reverse proxy (Caddy, Traefik or Nginx) for TLS, then reaches the Next.js app on port 3000. The app shares a Postgres 16 database with the pg-boss worker process for reminders, insights, backups and the Apple Health import. GHCR ships the multi-arch image; Coolify watches the digest and pulls; off-host S3-compatible storage is optional for backups.</desc>
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
@@ -47,7 +47,7 @@
<!-- Postgres (green) -->
<g>
<rect x="760" y="250" width="200" height="80" rx="10" fill="#44475a" stroke="#50fa7b" stroke-width="1.5" />
<text x="860" y="276" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">PostgreSQL 18</text>
<text x="860" y="276" text-anchor="middle" fill="#f8f8f2" font-size="14" font-weight="600">PostgreSQL 16</text>
<text x="860" y="296" text-anchor="middle" fill="#bd93f9" font-size="11">26 Prisma models</text>
<text x="860" y="312" text-anchor="middle" fill="#6272a4" font-size="11">+ pg-boss queues</text>
</g>

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

+1 -1
View File
@@ -10,7 +10,7 @@ copy.
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, Nightscout) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 18, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
-12
View File
@@ -216,18 +216,6 @@ JSON surfaces send the standard
the field is detected on the first refusal and retried without it,
so strict gateways and older local servers both work unmodified.
**Azure OpenAI.** Point the gateway at the resource's v1 endpoint,
`https://<resource>.openai.azure.com/openai/v1`, and put the
deployment name in Model. A reasoning model (gpt-5 on, the o-series)
takes `max_completion_tokens` and no `temperature`, the same as on
`api.openai.com`. HealthLog sends that from the first request when the
deployment is named after its model (`gpt-6-luna-eu`). A deployment
with a name of its own (`prod-chat`) gets the usual `max_tokens`
once; Azure refuses it, and the request goes out again with
`max_completion_tokens`. HealthLog remembers that for the endpoint and
deployment until the container restarts. Any other gateway that
refuses `max_tokens` the same way is handled the same way.
**When the gateway refuses.** OpenRouter answers a refused generation
with HTTP 200 and the reason inside the body, so every refusal used to
reach the provider health card as an empty reply with no status. It now
+5 -5
View File
@@ -295,7 +295,7 @@ Concrete consequences:
RMSSD HRV has its own ladder, **WHOOP ≻ OURA ≻ POLAR ≻ APPLE_HEALTH**,
separate from the SDNN one.
Override per-user via Settings → Source priority (`/settings/sources`).
Override per-user via Settings → Sources (`/settings/sources`).
## Failure modes
@@ -314,7 +314,7 @@ id for debugging.
## Garmin owners
Garmin Connect writes to Apple Health automatically, so the import path on this
page brings Garmin data in on iOS. The direct [Garmin](./garmin.md) integration
reads more (Body Battery, stress, Garmin's sleep score) with a key from your
own Garmin account.
Garmin has no direct connector for a self-hosted instance. Garmin Connect writes
to Apple Health automatically, so the import path on this page is how Garmin data
reaches HealthLog on iOS. See [garmin.md](./garmin.md) for what comes through and
what Garmin withholds.
+22 -56
View File
@@ -57,50 +57,23 @@ arrays:
}
```
Each array holds at most **10 000** entries and the file at most 16 MB.
The endpoint is rate-limited to **5 imports per hour per account**.
Existing rows (matched on the type + timestamp uniqueness, or on
`externalId` for mood entries) are skipped and counted under `skipped` in
the response.
The app's own files go straight in: the JSON export (**Export → JSON**,
read inside its `data` envelope) and the portable full backup. Every field
either file writes for a reading or a mood entry comes back, apart from
the row ids, the source (every imported row is stored as `IMPORT`) and the
sync bookkeeping of the account the file came from. An empty field, `null`
in the backup and `""` in the export, reads as absent, a backup's deletion
records are skipped and counted, and a reading in a display unit (lb, °F,
mmol/L) is converted to the stored one.
A mood entry's tags and rated factors are linked by key. A full backup
also carries the account's own tags, rated factors and tag groups, and the
import brings them along: a tag the importing account already has is used
as it is, and one it lacks is created (within the account's limit of 50
custom tags and 12 groups). The account's own tag is never overwritten: when
the file names it differently, the account's name stays and the response
lists the key in `keptMoodTagKeys` (`keptMoodCategoryKeys` for a group).
Moving a file between two accounts of one server works too; the tags come
over under keys of their own. A key the account cannot resolve at all is
dropped from that entry and named in `droppedTagKeys` or
`droppedFactorKeys`, and the rest of the entry lands.
Each array holds at most **10 000** entries. The endpoint is rate-limited
to **5 imports per hour per account**. Existing rows (matched on the
type + timestamp uniqueness, or on `externalId` for mood entries) are
skipped and counted under `skipped` in the response.
### Measurement entry
| Field | Required | Type | Notes |
| ---------------------- | -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label, not stored. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
| `valueMin` | no | number | Lower bound of a range reading, such as an hourly heart-rate statistic. Not above `valueMax`. |
| `valueMax` | no | number | Upper bound of a range reading. |
| `sleepStage` | no | enum | A sleep row's stage: `IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP` or `REM`. |
| `rhythmClassification` | no | enum | The device's verdict on an ECG, a rhythm alert or a steadiness reading. |
| `deviceType` | no | string | The writer of the reading (watch, phone), 1–32 chars. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
| Field | Required | Type | Notes |
| ---------------- | -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
### Glucose context
@@ -125,20 +98,14 @@ moment you know it.
### Mood entry
| Field | Required | Type | Notes |
| ---------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `note` | no | string | The entry's note, up to 500 chars. |
| `a1`–`a5` | no | integer | The five level-A values, `0`–`10`. Without `a1`, pleasantness is derived from `mood`. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
| `tz` | no | string | The IANA zone `date` is anchored to. |
| `factors` | no | array | Rated factors, `[{ "key": "…", "rating": 1–5 }]`; replace the entry's rated links. |
| `structuredTags` | no | array | Ticked tags by key; replace the entry's tag links. |
| `context` | no | object | The day context (work, contacts, leisure, an event, a note), as the backup writes it; replaces the entry's context. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
| Field | Required | Type | Notes |
| ------------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
### `MeasurementType` values and canonical units
@@ -194,7 +161,6 @@ hand-authored.
| `WALKING_ASYMMETRY` | percent (0–100) |
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
| `ACTIVE_ENERGY_BURNED` | kcal |
| `BASAL_ENERGY_BURNED` | kcal |
| `ENERGY_EXPENDITURE_KJ` | kJ |
| `VO2_MAX` | mL/(kg·min) |
| `PULSE_WAVE_VELOCITY` | m/s |
+80 -5
View File
@@ -1,7 +1,82 @@
# Dawarich integration
The Dawarich guide lives on the docs site:
<https://docs.healthlog.dev/guides/connect-dawarich/>. It covers what is read
and what is never stored, connecting an instance, allowing one on your own
network (`DAWARICH_PRIVATE_ORIGINS`), where the values show up, and
disconnecting.
HealthLog can read daily movement context from a [Dawarich](https://dawarich.app)
instance: how far you moved, how much of it by car or by train, how many places
you visited, how long you were away from home, and whether a day was spent in
another country. It is part of the Environment module and runs only while that
module is on.
HealthLog never stores a coordinate, a track, a place name or a visit. It reads
the day summaries and keeps one row of numbers per day. The place list is read
live when you pick your home and is not saved.
## What is read
Once a night (03:20 Europe/Berlin) HealthLog fetches the last seven days, ending
yesterday. Connecting fetches the last 30 days. Each request carries your API
key as `Authorization: Bearer <key>`.
| Dawarich endpoint | What HealthLog keeps |
| ----------------------- | -------------------------------------------------------------------------------------- |
| `GET /api/v1/timeline` | total distance, distance by car (`driving`), distance by train, places visited |
| `GET /api/v1/visits` | minutes spent at your home place, to work out time away from home |
| `GET /api/v1/residency` | the country of each day, sealed at rest, and whether it differs from your home country |
| `GET /api/v1/places` | nothing; the list is shown once so you can pick your home |
| `GET /api/v1/users/me` | nothing; used to check the key |
Time away from home is the day's length minus the time a visit at your home
place covers. Without a home place, or on a day without a single visit, it is
left empty rather than shown as zero. Dawarich leaves out a mode that moved
less than half a kilometre; HealthLog then reads that mode as zero for the day.
Residency needs API access on a hosted Dawarich plan; a self-hosted instance
has it. Without it there are no travel days, and everything else still works.
Dawarich keys residency by UTC date, so on a day that crosses a border late in
the evening the country can be the next morning's.
## Connect
1. In Dawarich, open your account settings and copy the API key.
2. In HealthLog, turn on the Environment module, then open
**Settings → Integrations → Dawarich**.
3. Enter the address of the instance and the key, and connect.
4. Pick your home place from the list, and set your home country if you like.
Without one, the country you spent most days in over the last year counts.
Run Dawarich and HealthLog in the same time zone. Dawarich groups the
timeline by its own zone, HealthLog by yours; if they differ, a day can come
back without its distances.
## A Dawarich instance on your own network
A public instance works without any server setting. An instance on a private
network (a LAN address, a `.lan` name, a container on the same host) is refused
unless the operator lists its exact origin:
```env
DAWARICH_PRIVATE_ORIGINS="http://dawarich.lan:3000"
```
The grammar is the one `NIGHTSCOUT_PRIVATE_ORIGINS` uses
([Nightscout](./nightscout.md)): one `scheme://host[:port]` per entry, comma
separated, no path, no wildcard and no range. Listing a host does not open its
subdomains or another port. The unspecified address, link-local and the
cloud-metadata range can never be listed. Restart HealthLog after changing it.
The variable is on the `environment:` whitelist in `docker-compose.yml`.
## Where the values show up
- The day view has an **On the move** section with the day's figures.
- Time away from home and the distance travelled join the correlation scan as
two more factors.
- Runs of at least two travel days that no trip covers yet are offered as
trips under **Settings → Environment → Location periods**. Using one fills
in the dates; you pick the place.
- The Coach and the MCP `get_day` tool see the figures and whether it was a
travel day, never the country.
## Disconnect
Disconnecting stops the nightly fetch and keeps the stored days. **Disconnect
and delete days** removes them as well. The days are part of the backup; the
address and key are not, so a restored account connects again.
+1 -1
View File
@@ -103,7 +103,7 @@ The Fitbit client id/secret are per-user (Settings), not env vars.
If Fitbit and another source (Withings, WHOOP, Apple Health) both supply the same
vital — resting heart rate, blood oxygen, body temperature or respiratory rate —
the source priority ladder in **Settings → Source priority** decides which one wins
the source priority ladder in **Settings → Sources** decides which one wins
per metric. FITBIT is ranked in every default metric ladder; override
per metric on that page.
+40 -102
View File
@@ -1,114 +1,52 @@
# Garmin
# Garmin (via Apple Health or Google Health Connect)
HealthLog reads Garmin Connect directly through Garmin's **Personal API**: you
create a key for your own account in Garmin Connect, paste it into HealthLog,
and the server pulls your data every hour. Nothing is pushed by Garmin, and
nothing is written back.
**There is no direct Garmin connector for a self-hosted HealthLog instance, and
there cannot be one.** Garmin's data-sharing program is business-partner-only:
it is approval-gated, requires a public callback endpoint, and is not the
"register your own app, paste a key" model the wearable integrations here rely
on. So instead of a broken half-integration, HealthLog reads Garmin data the
honest way — through the phone health platform your Garmin already feeds.
The Personal API is for your own data only. Each person on an instance
creates and pastes their own key; there is no shared app and no operator
setup.
## The path that works
## Create a key
```
Garmin device → Garmin Connect → Apple Health (iOS) ─┐
→ Google Health Connect ├─→ HealthLog
(Android) ─┘
```
1. Open [Garmin Connect → Settings → Personal API](https://connect.garmin.com/app/settings/personalApiToken).
2. Create a key and choose how long it stays valid: 30, 90, 180 days or one
year.
3. Copy it right away. Garmin shows the key once.
- **iOS:** Garmin Connect writes to Apple Health automatically. Import it with
the existing [Apple Health](./apple-health.md) path (the `export.zip` upload or
the native client's background sync).
- **Android:** since Garmin Connect 5.14.1 (July 2025) the app writes to **Google
Health Connect**. Connect HealthLog through the existing
[Google Health](./google-health.md) integration.
## Connect
Open **Settings → Integrations → Garmin**, paste the key, and pick the same
validity you chose in Garmin Connect. HealthLog checks the key with Garmin
before storing anything, so a mistyped or revoked key is refused on the spot.
The key is stored encrypted at rest and is never shown again, logged or sent
anywhere but Garmin.
Accounts registered with Garmin's China service work too: pick **Garmin
Connect China** as the service. The key is only ever sent to the service you
pick.
After connecting, the last 30 days arrive within a minute and the rest of your
history follows in the background. The card says "Older history is still
loading" until it is done.
## Key expiry
Garmin keys expire and cannot be extended. Garmin does not report a key's end
date through the API, so HealthLog works it out from the validity you picked
at connect, and replaces that estimate with Garmin's own date once the key is
in its last day (the short-lived tokens it buys are cut off at the key's end).
From seven days before the end the card says when the key expires. When it
has expired, or you revoked it in Garmin Connect, syncing stops and the card
asks for a new key: create one, open **Replace key** in the card's menu and
paste it. Nothing imported is lost. If you did not pick a validity, the card
says that it cannot warn ahead; **Key validity** in the card's menu sets it
afterwards, together with the day you created the key, without pasting the
key again. A key Garmin turned down is asked about again once a day, so a
refusal that passes on its own clears without you doing anything.
No extra code, no Garmin developer account, no approval queue — if your Garmin
already syncs to your phone's health platform, HealthLog already reads it.
## What comes through
| Garmin data | HealthLog type | Unit |
| --------------------------------------------------------------------- | -------------------------- | ------------- |
| Steps | `ACTIVITY_STEPS` | steps |
| Step distance | `WALKING_RUNNING_DISTANCE` | m |
| Floors climbed | `FLIGHTS_CLIMBED` | flights |
| Active calories | `ACTIVE_ENERGY_BURNED` | kcal |
| Resting heart rate | `RESTING_HEART_RATE` | bpm |
| All-day average stress level | `STRESS_LEVEL` | 0–100 |
| Body Battery (the day's high) | `BODY_BATTERY` | 0–100 |
| Sleep stages (light, deep, REM, awake) | `SLEEP_DURATION` per stage | minutes |
| Sleep score | `SLEEP_SCORE` | 0–100 |
| Average overnight SpO₂ | `OXYGEN_SATURATION` | % |
| Average overnight breathing rate | `RESPIRATORY_RATE` | breaths/min |
| Overnight HRV (RMSSD) | `HRV_RMSSD` | ms |
| Weight, BMI, body fat, body water, muscle and bone mass, visceral fat | the body-composition types | kg, %, rating |
| Activities | workouts | |
| Logged period days | the cycle tracker | |
Everything Garmin shares with Apple Health / Health Connect flows in: steps,
heart rate (including beat-to-beat variability), sleep stages (REM / deep /
light), calories (active and total), distance, weight and body composition,
cadence, and — partially — blood-oxygen saturation.
Notes:
## What Garmin withholds
- Garmin's HRV is RMSSD and lands on `HRV_RMSSD`. Garmin reports no SDNN, so
nothing is written to `HEART_RATE_VARIABILITY`.
- Stress and Body Battery are Garmin's own scores and keep types of their own.
They are not mixed into HealthLog's computed stress score.
- Days you did not wear the watch import nothing, not zeros.
- Period days are only added on days you have not logged yourself; your own
entries are never changed. Cycles Garmin only predicts are not imported.
- Weigh-ins are read back to the start of Garmin Connect, also from before
your first watch, so a scale-only account gets its whole history.
- A night you delete in Garmin Connect, or that Garmin turns into a nap, is
removed here on the next sync.
- Data Garmin does not provide for your account (a menstrual-cycle summary
when you track none, for example) is skipped, does not count as an error,
and is listed under **Details for a bug report** on the card.
- When the same data also arrives through Apple Health or Health Connect, the
source order in **Settings → Sources** decides which one a chart shows.
Garmin ranks with the other watches.
These are Garmin's **proprietary** scores. Garmin does **not** share them with
Apple Health or Health Connect, so no self-hosted integration — and no amount of
configuration — can obtain them. They are only available inside Garmin Connect
itself:
## What the Personal API does not carry
- Body Battery
- HRV Status
- VO₂ max
- Training Load and Training Effect
- Recovery Time
- Intensity Minutes
- Stress score
- Running dynamics
Training Readiness, Training Status, acute training load and recovery time
are not part of the Personal API. If you read them elsewhere, for example
through a Home Assistant integration, push them through an ingest token: see
[Home Assistant](./home-assistant.md#garmin-training-figures). They land as
`TRAINING_READINESS` and `ACUTE_TRAINING_LOAD` and show on the recovery page
beside Body Battery.
## Limits
- Garmin sets how many requests a key may make. HealthLog reads one resource
at a time and stops the hour's run at Garmin's first rate-limit answer; the
history backfill waits as long as Garmin asks before it goes on.
- Disconnecting removes the key from HealthLog. It stays valid at Garmin until
you revoke it there.
## Without a key
Garmin Connect also writes to Apple Health on iOS and to Google Health Connect
on Android, so the [Apple Health](./apple-health.md) and
[Google Health](./google-health.md) paths keep working for anyone who prefers
not to create a key. Body Battery, stress and Garmin's sleep score only come
through the Personal API.
If you rely on those specific numbers, keep the Garmin Connect app for them;
everything else lands in HealthLog through the platform path above.
+5 -5
View File
@@ -305,7 +305,7 @@ triggers the verification + CASA requirements above.)
If Google Health and another source (Withings, WHOOP, Apple Health) both supply
the same vital — resting heart rate, blood oxygen, respiratory rate — the source
priority ladder in **Settings → Source priority** decides which one wins
priority ladder in **Settings → Sources** decides which one wins
per metric.
## Disconnect
@@ -315,7 +315,7 @@ Previously synced measurements stay in the database.
## Garmin owners
Since Garmin Connect 5.14.1 (July 2025) the app writes to Google Health Connect,
so this path brings Garmin data in on Android. The direct [Garmin](./garmin.md)
integration reads more (Body Battery, stress, Garmin's sleep score) with a key
from your own Garmin account.
Garmin has no direct connector for a self-hosted instance. Since Garmin Connect
5.14.1 (July 2025) the app writes to Google Health Connect, so this integration
is how Garmin data reaches HealthLog on Android. See [garmin.md](./garmin.md) for
what comes through and what Garmin withholds.
-32
View File
@@ -149,38 +149,6 @@ Units are derived server-side from the type, so send the number in the unit
above and do not send a `unit` field. The full enum is in
`docs/api/openapi.yaml` under `MeasurementType`.
## Garmin training figures
The Garmin Personal API does not carry Training Readiness or the acute
training load (see [Garmin](./garmin.md)). If Home Assistant reads them, for
example through a Garmin Connect integration, send them through the same
ingest token. They have types of their own and appear on the recovery page:
| What the sensor reports | `type` | Unit expected |
| -------------------------- | --------------------- | ------------- |
| Garmin Training Readiness | `TRAINING_READINESS` | score 0–100 |
| Garmin acute training load | `ACUTE_TRAINING_LOAD` | load, no unit |
Both change about once a day. This automation pushes Training Readiness
whenever the sensor changes, through the `healthlog_measurement` command
above; the acute load is the same with its own sensor and type:
```yaml
automation:
- alias: "HealthLog: Garmin training readiness"
triggers:
- trigger: state
entity_id: sensor.garmin_training_readiness
conditions:
- condition: template
value_template: "{{ trigger.to_state.state | is_number }}"
actions:
- action: rest_command.healthlog_measurement
data:
type: TRAINING_READINESS
value: "{{ trigger.to_state.state | float }}"
```
## Room climate
Indoor CO₂, temperature and humidity are their own types, kept apart from the
+72 -4
View File
@@ -1,6 +1,74 @@
# Strava integration
The Strava guide lives on the docs site:
<https://docs.healthlog.dev/guides/connect-strava/>. It covers what Strava
adds (workouts only), how duplicate activities from a wearable are collapsed,
registering the Strava API application, the first sync and disconnecting.
HealthLog reads your Strava activities and folds them into the same workout
history as every other source. Strava is a **workout source only** — its API is
activity-centric and exposes no sleep, recovery, body-composition or glucose
data, so connecting it adds runs, rides, swims and the rest to your workout
surface and nothing else.
The OAuth app is brought **per user**: each self-hoster registers their own
Strava API application and pastes the client id/secret into Settings. Strava
caps every newly-created app at "single-player mode" (an athlete capacity of 1)
until you request a capacity increase — perfectly fine for a personal
self-host, and the reason a single shared app cannot serve many operators.
## What you get — and what you don't
**Covered** (one `Workout` row per activity): sport type, start and end time,
moving duration, distance, elevation gain, average and maximum heart rate (when
the activity recorded it), and calories. Power, cadence, the "trainer"/"commute"
flags and the activity name are kept on the row's metadata for provenance but
are **not** surfaced as training analytics — HealthLog treats Strava as another
workout source, not a training-load platform.
**Not covered**: Strava exposes no daily metrics (steps, sleep, resting heart
rate, weight, …). Those come from your wearable's own integration or from Apple
Health / Google Health Connect.
## Duplicate activities are collapsed automatically
If you record a run on an Apple Watch **and** it also lands in Strava, the same
run arrives twice — once via Apple Health, once via Strava. HealthLog keeps one
canonical row per logical workout using your **source-priority ladder**: by
default the device-native capture (Apple Watch, WHOOP, Withings) wins over the
Strava copy, because a Strava activity is often a re-upload with lower-fidelity
or missing heart-rate data. A run you record **only** on Strava (phone GPS)
survives untouched. You can promote Strava above the wearables in
**Settings → Sources** if you prefer the Strava copy.
## 1. Register a Strava API application
1. Sign in at <https://www.strava.com/settings/api>.
2. Create an application. For **Authorization Callback Domain** enter the host
of your HealthLog instance (the domain only, no scheme or path).
3. Copy the **Client ID** and **Client Secret**.
## 2. Paste the credentials into HealthLog
1. Open **Settings → Integrations** and find the **Strava** card.
2. Paste the Client ID and Client Secret into the **API Credentials** fields and
save. (An operator can instead configure a shared app via the
`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` environment variables; per-user
credentials always take precedence.)
3. Click **Connect Strava** and approve the access request. HealthLog requests
the `activity:read_all` scope so it can read every activity, including those
you marked "only me".
## 3. First sync
The first history import runs in the background shortly after you connect;
afterwards an hourly poll pulls new activities. Strava rotates its refresh token
on every refresh, and HealthLog persists the rotated token each time, so the
connection stays healthy without you re-authorising.
## Disconnecting
Disconnecting revokes the grant at Strava and clears the stored tokens.
Activities already imported stay in your history; reconnecting resumes the sync.
## Related
- Wearable that syncs to Strava but not directly to HealthLog? See the device's
own page, or ingest via [Apple Health](./apple-health.md) /
[Google Health Connect](./google-health.md).
- **Garmin owners:** Garmin has no direct connector — see [garmin.md](./garmin.md).
+4 -4
View File
@@ -88,7 +88,7 @@ or right away after reconnecting Withings once.
needed — `user.metrics` covers weight / BP / heart-list / sleep /
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
workout-equivalent stream for steps / energy / distance / floors.
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/oauth.ts`.
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/client.ts`.
4. Save the application. Withings will issue a **Client ID** and a
**Client Secret** — keep the secret out of any chat log; it grants
read access to every linked account's full health history.
@@ -145,7 +145,7 @@ callback. The portal only owns the OAuth callback URL.
## 4. Link from the Settings page
With the credentials saved in Settings, open
`/settings/integrations#withings` and click **Connect Withings**. The flow:
`/settings/integrations/withings` and click **Connect Withings**. The flow:
1. The Connect button redirects to Withings' `oauth2/authorize`.
2. The user signs in on Withings and grants the requested scopes.
@@ -181,7 +181,7 @@ import:
when Apple Health is also linked, so the Apple Health stream wins
for the cumulative axis. The Withings rows stay in the DB.
Override per-user via Settings → Source priority (`/settings/sources`).
Override per-user via Settings → Sources (`/settings/sources`).
## Troubleshooting
@@ -199,7 +199,7 @@ Override per-user via Settings → Source priority (`/settings/sources`).
### The webhook stops firing
Webhook subscriptions can lapse after a token revoke or a portal-
side cleanup. Re-subscribe by opening `/settings/integrations#withings`
side cleanup. Re-subscribe by opening `/settings/integrations/withings`
and clicking **Disconnect** then **Connect Withings** again — the
callback exchange re-runs `Notify.subscribe()` with a fresh callback
URL.
+477 -202
View File
@@ -1,232 +1,507 @@
# Account copies and restore
# Off-host backup & restore
HealthLog keeps copies of each account so that one account can be rolled back
on this server. These copies are per account. They cannot rebuild a lost
server: a restore writes into an account on this server, the one the copy
belongs to, or, when that account has been erased, one you choose (see
"Restoring an erased account"). To recover a server, you need a database dump
and the encryption keys. That procedure is in
[disaster-recovery.md](disaster-recovery.md).
HealthLog ships with an optional daily off-host backup that ships every
user's JSON dump, encrypted with AES-256-GCM under a SEPARATE key
(`BACKUP_ENCRYPTION_KEY`), to any S3-compatible bucket — Cloudflare R2,
AWS S3, Backblaze B2, MinIO, etc.
## What exists
The backup runs at **02:30 Europe/Berlin** every day from the worker
container (queue `data-backup-offhost`). Object key layout:
| Copy | Where | When | Kept |
| ----------------------- | ------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Weekly account rollback | this database | Sundays 03:00, or **Backup now** in Admin → Backups | one per account, replaced in place each week |
| Copy before a data wipe | this database | right before "Delete all data" deletes anything | until `ACCOUNT_DELETION_GRACE_DAYS` has passed, then the purge job deletes it |
| Uploaded copy | this database | when an admin uploads a file in Admin → Backups | until deleted |
| Off-host copy | your S3-compatible bucket | nightly at 02:30 Europe/Berlin, one object per account per day | as long as the bucket's lifecycle rule keeps it |
| Personal export | the person's device | Settings → Export, "Full backup" | wherever they keep it |
The weekly, pre-wipe and off-host copies carry the whole record, document
files and ECG traces included, with the database's ciphertext inside them as
stored. An uploaded copy holds whatever its file held. The personal
export is portable: readable values, and documents as metadata only. It is
meant for the person to keep and read. A restore refuses it when the file
lists any document, because it carries no document content to put back.
Two kinds of data are not in any account copy: workout GPS routes and sample
series. Credentials, devices, share links and account grants are left out on
purpose (see "What a restore does not touch").
The JSON import in Settings → Export & import is not a restore. It adds
measurements and mood entries, up to 16 MB and 10,000 entries per list.
## The deletion grace window
`ACCOUNT_DELETION_GRACE_DAYS` (14 by default, at most 30) sets how long an
erasure waits:
- **Deleting an account**, or a guardian deleting a **managed profile**, locks
the account at once. Every session, refresh token, API token, connector and
share link is revoked, sign-in is refused with the date the account is
erased, and nothing runs for the account any more: no reminders, syncs,
copies, scores or AI work. The record, its stored copies and its off-host
objects stay as they are. **Admin → Users** shows the account with its
erasure date and an **Undo deletion** button. When the window ends, the
hourly `account-deletion-purge` job erases the account and asks the bucket to
delete its off-host objects.
- **Delete all data** first stores the whole record as a copy of its own
(shown as "Before all data was deleted" in Admin → Backups), then empties the
record. An admin can restore that copy until the window ends. The off-host
objects from before the wipe are kept until then too.
Database dumps are not purged per account: a deleted account stays in them
until they age out (`DB_BACKUP_KEEP` nights on the server, and the bucket's
lifecycle rule for `database/`). The deletion dialogs say so.
`0` erases immediately, as releases before 1.46 did. The Data & Privacy page
and the deletion dialogs state the configured number.
## Restoring an account
In **Admin → Backups**, every stored copy has **Restore**. The dialog first
shows what the copy holds, then asks you to type `RESTORE`. The restore runs
in the background and replaces the account's data tables: every row the copy
holds comes back under its original id, and every row the account gained
after the copy was taken is deleted. If the current state is worth keeping,
press **Backup now** first.
_Also restore instance settings_ writes the registration, MFA, locale, module,
notification and AI settings from the copy back for the whole server. Leave it
clear unless those settings themselves need to go back.
To restore from an **off-host object**, fetch and decrypt it from a source
checkout with the backup variables of the server that wrote it:
```bash
BACKUP_S3_ENDPOINT=... BACKUP_S3_BUCKET=... \
BACKUP_S3_ACCESS_KEY=... BACKUP_S3_SECRET_KEY=... \
BACKUP_ENCRYPTION_KEY=<the key the object was written under> \
pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-<id>.json.enc /tmp/restored.json
```
<bucket>/YYYY-MM-DD/user-<userId>.json.enc
```
Then upload `/tmp/restored.json` in Admin → Backups (up to 512 MB after
compression) and restore it from its row. When the account `<id>` no longer
exists on this server, the upload offers to put the copy into another account
(next section).
## Wire format (binary)
Every restore, preview and upload checks first that this server holds every
application key the copy's content was written under, and refuses with the key
id named if not. Nothing is changed by a refused restore.
```
magic = "HLBK" (4 bytes, ASCII)
version = 0x04 (1 byte)
keyIdLen, keyId (1 byte + 12 ASCII hex: the first 12 hex digits of SHA-256(key))
iv = 12 random bytes (AES-GCM nonce)
ciphertext = N bytes (AES-256-GCM over gzip(JSON dump), key = BACKUP_ENCRYPTION_KEY,
associated data = "healthlog/offhost-backup/v4|<object key>")
authTag = 16 bytes (AES-GCM tag, trailing)
```
### Restoring an erased account
Version `0x04` (from v1.39.3) adds two things. The key id says which off-host
key an object needs, so a reader holding a retired key as well finds the right
one. The associated data is the object's own key in the bucket,
`YYYY-MM-DD/user-<id>.json.enc`: an object copied to another account's name or
another date does not decrypt, where before it opened as if it belonged there.
The restore script passes the key it downloaded, so nothing changes for an
operator.
Once an account has been erased (its deletion window ended and the hourly purge
ran), its copies on this server and in the bucket are gone, but a file you kept
still holds the record: a disaster-recovery download, an off-host object you
fetched before it expired, or the person's own full backup. Uploading it in
Admin → Backups is refused at first, because the account it names no longer
exists, and the console then asks where the records should go:
Four versions exist in the wild and all four restore. `0x01` encrypted the
JSON directly and `0x02` gzipped it first; both carry the tag in FRONT of the
ciphertext, which is what made them impossible to write a piece at a time —
GCM only produces the tag once the last block is in, so a leading tag means the
whole object has to exist before its first byte can be sent. `0x03` moves the tag to the end and changes nothing else: it still
covers every ciphertext byte, and the reader still verifies it before returning
a single byte of plaintext. Objects already in your bucket stay readable, and
the restore script needs no flag to tell them apart.
- **A new account.** Type a username. The account is created without a
password or email, as an ordinary account with the user role, and nobody
can sign into it yet. Before handing it over, set its first password with
**Reset password** in Admin → Users; the person can change it, add an email
and enrol a passkey from their settings. The account is made before the
file is read, with an `admin.user.create` audit row (`outcome: "created"`),
and removed again when the file is refused, with an `admin.user.delete` row
(`reason: "backup_upload_refused"`). When that row says `outcome: "failed"`,
the removal did not happen and the account is left over, holding nothing:
delete it in Admin → Users.
- **An existing account**, for someone who has signed up again. Pick the
account and type its username to confirm. The account must hold no records
yet: a restore replaces an account's records and does not merge them, so an
account with even one record of any kind a copy carries is refused, with the
kinds named. What signing up and the setup questions leave behind does not
count (the setup answers and the cycle settings): the restore replaces both
with the copy's. A reading or medication entered on setup's last screen is a
record, and the account is refused until it is deleted, because the restore
would delete it too. Managed profiles, accounts pending deletion and admin
accounts are not offered: an admin account is the operator's, its settings
are read under the operator's wider AI endpoint policy, and its role reaches
every account, so another person's record does not go into it.
## Required env vars
The file is then uploaded a second time, and its owner is changed to the chosen
account as it is read. Nothing else in the file changes. The stored copy is
then that account's copy in every respect but one: its preview, its download
and its restore are the ordinary ones, and **Restore** on its row brings
everything back under the chosen account. The one difference is that it is
stored as `RETARGETED_UPLOAD_<time>` rather than `MANUAL_UPLOAD_<time>`, and its
restore reads the file's settings as the file's author's, never as the
operator's: an AI endpoint on a private address comes back only when this host
grants it to ordinary accounts (`AI_PRIVATE_ORIGINS`), even if the account has
been made an admin since. The erased account's id is not stored, not in
the copy and not in the audit trail.
| Var | Required | Notes |
| --------------------------------- | -------- | ----------------------------------------------------------------------------------- |
| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64. **Different from `ENCRYPTION_KEY`.** |
| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://<account>.r2.cloudflarestorage.com` |
| `BACKUP_S3_BUCKET` | yes | |
| `BACKUP_S3_ACCESS_KEY` | yes | |
| `BACKUP_S3_SECRET_KEY` | yes | |
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number for your bucket's lifecycle rule |
| `BACKUP_ENCRYPTION_PREVIOUS_KEYS` | no | retired off-host keys, comma-separated, read only (see "Rotating the off-host key") |
A copy whose own account still exists on this server can only go back into
that account; choosing a target for it is refused. The copy does not carry
what a restore never touches (below), so a returning person signs in fresh,
and the links of a managed profile to its guardians do not come back: a
restored managed profile is an ordinary account.
## Bucket permissions
Once stored, the copy restores like any other: a restore run later, after the
account has gained records, replaces those too.
The worker needs `PutObject`, `GetObject`, `ListBucket`, `DeleteObject` and
`AbortMultipartUpload`. On Cloudflare R2 the **Object Read & Write** token
covers all of them.
A file goes into one account only. Its records keep the ids they had in the
erased account, so once it has been restored into one account, restoring the
same file into a second account would write those ids twice. The upload still
stores the second copy, but its restore is refused with
`backup.restore.rows_exist_elsewhere` and rolled back, and nothing changes in
either account. To move the record to another account, delete the first
account (or its records) before restoring into the second.
`DeleteObject` is needed because the app deletes objects in two cases, and
only those:
### What a restore does not touch
- **An account is deleted, or its data is wiped.** Deleting an account,
deleting a managed profile and "Delete all data" each write a deletion
request in the same transaction as the deletion. The `offhost-backup-purge` job lists the bucket, deletes every
`YYYY-MM-DD/user-<id>.json.enc` of that account and removes the request once
nothing of the account is left. It runs right after the deletion and again
every night at 03:40. A copy the nightly upload was writing while the account
was deleted is removed by the upload itself.
- **The connection test** deletes its own `_healthcheck/<ts>.bin`.
The admin wipe of every account deletes nothing in the bucket. It is you
clearing your own host, the bucket is yours, and its copies are the way back
from a wipe pressed by mistake. If you are handing the host on, empty the
bucket yourself.
Earlier versions of this page said the worker never deletes an object. That
was the design until v1.39.3, and it meant a deleted account's copies stayed
in the bucket for as long as the lifecycle rule kept them, or forever without
one. If the bucket refuses the delete (a credential without `DeleteObject`),
the request stays, the job retries it every night, and **Admin → Backups →
Off-host** shows how many deletions are outstanding and the bucket's answer.
`AbortMultipartUpload` only reaches an upload the same worker started and is
what clears the parts of a run that failed halfway. Without it, a failed
upload leaves parts that are billed and do not show in a bucket listing.
## Bucket lifecycle (required for retention)
The app does not expire objects by age, and nothing in the app reads
`BACKUP_RETENTION_DAYS`. Expiry is the storage provider's lifecycle rule, set
to match the number you wrote there. Without one, every nightly copy of every
account is kept forever. **Admin → Backups → Off-host** reads the bucket's
lifecycle configuration and says so when there is none, or when the credential
may not read it (grant `GetLifecycleConfiguration` to let it check):
```
Filter: "" (all objects)
Action: Expire after 30 days
```
For Cloudflare R2 add this from the bucket's **Settings → Lifecycle**.
## Smoke test
After deploying, hit `POST /api/admin/backup/test` (admin-only). It
performs a 1-byte PUT + GET round-trip and returns:
```json
{
"data": {
"endpoint": "https://...r2.cloudflarestorage.com",
"bucket": "healthlog-backups",
"region": "auto",
"putLatencyMs": 142,
"getLatencyMs": 38,
"ok": true
},
"error": null
}
```
The credentials are never returned.
## Restore
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
and run the restore script with the same backup credentials and
encryption key the backup was written under — a freshly generated
`BACKUP_ENCRYPTION_KEY` cannot decrypt any existing object:
```bash
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
BACKUP_S3_BUCKET=healthlog-backups \
BACKUP_S3_ACCESS_KEY=... \
BACKUP_S3_SECRET_KEY=... \
BACKUP_S3_REGION=auto \
BACKUP_ENCRYPTION_KEY=<the key the backup was written under> \
pnpm dlx tsx scripts/restore-backup.ts \
2026-05-08/user-clx123.json.enc \
/tmp/restored.json
```
Run this command from a source checkout with the production backup variables
exported. The script imports the full application dependency graph, which the
minimal production image does not expose as an operator scripting environment.
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
the operator (use `prisma db seed` or a custom script).
Restoring is not streamed and does not need to be. It holds the whole document,
because the next thing anyone does with a backup is parse it as one JSON
object, and handing back plaintext the auth tag has not yet covered would trade
the authentication for memory. It runs on your machine rather than in the
container, so give it room: a record of several hundred thousand measurements
decompresses to a few hundred megabytes, and
`NODE_OPTIONS=--max-old-space-size=2048` in front of the command is enough for
a 445 000-measurement account.
### Restoring from the backups console
An admin can restore a stored snapshot from the browser instead of the shell.
`/admin/backups` lists one row per account and per backup type: **Backup now**
enqueues the `data-backup` pass for every account on the instance, **Download**
decrypts one snapshot and hands back the JSON, and **Restore** opens a dialog
that first shows what the file carries and then requires the word `RESTORE` to
be typed before it will run. The restore targets the account the snapshot was
taken for, never the admin running it, and it refuses a file whose declared
owner does not match the stored row. Instance-wide settings ride along only
when you tick _Also restore instance settings_ in the dialog; leave it clear to
restore the account alone.
A stored copy this instance can no longer open — the key that wrote it dropped
from `ENCRYPTION_KEYS` one rotation too early, or bytes that are not the ones
written — is refused by all three buttons with `422` and
`meta.errorCode = backup.payload.undecryptable`, before anything is read back.
### What a backup deliberately does not carry
Every credential-shaped row is left out, and this is not an oversight to fix:
API tokens, trusted devices, step-up elevations, known devices, clinician share
links and account grants are not in any copy. A restore on this server leaves
them as they are, so shared access carries on. The reasoning per model is in
`src/lib/export/backup-plan.ts`.
links, and the account grants behind shared record access. Restoring data is
rolling a record back to a known state. Restoring an authorization is different
in kind, because a grant the owner revoked on Tuesday would come back alive out
of Monday's file with nobody deciding it and neither person told.
## Off-host copies
What this means in practice depends on where you restore to.
| Variable | Required | Notes |
| --------------------------------- | -------- | --------------------------------------------------------------------- |
| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64; not the same as `ENCRYPTION_KEY` |
| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://<account>.r2.cloudflarestorage.com` |
| `BACKUP_S3_BUCKET` | yes | |
| `BACKUP_S3_ACCESS_KEY` | yes | |
| `BACKUP_S3_SECRET_KEY` | yes | |
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number to put in the bucket's lifecycle rule |
| `BACKUP_ENCRYPTION_PREVIOUS_KEYS` | no | retired off-host keys, comma-separated, used for reading only |
**Onto the same instance.** Nothing changes. The restore replaces the account's
data tables and does not touch grants, tokens or devices, so shared access
carries on exactly as it was. Somebody who had read access before the restore
still has it afterwards, now looking at the restored data.
Objects are named `YYYY-MM-DD/user-<id>.json.enc`: the record as JSON,
gzipped and sealed with AES-256-GCM under `BACKUP_ENCRYPTION_KEY`, with the
object's own name as associated data and the key's id in the header.
**Onto a fresh instance.** None of it comes with the file. Nobody has access to
anybody's record, every API token has to be reissued, every device re-trusted,
and both people have to invite and accept again before sharing works. That is
the fail-safe direction — access lost, never access resumed — and re-consenting
is the right amount of ceremony for handing someone your health record a second
time. Plan for it rather than discovering it.
The nightly database dump goes into the same bucket, as
`database/<host of APP_URL>/healthlog-<time>-daily.dump.enc` with its manifest
beside it, sealed the same way. The folder keeps the dumps of instances that
share a bucket apart. It is the copy that rebuilds a lost server
([disaster-recovery.md](disaster-recovery.md)).
The full per-model reasoning lives in `src/lib/export/backup-plan.ts`, where
every excluded model carries a written verdict and a structural test refuses to
let a new model land without one.
**Permissions.** The worker needs `PutObject`, `GetObject`, `ListBucket`,
`DeleteObject` and `AbortMultipartUpload` (R2's Object Read & Write token covers
them). It deletes only two things: the objects of an account that was erased or
wiped, once the grace window has ended, and its own connection-test object. A
deletion the bucket refuses is retried every night and shown in Admin →
Backups → Off-host. The admin wipe of every account deletes nothing in the
bucket.
## Container memory (the nightly off-host job)
**Retention.** The app never expires objects by age. Set a lifecycle rule on
the bucket that covers both the account objects and `database/` (for example,
expire all objects after 30 days); Admin → Backups says when there is none.
This is the part that bites, and it bit the nightly job a release after it bit
the weekly one. The job runs inside the app process, so V8's heap limit is the
app's heap limit. Node sizes it from the container's memory cap: 1 GB gives a
524 MB old-space limit, the compose default of 1536m (since 1.40.1) gives 792 MB. A long-lived Next.js server is already holding a large share
of that before the job starts.
**Checking it works.** Admin → Backups → Off-host shows, per account, when the
last object landed: fresh, due (one night missed), stale (two missed), never,
or no record yet. A night that uploaded nothing for anybody fails the job with
the bucket's own error. Accounts pending deletion are not uploaded and are not
listed. On the 1st of each month at 04:11, a drill downloads the newest object
of every account, decrypts it and checks its shape and keys. It restores
nothing.
The uploader used to build the whole backup JSON as one string, gzipped
that whole string, ran a whole-buffer cipher pass over the result and handed
the finished buffer to a single `PutObject` — four full copies of the record
alive at once. On an account of 445 000 measurements the JSON alone is 242 MB,
and the first configured run took the container down seventeen seconds in with
`FATAL ERROR: Reached heap limit`. Because the job shares the app process, one
account's size restarted the instance for everybody on it.
**Rotating the off-host key.** Put the new key in `BACKUP_ENCRYPTION_KEY`, move
the old one to `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, restart. Remove the old key
once the lifecycle rule has expired every object written under it.
It streams now. The JSON is produced a page at a time, gzip and the cipher
consume it as it arrives, and the object goes up as a multipart upload that
holds two 8 MB parts. What the process holds is fixed by that pipeline's shape
rather than by the size of the record going through it: measured on the same
445 000-measurement account under `--max-old-space-size=450`, the old path
died and the new one finished holding tens of megabytes, writing a 9.1 MB
object that restores to the identical record.
## Keys a copy needs
Up to 1.43.4 that held for the tables that are long in rows, not for the
ones that are heavy in bytes. The stored documents and the ECG traces were
still read whole, and a vault of 190 MB cost about five times that in heap:
the bytes, their base64, the section's JSON and the preview's copies of each
row. A single account with that many documents took a 524 MB process down on
every weekly run, while 1.25 million measurements beside them fitted easily.
Both writers now read the traces a page at a time and the documents one at a
time, each document's bytes in slices of 1.5 MB. Measured on that account
under `--max-old-space-size=524`, the peak went from 930 MB (a crash) to
209 MB. The restore had the same problem in the other direction: it kept
every document of the file in memory and peaked at 1.25 GB on the same copy.
It now leaves the stored bytes out of its first read and writes the
documents one at a time from a second one, and the copy restores every
document byte for byte inside the 524 MB heap.
A copy carries the database's ciphertext as stored, so it needs the
application keys it was written under, also after a rotation. Keep a retired
`ENCRYPTION_KEYS` entry until no copy you may still restore needs it. **Admin →
Encryption → Keys the backups still need** lists them, the database dumps
included: a dump needs the key that was active when it was taken, for as long
as it is kept on this server or in the bucket. Off-host objects also need
`BACKUP_ENCRYPTION_KEY` (or the previous key that wrote them).
One ceiling remains, and it is structural rather than a memory bound: a
multipart upload carries 10 000 parts, so 80 GB is the largest object one
account can produce. Past it the account's backup fails with a clear refusal,
is counted in the run's `offhost_backup_oversized` meta, and the pass carries
on with everybody else.
## Size and memory
### Reading a failed run
Writing, reading and restoring a copy streams: the record goes through a page
at a time, documents one at a time, and a stored copy is kept as sealed pieces
of about a megabyte. Memory does not grow with the record. Give the app
container at least 1 GB.
A nightly run that could not upload for **anybody** now fails the pg-boss job
instead of reporting success. This is the case wrong credentials, a missing
bucket and an unreachable endpoint all land in, and the target's own sentence
rides out as the failure cause — `The request signature we calculated does not
match the signature you provided`, `The specified bucket does not exist`,
`connect ECONNREFUSED`. Check `offhost_backup_uploaded` against
`offhost_backup_total_users`: before this change a run where every single
upload failed still read `ok: true`, so a bucket could stay empty while the
jobs page looked healthy.
`BACKUP_MAX_STORED_MB` (default 2048) caps one account's stored copy. An account
past it fails alone, keeps its previous copy, and is counted as
`records_oversized`; the message names the setting.
A run where SOME account got a copy still succeeds, with the rest counted in
`offhost_backup_failed`. Failing the whole queue over one account's object
would re-upload everybody's on every retry.
Both backup passes note which account they are on. An account whose last
attempt never finished (the process died under it) is taken last on the next
run and named in Admin → Backups, so one record too large for the container
cannot keep everyone else from a copy.
### A run the process died under
Both backup jobs carry a pg-boss heartbeat of five minutes. While a run is
alive the worker refreshes it; a run whose process died (a restart, or a
container killed for memory) stops refreshing, and pg-boss fails the job with
`job heartbeat timeout` within minutes instead of leaving it `active` until its
two- or four-hour expiry. The retries the queue already allows then pick it up.
Each pass also notes when it started and when it got past every account
(`backup_pass_attempts`). An account whose last attempt started and never
finished is the one the process died under, and the next run takes it last, so
one record too large for the container cannot keep every account after it from
a copy. **Admin console → Backups** shows when the run in progress started and
names such an account under both the weekly and the off-host card. Seeing the
same account there twice means it needs more memory than the container has.
### Which account has no copy
The counts above say how many accounts were uploaded, never which. An account
whose object has failed every night for a month reads as `99 uploaded, 1
failed` each time, which looks like weather.
**Admin console → Backups → Off-host copies** answers it per account: when this
host last put that account's encrypted copy in the bucket, how big it was, and
how that compares with the nightly schedule. **Fresh** is inside one run,
**due** means one run produced nothing for that account, **stale** means two
did not, **never** means a run walked the account and no object has ever
landed for it, and **no record yet** means no run has recorded the account at
all. On a host without `BACKUP_S3_*` the card says off-host backup is not
configured and stops there.
The ledger starts empty on the release that adds it, so on a host that has
been uploading nightly for months every account reads **no record yet** until
the first run after the upgrade. That is the ledger having no history, not the
bucket being empty — which is why it is a separate word from **never**, and
why the row says so rather than claiming nothing has ever reached the bucket.
Each verdict carries six hours of slack on top of the schedule's own 24, and
that slack is load-bearing on two nights. The cron runs at 02:30 Europe/Berlin,
so on the DST fall-back Sunday two consecutive runs are 25 hours apart; and the
timestamp is when _that account's_ object landed, so on a cohort walked one
account at a time an account reached later tonight than last night drifts by
hours. Without the slack a healthy host would paint its whole cohort **due**
once a year.
The verdicts come from a ledger the worker writes when the object lands
(`offhost_backup_state`), not from a listing of the bucket. Keep the worker's
grant as documented — PutObject, GetObject, AbortMultipartUpload — and the card
still tells the truth. A row that goes stale while the run reports success is
that account's object being refused, and the reason is in the run's
`offhost_backup_failures` meta.
## The weekly in-database backup (`data-backup`)
Separate from the off-host job above, and easy to confuse with it. A second
pg-boss job writes one `WEEKLY_AUTO` copy per user into the database, the same
JSON document, gzipped and then encrypted under `ENCRYPTION_KEY` /
`ENCRYPTION_KEYS`, staying inside the instance. It is what
`/api/admin/backups/<id>/restore` reads, and an uploaded backup file is stored
the same way.
### How a copy is stored
From v1.39.2 a copy is kept as ordered pieces of about a megabyte in
`data_backup_chunks`, with the list of pieces on its `data_backups` row. The
writer produces the JSON a page at a time, gzips it as it goes and seals each
megabyte of gzip output on its own with AES-256-GCM. Reading it back (restore,
preview, download) takes the pieces one at a time. Nothing on either path holds
the whole copy, so the size of a copy has nothing to do with the app's memory.
Measured on an account of 2.6 million readings in a 1 GB container,
with the web server and the worker in one process: the backup took 87 seconds
and stored 97 MB in 96 pieces, the preview 20 seconds, the download of the
1.4 GB file 25 seconds, and the restore 264 seconds, which gave back every
reading exactly. The container peaked at 567 MB and did not restart. What the
backup, preview and download hold on top of the running app stays at a few tens
of megabytes and does not grow with the account. A 512 MB container is too
small for the app itself: its heap limit is 259 MB and a backup of any account,
even one with 130 000 readings, runs it out of heap.
Each piece carries, inside its encryption, which copy it belongs to, its
position, and whether it is the last. Before a restore deletes anything, every
piece is checked: a piece that is missing, moved, altered, taken from another
copy, or a copy that stops short of its last piece is refused with
`backup.payload.undecryptable`, and the account is left as it was.
The write is one transaction. Until the new copy is complete the previous one
stays in place and readable; a run that fails halfway leaves it untouched. A
second backup of the same account started while one is running (a manual run
during the weekly one, say) waits for the first to finish and then replaces
its copy; neither leaves a partial copy behind.
While a restore of an account's copy is queued or running, the weekly and
manual backup leave that account's copy alone and back up everyone else. The
run's `users_skipped_restoring` meta counts such accounts; the next run
replaces the copy as usual. If a copy is replaced anyway while it is being
read (a download or preview in progress when the weekly run finishes), the
reader answers `409` with `backup_changed` and "The backup was replaced by a
newer one while it was being read", not the undecryptable error, and a restore
rolls back with nothing changed. Start it again to use the new copy. A
download already sending when this happens stops partway; download again.
Copies written before v1.39.2 are a single value in `data_backups.data` and
still restore, preview and download as they did. The next weekly run replaces
the weekly copy in the new form, and a key rotation (below) converts every
remaining one, including uploaded copies. Until then those older copies are
read as one value, as before, which needs the memory they needed when they were
written.
Migration 0352 cannot be undone by going back to v1.39.1. That release reads
only the single value, so it finds a copy in pieces empty, and if it writes a
weekly copy into a row that has pieces, the row holds both forms. v1.39.2 then
refuses to read that row, because nothing says which of the two is current
("This backup holds both a single stored value and pieces ..."); the next
weekly backup replaces it. If you must go back, restore the database backup
you took before upgrading.
### The size limit
The one limit left is on storage: a copy of one account may take at most
`BACKUP_MAX_STORED_MB` megabytes (default 2048). It exists so a runaway copy
fails with a message instead of filling the database volume, which holds the
old and the new copy side by side while a backup runs. An account past it fails
for that account alone, is counted in the run's `users_failed` and
`records_oversized` meta, and keeps its previous copy; the pass carries on with
everybody else. The message names the setting. Raise it in `.env`, recreate the
app container, and make sure the database volume has room for two copies of
that size.
Before v1.39.2 the limit was a fifth of the app's heap, 105 MB in a 1 GB
container, because the copy had to pass through the app as one value. An
account with 1.75 million readings was past it, and the only way round was more
memory. That is no longer the case: `records_oversized` means the storage limit,
never memory.
The writer deliberately reads no heap gauge. A version that did shipped in
v1.38.6 and compared the whole process's live usage, garbage included, against
80 % of the limit. A Next.js server that has been up for a week sits at 400 MB
of largely collectable heap, so the weekly pass aborted every account on the
first chunk, including one whose whole stored copy is 1.2 MB, and reported
success while doing it. If you are on v1.38.6, a `data-backup` run that finishes
in seconds having backed up nothing is that defect and not your record.
### Key rotation
Rotation reaches a stored backup's envelope and nothing inside it. The
content of a disaster-recovery backup is the database's ciphertext, carried
as it is stored, and a restore writes it back verbatim: a note, a document or
a coach message inside a copy taken before a rotation still needs the key the
rotation retired. The same holds for every copy in the off-host bucket and for
every file an operator has downloaded. So the rule is: **keep a retired
`ENCRYPTION_KEYS` entry until no backup you may still restore needs it.**
**Admin → Encryption → Keys the backups still need** lists, per key id, the
stored copies that need it with the oldest date, and when the last off-host
copy needing it expires under the bucket's lifecycle rule. Copies written
before v1.39.3 did not record their keys and are listed as such.
Every restore, the restore preview, the admin upload and the monthly drill
check this before anything is changed: a file whose content needs a key this
server does not have (or an id whose key material differs) is refused with the
key id named, and the account is left as it was.
Re-encrypting the content of stored backups during rotation was considered and
not done: it would mean decrypting, parsing, re-encrypting and re-writing every
copy in full, and it still could not reach the off-host copies or downloaded
files, so the rule above would be needed regardless.
`scripts/rotate-encryption-key.ts` and the rotation in the admin console
re-seal every piece under the active key, in small batches, without reading
the backup inside. The link between a piece, its copy and its position is
inside the encryption, so it comes through rotation unchanged.
A copy still in the older single-value form (any of them, including the
`~hlgcm1.` form v1.38.6 to v1.39.1 wrote) is not re-sealed in place, which would need the
whole copy in memory several times over. Rotation converts it into pieces
under the active key instead, one copy at a time, reading the stored value a
few megabytes at a time; the copy keeps its date. The conversion of a copy is
one transaction, so a copy that fails its check is left exactly as it was and
counted as an error. Rotation up to v1.39.1 counted every `~hlgcm1.` copy
as an error; run it again after updating before retiring the old key.
### What a restore replaces
A restore replaces the account's data tables; it does not merge into them.
Every row the file carries is written back under its original id, and every row
the account gained after the snapshot was taken — readings, doses, mood
entries, documents — is deleted with the rest of the class before the file is
read back. Restoring Monday's copy on Wednesday therefore costs the account
everything it recorded on Tuesday. If the current state is worth keeping, take
a fresh snapshot from the backups console before restoring the old one.
## Rotating the off-host key
`BACKUP_ENCRYPTION_KEY` is the key new objects are written under. To rotate
it, set the new key there and move the old one into
`BACKUP_ENCRYPTION_PREVIOUS_KEYS` (comma-separated if there are several), then
restart. New objects are written under the new key; older objects stay
readable because every version-4 object names its key and older objects are
tried against each key in turn. Remove the old key once the lifecycle rule has
retired every object written under it, that is `BACKUP_RETENTION_DAYS` after
the restart.
## Monthly restore drill (automatic)
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
04:11 on the 1st of each month) exercises the read path end-to-end:
fetch every backup object of the most recent date from the bucket (one per
account), decrypt each under the current `BACKUP_ENCRYPTION_KEY`, JSON-parse
it, and sanity-check the payload shape. It performs **no database restore** —
it validates the artefacts, not the import path. Each account is checked and
reported on its own: one object that fails is named, and the others are still
read.
Outcomes:
- **Success** — record counts, object age, and sizes land in the
wide-event meta (`job.restore_drill`).
- **Stale chain** — the newest object is older than 3 days: the nightly
uploader has stalled (or the lifecycle rule is too aggressive). The
drill pages via the worker error reporter (stderr + GlitchTip).
- **Failure** — empty bucket, fetch error, decryption failure (wrong or
rotated key), malformed JSON: pages the same way, once per failing object,
with the object key and how many accounts were checked. A decryption
failure right after a `BACKUP_ENCRYPTION_KEY` change means the new
key cannot read the existing objects: put the old key into
`BACKUP_ENCRYPTION_PREVIOUS_KEYS` (see "Rotating the off-host key").
- **Content under a missing application key** — the object opens, but its
content was written under an `ENCRYPTION_KEYS` entry this server no longer
has, or under an id whose key material changed. The drill decrypts the
shortest value of each section under every key id it finds, and pages naming
the key when none of them opens. A restore of that object
would write back rows nobody can open; put the key back before restoring.
- **Not configured** — deployments without the `BACKUP_S3_*` vars skip
silently (wide-event warning only).
The drill lists the bucket to find the newest object, so it needs
`ListBucket` on top of the uploader's `PutObject`, `GetObject` and
`AbortMultipartUpload`. Cloudflare R2's **Object Read & Write** token covers
all four.
+39 -186
View File
@@ -13,13 +13,11 @@ Migrations land **before** the new app container takes traffic. The
canonical command sequence on a single-instance host:
```sh
# 0. Nothing to do by hand for the way back: when the new image brings
# migrations, its entrypoint first writes
# healthlog-<time>-pre-migration.dump into DB_BACKUP_DIR (the `backups`
# volume) and keeps the last two. A migration is not undone by going back
# to the previous image; that dump is the way back
# (docs/ops/disaster-recovery.md, "Going back to before a migration").
# Copy it off the host if the host itself is what you are worried about.
# 0. Dump the database first. A migration is not undone by going back to
# the previous image; the dump is the way back. See "Updates" in
# docs/self-hosting/getting-started.md.
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-before-update-$(date +%F).sql.gz
# 1. Pull the new image. If you pin a version with HEALTHLOG_IMAGE_REF,
# set it to the new one first.
@@ -35,15 +33,6 @@ docker compose run --rm app sh -c 'exit 0'
docker compose up -d app
```
Step 2 takes the pre-migration dump too, in the one-off container, before it
migrates. When the backups directory is on its volume and that dump fails (a
full disk, most often), the entrypoint stops before migrating and says so;
free the space and run the step again, or set `DB_BACKUP_BEFORE_MIGRATE=false`
for one start without the dump. Without the `backups` volume the directory
is not writable, or it is the image's own and sits inside the container that
the update replaces; either way the log carries a warning, no dump is taken,
and the migrations run.
Use `docker compose run` rather than a plain `docker run` for step 2. A
plain `docker run` starts outside the stack's network, where `db` does not
resolve, and the entrypoint gives up after thirty seconds with "Database
@@ -120,85 +109,6 @@ that for you, and each boot retries). Anything else is a session you can
judge: a stuck client may be ended with `SELECT pg_terminate_backend(<pid>)`.
You never need to run `migrate resolve` by hand for this case.
### A concurrent index build that failed
A migration that builds an index `CONCURRENTLY` (0477 is one) and fails
half-way, on a constraint violation, a cancel or a killed container, leaves an
INVALID index behind and its attempt on record as failed. Once you have mended
the cause and run `prisma migrate resolve --rolled-back <migration>`, the next
boot drops that INVALID index before it migrates, logs
`dropped the invalid index …`, and the re-run builds it again. Without that
step the re-run would find the index by name and skip it.
A build that is still running looks the same, INVALID with its migration not
yet finished. The boot therefore leaves every index alone while another
instance is migrating (it checks Prisma's migration lock first) and never
drops an index Postgres reports as being built, so a second container that
starts during the first one's migration cannot cancel it.
An INVALID index whose migration is already recorded applied is not touched at
boot. The boot names it on stderr with `WARNING — index … is INVALID`; rebuild
it with `REINDEX INDEX CONCURRENTLY "<index>";` when the database is quiet.
### The app's database role
The steps above run as the owner `DATABASE_URL` names: the dump, the
migrations and, after them, the job queue's own install or migration and the
setup of `healthlog_app_<database>_<hash>`, the role the app then connects as
([database-role.md](../self-hosting/database-role.md)). Neither the app nor
its queue ever runs a migration. A migration that creates a table needs
nothing more: the setup grants the role every table it lacks before the app
starts.
The queue schema `pgboss` stays the owner's. When an update brings a newer
pg-boss with a schema migration, the start runs it as the owner, including
its index builds, before the app starts; on a large queue that start takes as
long as the builds. Stop the old container (and a separate worker) before the
new one starts, as for any update with migrations. A web container and a
worker container that start together wait for each other on an advisory lock
and derive the same password. Going back to 1.46 runs as the owner, as 1.46
always did, and finds nothing in the queue schema it did not create.
### v1.46.0 notes
- **Postgres 18.** The compose file moves the bundled database from
Postgres 16 to 18. The first `docker compose up -d` with the new compose
file runs `db-upgrade`, which copies the database into a new
`postgres-data` volume, checks every table's row count, and only then lets
the app start; the old `pgdata` volume stays as it was, as the way back.
Pulling only the new image with the old compose file keeps 16, which this
release still runs on. Details, rollback and the steps for a database you
run yourself: [postgres-major-upgrade.md](postgres-major-upgrade.md).
- Migration `0485` drops `preview_features_json` from `users`, the same
pattern as `0430` in v1.44.0 below: do not run the previous image beside
the new schema. The sequence above stops the old container first; a
web/worker split stops both roles before the first new container starts.
- Migration `0487` drops four columns from `app_settings`
(`encryption_key_backup_confirmed_at`, `_key_id`, `_fingerprint` and
`_by_user_id`) after carrying the confirmation they held into the new
`recovery_kit_confirmations` table. The previous image reads those columns
on nearly every request that loads the instance settings and fails once
they are gone, so the same rule holds: never run it beside the new schema.
- The other migrations create tables (`0486`, `0487`), add columns
(`0480`, `0482`, `0483`, `0488`, `0489`) and build two indexes: `0477`
concurrently on `measurements`, `0488` without `CONCURRENTLY` on `users`,
which is small enough that the short lock does not matter. Three touch
rows: `0476` links API-written measurements to their token on accounts
that have exactly one measurement token, and `0478`/`0479` give PDFs whose
preview or text read failed one more attempt. On a prod-sized copy all of
them together finished in under a second.
- **Rolling back to v1.45 while accounts are pending deletion.** v1.46 keeps
a deleted account, a deleted managed profile and a data wipe's copy for a
grace window (`0488`). v1.45 knows none of that: an account pending
deletion can sign in again with its password and every background job runs
for it again, nothing ever erases it, a wipe's off-host purge held to the
end of its window runs at once, and v1.45's wipe deletes the copies taken
before earlier wipes. Before going back, list the accounts with
`SELECT id, username, deletion_purge_at FROM users WHERE deletion_requested_at IS NOT NULL;`
and either undo each deletion in the admin users list or let the purge
finish them first; after coming forward again, delete any that should
still go.
### v1.45.0 notes
A single-container install (`HEALTHLOG_PROCESS_TYPE=all`, the shipped
@@ -276,14 +186,10 @@ roll back:
app version reads forward-compatible-ish shapes for nearly every
release pair shipped on the v1.4.x line.
3. If a column-drop migration ships in the rollback target, hand-roll
the column back via a fresh forward migration
(`pnpm prisma migrate dev --create-only --name vX_Y_Z_rollback_<col>`),
edit the SQL, commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first. The columns each
release drops are named in its notes above: for v1.46.0 that is
`users.preview_features_json` (`0485`) and the four
`app_settings.encryption_key_backup_confirmed_*` columns (`0487`); the
key backup confirmation then has to be given again on v1.45.
the column back via a fresh forward migration (`pnpm prisma migrate
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
that saved the Coach lookback "Last year" reads its whole Coach
preference blob as defaults on the older version, metric exclusions
@@ -291,32 +197,13 @@ roll back:
pick another lookback before the rollback, or re-save their exclusions
after it.
When the data itself has to go back to before the update, restore the
pre-migration dump with the previous image; the steps are in
[`disaster-recovery.md`](disaster-recovery.md#going-back-to-before-a-migration).
## Catalog templates
The TrueNAS catalog bot follows new image tags on its own, so a routine
release needs nothing there. These changes do need a matching pull request to
`truenas/apps` (`ix-dev/community/healthlog/`) in the same release cycle, and
an update to `docs/self-hosting/unraid/healthlog.xml` and
`docs/self-hosting/portainer/templates.json`:
- a new required environment variable, or a new format or minimum for an
existing one (the form validates the secrets);
- a changed listening port, health check path or health semantics, or start
time beyond the template's five-minute start period;
- a required PostgreSQL major version, or an extension the stock `postgres`
image does not ship;
- any other change to the compose shape: a new container, a volume the app
must write to, a different user.
The catalog app sets `HEALTHLOG_PLATFORM=truenas`, `PORT`, `HOSTNAME`,
`DATABASE_URL`, `ENCRYPTION_KEY`, `API_TOKEN_HMAC_KEY`, `APP_URL`,
`NEXT_PUBLIC_APP_URL` and `SESSION_COOKIE_SECURE`; everything else reaches it
through the form's additional environment variables. See
`docs/self-hosting/truenas.md` for the operator side.
A new required environment variable, or a change to the compose shape, needs a
matching pull request to the TrueNAS apps catalog in the same release cycle
(and an update to `docs/self-hosting/unraid/healthlog.xml` and
`docs/self-hosting/portainer/templates.json`). See
`docs/self-hosting/truenas.md` for what the catalog template carries.
## Verify the served version
@@ -328,31 +215,13 @@ always confirm the served build after a deploy:
```sh
# Asserts prod + demo serve 1.9.0, prints each target's buildSha,
# exits non-zero on a mismatch or timeout after a bounded retry loop.
HEALTHLOG_PROD_URL=https://healthlog.example.com \
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
```
`HEALTHLOG_PROD_URL` is the base URL of your instance; it has no default and
is required unless you pass `--only=demo`. Pass `--only=prod` /
`--only=demo` to check a single target, or
Pass `--only=prod` / `--only=demo` to check a single target, or
`--attempts=N --interval=ms` to widen the retry window while a slow
pull settles.
The worker loads each job's handler module on that job's first run, so a
module the image cannot load does not stop the worker from starting. Once
it has bound its queues, the worker loads every handler a single time in
the background and reports each one that fails, normally within a minute
of start. Each failure is a `job.run.failed` event at `error` with
`reason: "handler_load_failed"` and the queue's name, and stderr also gets
`[pg-boss] [registry] preloaded N/M job handlers; failed to load: <queues>`.
If either shows up after a deploy, the image is broken for those queues,
and their jobs will fail when they run. Roll back (see Rollback above)
instead of waiting for the retries to run out:
```sh
docker compose logs --since 5m app 2>&1 | grep -E 'handler_load_failed|failed to load:'
```
## Walk the synthetic journey
The version check proves the instance serves the right build. It does not
@@ -567,45 +436,29 @@ low-friction default.
## Deploy-status webhook: replay protection
`POST /api/internal/deploy-webhook` accepts two kinds of request.
`POST /api/internal/deploy-webhook` authenticates via the static
`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally
enforces a freshness window: a request carrying an
`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds,
or ISO-8601) is rejected with `401 {"status":"stale"}` when the
timestamp lies more than **5 minutes** from server time — so a captured
request cannot be replayed later to re-page admins or forge deploy
outcomes in the audit log.
- **Secret header.** `X-Deploy-Webhook-Secret` carries the shared secret.
This is what Coolify's stock notification sender can do. It does not
protect against a replay: anyone who captures one request holds the
secret and can send any request they like. An `X-Deploy-Webhook-Timestamp`
beside it is still checked (a stale or unparseable one is refused with
`401 {"status":"stale"}`), but it is covered by nothing, so treat it as a
staleness hint. Versions before 1.46 described it as replay protection.
- **Signature.** `X-Deploy-Webhook-Signature: sha256=<hex>` is the
HMAC-SHA256, under `DEPLOY_WEBHOOK_SECRET`, of the
`X-Deploy-Webhook-Timestamp` value, a dot, and the exact request body. The
timestamp (unix seconds, unix milliseconds or ISO-8601) must lie within
**5 minutes** of server time. A signed request sends no secret, and a
captured one is useless once the window has passed.
Two modes:
Two settings tighten this:
- **Default (tolerant).** Requests _without_ the header pass — Coolify's
stock notification sender does not attach one. A header that is
present but stale or unparseable is always rejected.
- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app
environment to make the header mandatory, closing the replay window
completely. Use this when the sender (or a reverse proxy in front of
the app) can attach the timestamp, e.g.:
- `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` requires a fresh
`X-Deploy-Webhook-Timestamp` on a request that uses the secret header; one
without it is refused with `401 {"status":"stale"}`. This is what the
setting has always meant. It keeps an old capture out, but not someone who
saw the secret and sends a new request with a new timestamp.
- `DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true` accepts signed requests only; the
secret header alone is refused with 401. This is the one that stops a
replay. Use it when the sender, or a small relay in front of the app, can
sign:
```sh
TS=$(date +%s)
BODY='{"status":"success"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -X POST https://<host>/api/internal/deploy-webhook \
-H "X-Deploy-Webhook-Timestamp: $TS" \
-H "X-Deploy-Webhook-Signature: sha256=$SIG" \
-H "Content-Type: application/json" \
-d "$BODY"
```
Both may be set; a signed request satisfies both. An operator who set the
timestamp flag before 1.46 keeps exactly the behaviour they had.
```sh
curl -X POST https://<host>/api/internal/deploy-webhook \
-H "X-Deploy-Webhook-Secret: $SECRET" \
-H "X-Deploy-Webhook-Timestamp: $(date +%s)" \
-H "Content-Type: application/json" \
-d '{"status":"success"}'
```
-240
View File
@@ -1,240 +0,0 @@
# Disaster recovery
The copy that rebuilds a lost server is the database dump. This page says
what is dumped and where it goes, which keys a restore needs, how to restore
onto a new server, and how the procedure is tested.
## What is backed up, and where
| Copy | When | Where | Kept |
| ----------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------- |
| Nightly dump | 01:30 Europe/Berlin, the zone every schedule runs in (`DB_BACKUP_SCHEDULE`) | `DB_BACKUP_DIR`, the `backups` volume of the bundled compose file | the newest 7 (`DB_BACKUP_KEEP`) |
| Pre-migration dump | on start, when the new image brings migrations, before them | the same directory | the newest 2 |
| Off-host copy of a dump | right after each nightly dump, if the bucket is configured | your bucket, under `database/<host of APP_URL>/`, sealed with `BACKUP_ENCRYPTION_KEY` | your bucket's lifecycle rule |
A dump is `pg_dump --format=custom` of the whole database: every account,
sign-ins, passkeys, sharing grants and managed profiles, documents, the job
queue and the instance settings. It is two files:
- `healthlog-<UTC time>-<daily|pre-migration>.dump`, the archive;
- `healthlog-<UTC time>-<daily|pre-migration>.manifest.json` beside it: the
app version and build, the newest applied migration, the Postgres version,
the archive's size and SHA-256, the row count of every table, and the
fingerprints (never the values) of the keys the database needs.
A dump counts as good only when both files exist under their final names. The
archive is written as `.partial`, read back with `pg_restore --list`, and
renamed after its manifest; a run that fails leaves nothing that looks good.
The admin backups page shows the newest good dump, where it is, and the
newest failed run. The start log says on every boot where dumps go, or that
none can be kept.
Local dumps are not encrypted. They sit beside the database volume, in the
same trust zone: whoever can read one volume can read the other. The columns
HealthLog encrypts stay encrypted inside the dump, under `ENCRYPTION_KEYS`.
Keep a copy of the dumps off the host; the off-host bucket does that for you,
and it seals each copy with `BACKUP_ENCRYPTION_KEY` in the envelope the
per-account copies use.
A dump holds every account that existed when it was taken, including one
deleted since. Deleted accounts leave the dumps as they age out: after
`DB_BACKUP_KEEP` nights on the server, and after your lifecycle rule in the
bucket. A pre-migration dump goes once it is older than every nightly dump
kept, except the one taken before the latest update: that one is the way back
from it and stays until the next update writes a newer one. On an install
that rarely updates, delete it by hand once you no longer want the way back,
or state that exception with the retention period. Give that rule a prefix that covers `database/` too; the admin
backups page reads the bucket's rules and says when none does, and the
recovery readiness card calls it attention and tells the admins once. This is the
retention period to state where you tell people that deleting their account
deletes their data.
A dump is kept on the server only when `DB_BACKUP_DIR` survives the container
being replaced: a mounted volume or bind mount, or any writable directory when
HealthLog does not run in a container. The image creates the default
directory, so an install that runs it without the `backups` volume can write
there, but the directory sits in the container's own layer and the next update
throws it away. HealthLog reads the container's mount table to tell the two
apart and treats such a directory, or one on a tmpfs, like one it cannot
write: the nightly dump is uploaded off-host and nothing is kept on the
server, and no dump is taken before migrations; with no bucket either, no
dump is taken at all. The start log, the admin backups page and the recovery
readiness card say so.
The per-account copies (the weekly in-database copy, the nightly per-account
objects) roll one account back on the same server. They cannot rebuild a
server; see [`backup-restore.md`](backup-restore.md).
## The keys a restore needs
| Key | Without it |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `ENCRYPTION_KEYS` + `ENCRYPTION_ACTIVE_KEY_ID`, or `ENCRYPTION_KEY` | Nothing encrypted can be read. The restore refuses when the active key is missing or different. Keep every retired id still in the map. |
| `API_TOKEN_HMAC_KEY` | No data is lost, but every session, app token and share link stops working; people sign in again and re-create tokens. |
| `BACKUP_ENCRYPTION_KEY` (and `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) | Off-host copies do not open. |
| `POSTGRES_PASSWORD` | Not needed: a new server can use a new one. |
The restore compares the keys in its environment with the fingerprints in the
manifest before it writes anything, and names each key that is missing or
different.
## Restore onto a new server
1. Install Docker and take the `docker-compose.yml` of the version that wrote
the dump or a newer one. The manifest's `appVersion` names it; to run that
exact version set `HEALTHLOG_IMAGE_REF=:v<version>` in `.env`.
2. Write `.env` with the keys from your records (see above) and a new
`POSTGRES_PASSWORD`.
3. Do not run `docker compose up` yet. The app would create an empty schema,
and the restore only writes into a database with no tables. Start only the
database:
```bash
docker compose up -d db
```
4. Restore. When the host is lost, its local dumps went with it, and the
copy is the one in your bucket: put the `BACKUP_S3_*` variables and
`BACKUP_ENCRYPTION_KEY` (with any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) in
`.env` as on the old server, then
```bash
docker compose run --rm db-restore list --from-bucket
docker compose run --rm db-restore latest --from-bucket
```
`latest --from-bucket` downloads the newest dump of this instance whose
manifest is in the bucket too, opens both with `BACKUP_ENCRYPTION_KEY`
and restores. Name a dump instead of `latest` to take an older one.
Each instance uploads under its own folder, `database/<host of APP_URL>/`,
so set `APP_URL` as on the old server. A bucket shared with another
instance holds its dumps too: `latest` refuses rather than take one of
those, and `list --from-bucket` marks them. To restore one on purpose
(after moving to a new domain, the old one), add
`--instance=<old host>`, or name the dump by its full key,
`database/<host>/healthlog-….dump.enc`.
The opened copy is a whole database, decrypted, in a scratch directory
beside the backups. The restore removes it when it ends, on Ctrl-C and
`docker compose stop` too. One left by a restore that was killed outright
is removed when the next restore starts, and by the nightly retention.
With the files in hand instead (copied off the host, or downloaded from
the bucket with your own tools: both `<name>.dump.enc` and
`<name>.manifest.json.enc`, or a `.dump` with its `.manifest.json`), put
them in a directory such as `./restore`, make them readable for the
container (`chmod a+r restore/*`) and name the dump:
```bash
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore list /restore
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore \
/restore/healthlog-20261024T233000Z-daily.dump
```
Either way it checks the archive against the manifest's SHA-256, the keys
against the manifest's fingerprints, and that the database is empty. Then
it restores in one transaction, gathers planner statistics and counts
every table against the manifest. A count that differs empties the
database again and fails. Wait for the line that starts with `Restored`.
5. Start the stack and check it:
```bash
docker compose up -d
curl -s http://localhost:3000/api/health
```
Sign in. The app's own key check runs at start and refuses to serve on a
key that does not open the data.
The restore runs as the owner `DATABASE_URL` names, and the restored tables
belong to it. The app connects as its own role,
`healthlog_app_<database>_<hash>`
([database-role.md](../self-hosting/database-role.md)): the start in step 5
grants that role the restored tables, the queue's included, before the app
runs; the queue schema stays the owner's. A dump carries grants to the role;
`db-restore` leaves them out (`--no-owner --no-privileges`). Restoring with `pg_restore` by hand, pass
the same two flags, or it fails on a database without the role.
When the dumps are still in the server's `backups` volume (the database
volume was lost, the backups volume was not), step 4 is
`docker compose run --rm db-restore latest`.
`--ignore-key-mismatch` restores although the active encryption key differs.
Use it only when that key is lost for good: everything encrypted stays
unreadable.
## Going back to before a migration
The entrypoint dumps the database before it applies migrations. To return to
that state (the previous image, the data as it was before the update):
```bash
docker compose stop app
# Keep the current state too, in case it is wanted later.
docker compose exec -T db pg_dump -U healthlog -Fc healthlog > healthlog-current.dump
docker compose exec db dropdb -U healthlog healthlog
docker compose exec db createdb -U healthlog healthlog
docker compose run --rm db-restore list
docker compose run --rm db-restore healthlog-<time>-pre-migration.dump
# Set HEALTHLOG_IMAGE_REF in .env to the version you ran before the update.
docker compose up -d
```
The dump before a migration is taken while the new image starts. If it fails
on a directory that keeps it (a full disk, most often), the start stops before
anything is migrated and the log names the switch: `DB_BACKUP_BEFORE_MIGRATE=false`
starts without it, once.
## Knowing the copies are there
Nobody opens the backups page until the day it matters, so the server tells
the admins instead:
- The **Recovery readiness** card, on the admin overview and on Admin,
Backups, judges every copy in one verdict each: the database dump (fresh,
failed, or kept nowhere that survives the container), the weekly account
copies, the off-host copies, the last restore drill, whether the bucket is
set up, whether its lifecycle rule ever removes the dumps under
`database/`, and the recovery kit.
- The **recovery kit** under Admin, Encryption lists every key a restore
needs (the table above), by id and fingerprint, and lets an admin check a
saved copy and confirm it. It is due again after any key change.
- An hourly check sends each admin a **Backup health** notification
(`BACKUP_HEALTH`) when a signal on the card turns to needs attention or at
risk, a reminder once a day while one stays at risk, and one more when it
recovers. Each admin chooses the channels for it under Notifications.
[Getting started](../self-hosting/getting-started.md#save-the-recovery-kit)
walks through saving the kit.
## How this is tested
`scripts/compose-dr-drill.sh` plays this page against real containers: it
builds the image, seeds several accounts (the demo account with documents
and encrypted notes, a Guardian with a managed profile), lets the scheduled
job take the dump and upload it to an S3-compatible bucket, destroys every
volume of the stack (the local dumps included), and restores on a new stack
given only the bucket and the keys
(`docker compose run --rm db-restore latest --from-bucket`). A second run
restores from the dump files instead (`DRILL_SOURCE=local`). Both then count
every table against the manifest and boot the app: health, sign-in, an
encrypted note read back, a document opened, the Guardian still seeing the
profile. It runs in CI nightly and on every change to the
compose file, the image or the dump and restore code
(`.github/workflows/compose-dr-drill.yml`), and it is checked to fail on a
wrong encryption key, a wrong off-host key, a dump that lost a table's rows
and a target that is not empty.
Test your own copies too: once, and after any change to how the server is
run, restore last night's dump on a spare machine with the steps above.
## Settings
| Variable | Default | What it does |
| -------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `DB_BACKUP_ENABLED` | on | `false` stops the nightly dump. |
| `DB_BACKUP_DIR` | `/var/lib/healthlog/backups` | Where dumps are written and read. Change it only together with the volume mount. |
| `DB_BACKUP_SCHEDULE` | `30 1 * * *` | Five-field cron, Europe/Berlin time like every HealthLog schedule. |
| `DB_BACKUP_KEEP` | `7` | Nightly dumps kept (at least 1). Up to two pre-migration dumps are kept besides, none older than the oldest nightly dump except the newest. |
| `DB_BACKUP_BEFORE_MIGRATE` | on | `false` starts an update without the dump before its migrations. |
+4 -9
View File
@@ -137,15 +137,10 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
> So before step 4, also check **Admin → Encryption → Keys the backups still
> need**. It lists, per key id, the stored copies that need it with the oldest
> date, and when the last off-host copy needing it expires under the bucket's
> lifecycle rule. The database dumps are listed too: a dump is restored only
> with the key that was active when it was taken (`db-restore` refuses
> without it), so the old key stays listed while a nightly or pre-migration
> dump taken under it is on this server, and until the bucket's rule for
> `database/` has expired the off-host copies. Keep the old key in
> `ENCRYPTION_KEYS` until it is no longer listed there (the weekly copy
> replaces itself within a week; delete old uploaded copies you no longer
> want, and wait out the dump and off-host retention), or accept that those
> copies cannot be restored. Copies written before v1.39.3
> lifecycle rule. Keep the old key in `ENCRYPTION_KEYS` until it is no longer
> listed there (the weekly copy replaces itself within a week; delete old
> uploaded copies you no longer want, and wait out the off-host retention), or
> accept that those copies cannot be restored. Copies written before v1.39.3
> did not record their keys and are listed as such: treat them as needing
> every key that existed when they were written.
>
+4 -17
View File
@@ -96,9 +96,8 @@ maintenance refuses to start while one is.
Have at least twice the largest size free on the database volume.
- **Take a backup**: a database dump, as in
[Updates](../self-hosting/getting-started.md#updates), as before any
operation on the whole table.
- **Take a backup** (Settings, Data, Backups), as before any operation on the
whole table.
## Starting it
@@ -107,12 +106,10 @@ In the admin area, or with a signed-in admin session:
```sh
curl -X POST https://<your-instance>/api/admin/maintenance/measurements \
-H 'content-type: application/json' \
--cookie '__Host-healthlog_session=<session>' \
--cookie 'healthlog_session=<session>' \
-d '{"vacuum": true, "reindex": true}'
```
On a plain-HTTP install the cookie is named `healthlog_session`.
The answer is `202` with `{ "enqueued": true }`, or `enqueued: false` when a
run is already queued or running. A Bearer token is refused: the route is
cookie-only, like every admin route. Without a background worker the route
@@ -214,7 +211,7 @@ So an index is retired in two halves (v1.43.8, migration 0416, for
exactly as the schema does, and `prisma migrate dev` / `migrate diff` see
no drift and generate nothing.
2. The index name goes into `RETIRED_MEASUREMENT_INDEXES` in
`src/lib/jobs/retired-measurement-indexes.mjs`. Every run of this job, the
`src/lib/jobs/measurement-maintenance.ts`. Every run of this job, the
nightly 03:40 check included, drops each name still present with
`DROP INDEX CONCURRENTLY IF EXISTS`, waiting at most 60 seconds for its
locks. A run that runs out of time leaves the index (possibly marked
@@ -242,16 +239,6 @@ SET lock_timeout = '60s';
DROP INDEX CONCURRENTLY IF EXISTS "measurements_user_id_type_measured_at_idx";
```
When the app connects as its restricted role
([database-role.md](../self-hosting/database-role.md)), the job may vacuum and
rebuild (`MAINTAIN`) but not drop: `DROP INDEX` belongs to the table's owner.
A run then skips the drops, of retired indexes and of the invalid copy a
failed rebuild leaves, names what it found under
`maintenance_drops_left_for_boot` with a warning, and the next start drops
each as the owner with `DROP INDEX CONCURRENTLY`, waiting at most five
seconds. Until then a failed rebuild's copy holds its disk; restart the
container, or drop it by hand as the owner with the statement above.
For the next large index, follow the same two halves.
`src/__tests__/migration-lock-timeout-guard.test.ts` refuses a
`DROP INDEX CONCURRENTLY` in any migration and requires every retired name to
-168
View File
@@ -1,168 +0,0 @@
# Moving the database to a newer Postgres major
From v1.46 the compose file runs Postgres 18. Installs from v1.45 or earlier
ran 16, and a major version cannot open the data directory of an older one,
so the compose file carries a one-time step that moves the database across
on the first start after the update. Nothing needs doing by hand on a stack
that runs the project's `docker-compose.yml`.
Installs that bring their own Postgres (Unraid, an existing database server)
are not touched: HealthLog runs on 16 and 18 alike, and the move there is
yours to schedule with the steps under [By hand](#by-hand).
## What happens on the first start
`db-upgrade` is a short-lived container next to `db`. It starts after the new
Postgres 18 database is up and before the app, and it exits in a second when
there is nothing to move. When the old `pgdata` volume holds a Postgres 16
database and the new one is empty, it:
1. checks that the database service runs Postgres 18, and that there is room
on the disk for a second copy and for the dump the app takes before its
first migration;
2. starts Postgres 16 inside its own container on the old volume, reachable
only from inside that container;
3. streams a dump into Postgres 18 in a single transaction;
4. compares the row count of every table between the two;
5. gathers the planner statistics, which a restore does not carry;
6. stops Postgres 16 and leaves a marker file on the old volume.
The app starts once that succeeded, applies the release's migrations and
serves as before. On a typical personal record the whole move takes seconds;
a database of a few gigabytes takes a few minutes. Watch it with
`docker compose logs -f db-upgrade`.
If any step fails, the new database stays empty, the app does not start, and
the log says what happened. A move cut short by a restart or a full disk is
run again from scratch on the next `docker compose up -d`.
The old `pgdata` volume keeps the database as it was. Postgres 16 runs on it
during the move, so it writes a checkpoint there (and recovers first after an
unclean stop), and the marker file is added; no data in it changes. That is
the way back.
## Going back
Stop the stack, put the two lines of the `db` service back the way v1.45 had
them, and start it with the v1.45 image:
```yaml
db:
image: postgres:16.15-alpine
volumes:
- pgdata:/var/lib/postgresql/data
```
```bash
docker compose down
HEALTHLOG_IMAGE_REF=:v1.45.0 docker compose up -d
```
Anything written after the move lives only in the Postgres 18 volume.
`db-upgrade` can stay in the file: it sees that the database service runs
Postgres 16 and leaves that volume alone. This also holds after a move that
stopped half-way.
## Updating again after going back
If you used Postgres 16 again after going back, the Postgres 18 volume holds
the state of the day of the move, not what you wrote since. `db-upgrade`
notices that the old database changed after the move and refuses to start on
the old copy. To move the current 16 database, remove the old copy and the
marker, then start again:
```bash
docker compose down
docker volume rm <project>_postgres-data
docker compose run --rm --entrypoint rm db-upgrade \
/var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18
docker compose up -d
```
`docker volume ls` shows the volume's full name. Removing it deletes what was
written on Postgres 18 before you went back; take a dump of it first if any
of that matters.
## Afterwards
Keep the old volume until the new database has run for a while, then remove
it. It is a complete copy of the database as it stood before the move, and
nothing that happens afterwards reaches it: an account deleted, a document
purged from the trash, or a key rotated after a leak is still there in the
old volume, and so is everything else for anyone who can read the host's
Docker volumes. Removing it is part of the move, not optional tidying.
`docker volume ls` shows its full name, prefixed with the compose project:
```bash
docker volume rm <project>_pgdata
```
`db-upgrade` then finds nothing to move. A later release drops the service
and the `pgdata` line.
## Starting over
When the move has run and the Postgres 18 volume is empty again (deleted by
hand, say), `db-upgrade` refuses to import the old state a second time, since
that would quietly bring back the database as it was on the day of the move.
To do exactly that on purpose, remove the marker and start again:
```bash
docker compose run --rm --entrypoint rm db-upgrade \
/var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18
docker compose up -d
```
## By hand
For a Postgres you run yourself, or a compose file you have changed so much
that the step does not fit, the move is a dump and a restore. Take the
encryption key's copy out of its safe place first: the dump is unreadable
without it, and this is the moment you would find out.
```bash
# 1. Stop the app so nothing writes while the dump runs (and app-worker, if
# you run the worker role in its own container).
docker compose stop app
# 2. Dump the database in Postgres' custom format.
docker compose exec -T db pg_dump -U healthlog -Fc healthlog \
> healthlog-pg16-$(date +%F).dump
# 3. Stop the old database. Its volume stays untouched.
docker compose stop db
```
4. Point the `db` service at Postgres 18 and a fresh volume. From 18 on, the
image keeps its data under `/var/lib/postgresql/18/docker` and expects the
volume mounted one level up, at `/var/lib/postgresql`; mounted at
`.../data` it refuses to start with "there appears to be PostgreSQL data
in: /var/lib/postgresql/data".
```bash
# 5. Start the new database and restore the dump into it.
docker compose up -d db
docker compose exec -T db pg_restore -U healthlog -d healthlog --no-owner \
< healthlog-pg16-<date>.dump
# 6. Gather the planner statistics. A restore brings the rows but not the
# statistics, and until autovacuum gets round to every table the planner
# guesses at their sizes, which can turn a sub-second page into one that
# takes many seconds.
docker compose exec -T db vacuumdb -U healthlog -d healthlog --analyze-in-stages
# 7. Start the app and confirm it.
docker compose up -d app
curl -s http://localhost:3000/api/version
```
The start log should say there are no pending migrations beyond the release's
own: the dump carried the migration history with it.
If you had run `CREATE EXTENSION pg_stat_statements;` for
[query statistics](query-statistics.md), the dump carries it and the compose
`command:` still loads the library, so nothing needs doing there.
`pg_upgrade --link` is faster on a very large database, but it needs both
majors' binaries in one container; for a personal health record the dump is
seconds to minutes and has nothing to set up.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

+5 -9
View File
@@ -214,15 +214,11 @@ as something to log in as.
or delete the profile under a fresh second factor. Deleting the profile
deletes everything recorded for it.
- A guardian may keep that record's settings: modules, units, thresholds,
its language and timezone, and its notification preferences. A guardian
can also connect and disconnect the record's Withings, WHOOP, Oura, Polar,
Strava and Google Health from its integrations page; the data lands in the
profile's record, and a profile without its own provider app uses the
guardian's. Fitbit (no new connections), Nightscout and Dawarich cannot be
connected for a profile. Sync, the connection test and provider
credentials are not guardian surfaces: once connected, the record syncs on
its own schedule, and their deep links land on the shared-record
unavailable page rather than on a raw refusal.
its language and timezone, and its notification preferences. Connected
services are visible as a status only. Connect, callback, sync,
credential, disconnect, resume and test are not guardian surfaces in
this release, and their deep links land on the shared-record unavailable
page rather than on a raw refusal.
- **Its reminders go to its guardians.** A record with no login has no
channels of its own, so medication reminders, measurement reminders,
safety-floor alerts and low-stock alerts fan out to every active
-176
View File
@@ -1,176 +0,0 @@
# The app's database role
The container connects to Postgres under two names. The user in
`DATABASE_URL` (on the bundled compose file, `healthlog`, which the official
Postgres image makes a superuser) owns the schema: the entrypoint uses it for
the dump before an update, the migrations, the job queue's own install and
migrations, and the restore. The app itself, web and worker, connects as a
role the entrypoint sets up for this database after the migrations on every
start. Nothing to configure: the first start of a release with this change
creates it.
The point is the blast radius of a bug. If a dependency of the app were ever
made to run code inside the container, the database connection it found would
be one that can read and write rows, not one that can drop tables, rewrite the
schema, plant code for the owner to run, or reach the server's files.
## Its name
`healthlog_app_<database>_<hash>`: the database's name, cut to letters,
digits and underscores, and eight hex digits of a hash of it. On the bundled
compose file that is `healthlog_app_healthlog_` and the hash. The start log
names it, and so does `pg_stat_activity`.
Postgres roles belong to the whole server, not to one database. One role per
database keeps two HealthLog instances on one server (production and staging
on a shared Postgres) apart: each has its own role and password, neither
locks the other out, and neither role has any grant in the other database. A
role that holds an explicit `CONNECT` grant on another database is not used
(the start says so). Postgres lets every role connect to a database whose
`CONNECT` is granted to `PUBLIC`, the default; the role finds nothing to read
there. To shut the door as well, revoke it on each HealthLog database:
`REVOKE CONNECT ON DATABASE <name> FROM PUBLIC;` (and grant it back to any
monitoring user that relied on it).
## What the role may and may not do
| May | May not |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------- |
| read, insert, update and delete rows in every table of the app's schema | create, alter or drop tables, indexes, types or functions anywhere |
| use and advance its sequences | write `_prisma_migrations` (it may read it) |
| `VACUUM`, `ANALYZE` and `REINDEX` the `measurements` table (`MAINTAIN`) | create triggers or rules, own anything |
| read and write the job queue's rows in `pgboss` | write `pgboss.bam` or the version in `pgboss.version` (it may read) |
| create temporary tables | create roles, schemas or databases; become the owner (`SET ROLE`) |
| | act as a superuser: no `COPY … PROGRAM`, no `pg_read_file` |
The start also takes `CREATE` on the app's schema away from `PUBLIC`, which
Postgres 15 and newer already do for a new database but a database first
created on an older major still allows.
## The job queue's schema
`pgboss` belongs to the owner. pg-boss changes its schema when it installs,
when it migrates (and in the index builds a migration leaves in
`pgboss.bam`), and for queues created with `partition: true`, which HealthLog
does not use. So the start installs and migrates the queue schema as the
owner, with pg-boss's own plans, before the app runs, and the worker starts
pg-boss with `migrate: false`: it only reads and writes rows, and refuses to
run on a schema that is not at its version rather than change it. A new queue
is a row.
The role may not write the two tables whose contents decide what runs as the
owner: `pgboss.bam` holds SQL that pg-boss's migration runner executes, and
the version in `pgboss.version` decides which migrations run. Owning nothing
in the schema, the role cannot plant a trigger, a rule or a function body
that a later session as the owner would run with the owner's rights: a
rollback to 1.46 (which runs as the owner throughout), `DB_APP_ROLE=off`, a
container that fell back to the owner, or the old container during a deploy.
For the same reason a container that falls back to the owner and one that
runs as the role can share the schema: the owner creates every object in it
either way, and the next start grants the role what is new.
Every start, on every path (the role, `DB_APP_ROLE=off`, a fallback), also
takes back anything in `pgboss` that a HealthLog-made role owns and drops any
trigger or rule there (pg-boss defines none). A pre-release build handed the
schema to a server-wide `healthlog_app` role; when the start finds that, it
also compares every queue function with pg-boss's own definition and puts
back the ones that differ, retires that role's grants in this database, and
drops it once no database refers to it. The log names each.
pg-boss rebuilds bloated queue indexes only on tables it owns; under the role
it reports them. The start rebuilds them as the owner, one at a time with a
five-second lock wait.
## The password
Derived from `API_TOKEN_HMAC_KEY` and the database's name, so a separate
worker container ([scaling.md](scaling.md)) arrives at the same one without a
new variable, and stored as a SCRAM hash. Rotating `API_TOKEN_HMAC_KEY` with a
web/worker split means restarting all containers together: the first one to
start sets the new password, and a container still on the old key cannot
open new connections until it restarts with the new one.
The owner's URL is replaced, not kept beside the role's, so it is not in the
app's environment. `docker compose exec app …` still starts with the
container's configured environment, so the operator scripts
(`reset-password.mjs` and the others) run as the owner as before.
Each start re-applies the grants, so a table the new migrations created is
covered before the app starts, and a restored database is put right on the
start after the restore.
## What the start log says
When it is in place:
```text
HealthLog: the app connects as healthlog_app_healthlog_1a2b3c4d: rows and the queue, no schema changes (DB_APP_ROLE=off turns this off)
```
When it is not, one notice names the reason, and the app runs as the
`DATABASE_URL` user, exactly as every earlier release did:
```text
HealthLog: notice: the app connects as healthlog, the user DATABASE_URL names, not as a restricted role: <reason>. …
```
The reasons:
- **The user may not create roles.** A managed database (RDS, Cloud SQL,
Supabase and the like) or a database server where you gave HealthLog an
account without `CREATEROLE`. Either leave it, or create the split yourself
(below) and set `DB_APP_ROLE=off`.
- **Postgres older than 17.** The maintenance job's `VACUUM` and index rebuild
need the `MAINTAIN` privilege, which Postgres 17 introduced; before it only
the table's owner may run them. The bundled database is Postgres 18; see
[postgres-major-upgrade.md](../ops/postgres-major-upgrade.md) for one you
run yourself.
- **The role cannot log in.** A `pg_hba.conf` that lists users by name needs a
line for the role beside the one for the owner.
- **The role holds `CONNECT` on another database.** Revoke it there.
- **A role of that name exists that HealthLog did not create** for this
database. The start leaves a role it does not recognise alone.
- **The queue schema is newer than this image's pg-boss**, after going back
to an older image: only the owner runs on it.
- **Setting it up failed**, most often a lock it waited 15 seconds for while
another container held it. The next start tries again.
None of these stops the start.
## Turning it off
Set `DB_APP_ROLE=off` in `.env` and recreate the container. The app then
connects as the `DATABASE_URL` user. Use it when you manage database roles
yourself, including when `DATABASE_URL` already names a restricted role of
your own; then that role needs what the table above lists, and the
migrations and the queue's install need a user that owns the schema.
The role stays in the database when you turn it off and owns nothing. To
remove it, as the owner, with the name from the start log:
```sql
DROP OWNED BY healthlog_app_healthlog_1a2b3c4d;
DROP ROLE healthlog_app_healthlog_1a2b3c4d;
```
## Going back to 1.46
1.46 knows nothing of the role and runs as the owner, as it always did. The
queue schema is the owner's, so it finds nothing it did not create. The role
stays, unused, until you drop it as above.
## Index drops at start
`DROP INDEX` belongs to a table's owner; `MAINTAIN` does not include it. The
measurement maintenance therefore no longer drops a retired index or the
invalid copy a failed rebuild leaves; it names them on its run and the next
start drops them as the owner, waiting at most five seconds for each
([measurement-maintenance.md](../ops/measurement-maintenance.md)).
## Dumps and restores
The nightly dump runs as the role and reads everything through its grants. A
dump carries those grants; `db-restore` leaves them and the ownership out
(`--no-owner --no-privileges`), restores as the owner, and the next start
grants again. A restore of your own with `pg_restore` should do the same, or
it fails on a database without the role.
+7 -12
View File
@@ -82,18 +82,13 @@ limit for a document).
- Imports count against the person's normal upload allowance (60 an hour) and
storage quota.
- A document that is already in HealthLog is not stored twice. Each import is
keyed by system, instance (the archive's origin) and document id, so the
same id on two Paperless-ngx instances is two documents; a key imported
before v1.39.3 carries no instance and matches any instance. These are the
same rules the document token and `scripts/import-documents.mjs` follow.
- A document the person deleted in HealthLog is never brought back by the
document token or the import script, however often they re-send it. The
person can bring it back themselves: from Recently deleted on the Documents
page for 30 days, or by picking it again in the picker, which says "In Recently
deleted" or "Deleted in HealthLog" on such a row. Picking one still in
Recently deleted restores that same document; picking one already removed
for good imports it again.
- A document that is already in HealthLog is not stored twice, and one the
person deleted in HealthLog is not brought back by picking it again. Each
import is keyed by system, instance (the archive's origin) and document id,
so the same id on two Paperless-ngx instances is two documents; a key
imported before v1.39.3 carries no instance and matches any instance. These
are the same rules the document token and `scripts/import-documents.mjs`
follow.
- A picked document follows the person's own automatic AI reading setting.
- Only the owner of a record can connect an archive or import from it. People
with shared access to a record, whatever their level, do not see the picker.
+4 -5
View File
@@ -86,11 +86,10 @@ backfill button in settings fetches both for the range it is given.
Since v1.42 the home, each dated location period and each stored day keep
their coarse location sealed with the instance's encryption key (one value
per row, `{ lat, lon, label }`, under the label
`healthlog/environment-location/v1`). The boot-time encryption backfill sealed
rows written by older releases and cleared their readable columns, and
migration `0409` (v1.43) dropped those columns; it refuses to run while a row
still holds a readable location
([migration-0409-plaintext-location.md](../ops/migration-0409-plaintext-location.md)). Key rotation (`scripts/rotate-encryption-key.ts`, or the
`healthlog/environment-location/v1`). The boot-time encryption backfill seals
rows written by older releases and clears their readable columns; the
readable columns are dropped in a later release once the backfill reports
zero rows everywhere. Key rotation (`scripts/rotate-encryption-key.ts`, or the
in-app rotation) covers all three.
What the person sees is unchanged: the settings page and
+37 -95
View File
@@ -9,15 +9,8 @@ already installed, longer if a TLS-fronted public hostname is in scope
- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml`
pulls a multi-arch image from GitHub Container Registry (linux/amd64
and linux/arm64), so a Raspberry Pi 4 or 5 or an x86 VPS all work
unchanged. The image is built on the official Node 22 image (Debian
slim, glibc). Document thumbnails and
scanned-PDF rendering use a native renderer that runs on every arm64
board, the Pi 4 included, and on x86-64 CPUs with AVX2; on an older x86
CPU (Celeron/Atom-class NAS chips) it switches itself off and those two
features fall back to text only. `NATIVE_CANVAS=off` or `on` in `.env`
overrides that detection (`on` crashes PDF processing on a CPU that truly
lacks the instructions).
- linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
unchanged.
- **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data
volume grows with measurement history; the Apple Health import worker
can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app
@@ -30,12 +23,11 @@ already installed, longer if a TLS-fronted public hostname is in scope
below the supported floor. On a busy instance, raise it.
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
integration endpoints you plan to enable (Withings, OpenAI, etc.).
The environmental-context module additionally reaches
`archive-api.open-meteo.com`, `air-quality-api.open-meteo.com` and
`geocoding-api.open-meteo.com` for weather, air quality and the city
search. It is on by default, but nothing leaves the host until someone
sets a home location, and every outbound call goes through the
SSRF-guarded egress wrapper.
The opt-in environmental-context module additionally reaches
`archive-api.open-meteo.com` and `geocoding-api.open-meteo.com` for
weather and daylight; it stays off until you
enable it and set a home location, and every outbound call goes
through the SSRF-guarded egress wrapper.
Three requests happen by default without anything connected: the
HaveIBeenPwned range query when a password is set (only the first five
characters of the password's SHA-1 are sent), a daily check of the
@@ -72,11 +64,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
```
| Variable | Purpose | Rotation cost |
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token, share link and cycle calendar-feed link, and signs out every browser session |
| Variable | Purpose | Rotation cost |
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token and signs out every browser session |
You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml`
stack: compose constructs the connection string inside the `app`
@@ -89,9 +81,7 @@ explicitly in `.env` (see the commented example there).
### Using an existing or shared Postgres
HealthLog can live in a database on a Postgres instance you already run,
version 16 or newer; the compose file ships 18, and every release is tested
on both.
HealthLog can live in a database on a Postgres instance you already run.
It stores every time as UTC and pins each connection it opens to UTC
(`TimeZone=UTC`, sent with the libpq `options` startup parameter), so the
server's or the database's own `timezone` setting does not matter: an
@@ -115,12 +105,9 @@ ALTER DATABASE healthlog SET timezone TO 'UTC';
docker compose up -d
```
Compose starts two services and one short-lived step:
Compose starts two services:
- `db` — PostgreSQL 18 with a named volume for persistence.
- `db-upgrade` — moves a Postgres 16 database from v1.45 or earlier into
the new one, once, and exits; on a new install it has nothing to do. See
[Moving to a newer Postgres major](../ops/postgres-major-upgrade.md).
- `db` — PostgreSQL 16 with a named volume for persistence.
- `app` — HealthLog itself, listening on `localhost:3000`. The image
runs `prisma migrate deploy` on boot so the schema lands
automatically; subsequent restarts no-op when the database is
@@ -278,53 +265,30 @@ ps` — the `app` healthcheck polls `/api/health` every 30 seconds
`pull_policy: always` on the `app` service, and verify the running
build with `GET /api/version` (it returns `version` + `buildSha`).
## Save the recovery kit
## Back up your encryption key
No key HealthLog encrypts with is stored in the database. Without them the
encrypted parts of the database, a database dump and every backup copy cannot
be read. The recovery kit under Admin, Encryption lists every key a restore of
this server needs:
`ENCRYPTION_KEY` (or the `ENCRYPTION_KEYS` map) is not stored in the database.
Without it, the encrypted parts of the database and every in-database backup
cannot be read. Keep a copy outside the server, then confirm it under Admin,
Encryption: the step shows the key id and a fingerprint (the first 12 hex
characters of SHA-256 over the key) and can check a pasted copy without
storing it. Admins see a dashboard reminder until the active key is confirmed.
- `ENCRYPTION_KEY` (or each id in the `ENCRYPTION_KEYS` map), including a
retired id for as long as a stored or off-host backup copy still needs it;
- `BACKUP_ENCRYPTION_KEY` and any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, when
off-host copies are set up;
- `API_TOKEN_HMAC_KEY`, without which every session, token, share link and
calendar-feed link ends (no data is lost).
Each key is shown by id and a fingerprint (the first 12 hex characters of
SHA-256 over the key), never the key itself, and **Check my copy** compares a
pasted copy without storing it. Keep a copy of each outside the server, then
confirm the kit. Admins see a dashboard reminder until it is confirmed, and the
kit is due again whenever a key it needs changes: a rotation, a new backup
key, a re-keyed install. The kit also links the
[disaster recovery guide](../ops/disaster-recovery.md).
The **Recovery readiness** card on the admin overview and the Backups page
judges every backup signal in one place: the database dump, the weekly account
copies, the off-host copies, the last restore drill, whether off-host copies
are set up, whether the bucket ever removes old database dumps, and the
recovery kit. An hourly check sends each admin a
**Backup health** notification when a signal turns to "needs attention" or "at
risk", once a day while it stays at risk, and once when it recovers. Route or
switch it off per channel under Notifications.
| Platform | Where the key is set |
| --------- | -------------------------------------------------------------------------------------------------- |
| Compose | `.env` next to `docker-compose.yml` |
| TrueNAS | Apps, HealthLog, Edit: the Encryption Key field (`ENCRYPTION_KEY` in a YAML install's environment) |
| Coolify | the application's Environment Variables |
| Unraid | the container template (Docker tab, Edit) or the Compose `.env` |
| Portainer | the stack's Environment variables |
| Platform | Where the key is set |
| --------- | --------------------------------------------------------------- |
| Compose | `.env` next to `docker-compose.yml` |
| TrueNAS | Apps, HealthLog, Edit, Environment |
| Coolify | the application's Environment Variables |
| Unraid | the container template (Docker tab, Edit) or the Compose `.env` |
| Portainer | the stack's Environment variables |
Set `HEALTHLOG_PLATFORM` (`compose`, `truenas`, `coolify`, `unraid`,
`portainer`) to open the kit on your platform's instructions.
`portainer`) to open the step on your platform's instructions.
To compute the fingerprint of your copy of a hex key, and of the token key:
To compute the fingerprint of your copy:
```bash
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
printf %s "<API_TOKEN_HMAC_KEY>" | sha256sum | cut -c1-12
```
**HealthLog refuses to serve: encryption key does not match this database.**
@@ -377,15 +341,6 @@ written under that key, so remove the setting once the key is confirmed.
Your database is the only stateful piece — back it up, and back up the
key that decrypts it.
**Database dumps the app takes itself.** Every night, and before an update
applies migrations, the app writes a `pg_dump` of the whole database with a
manifest into the `backups` volume and keeps the newest few. With the
off-host bucket configured, each nightly dump is also uploaded, encrypted.
`docker compose run --rm db-restore latest` restores one into an empty
database. What is kept where, the keys a restore needs, and restoring onto a
new server: [`docs/ops/disaster-recovery.md`](../ops/disaster-recovery.md).
Keep a copy of those dumps off this host.
**Local snapshot (the common single-box case).** Dump the `db` service
with `pg_dump`:
@@ -419,11 +374,8 @@ encryption key safely (keep the old key until zero rows remain on it) see
A new image can bring database migrations, and `docker-entrypoint.sh`
applies them on the first start of that image. Most are additive, but some
rewrite data, and going back to the previous image does not undo them. The
way back from a migration that went wrong is a dump taken before it. The
entrypoint takes one itself (`healthlog-<time>-pre-migration.dump` in the
`backups` volume, see
[`docs/ops/disaster-recovery.md`](../ops/disaster-recovery.md)); a dump of
your own before every pull, kept off the host, is the second copy:
only way back from a migration that went wrong is the dump you took before
it. So take one before every pull:
```bash
# 1. Dump the database (same command as under "Backup and restore").
@@ -441,27 +393,17 @@ curl -s http://localhost:3000/api/version
Keep the dump until the new version has run for a few days, and keep your
encryption key with it: a dump without the key cannot be read.
The database image is a separate matter: a new Postgres minor is a change of
the `image:` line. The move from 16 to 18 runs by itself on the first start
of the compose file from v1.46; a database you run yourself moves by dump
and restore. Both are in
[`docs/ops/postgres-major-upgrade.md`](../ops/postgres-major-upgrade.md).
The start log says what is about to happen before the migrations run:
```text
HealthLog: 2 database migrations pending. Unless DB_BACKUP_BEFORE_MIGRATE=false, a pg_dump of the database is written to DB_BACKUP_DIR before they run (docs/ops/disaster-recovery.md); a migration is not undone by going back to the previous image.
HealthLog: taking a database dump before 2 pending migration(s) into /var/lib/healthlog/backups...
HealthLog: pre-migration dump written: healthlog-20261025T081502Z-pre-migration.dump (212.4 MB, 231 tables, 38 s).
HealthLog: 2 database migrations pending. Back up the database before updating (pg_dump, see the Updates section of docs/self-hosting/getting-started.md); a migration is not undone by going back to the previous image.
HealthLog: Running database migrations...
```
or `HealthLog: Database schema is up to date.` when the image brings none.
The migrations run right after the dump. A dump that fails on a writable
directory stops the start before anything is migrated, and the log names the
switch that skips it once (`DB_BACKUP_BEFORE_MIGRATE=false`). Your own dump
belongs before step 2, not after you see these lines; by then the database is
already being migrated.
The line is a notice and does not pause the start: the migrations run right
after it. So the dump belongs before step 2, not after you see the line; by
then the database is already being migrated.
If an update to v1.43 stops at migration `0409_drop_plaintext_location`,
nothing was changed; the recovery is in
+4 -4
View File
@@ -19,7 +19,7 @@ on top of it, see [building skills](../api/mcp-skills.md).
> **Read this before enabling writes.** A remote, write-capable connector
> reached over OAuth is a powerful combination. Read the
> [trust model](#trust-model--read-this-before-enabling-writes) at the end
> [trust model](#trust-model-read-this-before-enabling-writes) at the end
> of this page first.
## Prerequisites
@@ -43,7 +43,7 @@ LAN-only origin works for Claude Desktop over stdio).
The switch lives with every other module: **Settings → Modules**, the
**MCP** row. Turn it on and the `/mcp` endpoint goes live for your account.
**Settings → MCP connector** is where you use it. The page has three parts:
**Settings → MCP** is where you use it. The page has three parts:
- **State** — whether the module is on, with a link to its switch in
Modules, and your endpoint URL (`<origin>/mcp`). Turning the module off
@@ -55,7 +55,7 @@ The switch lives with every other module: **Settings → Modules**, the
## 2. Mint a connector token
Still in **Settings → MCP connector → Tokens**:
Still in **Settings → MCP → Tokens**:
1. Choose the scope with the **write** toggle:
- **off** → a read-only token (`health:read`). This is the default and
@@ -106,7 +106,7 @@ refused (`403`) as a DNS-rebinding defence.
client-id-metadata-document clients with dynamic registration as a
fallback.
3. Approve the consent screen. The connection then appears under
**Settings → MCP connector → Connections**, where you can revoke it.
**Settings → MCP → Connections**, where you can revoke it.
No token to paste — OAuth mints and rotates a short-lived `health:read`
access token (60 minutes, with a refresh token) behind the scenes.
+2 -2
View File
@@ -1,7 +1,7 @@
# HealthLog on Portainer
HealthLog ships a Portainer **app template** that deploys the app and its
PostgreSQL 18 database as one stack from the project `docker-compose.yml`.
PostgreSQL 16 database as one stack from the project `docker-compose.yml`.
## Add the template
@@ -39,7 +39,7 @@ curl -s http://<host>:3000/api/version
## Notes
- Persistent state lives in one named volume (`postgres-data`, the Postgres data);
- Persistent state lives in one named volume (`pgdata`, the Postgres data dir);
the app container is stateless. Back up the database to back up everything.
- Pin a release tag (`HEALTHLOG_IMAGE_REF=:vX.Y.Z`) for deliberate upgrades, or
track `:latest`. Skim the
+2 -2
View File
@@ -5,7 +5,7 @@
"type": 3,
"title": "HealthLog",
"name": "healthlog",
"description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 18 as one stack.",
"description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 16 as one stack.",
"note": "Generate each secret with: openssl rand -hex 32 (a 64-char hex string). For plain-HTTP / LAN access set SESSION_COOKIE_SECURE to false, or login fails. Set NEXT_PUBLIC_APP_URL and APP_URL to how you reach the app. Full guide: https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/getting-started.md",
"categories": ["Tools", "Other"],
"platform": "linux",
@@ -33,7 +33,7 @@
{
"name": "HEALTHLOG_PLATFORM",
"label": "Platform hint",
"description": "Opens the admin's recovery kit on the Portainer instructions.",
"description": "Opens the admin's encryption key backup step on the Portainer instructions.",
"default": "portainer"
},
{
+10 -43
View File
@@ -95,52 +95,19 @@ different things depending on which button you press.
two actions below, the row stays. A start that was folded into a later
cycle stays for as long as that cycle does, because deleting the later
cycle gives it back.
- **Settings → Advanced → Delete all data** removes the rows, tombstones
- **Settings → Delete All Data** removes the rows outright, tombstones
included. Everything the account owns goes except what you sign in
with; the confirmation dialog lists it. Before it does, it keeps a copy
of the account on the server for the deletion grace window (14 days by
default), so a mistaken wipe can be restored; the copy is erased when
the window ends.
- **Deleting the account** locks it at once and erases it, with every
record, when the grace window ends. Until then an admin can undo it.
with; the confirmation dialog lists it.
- **Deleting the account** removes the account row, and the database
cascades every record with it.
`ACCOUNT_DELETION_GRACE_DAYS=0` makes both immediate again, without the
copy and without an undo.
Three consequences follow, and they matter for the seizure threat model
Two consequences follow, and they matter for the seizure threat model
above. A host imaged while a per-entry deletion is the only deletion you
have performed still holds that entry; so does a host imaged inside the
grace window. A backup taken before any of these actions still holds
whatever it captured, because a backup is a copy of a moment: the nightly
database dumps keep a deleted record until they age out (`DB_BACKUP_KEEP`
nights on the server, and your bucket's lifecycle rule for `database/`).
And after the move to Postgres 18 the old `pgdata` volume is a full copy
of the database from that day until you remove it. If your reason for
deleting is that the data must not be recoverable from the machine, set
the grace window to 0 or wait it out, remove the old volume, and let the
dumps age out or delete them
([backup-restore.md](../ops/backup-restore.md)).
## Calendar subscription
The cycle settings can publish your forecast as a calendar feed that Google
Calendar, Apple Calendar or Outlook subscribes to. It is off by default, and
turning it on changes the arrangement this page describes: from then on the
feed's dates also sit with that calendar provider, on its servers, under its
policies. That is exactly the cloud vendor self-hosting otherwise keeps out of
the picture.
What the feed carries is deliberately small. Each event is an all-day date
range and a title, nothing else: no symptoms, notes, flow, temperatures, test
results, contraception or intercourse fields. By default the title is a dot
and the calendar is named "HealthLog", so a glance at a shared screen says
nothing; past cycles are left out unless you ask for them, and the fertile
window is only offered when your goal is trying to conceive.
Turning the subscription off, or rotating its link, stops the provider's next
poll. It does not reach the copies the provider already fetched: remove the
subscribed calendar there as well. The link itself is a credential; see
[keeping it out of access logs](./reverse-proxy.md#credentials-in-the-url-path-keep-them-out-of-access-logs).
have performed still holds that entry. And a backup taken before any of
these actions still holds whatever it captured, because a backup is a
copy of a moment. If your reason for deleting is that the data must not
be recoverable from the machine, use one of the two whole-record actions
and rotate your backups.
## Practical hardening
+13 -150
View File
@@ -16,19 +16,17 @@ tailnet with no extra container. See
## Pre-flight — env vars the proxy interacts with
| Variable | Required for | Notes |
| ------------------------ | ---------------------------------------------- | ---------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
| `TRUSTED_PROXY_CIDRS` | Which connections may report a client address | Unset: any private address; see below |
| `TRUST_CF_CONNECTING_IP` | Reading the visitor address Cloudflare sends | Off unless `1`; see the warning below |
| Variable | Required for | Notes |
| --------------------- | ---------------------------------------------- | ---------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
Set the URL pair to the public origin (`https://your-instance.example.com`)
and restart the `app` container. The image reads both at startup; no
rebuild needed.
All five variables are on the shipped `docker-compose.yml`
All three variables are on the shipped `docker-compose.yml`
`environment:` whitelist, so a value in `.env` reaches the container
without editing the compose file. (Before v1.37.33 the whitelist was
missing `TRUST_PROXY_HOPS`; on those versions add
@@ -42,105 +40,22 @@ chain from the **right**, counting back `TRUST_PROXY_HOPS` entries.
This guards against a client rotating `X-Forwarded-For` per request
to defeat IP-based rate limits.
| Topology | `TRUST_PROXY_HOPS` |
| --------------------------------------------------------- | ---------------------------------- |
| App is internet-facing with no proxy | `0` (the connection's own address) |
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
| Cloudflare → your proxy → app | `2` |
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
| Topology | `TRUST_PROXY_HOPS` |
| --------------------------------------------------------- | ------------------------------------- |
| App is internet-facing with no proxy | `0` (no forwarding header is trusted) |
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
| Cloudflare → your proxy → app | `2` |
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
A misconfigured count logs a one-shot warning to stderr and collapses
every anonymous caller into one shared rate-limit bucket. Match the
value to the actual hop count.
Forwarding headers are believed only from a connection whose own address
is not a public one, which is where a proxy you run connects from:
- IPv4: loopback (`127.0.0.0/8`), `0.0.0.0/8`, the private ranges
(`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), CGNAT
(`100.64.0.0/10`, Tailscale), link-local (`169.254.0.0/16`), the reserved
blocks `192.0.0.0/24` and `198.18.0.0/15`, and everything from
`224.0.0.0` up (multicast and reserved);
- IPv6: `::` and `::1`, link-local (`fe80::/10`), site-local (`fec0::/10`),
unique-local (`fc00::/7`, Tailscale), multicast (`ff00::/8`), and an
IPv4-mapped or embedded address from one of the IPv4 ranges above.
The reserved and multicast blocks are in the list because no host on the
internet can open a connection from them, so a peer there sits on a network
you run. A request that reaches HealthLog straight from a public
address carries headers its sender wrote, so HealthLog ignores them and
uses the connection's own address, and says so once in the log. The log
line also shows up when the published port is open to the internet beside
the proxy; bind it to the proxy's network or to `127.0.0.1` then.
That list is the default when `TRUSTED_PROXY_CIDRS` is unset, and it is
only as good as the network in front of the port. Read the next section
before relying on it.
### `TRUSTED_PROXY_CIDRS` — name the proxy
Set `TRUSTED_PROXY_CIDRS` to the addresses your reverse proxy connects
from, as comma-separated CIDRs (IPv4 or IPv6; a bare address counts as a
single host). Once it is set, only a connection from inside those ranges
may report the client's address. Every other connection is its own client,
whatever its address, private ones included, and the log says so once.
Why it matters: some setups make every caller look like a private address.
Docker Desktop, OrbStack, and Docker's `docker-proxy` (the userland proxy
behind a published port such as `3000:3000`) forward each connection from
the gateway address of the Docker network, which is private. On those
hosts, someone who can reach the published port directly, from the LAN or
from the internet if the port is open, passes the private-address check
and can again choose the address that every per-IP rate limit, the audit
log and the sign-in notices see, a new one per request if they like.
Naming your proxy closes that. The default port binding in
`docker-compose.yml` stays as it is (LAN installs rely on it); this
variable is the fix.
What to set:
| Where the proxy runs | `TRUSTED_PROXY_CIDRS` |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| On the same host, proxying to `127.0.0.1:3000` (bare Nginx, Caddy) | `127.0.0.1/32,::1/128` |
| In a container on a Docker network with HealthLog (Traefik, NPM, Coolify) | that network's subnet, e.g. `172.18.0.0/16` (`docker network inspect <network>` shows it under `IPAM`) |
| On another machine that connects from a public address | that machine's address, e.g. `198.51.100.7/32` |
| No proxy, or `tailscale serve` only | leave it unset |
At boot the app logs one `[proxy-trust]` line naming the rule in force:
which connections it reads forwarding headers from and how many hops deep.
A value that is not a CIDR is logged as an error, and until it is fixed no
connection may report a client address.
**A proxy that connects from a public address.** Without
`TRUSTED_PROXY_CIDRS`, such a proxy is not believed: every request through
it resolves to the proxy's own address, so all your users share one
rate-limit bucket and one person mistyping a password can hold sign-in back
for everyone. Releases before v1.46 believed any peer, so this shows up after
the upgrade. Set `TRUSTED_PROXY_CIDRS` to the proxy's address (or put the proxy
on a private network with HealthLog) and restart.
### `TRUST_CF_CONNECTING_IP` — only when the origin takes Cloudflare only
With `TRUST_CF_CONNECTING_IP=1` HealthLog takes the visitor's address from
the `CF-Connecting-IP` header, before any of the checks above: from every
connection, whatever its address, and whatever `TRUST_PROXY_HOPS` says.
Cloudflare sets that header on every request through its edge, but any
client can send it too. So the flag is safe only when nothing but
Cloudflare can reach the origin: a Cloudflare Tunnel with no published
port, or a firewall that admits only
[Cloudflare's address ranges](https://www.cloudflare.com/ips/) to the port
HealthLog or its proxy listens on (Authenticated Origin Pulls on top is
better still). If the origin also answers on its own public address, a
caller who finds it chooses the address every per-IP rate limit, the audit
log and the sign-in locations record, and changes it on every request.
Without Cloudflare in front, leave the flag unset.
`X-Real-IP` gets the same trust as `X-Forwarded-For`. It is read only
when `TRUST_PROXY_HOPS` is exactly `1` and the request carries no
`X-Forwarded-For`, which covers a single proxy that sets only
`X-Real-IP`. With `0` neither header is believed, because without a proxy
in front both come from the caller; the address the connection came from
is used instead. With `2` or more, the hop count
in front both come from the caller. With `2` or more, the hop count
describes an `X-Forwarded-For` chain, so have your proxies send one.
If requests arrive with a forwarding header the setting does not let
@@ -327,58 +242,6 @@ The one-shot stderr warning `[getClientIp] TRUST_PROXY_HOPS=N but
X-Forwarded-For carried M entries` confirms a mismatch. Adjust the
env var down to the actual chain length and restart.
If every request logs the same client address, the proxy's own, the proxy
is not one HealthLog believes: the `[getClientIp]` warning about a
connection "from a public address" or "from outside TRUSTED_PROXY_CIDRS"
names the case. Add the proxy's address to `TRUSTED_PROXY_CIDRS`.
A `TRUST_PROXY_HOPS` value that is not a whole number resolves no client
address at all (requests keep working) and is named in the `[proxy-trust]`
boot line.
## Credentials in the URL path: keep them out of access logs
A few HealthLog URLs carry their credential in the path, because the caller
cannot send a header: the cycle calendar feed (`/api/cycle/feed/hlf_…`,
polled by Google, Apple or Outlook), the clinician share view (`/c/hls_…`)
and the provider webhooks. HealthLog scrubs these segments from its own logs
and error reports. It cannot reach your proxy's access log, which records the
full path of every request by default, so anyone who can read that log can
read a live feed link.
Either drop those requests from the access log or rewrite the path before it
is written. For Caddy, skip logging the token-bearing paths:
```caddyfile
your-instance.example.com {
@credential_paths path /api/cycle/feed/* /c/*
log_skip @credential_paths
reverse_proxy app:3000
}
```
For Nginx, log a redacted path instead of `$request`:
```nginx
map $request_uri $redacted_uri {
~^/api/cycle/feed/ /api/cycle/feed/[REDACTED];
~^/c/ /c/[REDACTED];
default $request_uri;
}
log_format redacted '$remote_addr - [$time_local] "$request_method $redacted_uri" '
'$status $body_bytes_sent "$http_user_agent"';
access_log /var/log/nginx/access.log redacted;
```
Traefik's access log has no per-path filter; turn `accessLog` off, or keep it
on a host only you can read. If a feed link has been in a log you do not
control, rotate it in the app (Cycle, Settings, Calendar subscription, Rotate
link): the old one stops working on the provider's next poll.
The feed answers with `Cache-Control: private, no-cache` and an `ETag`, so a
caching proxy in front must pass `If-None-Match` through rather than cache the
body for everyone.
## Running over plain HTTP on a LAN (no reverse proxy)
The session cookie carries the `Secure` flag by default under
+2 -77
View File
@@ -129,19 +129,9 @@ looks healthy. Deploy both from the same image tag
(`HEALTHLOG_IMAGE_REF` is shared by `app` and the `app-worker` block)
and restart the worker together with the web containers.
## Database role
Both containers run the entrypoint, and both set up the database role the
app connects as ([database-role.md](database-role.md)). They take turns on an
advisory lock and derive the same password from `API_TOKEN_HMAC_KEY` and the
database's name, which the shared environment block gives both, so a worker
that starts after the web container changes nothing the web container relies
on. Rotating `API_TOKEN_HMAC_KEY` changes that password: restart all
containers together.
## Healthchecks
- `app` (web) healthcheck: a `node -e` fetch of `/api/health` every 30s.
- `app` (web) healthcheck: `wget /api/health` every 30s.
- `app-worker` does not expose a port; its liveness is tracked
in-memory inside the process (`src/lib/jobs/worker-status.ts`) and
read back by `/api/admin/status`. In a web/worker split this means
@@ -184,7 +174,7 @@ sized for the web traffic alone leaves the worker's jobs queueing for
the same connections.
Plan total Postgres slots as **container_count × budget**.
A stock Postgres container ships with `max_connections = 100`, so
A stock Postgres 16 container ships with `max_connections = 100`, so
the safe envelope is:
| Web replicas | Worker replicas | Total containers | Pool slots used | Headroom under 100 |
@@ -260,68 +250,3 @@ The implementation lives in `src/lib/db.ts → getConnectionBudget()`
(split into `getPrismaPoolMax()` and `getPgBossPoolMax()`); the
20-slot default was chosen after measuring on a 4-CPU production
container — see the inline rationale in `src/lib/db.ts`.
## Large accounts
An account that holds tens of millions of readings, most often a
full-history Google Health backfill of minute-by-minute heart rate, runs
into the per-statement limit the app sets on every database connection:
60 seconds, from `DATABASE_STATEMENT_TIMEOUT_MS` (unset means 60000, `0`
turns the limit off).
### What it looks like
- Dashboard cards stay empty, or `/api/daily/digest` and other reads
answer 500.
- The log shows `job.run.failed` for `rollup-full-backfill` with
`consecutiveFailures` counting up, and Postgres error `57014`:
`canceling statement due to statement timeout`.
- The admin status page lists `rollup-full-backfill` under failing
background jobs, and every admin gets a push once a system job has
failed three jobs in a row (retries of one job count once; a job that runs
per account counts per account), a reminder at most once a day while it
keeps failing, and a notice when the failing job runs again. A one-off job
that failed and does not run again is announced once.
### What the app does about it
- The full rollup backfill, which runs at boot for any account whose
summary tier is incomplete (and after a release that adds a column to it),
folds the five-year window one calendar year at a time. Every statement of
it runs under its own limit of five minutes, whatever
`DATABASE_STATEMENT_TIMEOUT_MS` says, and a run that reaches its time budget
hands the remaining years to a follow-up job. The readers that fall back to
the raw readings while that tier is incomplete are fast again once it has
finished.
- Heart rate older than 90 days is folded into one mean per hour, for Apple
Health, Health Connect, Google Health and Oura alike. The DAY summaries keep
the figures taken from the raw samples, and a later backfill or full sync
does not write the folded samples back. On an account with a dense history
the first fold works through the backlog over a few nights, one day per
transaction, and leaves an account alone while its summary tier is still
being rebuilt. Postgres reuses the freed table pages for new rows; to give
the index space back to the disk, run the index rebuild of the measurement
maintenance job from Admin, System status, once the backlog is through
(`docs/ops/measurement-maintenance.md`).
### When to raise the limit
Normally never: the long background passes bring their own limits. Raise
`DATABASE_STATEMENT_TIMEOUT_MS` only when request reads still time out on a
large account after the backfill has finished (the failing-jobs card is
empty), or when something between the app and Postgres sets a lower limit.
In that case also check `SHOW statement_timeout;` as the database role
HealthLog connects with: a limit set on the role or the database, or by a
pooler, applies too. Recreate the container after changing the variable so
every connection picks it up:
```yaml
# docker-compose.yml
services:
app:
environment:
DATABASE_STATEMENT_TIMEOUT_MS: "180000" # three minutes
```
A higher limit lets one slow statement hold a pool connection longer, so
raise it in steps rather than turning it off.
+1 -2
View File
@@ -101,6 +101,5 @@ and host names before it is shown.
`docker compose logs app` prints every wide event as one JSON line;
`LOG_LEVEL=debug` adds debug events. With `LOKI_ENDPOINT` set the same events
go to Loki, and GlitchTip (Admin, Integrations, once a DSN is set there)
receives unexpected server errors.
go to Loki, and GlitchTip (Admin, General) receives unexpected server errors.
Each line carries `request_id`, so an error id finds its line there too.
+60 -187
View File
@@ -1,167 +1,82 @@
# HealthLog on TrueNAS
# HealthLog on TrueNAS SCALE
HealthLog runs on TrueNAS Community Edition (formerly SCALE) as a Docker app:
one container from `ghcr.io/mbombeck/healthlog` and one PostgreSQL database.
The app container is stateless. Everything you care about lives in the
database dataset and in one value you must keep yourself: the encryption key.
HealthLog runs on TrueNAS SCALE as a Docker app: one `app` container from
`ghcr.io/mbombeck/healthlog` and one PostgreSQL 16 database. The app container
is stateless; everything you care about lives in the database dataset and in
one value you must keep yourself: the encryption key.
> **Status: pending catalog review.** A HealthLog app for the TrueNAS
> Community train has been prepared but is not listed yet. Until it is, use
> [Install via YAML](#before-the-listing-install-via-yaml) at the end of this
> page. This note goes away once the app appears under Apps, Discover Apps.
## What the app needs
## Install from the Community train
| Setting | Value |
| ------------ | ---------------------------------------------------------------------------------------- |
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
| Port | `3000` (HTTP) |
| User | runs as uid `1001`; no extra capabilities, `no-new-privileges` is fine |
| Health check | `GET /api/health`, `200` when ready |
| Start period | at least 60 s, with more retries than the image's 5 × 30 s: migrations run at every boot |
| Database | PostgreSQL 16 (17 and later are not a drop-in), no extensions |
| Volumes | none for the app; the database gets its own dataset |
1. Generate two secrets. Open the TrueNAS shell (System, Shell) and run the
command twice, once for each field:
## Environment
```bash
openssl rand -hex 32
```
| Variable | Required | Notes |
| ----------------------- | -------- | ------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | yes | `postgresql://<user>:<password>@<host>:5432/<db>` |
| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS (see below). |
| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. |
| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` |
| `APP_URL` | yes | the same address |
| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy |
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's key backup step opens on the TrueNAS instructions |
| `GEOLITE2_DIR` | no | a mounted directory of GeoLite2 `.mmdb` files, so sign-in locations are resolved on the NAS |
Each run prints 64 hex characters. Put both in your password manager
before you continue; see [Save the recovery kit](#save-the-recovery-kit).
2. Open Apps, Discover Apps, search for **HealthLog** and choose Install.
3. Fill in the form:
| Field | What to enter |
| --------------------- | -------------------------------------------------------------------------------------------------------------- |
| Timezone | your timezone |
| Database Password | any strong password; only the two containers use it |
| Encryption Key | the first `openssl rand -hex 32` output |
| Token Signing Key | the second output. Changing it later signs everyone out |
| App URL | the address you open HealthLog at, with scheme and port, for example `http://192.168.1.10:30536` |
| Secure Cookies | leave off when you open HealthLog over `http://` on the LAN; turn on behind an HTTPS reverse proxy (see below) |
| WebUI Port | `30536` unless that port is taken |
| Postgres Data Storage | the default ixVolume, or a host path on a dataset you snapshot |
| Resources | keep memory at 1536 MB or more; the default of 4096 MB is fine |
4. Install. The first start applies the database migrations before the web
server listens, so give it a minute. The app shows as running once
`/api/health` answers.
5. Open the App URL. The first account you register becomes the admin.
The App URL has to match the address in the browser: passkeys, sign-in
redirects and the links in notifications are built from it. If you later put
HealthLog behind a reverse proxy with its own domain, change App URL to that
`https://` address.
Settings the form does not show go under Additional Environment Variables, for
example `UPDATE_CHECK_DISABLED=1`. The full list is in
[`.env.production.example`](../../.env.production.example) and on the
[environment variables reference](https://docs.healthlog.dev/reference/environment-variables/).
To use your own GeoLite2 databases, add the directory under Additional Storage
and point `GEOLITE2_DIR` at its mount path ([geolite2.md](geolite2.md)).
HealthLog writes a whole-database dump every night, and one before each update
that brings migrations, into `/var/lib/healthlog/backups` inside the app
container. Give that path a dataset of its own (a host path or an ixVolume,
writable by uid `1001`), under Additional Storage if the form has no field for
it. Without one the directory sits inside the container, which an update
replaces, so HealthLog keeps no dump there: the start log, the admin backups
page and the recovery readiness card say so, and with an off-host bucket the
nightly dump goes to the bucket only.
`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app
and runs the background jobs.
## Plain HTTP on the LAN
Most NAS installs are reached over `http://`. Leave **Secure Cookies** off for
that. With it on, the browser drops the sign-in cookie on a plain-HTTP page,
and the login page says so before you type a password: "Signing in cannot
complete on this page". Turn it on only when HealthLog is served over
`https://`, and set App URL to that `https://` address.
Most NAS installs are reached over `http://`. Set `SESSION_COOKIE_SECURE=false`
for that. If it is left at its default, the browser drops the sign-in cookie
and the login page says so before you type a password: "This server only sets
its sign-in cookie over https://".
## Save the recovery kit
## Back up your encryption key
HealthLog encrypts tokens, notes, documents and in-database backups with the
Encryption Key. The key is not stored in the database. If the app is deleted
and installed again with a new key over the old database dataset, the data is
HealthLog encrypts tokens, notes, documents and in-database backups with
`ENCRYPTION_KEY`. The key is not stored in the database. If the app is deleted
and reinstalled with a new key over the old database dataset, the data is
still there but cannot be read.
TrueNAS keeps the values you typed into the form with the app's configuration
on the apps pool. Treat that as a convenience, not as your copy: deleting the
app removes it, and a reinstall starts with empty fields. Keep the key in a
password manager or another place outside the NAS.
1. Open Apps, select HealthLog and choose Edit. The key is the Encryption Key
field under HealthLog Configuration.
2. Sign in as the admin and open Admin, Encryption. The **Recovery kit** lists
every key a restore needs (the Encryption Key, the token key and, with
off-host copies, the backup key), each with an id and a fingerprint, the
first 12 hex characters of SHA-256 over the key. Compare it with your copy:
1. Copy `ENCRYPTION_KEY` from the app's settings (Apps, HealthLog, Edit,
Environment) into your password manager.
2. Sign in as the admin and open Admin, Encryption. The step shows the key id
and a fingerprint, the first 12 hex characters of SHA-256 over the key.
Compare it with your copy:
```bash
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
```
or paste the copy into **Check my copy**. It is compared and dropped, never
stored or logged.
or paste the copy into "Check my copy" (it is compared and dropped, never
stored or logged).
3. Press **I have a copy of every key**. Until you do, admins see a reminder on
the dashboard. After a key rotation or a new backup key the kit is due again.
The catalog app sets `HEALTHLOG_PLATFORM=truenas`, so the kit opens on these
instructions.
## Backups and restore
A restore needs two things: a backup of the database **and** the Encryption
Key that was active when it was taken. One without the other gets you nothing
back.
- **Snapshots.** Snapshot the Postgres Data Storage dataset (the ixVolume
lives under the apps pool, a host path wherever you put it). Stop the app
first, or the snapshot holds a database that was mid-write.
- **A dump.** Open a shell on the `postgres` container (Apps, HealthLog,
the container's Shell) and run:
```bash
pg_dump -U healthlog healthlog > /var/lib/postgresql/healthlog-$(date +%F).sql
```
The file lands in the Postgres Data Storage dataset, next to the database
files, so copy it off the NAS as well.
- **The nightly dump.** With a dataset on `/var/lib/healthlog/backups` (see
above), the newest seven nightly dumps and the last two taken before a
migration stay there; [disaster-recovery.md](../ops/disaster-recovery.md)
restores one onto a new server.
- **Off-host.** HealthLog can push encrypted nightly backups to any
S3-compatible bucket; see [backup-restore.md](../ops/backup-restore.md).
To restore a dump, run the app with the Encryption Key the dump was taken
under, put the dump file in the Postgres Data Storage dataset, and from the
`postgres` container shell replace the database with it:
```bash
psql -U healthlog -d postgres \
-c "DROP DATABASE healthlog WITH (FORCE);" \
-c "CREATE DATABASE healthlog OWNER healthlog;"
psql -v ON_ERROR_STOP=1 -U healthlog healthlog < /var/lib/postgresql/healthlog-YYYY-MM-DD.sql
```
Then restart the app. Restoring over the existing database without dropping
it first does not work: the tables the background queue creates on its own
refuse a plain overwrite.
3. Confirm the backup. Until you do, admins see a reminder on the dashboard.
After a key rotation the step comes back for the new key.
## When the key does not match the database
At every start HealthLog checks that its key opens the data already in the
database. A missing or malformed key stops the container at once with
`HealthLog: ERROR — encryption configuration is invalid.` in its log. A
well-formed key that does not open the stored data is different: HealthLog
refuses to serve instead of failing on every encrypted value. Pages show
"HealthLog cannot open its data", API requests answer `503` with
`meta.errorCode` `encryption.key_mismatch`, `/api/health` answers `503` with
`reason: "encryption_key_mismatch"`, and no background jobs run. TrueNAS
shows the app as unhealthy rather than crashed, so the reason stays visible in
the app's logs.
database. If it does not, it refuses to serve instead of failing on every
encrypted value: pages show "HealthLog cannot open its data", API requests
answer `503` with `meta.errorCode` `encryption.key_mismatch`, `/api/health`
answers `503` with `reason: "encryption_key_mismatch"`, and no background jobs
run. TrueNAS shows the app as unhealthy rather than crashed, so the reason
stays visible.
Fix it one of three ways, then restart the app:
1. Put the original Encryption Key back (Apps, HealthLog, Edit).
2. Point the app at the database that belongs to this key, for example by
restoring that dataset.
1. Put the original `ENCRYPTION_KEY` back.
2. Point `DATABASE_URL` at the database that belongs to this key.
3. Start fresh with an empty database. The old data and its backups need the
old key.
@@ -176,8 +91,9 @@ checks again at the next start.
If you are certain the configured key is the one the data was written with,
and the app still refuses because the check recorded a different key earlier,
remove that record and restart. Open a shell on the `postgres` container and
run, with the key id the log names (`v1` unless you use `ENCRYPTION_KEYS`):
remove that record and restart. Open a shell on the database container (Apps,
HealthLog, the database container's Shell) and run, with the key id the log
names (`v1` unless you use `ENCRYPTION_KEYS`):
```bash
psql -U healthlog healthlog \
@@ -192,7 +108,7 @@ id: the rotation script removes its record once no values remain under it.
On a first start there is no record to remove. If the check refuses there and
you are certain the key is right, add `ENCRYPTION_KEY_CHECK` = `warn` under
Additional Environment Variables as a last resort. The check still runs and
Apps, HealthLog, Edit, Environment as a last resort. The check still runs and
logs, `/api/health` reports `warning: "encryption_key_mismatch"` without
failing, the app serves, and no key is recorded until the check passes. This
switches off a safety check: with a wrong key every encrypted value fails and
@@ -201,49 +117,6 @@ confirmed.
## Updates
The catalog follows HealthLog releases on its own: when a new image is
published, the TrueNAS catalog picks it up and the app shows an update. Take a
snapshot or a dump first, then update.
Database migrations run at every start, before the web server listens, and
only go forward. The start log says how many are pending. Going back to an
older version with Roll Back does not undo them, and an older version may not
start against a database a newer one has migrated. The way back is the
database backup you took before the update, restored together with the older
version.
The Postgres Image field offers the Postgres major versions the catalog
supports. Changing it runs a one-way upgrade of the database files, so back up
before you touch it.
## Before the listing: install via YAML
Until the catalog app is listed, install HealthLog with Apps, Discover Apps,
the menu next to Custom App, Install via YAML, using these values:
| Setting | Value |
| ------------ | -------------------------------------------------------------------------------------- |
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
| Port | `3000` (HTTP) |
| User | runs as uid `1001` by default; no extra capabilities, `no-new-privileges` is fine |
| Health check | `GET /api/health`, `200` when ready |
| Start period | 10 min, like the image: migrations run at every boot and wait out a held lock |
| Database | PostgreSQL 18 (16 works too), no extensions, in its own container with its own dataset |
| Volumes | `/var/lib/healthlog/backups` for the database dumps (uid `1001`); the database its own |
| Variable | Required | Notes |
| ----------------------- | -------- | ---------------------------------------------------------------------------------------- |
| `DATABASE_URL` | yes | `postgresql://<user>:<password>@<host>:5432/<db>` |
| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS. |
| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. |
| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` |
| `APP_URL` | yes | the same address |
| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy |
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's recovery kit opens on the TrueNAS instructions |
`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app
and runs the background jobs. In a YAML install the key sits in the
environment block, so the place to copy it from is `ENCRYPTION_KEY` there.
To move from a YAML install to the catalog app later, take a dump, install the
catalog app with the same key, and restore the dump as described under
[Backups and restore](#backups-and-restore).
Pick the new tag, take a snapshot of the database dataset first, and update.
Migrations run on start; the first start of a new install applies all of them,
which is why the start period above is generous.
+18 -33
View File
@@ -6,13 +6,10 @@ self-contained install (app + database as one unit) and is recommended. The
Unraid UI, but because CA templates describe a single container you install
PostgreSQL separately and wire the two together.
HealthLog stores all persistent state in PostgreSQL. The app container keeps one
more thing, its database dumps: every night, and before each update that brings
migrations, it writes a whole-database dump to `/var/lib/healthlog/backups`
(the offline geo-IP databases are either baked in at build time or mounted
read-only; replaceable either way, not state). A backup of the database is a
backup of everything, and those dumps are that backup, so that path needs a
share of its own.
HealthLog stores all persistent state in PostgreSQL — the app container itself is
stateless (the offline geo-IP databases are either baked in at build time or
mounted read-only; replaceable either way, not state). A backup of
the database is a backup of everything.
## What you need
@@ -72,12 +69,10 @@ curl -s http://tower:3000/api/version
Use this if you prefer the per-app Unraid UI. You install PostgreSQL yourself,
then add HealthLog pointed at it.
### Step 1 — install PostgreSQL
### Step 1 — install PostgreSQL 16
1. In **Apps** (Community Applications), search for a **PostgreSQL** container
(for example jj9987's `postgresql`). Install version 18; an existing 16
works too. Moving a 16 to 18 later is a dump and restore, see
[Moving to a newer Postgres major](../ops/postgres-major-upgrade.md#by-hand).
(for example jj9987's `postgresql`). Install version 16.
2. Set:
- `POSTGRES_USER` = `healthlog`
- `POSTGRES_PASSWORD` = your generated password
@@ -106,20 +101,12 @@ Fill in the fields:
| `SESSION_COOKIE_SECURE` | `false` for plain-HTTP LAN access |
| `NEXT_PUBLIC_APP_URL` | `http://tower:3000` (or your domain) |
| `APP_URL` | same as `NEXT_PUBLIC_APP_URL` |
| Database dumps | `/mnt/user/appdata/healthlog/backups` (the default) |
Replace `<password>` and `<postgres-host>` with the values from Step 1. The
dumps folder must be writable by the app's user, uid `1001`; create it once
from the terminal with
`mkdir -p /mnt/user/appdata/healthlog/backups && chown 1001:1001 /mnt/user/appdata/healthlog/backups`.
Apply, then open `http://tower:3000` — the first registered user becomes the
admin.
Replace `<password>` and `<postgres-host>` with the values from Step 1. Apply,
then open `http://tower:3000` — the first registered user becomes the admin.
All account data is in the PostgreSQL container you installed in Step 1, and
the nightly dumps of it land in the dumps folder. Without that path the dumps
would sit inside the HealthLog container, which every update replaces, so
HealthLog keeps none: the start log, the admin backups page and the recovery
readiness card say so. Back up both.
The HealthLog container has no persistent volume of its own; all state is in the
PostgreSQL container you installed in Step 1. Back that one up.
---
@@ -127,22 +114,20 @@ readiness card say so. Back up both.
`SESSION_COOKIE_SECURE=false` is required for plain-HTTP LAN installs
(`http://tower:3000`). Left at its default, the session cookie carries the
`Secure` flag, which the browser would drop over HTTP. The login page says so
before anything is sent: "Signing in cannot complete on this page".
`Secure` flag, the browser refuses to send it over HTTP, and login appears to
"do nothing" — the page reloads to the login screen with no error.
Set `SESSION_COOKIE_SECURE=true` (or leave it unset) only when a TLS reverse
proxy serves HealthLog over HTTPS. In that case also point `NEXT_PUBLIC_APP_URL`
and `APP_URL` at the public `https://` URL.
## Save the recovery kit
## Back up your encryption key
Copy `ENCRYPTION_KEY`, `API_TOKEN_HMAC_KEY` and, with off-host copies,
`BACKUP_ENCRYPTION_KEY` from the template into your password manager, then
confirm the recovery kit under Admin, Encryption (set
`HEALTHLOG_PLATFORM=unraid` so the kit opens on the Unraid instructions). A
reinstall with a new key over the old database makes the data unreadable;
HealthLog then refuses to serve and says so. See
[Getting started](getting-started.md#save-the-recovery-kit).
Copy `ENCRYPTION_KEY` from the template into your password manager, then
confirm it under Admin, Encryption (set `HEALTHLOG_PLATFORM=unraid` so the step
opens on the Unraid instructions). A reinstall with a new key over the old
database makes the data unreadable; HealthLog then refuses to serve and says
so. See [Getting started](getting-started.md#back-up-your-encryption-key).
## Updates
+4 -6
View File
@@ -2,7 +2,7 @@
<!--
HealthLog — Unraid Community Applications template.
CA templates describe ONE container. HealthLog needs PostgreSQL (18, or 16), so
CA templates describe ONE container. HealthLog needs PostgreSQL 16, so
install a PostgreSQL container FIRST (CA has community PostgreSQL templates),
create a `healthlog` database + user, then point DATABASE_URL below at it.
Full walkthrough: docs/self-hosting/unraid.md.
@@ -26,7 +26,7 @@
cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no
vendor cloud, no telemetry.
REQUIRES a separate PostgreSQL 18 (or 16) container — install one first (CA has
REQUIRES a separate PostgreSQL 16 container — install one first (CA has
community PostgreSQL templates), create a database and user, then set
DATABASE_URL below. Step-by-step guide:
https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/unraid.md
@@ -46,14 +46,14 @@
<Config Name="WebUI Port" Target="3000" Default="3000" Mode="tcp" Description="HTTP port HealthLog listens on." Type="Port" Display="always" Required="true" Mask="false">3000</Config>
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL (18, or 16). Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL 16. Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="ENCRYPTION_KEY" Target="ENCRYPTION_KEY" Default="" Mode="" Description="AES-256-GCM key for data at rest. Generate with: openssl rand -hex 32 (64-char hex). Back this up — losing it makes encrypted data unrecoverable." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="API_TOKEN_HMAC_KEY" Target="API_TOKEN_HMAC_KEY" Default="" Mode="" Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="SESSION_COOKIE_SECURE" Target="SESSION_COOKIE_SECURE" Default="false" Mode="" Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy." Type="Variable" Display="always" Required="true" Mask="false">false</Config>
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's recovery kit on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's encryption key backup step on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
<Config Name="ENCRYPTION_KEY_CHECK" Target="ENCRYPTION_KEY_CHECK" Default="enforce" Mode="" Description="Start-up encryption key check. enforce refuses to serve when the key does not open the stored data. warn only logs it: a last resort for a refusal you are certain is false, and it disables a safety check." Type="Variable" Display="advanced" Required="false" Mask="false">enforce</Config>
<Config Name="NEXT_PUBLIC_APP_URL" Target="NEXT_PUBLIC_APP_URL" Default="http://tower:3000" Mode="" Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy." Type="Variable" Display="always" Required="true" Mask="false"/>
@@ -63,6 +63,4 @@
<Config Name="ENCRYPTION_KEYS" Target="ENCRYPTION_KEYS" Default="" Mode="" Description="Optional. Key-rotation map, e.g. {&quot;v1&quot;:&quot;&lt;hex&gt;&quot;,&quot;v2&quot;:&quot;&lt;hex&gt;&quot;}. Leave empty unless rotating; use ENCRYPTION_KEY instead." Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="ENCRYPTION_ACTIVE_KEY_ID" Target="ENCRYPTION_ACTIVE_KEY_ID" Default="" Mode="" Description="Optional. Active key id for the ENCRYPTION_KEYS map (e.g. v2). Leave empty when using ENCRYPTION_KEY." Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="Database dumps" Target="/var/lib/healthlog/backups" Default="/mnt/user/appdata/healthlog/backups" Mode="rw" Description="Where HealthLog keeps its nightly database dump and the one it takes before each update that brings migrations. Without this path the dumps would sit inside the container, which an update replaces, so none is kept. Must be writable by uid 1001 (chown 1001:1001 the folder once)." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/healthlog/backups</Config>
</Container>
+1 -1
View File
@@ -311,7 +311,7 @@ Tiles at the top of the dashboard live in a **single row**. Width caps
at the chart's width directly below. **Never** wrap to a second row.
If the user enables more tiles than fit, the dashboard surfaces a banner
inviting them to deactivate or reorder via Settings → Arrangement. The
inviting them to deactivate or reorder via Settings → Dashboard. The
layout editor in Settings shows a live preview of the constraint and
flags overflow before save.
-55
View File
@@ -1,55 +0,0 @@
# Subscribing to your cycle forecast
HealthLog can publish your cycle forecast as a calendar subscription, so the
next few periods show up in the calendar app you already use. The feed is
computed fresh every time your calendar app asks for it; HealthLog stays the
only place the data is kept.
## Turning it on
Open **Cycle**, then **Settings**, and find **Calendar subscription**. Before
the button, the card says what it means: your calendar provider stores the
dates on its servers, and turning the subscription off later does not remove
copies it already holds. HealthLog asks you to confirm it is you (a password or
passkey) and then shows the link once. Copy it, or use **Open in calendar app**.
Only you can turn it on for your own record. For a managed profile (a child's
record, for instance), its guardian can. Someone you have shared your record
with cannot, whatever level of access you gave them: the feed puts your dates
in their calendar provider's cloud, and that is your decision alone. It is
managed on the web; the iOS app shows where to find it.
## Adding it to your calendar
- **Google Calendar:** Other calendars, From URL, paste the link. Google
refreshes subscriptions on its own schedule, roughly every 12 to 24 hours.
- **Apple Calendar:** on a Mac, File, New Calendar Subscription. On iPhone,
Settings, Calendar, Accounts, Add Account, Other, Add Subscribed Calendar.
Apple lets you choose how often it refreshes.
- **Outlook:** Add calendar, Subscribe from web, paste the link. Outlook
refreshes every few hours.
## What the feed shows
- The next period and, once three cycles are logged, a few after it (three
by default, up to six). Each event covers the whole window the period may
fall in, and the window widens the further out it is. The list stops itself
where a date would be more guess than estimate (beyond about ±10 days, or
half a cycle).
- Optionally, your past cycles (up to twelve).
- Optionally, when your goal is trying to conceive, the estimated fertile
window of the next cycle. It is an estimate, not a contraceptive method.
Titles are discreet by default: each event is a dot and the calendar is called
"HealthLog". Descriptive titles name the period instead. Events never carry
symptoms, notes or any other detail of your log.
When a period is later than its window, the feed shows no forecast until you
log it, instead of guessing a new date. With predictions switched off, only
past cycles appear (if you chose to include them).
## Rotating and turning off
**Rotate link** replaces the link; calendars using the old one stop updating.
**Turn off** ends the link. In both cases, remove the old subscribed calendar
in your calendar app too: what it already fetched stays there until you do.
+43 -239
View File
@@ -18,11 +18,7 @@
* 5. what was deleted is back under its ORIGINAL id, and what arrived after
* the snapshot is gone — which is what "the restore replaces the
* account's data tables" in `docs/ops/backup-restore.md` means when it is
* spelled out;
* 6. a second person downloads their own full backup, their account is
* recreated empty under the same id (the runbook's new host), the admin
* uploads the file and restores it, and the person signs in to the same
* rows under the same ids.
* spelled out.
*
* Refusing controls, because a restore path that cannot say no is worse than
* none: a stored copy with one flipped ciphertext byte is refused and changes
@@ -40,20 +36,19 @@
*
* Everything the journey does, it does through the app: the readings and the
* document ride the page's own `fetch`, the archive and the snapshot ride the
* real buttons, the restore rides the typed-confirmation dialog. The only
* real buttons, the restore rides the typed-confirmation dialog. The only two
* things reaching Postgres directly are the ones a browser cannot express —
* resetting the accounts between repetitions, corrupting one stored copy so
* the authenticated envelope has something to refuse (both in
* `setup/backup-journey-fixture.ts`), and recreating the second account under
* its own id (`setup/critical-fixture.ts`).
* resetting the account between repetitions, and corrupting one stored copy so
* the authenticated envelope has something to refuse. Both live in
* `setup/backup-journey-fixture.ts`.
*
* Serial and single-project: the five tests are five acts of one journey, they
* mutate the accounts destructively, and the last one moves a session's record
* Serial and single-project: the four tests are four acts of one journey, they
* mutate the account destructively, and the last one moves a session's record
* selector. Its jar is its own for the reason `global-setup.ts` spells out.
*/
import { readFile } from "node:fs/promises";
import type { Page, Response } from "@playwright/test";
import type { Page } from "@playwright/test";
import { EXPORT_ARGON2_PARAMS } from "@/lib/export/passphrase-archive";
@@ -70,13 +65,6 @@ import {
storedMeasurements,
storeTamperedCopy,
} from "./setup/backup-journey-fixture";
import {
accountRowCounts,
clearLoginBuckets,
criticalAccount,
recreateCriticalAccount,
resetCriticalAccount,
} from "./setup/critical-fixture";
import {
actWithReproof,
completeReproofWithPassword,
@@ -91,20 +79,8 @@ test.describe.configure({ mode: "serial" });
* The pass backs up every account in the database, the restore rewrites a
* dozen tables in one transaction, and the archive runs Argon2id at the OWASP
* cost. None of that fits the suite's 30-second default on a loaded runner.
* On top of that, a test must hold the restore wait below in full.
*/
test.setTimeout(240_000);
/**
* How long a queued restore may take to finish. The restore shares the heavy
* lane with the backup pass, and a restore that finds the lane taken goes
* back to the queue for `LANE_DEFER_SECONDS` (60 s, `src/lib/jobs/heavy-lane.ts`).
* The pass this journey starts can still be finishing when the restore is
* asked for: on a loaded machine its tail ran 15 s, the restore was put back
* by 60, and a 60-second wait ran out one second before it started. The
* budget holds one deferral and a second yield with room to run.
*/
const RESTORE_BUDGET_MS = 135_000;
test.setTimeout(180_000);
/** Long enough to clear the export card's own 12-character minimum. */
const ARCHIVE_PASSPHRASE = "e2e-backup-journey-passphrase";
@@ -115,8 +91,6 @@ const RESTORED_WEIGHT_KG = 81.4;
const UNTOUCHED_PULSE_BPM = 58;
/** The reading that arrives AFTER the snapshot and must not survive it. */
const POST_SNAPSHOT_PULSE_BPM = 66;
/** The second account's reading, carried through its own backup. */
const FRESH_ACCOUNT_WEIGHT_KG = 67.3;
const MEDICATION_NAME = "E2E Backup Tablet";
@@ -317,45 +291,6 @@ async function restoreThroughDialog(page: Page, id: string) {
return answered;
}
/**
* Wait for a queued restore to finish, and fail with its code if it failed.
*
* The restore runs on the `backup-restore` queue: the request answers 202
* with the job and its status route, and the worker inside the server under
* test does the work. Nothing after this is read until the job says it is
* done, so every assertion is about the finished restore rather than a moment
* in the middle of it.
*/
async function awaitRestore(page: Page, response: Response): Promise<void> {
expect(response.status()).toBe(202);
const queued = (await response.json()) as {
data: { jobId: string; status: string; statusUrl: string };
};
expect(queued.data.status).toBe("queued");
expect(queued.data.statusUrl).toBe(
`/api/admin/backups/restores/${queued.data.jobId}`,
);
await expect
.poll(
async () => {
const res = await page.request.get(queued.data.statusUrl);
const body = (await res.json()) as {
data: {
status: string;
failure: { code: string } | null;
} | null;
};
// A failed job ends the wait with its code in the message instead of
// running the clock out on a state that will never change.
return body.data?.status === "failed"
? `failed: ${body.data.failure?.code ?? "unknown"}`
: body.data?.status;
},
{ timeout: RESTORE_BUDGET_MS, intervals: [250, 500, 1_000] },
)
.toBe("succeeded");
}
test.describe("Backup and restore, through the settings surfaces", () => {
test.use({ storageState: BACKUP_STORAGE_STATE_PATH });
@@ -384,7 +319,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
tamperedCopyId = null;
});
test("a record is written, sealed into an HLX1 archive, and snapshotted @critical", async ({
test("a record is written, sealed into an HLX1 archive, and snapshotted", async ({
page,
}) => {
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
@@ -570,7 +505,7 @@ test.describe("Backup and restore, through the settings surfaces", () => {
expect(await storedMeasurements()).toEqual(before);
});
test("the snapshot puts back what was deleted and drops what came after @critical", async ({
test("the snapshot puts back what was deleted and drops what came after", async ({
page,
}) => {
const record = requireSeeded();
@@ -628,10 +563,39 @@ test.describe("Backup and restore, through the settings surfaces", () => {
expect(beforeRestore.map((m) => m.id)).toContain(laterId);
await openBackupsConsole(page);
await awaitRestore(
page,
await restoreThroughDialog(page, requireSnapshot()),
const response = await restoreThroughDialog(page, requireSnapshot());
// The restore runs on the `backup-restore` queue: the request answers 202
// with the job and its status route, and the worker inside the server
// under test does the work. Nothing below is read until the job says it
// is done, so every assertion is about the finished restore rather than
// a moment in the middle of it.
expect(response.status()).toBe(202);
const queued = (await response.json()) as {
data: { jobId: string; status: string; statusUrl: string };
};
expect(queued.data.status).toBe("queued");
expect(queued.data.statusUrl).toBe(
`/api/admin/backups/restores/${queued.data.jobId}`,
);
await expect
.poll(
async () => {
const res = await page.request.get(queued.data.statusUrl);
const body = (await res.json()) as {
data: {
status: string;
failure: { code: string } | null;
} | null;
};
// A failed job ends the wait with its code in the message instead
// of running the clock out on a state that will never change.
return body.data?.status === "failed"
? `failed: ${body.data.failure?.code ?? "unknown"}`
: body.data?.status;
},
{ timeout: 60_000, intervals: [250, 500, 1_000] },
)
.toBe("succeeded");
const afterRestore = await storedMeasurements();
@@ -686,166 +650,6 @@ test.describe("Backup and restore, through the settings surfaces", () => {
),
).toHaveCount(0);
});
test("a full backup the operator restores brings the record back into a fresh account @critical", async ({
page,
browser,
}, testInfo) => {
// The second account: a person with a record of their own, who downloads
// the file the export card offers. Its jar is its own; the act below
// gives it a session that needs a re-proof, like the admin's.
const person = criticalAccount("restore", testInfo);
await resetCriticalAccount(person, [
"DELETE FROM measurement_rollups WHERE user_id = $1",
"DELETE FROM measurements WHERE user_id = $1",
"DELETE FROM mood_entry_rollups WHERE user_id = $1",
"DELETE FROM mood_entries WHERE user_id = $1",
"DELETE FROM rate_limits WHERE key = 'export:' || $1",
]);
const context = await browser.newContext({
storageState: person.storageStatePath,
baseURL: testInfo.project.use.baseURL,
});
let backupPath = "";
let written: { weightId: string; moodId: string };
try {
const own = await context.newPage();
const endOwnSession = await useStaleSession(own, person.username);
try {
await own.goto("/measurements", { waitUntil: "domcontentloaded" });
await expect(own.locator(MEASUREMENTS_SETTLED).first()).toBeVisible({
timeout: 30_000,
});
written = await own.evaluate(async (weightKg: number) => {
const post = async (path: string, body: unknown) => {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = (await res.json()) as { data: { id: string } | null };
if (!res.ok || !json.data) {
throw new Error(`${path} answered ${res.status}`);
}
return json.data.id;
};
const at = new Date(Date.now() - 90 * 60_000).toISOString();
return {
weightId: await post("/api/measurements", {
type: "WEIGHT",
value: weightKg,
measuredAt: at,
}),
moodId: await post("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: at,
}),
};
}, FRESH_ACCOUNT_WEIGHT_KG);
await own.goto("/settings/export", { waitUntil: "domcontentloaded" });
await expect(own.getByTestId("export-card-full-backup")).toBeVisible({
timeout: 30_000,
});
const download = own.waitForEvent("download");
await actWithReproof(own, person.password, () =>
own.getByTestId("export-action-full-backup").click(),
);
// Kept past the context: a download's own file goes with it.
backupPath = testInfo.outputPath("full-backup.json");
await (await download).saveAs(backupPath);
} finally {
await endOwnSession();
}
} finally {
await context.close();
}
// ── the account is gone, and comes back empty under the same id ──────
// What a new host is to the runbook (`docs/ops/backup-restore.md`): the
// backup names its owner by id, so the account is recreated under it
// before the upload. A browser cannot express this; it is the one act
// here that goes to Postgres.
await recreateCriticalAccount(person);
expect(await accountRowCounts(person)).toEqual({
measurements: 0,
moodEntries: 0,
});
// ── the operator uploads the file and restores it ───────────────────
await openBackupsConsole(page);
const uploaded = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/admin/backups/upload" &&
res.request().method() === "POST" &&
res.status() !== 401,
);
await actWithReproof(page, E2E_BACKUP_ADMIN.password, () =>
page
.locator('input[type="file"][accept*="application/gzip"]')
.setInputFiles(backupPath),
);
const upload = await uploaded;
expect(upload.status(), (await upload.text()).slice(0, 400)).toBe(201);
const uploadId = ((await upload.json()) as { data: { id: string } }).data
.id;
await expect(snapshotRow(page, uploadId)).toHaveCount(1, {
timeout: 30_000,
});
await awaitRestore(page, await restoreThroughDialog(page, uploadId));
// ── and the person signs in to their record ──────────────────────────
const back = await browser.newContext({
baseURL: testInfo.project.use.baseURL,
});
try {
await clearLoginBuckets();
const login = await back.request.post("/api/auth/login", {
data: { email: person.username, password: person.password },
});
expect(login.status()).toBe(200);
const signedIn = await back.newPage();
// The same rows, under their own ids: a restore, not a re-entry.
const measurements = (
(await (await signedIn.request.get("/api/measurements")).json()) as {
data: { measurements: Array<{ id: string; value: number }> };
}
).data.measurements;
expect(measurements).toEqual([
expect.objectContaining({
id: written.weightId,
value: FRESH_ACCOUNT_WEIGHT_KG,
}),
]);
const moods = (
(await (await signedIn.request.get("/api/mood-entries")).json()) as {
data: { entries: Array<{ id: string; mood: string }> };
}
).data.entries;
expect(moods).toEqual([
expect.objectContaining({ id: written.moodId, mood: "OKAY" }),
]);
await signedIn.goto("/measurements", { waitUntil: "domcontentloaded" });
await expect(
signedIn
.locator(
'[data-testid="measurement-row"][data-measurement-type="WEIGHT"]' +
`[data-measurement-value="${FRESH_ACCOUNT_WEIGHT_KG}"]`,
)
.first(),
).toBeVisible({ timeout: 30_000 });
} finally {
await back.close();
}
// The admin's own record is untouched by a restore aimed at somebody else.
expect(
(await storedMeasurements()).filter((m) => m.deletedAt === null),
).toHaveLength(2);
});
});
test.describe("A read-level delegate inside the record", () => {
+8 -25
View File
@@ -85,29 +85,6 @@ async function shellReady(page: Page): Promise<void> {
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
}
/**
* Scan the open palette once it has finished opening. The dialog fades in,
* and a scan that lands mid-fade reads every colour blended with the page
* behind it: on a loaded machine the selected option's text measured 4.47:1
* against a 4.5:1 floor it clears once the fade ends. Finite animations are
* awaited, bounded, so a stuck one costs the wait and not the test.
*/
async function scanPalette(page: Page) {
await page.evaluate(async () => {
const finite = document
.getAnimations()
.filter((a) => a.effect?.getTiming().iterations !== Infinity);
await Promise.race([
Promise.allSettled(finite.map((a) => a.finished)),
new Promise((resolve) => window.setTimeout(resolve, 2_000)),
]);
});
return new AxeBuilder({ page })
.include('[data-slot="command-palette"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
}
test.describe("command palette", () => {
test.beforeEach(async ({ context }, testInfo) => {
test.skip(
@@ -204,7 +181,10 @@ test.describe("command palette", () => {
page.locator('[data-group="records"] mark').first(),
).toHaveText("Shoulder");
const axe = await scanPalette(page);
const axe = await new AxeBuilder({ page })
.include('[data-slot="command-palette"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations).toEqual([]);
// The arrows reach it after the local options, and Enter opens it.
@@ -246,7 +226,10 @@ test.describe("command palette", () => {
await expect(palette(page)).toBeVisible();
await expect(input(page)).toBeFocused();
const axe = await scanPalette(page);
const axe = await new AxeBuilder({ page })
.include('[data-slot="command-palette"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations).toEqual([]);
await page.keyboard.press("Escape");
-34
View File
@@ -332,40 +332,6 @@ test.describe("the day view", () => {
await expect(page.locator('[data-slot="day-strip"]')).toHaveCount(0);
});
test("beside the docked day, every blood-pressure value keeps its unit on its line", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"docked from 1280 px",
);
// The narrowest docked width: each half of the stat card is narrowest
// here. Four cells a row left "1xx mmHg" no room, so the systolic values
// broke onto two lines while the two-digit diastolic ones fitted.
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/insights/blood-pressure?day=${isoDaysAgo(3)}`);
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
const values = page.locator(
'[data-slot="metric-stat-series"] [data-slot="metric-stat"] > p:nth-child(2)',
);
await expect(values).toHaveCount(8);
const cells = await values.evaluateAll((els) =>
els.map((el) => {
const lineHeight = parseFloat(getComputedStyle(el).lineHeight);
return {
text: el.textContent ?? "",
lines: Math.round(el.getBoundingClientRect().height / lineHeight),
overflow: el.scrollWidth - el.clientWidth,
};
}),
);
for (const cell of cells) {
expect(cell.text, cell.text).toMatch(/mmHg$/);
expect(cell.lines, cell.text).toBe(1);
expect(cell.overflow, cell.text).toBeLessThanOrEqual(0);
}
});
test("the strip runs the full height; the search magnifier stays clear of it", async ({
page,
}, testInfo) => {
+3 -7
View File
@@ -136,7 +136,7 @@ test.describe("delegated writes", () => {
).toBeVisible();
}
test("the owner invites at a level that may add entries @critical", async () => {
test("the owner invites at a level that may add entries", async () => {
await openInviteForm();
const identifier = ownerPage.locator(
@@ -183,9 +183,7 @@ test.describe("delegated writes", () => {
).toBe(WRITE_LEVEL_VALUE);
});
test("the delegate accepts and opens the record @critical", async ({
page,
}) => {
test("the delegate accepts and opens the record", async ({ page }) => {
await page.goto("/settings/access");
await page.locator('[data-slot="grant-accept"]').first().click();
@@ -205,9 +203,7 @@ test.describe("delegated writes", () => {
await expect(banner).not.toContainText("read it, not change it");
});
test("the reading they add lands in the owner's record @critical", async ({
page,
}) => {
test("the reading they add lands in the owner's record", async ({ page }) => {
await page.goto("/measurements");
await expect(
page.locator('[data-slot="shared-record-banner"]'),
+13 -10
View File
@@ -49,8 +49,7 @@
import { readFile } from "node:fs/promises";
import type { Page, Response } from "@playwright/test";
import { readPdfText } from "@/lib/documents/pdfjs";
import { PDFParse } from "pdf-parse";
import {
E2E_REPORT_OWNER,
@@ -287,6 +286,7 @@ async function chooseStandardPdfScope(page: Page): Promise<void> {
interface GeneratedReport {
status: number;
contentType: string;
contentLength: string | undefined;
bytes: Buffer;
text: string;
filename: string;
@@ -342,11 +342,18 @@ async function generateReport(page: Page): Promise<GeneratedReport> {
expect(response.headers()["content-type"] ?? "").toContain("application/pdf");
expect(bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES);
const text = await readPdfText(new Uint8Array(bytes));
const parser = new PDFParse({ data: new Uint8Array(bytes) });
let text: string;
try {
text = (await parser.getText()).text;
} finally {
await parser.destroy();
}
return {
status: response.status(),
contentType: response.headers()["content-type"] ?? "",
contentLength: response.headers()["content-length"],
bytes,
text,
filename: download.suggestedFilename(),
@@ -368,13 +375,9 @@ function expectRealPdf(report: GeneratedReport): void {
expect(report.contentType).toContain("application/pdf");
expect(report.bytes.subarray(0, 5).toString("latin1")).toBe("%PDF-");
expect(report.bytes.byteLength).toBeGreaterThan(MIN_PDF_BYTES);
// A body truncated on the way out keeps its header but loses the trailer,
// which a magic-number check alone would miss. The route's Content-Length
// cannot say this: Next drops that header from every route response since
// 16.4.5, so the bytes have to.
expect(report.bytes.subarray(-1024).toString("latin1").trimEnd()).toMatch(
/%%EOF$/,
);
// The route declares the length it sent; a disagreement means the body was
// truncated on the way out, which a magic-number check alone would miss.
expect(report.contentLength).toBe(String(report.bytes.byteLength));
// The name the CLIENT anchor invents, not the route's `Content-Disposition`
// — the two differ by a word today. This says the panel saved a PDF, and it
// is not contract coverage of the header.
+8 -55
View File
@@ -20,19 +20,15 @@ import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { E2E_VAULT, VAULT_STORAGE_STATE_PATH } from "./setup/global-setup";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import {
ensureVaultFixture,
seedNamespaceDocs,
vaultFixtureIds,
KNIE_EPISODE_ID,
MRT_DOC_ID,
} from "./setup/vault-fixture";
// The vault on an account of its own (`E2E_VAULT`): its layout is account
// state, and the month-grid tests read the default.
const { knieEpisodeId: KNIE_EPISODE_ID, mrtDocId: MRT_DOC_ID } =
vaultFixtureIds(E2E_VAULT.username);
const DEFAULT_MAX_FILE_BYTES = 26_214_400;
/** Scroll the shell's scroll container until `locator` is visible (the
@@ -149,10 +145,10 @@ test.describe("document vault", () => {
await serveAiBlock(page, aiBlockAvailable());
});
test.use({ storageState: VAULT_STORAGE_STATE_PATH });
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture(E2E_VAULT.username);
await ensureVaultFixture();
});
// ── v1.42 — the archive as a month grid of preview tiles ──────────────
@@ -536,7 +532,7 @@ test.describe("document vault", () => {
test("bulk: 50-item kind change round-trips", async ({ page, isMobile }) => {
test.skip(isMobile, "range selection is a desktop pointer flow");
test.setTimeout(90_000);
await seedNamespaceDocs("bulkfifty", 50, E2E_VAULT.username);
await seedNamespaceDocs("bulkfifty", 50);
// Shared filing date → the namespace orders by id desc: "050" first,
// "001" last. Anchor on the first card, shift-click the last.
@@ -572,7 +568,7 @@ test.describe("document vault", () => {
isMobile,
}) => {
test.skip(isMobile, "bulk selection is a desktop pointer flow");
await seedNamespaceDocs("bulkpf", 5, E2E_VAULT.username);
await seedNamespaceDocs("bulkpf", 5);
await page.goto("/documents?q=bulkpf");
for (let i = 1; i <= 5; i++) {
@@ -607,7 +603,7 @@ test.describe("document vault", () => {
}) => {
test.skip(isMobile, "range selection is a desktop pointer flow");
test.setTimeout(120_000);
await seedNamespaceDocs("bulkchunk", 101, E2E_VAULT.username);
await seedNamespaceDocs("bulkchunk", 101);
// Newest-first by id tiebreak: "101" renders first, "001" last. Walk
// the infinite query to the end (pages of 50) so the range can span
@@ -820,49 +816,6 @@ test.describe("document vault", () => {
await expect(openButton(page, `${marker}-copy.pdf`)).not.toBeVisible();
});
test("a busy server is waited out and the file still lands", async ({
page,
isMobile,
}) => {
test.skip(isMobile, "runs once on the desktop project (shared bucket)");
// The route answers the first POST the way a full upload slot does; the
// queue must retry it after Retry-After instead of showing an error.
let refused = 0;
await page.route("**/api/documents/inbound", async (route) => {
if (route.request().method() === "POST" && refused === 0) {
refused += 1;
await route.fulfill({
status: 429,
headers: { "Retry-After": "1", "Content-Type": "application/json" },
body: JSON.stringify({
data: null,
error: "Too many uploads are already in progress.",
meta: {
errorCode: "documents.inbound.uploadBusy",
reason: "uploadBusy",
},
}),
});
return;
}
await route.fallback();
});
await page.goto("/documents");
const marker = `busy-probe-${Date.now()}`;
await uploadViaPicker(page, `${marker}.pdf`, uniquePdf(marker));
await page
.getByRole("searchbox", { name: "Search documents" })
.fill(marker);
await expect(openButton(page, `${marker}.pdf`)).toBeVisible({
timeout: 15_000,
});
expect(refused).toBe(1);
await expect(
page.locator('[data-slot="document-upload-card"]'),
).toHaveCount(0);
});
// ── Admin cap: commit-on-Enter + rejection copy naming the limit ──────
test("a 1 MB admin cap rejects an oversized upload with the limit in the copy", async ({
+1 -7
View File
@@ -62,14 +62,8 @@ function decimal(value: string): RegExp {
return new RegExp(value.replace(".", "[.,]"));
}
/**
* The list, filtered to blood glucose. Unfiltered it shows the account's
* newest page of every type, and other specs keep writing readings to the
* same account, so on a database a few runs old a reading typed for
* "yesterday" falls off that page and the count reads one short.
*/
async function openList(page: Page): Promise<void> {
await page.goto("/measurements?type=BLOOD_GLUCOSE");
await page.goto("/measurements");
await expect(page.locator(LIST_SETTLED).first()).toBeVisible({
timeout: 30_000,
});
-161
View File
@@ -1,161 +0,0 @@
/**
* The app header keeps its height in every language.
*
* A translated label that is a little longer than the English one wraps, and
* a header that wraps grows: the dashboard's title row once took a second
* line in one locale while every English check stayed green. The top bar and
* the dashboard's page-header row are measured here in every shipped locale,
* on a 390 px phone and on a desktop window, against the height English gives
* them on the same viewport.
*
* The language is switched the way the app switches it, through
* `PUT /api/auth/me/locale`, which writes the account's language and the
* locale cookie; `<html lang>` is then asserted before anything is measured,
* so a switch that silently did not happen cannot pass as "same height".
*
* The locale list is the app's own (`src/lib/i18n/config.ts`), so a language
* added there is swept without touching this file.
*/
import type { Page } from "@playwright/test";
import { locales, type Locale } from "@/lib/i18n/config";
import { HEADER_LOCALES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
const VIEWPORTS = [
{ label: "phone 390", width: 390, height: 844 },
{ label: "desktop 1280", width: 1280, height: 800 },
] as const;
type ViewportLabel = (typeof VIEWPORTS)[number]["label"];
interface HeaderGeometry {
topBar: number;
pageHeader: number;
/** For the failure message: what wrapped, not only that something did. */
title: string;
titleHeight: number;
}
/** English first: every other locale is compared with what it measured. */
const ORDER: Locale[] = ["en", ...locales.filter((l) => l !== "en")];
const reference = new Map<ViewportLabel, HeaderGeometry>();
async function readHeader(page: Page): Promise<HeaderGeometry | null> {
return page.evaluate(() => {
const topBar = document.querySelector('[data-slot="top-bar"]');
const rows = document.querySelectorAll(
'main [data-slot="page-header-row"]',
);
if (!topBar || rows.length !== 1) return null;
const row = rows[0];
const title = row.querySelector("h1");
return {
topBar: topBar.getBoundingClientRect().height,
pageHeader: row.getBoundingClientRect().height,
title: title?.textContent ?? "",
titleHeight: title?.getBoundingClientRect().height ?? 0,
};
});
}
/**
* Read the header until two reads a frame apart agree. The greeting swaps to
* the personalised line after hydration; measuring mid-swap would compare a
* frame nobody keeps.
*/
async function settledHeader(page: Page): Promise<HeaderGeometry> {
await page.evaluate(() => document.fonts.ready.then(() => undefined));
let previous: HeaderGeometry | null = null;
for (let attempt = 0; attempt < 20; attempt += 1) {
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
const current = await readHeader(page);
if (
current &&
previous &&
current.topBar === previous.topBar &&
current.pageHeader === previous.pageHeader
) {
return current;
}
previous = current;
}
throw new Error(
`header did not settle: ${JSON.stringify(previous ?? "not found")}`,
);
}
test.describe("header height in every locale", () => {
test.use({ storageState: HEADER_LOCALES_STORAGE_STATE_PATH });
// The account's language is one value; the locales take turns on it.
// It runs in the desktop project only (see `playwright.config.ts`) and sets
// both viewports itself.
test.describe.configure({ mode: "serial" });
for (const locale of ORDER) {
test(`top bar and dashboard header keep the English height in ${locale}`, async ({
page,
}) => {
test.setTimeout(60_000);
const switched = await page.request.put("/api/auth/me/locale", {
data: { locale },
});
expect(switched.status(), await switched.text()).toBe(200);
for (const viewport of VIEWPORTS) {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator("html")).toHaveAttribute("lang", locale);
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
await expect(
page.locator('main [data-slot="page-header-row"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="dashboard-header-greeting"]'),
).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.innerWidth))
.toBe(viewport.width);
const measured = await settledHeader(page);
const at = `${locale}, ${viewport.label}`;
// A band that is not there measures 0 in every locale and "agrees".
expect(
measured.topBar,
`top bar has no height (${at})`,
).toBeGreaterThan(32);
expect(
measured.pageHeader,
`page header has no height (${at})`,
).toBeGreaterThan(20);
if (locale === "en") {
reference.set(viewport.label, measured);
continue;
}
const english = reference.get(viewport.label);
expect(english, "the English reference ran first").toBeDefined();
const detail = `title "${measured.title}" ${measured.titleHeight}px tall vs "${english!.title}" ${english!.titleHeight}px`;
expect(
Math.abs(measured.topBar - english!.topBar),
`top bar is ${measured.topBar}px in ${at}, ${english!.topBar}px in English`,
).toBeLessThanOrEqual(1);
expect(
Math.abs(measured.pageHeader - english!.pageHeader),
`dashboard header is ${measured.pageHeader}px in ${at}, ${english!.pageHeader}px in English; ${detail}`,
).toBeLessThanOrEqual(1);
}
});
}
});
-104
View File
@@ -1,104 +0,0 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
import {
expectNoHorizontalOverflow,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* The labs list under its categories.
*
* The account is the one `globalSetup` seeds for the mobile route sweep: one
* marker under its own panel and one qualitative reading without a panel, so
* the server answers two categories and `grouped: true`. The grouping choice
* is served to this page only (the layout read is answered in the browser
* with the account's own layout plus `groupBy: "category"`), so the shared
* account's stored preferences stay as other specs expect them.
*/
const LAYOUT = "**/api/settings/module-list-layout";
const GROUP = '[data-slot="lab-list-group"]';
async function serveGrouped(page: Page, groupsOpen = "expanded") {
await page.route(LAYOUT, async (route) => {
if (route.request().method() !== "GET") return route.continue();
const response = await route.fetch();
const body = (await response.json()) as {
data: { lists: { labs: Record<string, unknown> } };
};
body.data.lists.labs = {
...body.data.lists.labs,
groupBy: "category",
groupsOpen,
saved: true,
};
await route.fulfill({ response, json: body });
});
}
test.describe("the lab list under its categories", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "de", url: baseURL },
]);
});
test("shows one disclosure per category and folds a category away", async ({
page,
}) => {
await serveGrouped(page);
await page.goto("/labs", { waitUntil: "domcontentloaded" });
const headers = page.locator(GROUP);
await expect(headers).toHaveCount(2);
await expect(headers.first()).toHaveAttribute("aria-expanded", "true");
const names = page.locator('[data-slot="lab-list-analyte"]');
const before = await names.count();
expect(before).toBeGreaterThan(1);
await headers.first().click();
await expect(headers.first()).toHaveAttribute("aria-expanded", "false");
await expect(names).toHaveCount(before - 1);
// The choice holds across a reload in this browser.
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.locator(GROUP).first()).toHaveAttribute(
"aria-expanded",
"false",
);
});
test("starts with every category closed when the person chose so", async ({
page,
}) => {
await serveGrouped(page, "collapsed");
await page.goto("/labs", { waitUntil: "domcontentloaded" });
const headers = page.locator(GROUP);
await expect(headers).toHaveCount(2);
for (const header of await headers.all()) {
await expect(header).toHaveAttribute("aria-expanded", "false");
}
await expect(page.locator('[data-slot="lab-list-analyte"]')).toHaveCount(0);
});
test("fits a phone and passes axe", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await serveGrouped(page);
await page.goto("/labs", { waitUntil: "domcontentloaded" });
await expect(page.locator(GROUP)).toHaveCount(2);
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, "/labs grouped at 390 px");
const results = await new AxeBuilder({ page })
.include('[data-slot="lab-list"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(results.violations.map((v) => v.id)).toEqual([]);
});
});
-46
View File
@@ -1,46 +0,0 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The tobacco section's typed fields take what a person types one key at a
* time. Through v1.47.0 neither did: "When you stopped" only accepted text
* that was already a whole YYYY-MM month, so every keystroke was thrown away
* and only a paste got in, and the start year clamped each keystroke into
* 1900..this year, so the first digit became 1900.
*
* The test types and leaves the fields without saving, so the shared
* account keeps whatever answers it had.
*/
test.describe("life circumstances typed fields", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("start year and quit month take keystrokes", async ({ page }) => {
await page.goto("/settings/anamnesis", { waitUntil: "domcontentloaded" });
const section = page.locator('[data-slot="life-section-tobacco"]');
await section.locator("summary").click();
await section.locator("#life-tobacco-status").selectOption("FORMER");
const start = section.locator("#life-tobacco-start");
await start.fill("");
await start.pressSequentially("1985");
await expect(start).toHaveValue("1985");
await start.blur();
await expect(start).toHaveValue("1985");
const quit = section.locator("#life-tobacco-quit");
await quit.fill("");
await quit.pressSequentially("2019-03");
await expect(quit).toHaveValue("2019-03");
await quit.blur();
await expect(quit).toHaveValue("2019-03");
// A single-digit month is padded when the field is left.
await quit.fill("");
await quit.pressSequentially("2018-7");
await expect(quit).toHaveValue("2018-7");
await quit.blur();
await expect(quit).toHaveValue("2018-07");
});
});
+44 -228
View File
@@ -1,89 +1,24 @@
import type { BrowserContext, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
clearLoginBuckets,
criticalAccount,
resetCriticalAccount,
type CriticalAccount,
} from "./setup/critical-fixture";
/**
* Signing in and out, against the real server.
* Smoke checks on the login surface — must render, must wire the
* password-manager autofill hints (autoComplete="username" /
* "current-password"), must let the user submit.
*
* Every test starts without a session and signs in through the form, so what
* it reads is what the login route, the session table and the shell did, not
* a payload typed into the test. The account is the sign-in journey's own
* (one per project, see `critical-fixture.ts`): it gets no jar from global
* setup, because the tests here end the sessions they open and a jar captured
* once would be dead for a retry.
*
* The passkey half runs through Chromium's virtual authenticator, attached
* over CDP. It is a real authenticator as far as the page and the server are
* concerned: the registration and the assertion are signed, and the server
* verifies both against the origin and RP id the suite runs under.
* These do NOT log in (no DB seed required); the password-flow
* roundtrip is exercised by the integration test in
* tests/integration/auth-flow.test.ts.
*/
test.use({ storageState: { cookies: [], origins: [] } });
const SESSION_COOKIE = "healthlog_session";
async function openPasswordForm(page: Page): Promise<void> {
async function openPasswordForm(page: import("@playwright/test").Page) {
await page.goto("/auth/login");
// The login page opens in passkey mode; the password form mounts after
// this button. The button is server-rendered, so the click is retried
// until the form it mounts is there.
const usePassword = page.getByTestId("login-use-password");
const email = page.getByTestId("login-email");
await expect(async () => {
await usePassword.click({ timeout: 2_000 });
await expect(email).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 30_000 });
}
async function signInWithPassword(
page: Page,
account: CriticalAccount,
): Promise<void> {
await openPasswordForm(page);
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill(account.password);
// The login route allows five attempts per IP per quarter hour and the
// whole suite is one IP; see `clearLoginBuckets`.
await clearLoginBuckets();
await page.getByTestId("login-password-submit").click();
await page.waitForURL((url) => url.pathname === "/", { timeout: 30_000 });
}
async function sessionCookie(context: BrowserContext): Promise<string> {
const cookie = (await context.cookies()).find(
(c) => c.name === SESSION_COOKIE,
);
expect(cookie, "the login answered without a session cookie").toBeTruthy();
return cookie!.value;
}
/** The username `/api/auth/me` answers for this context's session. */
async function signedInAs(page: Page): Promise<string | null> {
const res = await page.request.get("/api/auth/me");
if (res.status() !== 200) return null;
return ((await res.json()) as { data: { username: string } }).data.username;
}
async function signOutThroughTheMenu(page: Page): Promise<void> {
// The desktop sidebar and the phone top bar each carry a user menu; only
// one of them is on screen at a given width.
const trigger = page
.getByRole("button", { name: "User menu" })
.filter({ visible: true })
.first();
const signOut = page.getByRole("menuitem", { name: "Sign out" });
await expect(async () => {
await trigger.click({ timeout: 2_000 });
await expect(signOut).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 30_000 });
await signOut.click();
await page.waitForURL((url) => url.pathname === "/auth/login");
// The login page defaults to passkey mode; the password form is
// mounted only after clicking the "Login with password" / "Mit
// Passwort anmelden" outline button.
await page
.getByRole("button", {
name: /sign in with password|login with password|mit passwort/i,
})
.click();
}
test.describe("login page", () => {
@@ -92,166 +27,47 @@ test.describe("login page", () => {
}) => {
await openPasswordForm(page);
const username = page.getByTestId("login-email");
const username = page
.getByLabel(/username|benutzername|email|e-mail/i)
.first();
await expect(username).toBeVisible();
await expect(username).toHaveAttribute("autoComplete", /username|email/);
const password = page.getByTestId("login-password");
const password = page.locator('input[type="password"]').first();
await expect(password).toBeVisible();
await expect(password).toHaveAttribute("autoComplete", "current-password");
});
test("refuses a wrong password and opens no session", async ({
page,
context,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
test("rejects an obviously-wrong credential pair", async ({ page }) => {
await openPasswordForm(page);
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill("not-this-account-password");
await clearLoginBuckets();
const answer = page.waitForResponse(
(res) =>
res.url().endsWith("/api/auth/login") &&
res.request().method() === "POST",
);
await page.getByTestId("login-password-submit").click();
expect((await answer).status()).toBe(401);
await expect(page.getByText("Invalid credentials")).toBeVisible();
await expect(page).toHaveURL(/\/auth\/login/);
expect(
(await context.cookies()).some((c) => c.name === SESSION_COOKIE),
).toBe(false);
});
});
await page
.getByLabel(/username|benutzername|email|e-mail/i)
.first()
.fill("nobody-here");
await page.locator('input[type="password"]').first().fill("not-the-pw");
test.describe("signing in and out", () => {
test("a password sign-in lands on the dashboard as that account @critical", async ({
page,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await signInWithPassword(page, account);
await expect(page.locator("main")).toBeVisible();
expect(await signedInAs(page)).toBe(account.username);
});
test("signing out ends the session on the server, not only in the browser @critical", async ({
page,
context,
playwright,
baseURL,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
await signInWithPassword(page, account);
const cookie = await sessionCookie(context);
// A second client holding the same cookie, the way a copied jar or a
// stolen cookie would.
const replay = await playwright.request.newContext({
baseURL,
extraHTTPHeaders: { cookie: `${SESSION_COOKIE}=${cookie}` },
});
try {
// Positive control: the replayed cookie is a working session before
// the sign-out, so the 401 below is about the sign-out.
expect((await replay.get("/api/auth/me")).status()).toBe(200);
await signOutThroughTheMenu(page);
// The browser dropped its cookie; that alone would pass with a server
// that kept the row. The proof is the old cookie being refused.
expect(await signedInAs(page)).toBeNull();
expect((await replay.get("/api/auth/me")).status()).toBe(401);
expect((await replay.get("/api/measurements?limit=1")).status()).toBe(
401,
);
} finally {
await replay.dispose();
}
// And the protected shell sends the browser back to the form.
await page.goto("/");
await page.waitForURL((url) => url.pathname === "/auth/login");
});
test("a passkey registered in settings signs the account back in @critical", async ({
page,
context,
}, testInfo) => {
const account = criticalAccount("auth", testInfo);
// A retry, or the last run, may have left a credential on the account;
// the list below has to count only the one this test registers.
await resetCriticalAccount(account, [
"DELETE FROM passkeys WHERE user_id = $1",
]);
const cdp = await context.newCDPSession(page);
await cdp.send("WebAuthn.enable");
const { authenticatorId } = await cdp.send(
"WebAuthn.addVirtualAuthenticator",
{
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
},
// Intercept the API call so the test does not depend on a real
// backend rejecting the credentials. The login form's error
// surfacing is what we actually want to prove here.
await page.route("**/api/auth/login", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Invalid credentials" }),
}),
);
try {
await signInWithPassword(page, account);
// The form has multiple buttons matching /sign in/ — both the
// passkey CTA and the password submit. Filter to the submit button
// so the click lands on the right one.
await page
.locator('button[type="submit"]')
.filter({ hasText: /login|anmelden|sign in/i })
.click();
await test.step("register a passkey under the current password", async () => {
await page.goto("/settings/security");
await page.getByRole("button", { name: "Add passkey" }).click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible();
await dialog.locator('input[type="password"]').fill(account.password);
await dialog.getByRole("button", { name: "Verify and add" }).click();
await expect(dialog).toBeHidden();
await expect(
page.getByTestId("passkeys-list").locator("li"),
).toHaveCount(1);
const stored = await cdp.send("WebAuthn.getCredentials", {
authenticatorId,
});
expect(stored.credentials).toHaveLength(1);
expect(stored.credentials[0].isResidentCredential).toBe(true);
});
await signOutThroughTheMenu(page);
expect(await signedInAs(page)).toBeNull();
await test.step("sign in with it", async () => {
await clearLoginBuckets();
await page
.getByRole("button", { name: "Sign in with Passkey" })
.click();
await page.waitForURL((url) => url.pathname === "/", {
timeout: 30_000,
});
expect(await signedInAs(page)).toBe(account.username);
// The server recorded the use against the stored credential.
const res = await page.request.get("/api/auth/passkeys");
expect(res.status()).toBe(200);
const passkeys = (
(await res.json()) as {
data: Array<{ lastUsedAt: string | null }>;
}
).data;
expect(passkeys).toHaveLength(1);
expect(passkeys[0].lastUsedAt).not.toBeNull();
});
} finally {
await cdp
.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId })
.catch(() => undefined);
}
await expect(
page.getByText(/invalid credentials|ungültig|falsch/i),
).toBeVisible({ timeout: 5_000 });
});
});
+148 -111
View File
@@ -1,92 +1,140 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { openMenu } from "./open-menu";
/**
* Add-measurement flow, against the real server: the dashboard's quick-entry
* menu, the dialog-mounted MeasurementForm, the real POST to
* `/api/measurements`, and then every surface that has to agree about the
* reading afterwards: the dashboard tile, the weight chart (through the data
* table that lists exactly the points it drew) and the day view.
* Add-measurement flow — exercises the dashboard's quick-entry dropdown,
* the dialog-mounted MeasurementForm, the POST to /api/measurements, and
* the resulting query invalidation that surfaces the row on
* `/measurements`.
*
* Until v1.46 this spec answered its own POST and its own list read with a
* route stub, so it proved that the list renders a row the test typed in.
* Nothing here is stubbed now. The account is the measurement journey's own,
* one per project (`critical-fixture.ts`), and is emptied of readings before
* the test, so "the tile shows the weight" can only mean the weight this test
* entered.
*
* Weight is the simplest type (single numeric input + datetime), so it keeps
* the spec on the round trip rather than on the blood-pressure batch.
* Weight is the simplest type (single numeric input + datetime), so it
* keeps the spec focused on the round-trip rather than the BP three-field
* batched submit.
*/
/** Distinct from anything a fixture seeds, so a match is this reading. */
const WEIGHT_KG = "81.7";
/** Today in the browser's zone (`timezoneId` in `playwright.config.ts`). */
function todayInBerlin(): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
/** The account's weight readings, as the list route answers them. */
async function storedWeights(
page: Page,
): Promise<Array<{ value: number; unit: string }>> {
const res = await page.request.get("/api/measurements?type=WEIGHT");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: { measurements: Array<{ value: number; unit: string }> };
};
return data.measurements.map(({ value, unit }) => ({ value, unit }));
}
test.describe("add measurement flow", () => {
test.use({ storageState: criticalStorageState("measurement") });
test.use({ storageState: STORAGE_STATE_PATH });
// This journey lands on the dashboard, which is the most expensive page in
// the product to render cold, and then reads three more surfaces. The
// assertions stay as tight as they are; the budget is the pages'.
// the product to render cold: the route segment suspends behind the server
// render and shows `loading.tsx` until it resolves. On a shared runner with
// the whole suite in flight that has overrun the 30 s default, and the test
// then died on the quick-add trigger rather than on anything it is about.
// The subject here is the add-then-appears round trip, not the dashboard's
// cold-start latency, so the budget is the page's and the assertions stay
// exactly as tight as they were.
test.setTimeout(90_000);
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("measurement", testInfo), [
"DELETE FROM measurement_rollups WHERE user_id = $1",
"DELETE FROM measurements WHERE user_id = $1",
]);
});
test("a weight entered on the dashboard reaches the tile, the chart and the day view @critical", async ({
test("creating a weight reading surfaces it in the list", async ({
page,
}) => {
// Capture the POST payload so we can assert against it independently
// of any list-fetch response Playwright might cache. Match both
// `/api/measurements` (POST creates a reading) and the list query
// `/api/measurements?type=...&limit=...&offset=...` (GET).
let postedBody: unknown = null;
await page.route("**/api/measurements*", async (route) => {
const req = route.request();
if (req.method() === "POST") {
postedBody = JSON.parse(req.postData() ?? "null");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: "m_e2e_1", success: true },
error: null,
}),
});
return;
}
// GET — return the just-posted reading so the list view shows it.
// The list expects `data.measurements: Measurement[]` plus
// `data.meta.total`; chart fetches use the same envelope.
const body = postedBody as {
type?: string;
value?: number;
measuredAt?: string;
} | null;
// The list-page `Measurement` shape requires `unit` and `source`
// — without them the row crashes the render and the e2e poll
// never sees "78.4". Mock with the same fields a real reading
// carries.
const measurements = body
? [
{
id: "m_e2e_1",
type: body.type,
value: body.value,
unit: body.type === "WEIGHT" ? "kg" : "",
source: "MANUAL",
measuredAt: body.measuredAt,
notes: null,
},
]
: [];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { measurements, meta: { total: measurements.length } },
error: null,
}),
});
});
// Dashboard's analytics fetch — empty summaries are fine; we only
// need the page to render so we can click "Add".
//
// v1.4.39.3 — match `/api/analytics` AND any sliced variant
// (`?slice=summaries`). The v1.4.39.2 dashboard split fires both
// requests in parallel; the previous string glob missed the slim
// variant which then fell through to the real route and slowed
// the page paint enough that the post-mutation list query never
// surfaced inside the 10 s window.
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
await page.goto("/", { waitUntil: "domcontentloaded" });
// The quick-entry trigger sits at the top of `<main>`. Waiting for it,
// rather than for the route skeleton to go, is waiting for the thing the
// next line clicks.
const addTrigger = page
.locator("main")
// Open the "Add" dropdown — the dashboard's quick-entry trigger sits
// at the top-right of `<main>`. Scope the locator there so we don't
// accidentally match an "Add" button on the sidebar.
const main = page.locator("main");
const addTrigger = main
.getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i })
.first();
// Wait for the trigger itself rather than for the route-level skeleton to
// go. The skeleton is also absent while the protected shell holds the
// whole page behind its hydration gate, so its disappearance is not a
// statement about the suspended segment having resolved; the control the
// next line clicks is. `networkidle` says nothing about either.
await expect(addTrigger).toBeVisible({ timeout: 60_000 });
// The account holds no weight yet, so whatever the surfaces below show
// can only come from this reading.
expect(await storedWeights(page)).toEqual([]);
await page.waitForLoadState("networkidle");
await openMenu(page, addTrigger);
// v1.5: the menu items now have distinct labels — the
// measurement entry says "Measurement" / "Messung" instead of "Add",
// so we can target it directly.
// The desktop rail opens a dropdown, the phone shell the capture sheet
// the bottom nav's plus opens; the measurement entry is in either.
await page
@@ -95,63 +143,52 @@ test.describe("add measurement flow", () => {
.first()
.click();
// Form should be visible inside the dialog. Switch type to WEIGHT
// (combo-box defaults to BLOOD_PRESSURE).
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// The type select defaults to blood pressure.
// The Select trigger renders the current label — click to open and
// pick the weight option.
await dialog.getByRole("combobox").first().click();
await page
.getByRole("option", { name: /weight|gewicht/i })
.first()
.click();
await dialog.locator("#value").fill(WEIGHT_KG);
const created = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/measurements" &&
res.request().method() === "POST",
);
// Weight is single-value mode — fill the value field with 78.4 kg.
const valueInput = dialog.locator("#value");
await valueInput.fill("78.4");
// Save
await dialog.getByRole("button", { name: /save|speichern/i }).click();
const response = await created;
expect(response.status()).toBe(201);
await expect(dialog).toBeHidden();
await test.step("the server stored exactly that reading", async () => {
expect(await storedWeights(page)).toEqual([
{ value: Number(WEIGHT_KG), unit: "kg" },
]);
// Dialog closes when the mutation succeeds
await expect(dialog).toBeHidden({ timeout: 5_000 });
// Assert the POST went through with the expected shape
expect(postedBody).toMatchObject({
type: "WEIGHT",
value: 78.4,
});
await test.step("the dashboard tile", async () => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const tile = page.locator(
'[data-slot="dashboard-tile-link"][data-tile-id="weight"]',
);
await expect(tile).toBeVisible({ timeout: 60_000 });
await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText(
WEIGHT_KG,
);
});
// Navigate to the list and confirm the entry surfaces. The list
// page renders entries from /api/measurements which we've mocked
// to echo the just-posted row.
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
await test.step("the weight chart", async () => {
await page.goto("/insights/weight", { waitUntil: "domcontentloaded" });
const table = page.locator('[data-slot="chart-data-table"]').first();
await expect(table).toBeVisible({ timeout: 30_000 });
await table.locator('[data-slot="chart-data-table-toggle"]').click();
const rows = table.locator('[data-slot="chart-data-table-row"]');
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText(WEIGHT_KG);
});
await test.step("the day view", async () => {
await page.goto(`/measurements?day=${todayInBerlin()}`, {
waitUntil: "domcontentloaded",
});
const value = page.locator(
'[data-slot="day-view"] [data-slot="day-value"][data-type="WEIGHT"]',
);
await expect(value).toBeVisible({ timeout: 30_000 });
await expect(value).toContainText(WEIGHT_KG);
});
// The list renders a desktop table AND a mobile card grid (one is
// hidden via `md:hidden`/`hidden md:block`). At least one match
// means our row landed in the DOM regardless of viewport.
await expect
.poll(
async () =>
await page
.locator("main")
.getByText(/78[.,]4/)
.count(),
{ timeout: 10_000 },
)
.toBeGreaterThanOrEqual(1);
});
});
+1 -1
View File
@@ -363,7 +363,7 @@ test.describe("medication adherence journey", () => {
});
});
test("a taken and a skipped dose move the card, the history and the dashboard tile @critical", async ({
test("a taken and a skipped dose move the card, the history and the dashboard tile", async ({
page,
}) => {
// The wizard is a seven-step dialog with per-step validation gating Next;
+2 -363
View File
@@ -2,11 +2,6 @@ import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
@@ -42,16 +37,9 @@ const CASES: readonly Case[] = [
async function expectNoSeriousViolations(page: Page, label: string) {
// The pointer rests where the last tap landed; a hover tint under it is
// not what the screen shows at rest. Moving it away starts the tint's
// transition back, so the scan waits for every transition to settle rather
// than for a guessed number of milliseconds. Transitions only: a spinner's
// or a skeleton's endless animation would never settle.
// not what the screen shows at rest.
await page.mouse.move(0, 0);
await page.waitForFunction(() =>
document
.getAnimations()
.every((a) => !(a instanceof CSSTransition) || a.playState !== "running"),
);
await page.waitForTimeout(300);
const results = await new AxeBuilder({ page })
.include(
'[data-slot="mental-health-check-in"], [data-slot="mental-health-result"]',
@@ -183,352 +171,3 @@ test.describe("mental-health check-in, every instrument", () => {
});
}
});
/**
* The questionnaires in the reader's language. Where a validated translation
* exists the form shows it and nothing else; where the text is HealthLog's
* own translation it carries the note and the one-tap switch to the English
* original, and the result says it was answered in an unvalidated
* translation. Which cells are unvalidated is pinned by
* `instrument-wording.test.ts`; this journey proves the surface follows it.
*/
const UNVALIDATED: Record<string, readonly Case["id"][]> = {
de: ["SCI"],
nl: ["SCI"],
ko: ["SCI"],
"pt-BR": ["WHO5", "SCI"],
};
function caseOf(id: Case["id"]): Case {
const found = CASES.find((c) => c.id === id);
if (!found) throw new Error(`no case for ${id}`);
return found;
}
async function openInLocale(page: Page, locale: string) {
await page.context().addCookies([
{
name: "healthlog-locale",
value: locale,
url: process.env.E2E_BASE_URL ?? "http://localhost:3000",
},
]);
await page.goto("/mental-wellbeing");
}
async function answerAll(page: Page, c: Case, position: number) {
const wizard = page.locator(
`[data-slot="mental-health-check-in"][data-instrument="${c.id}"]`,
);
for (let step = 1; step <= c.items; step++) {
const current = wizard.locator(
`[data-slot="check-in-step"][data-step="${step}"]`,
);
await expect(current).toBeVisible();
await current
.locator('[role="radio"]')
.nth(position - 1)
.click();
}
if (c.followUp) {
await wizard.locator('[data-slot="check-in-next"]').click();
}
await expect(wizard.locator('[data-slot="check-in-review"]')).toBeVisible();
await wizard.locator('[data-slot="check-in-submit"]').click();
const result = page.locator(
`[data-slot="mental-health-result"][data-instrument="${c.id}"]`,
);
await expect(result).toBeVisible();
return result;
}
test.describe("mental-health check-in, in the reader's language", () => {
test.use({ storageState: STORAGE_STATE_PATH });
for (const [locale, unvalidated] of Object.entries(UNVALIDATED)) {
test(`${locale}: the note and the switch appear only on an unvalidated translation`, async ({
page,
}) => {
test.setTimeout(120_000);
await openInLocale(page, locale);
await expect(page.locator("html")).toHaveAttribute("lang", locale);
for (const c of CASES) {
await page
.locator(`[data-slot="instrument-card"][data-instrument="${c.id}"]`)
.locator('[data-slot="instrument-card-start"]')
.click();
const wizard = page.locator(
`[data-slot="mental-health-check-in"][data-instrument="${c.id}"]`,
);
await expect(
wizard.locator('[data-slot="check-in-step"][data-step="1"]'),
).toBeVisible();
const expected = unvalidated.includes(c.id) ? 1 : 0;
await expect(
wizard.locator('[data-slot="check-in-unvalidated-note"]'),
).toHaveCount(expected);
await expect(
wizard.locator('[data-slot="check-in-wording-toggle"]'),
).toHaveCount(expected);
await expect(
wizard.locator('[data-slot="check-in-question"]'),
).toHaveAttribute("lang", locale);
if (expected === 1) {
// On screen together with the question, at every width: the step
// scrolls to its top block, note and stem included.
await expect(
wizard.locator('[data-slot="check-in-unvalidated-note"]'),
).toBeInViewport();
await expectNoSeriousViolations(page, `${locale} ${c.id}`);
}
await wizard.locator('[data-slot="check-in-back-to-landing"]').click();
}
// An unvalidated translation answered as shown: the result says so.
const sci = caseOf("SCI");
await page
.locator('[data-slot="instrument-card"][data-instrument="SCI"]')
.locator('[data-slot="instrument-card-start"]')
.click();
const result = await answerAll(page, sci, 2);
await expect(
result.locator('[data-slot="result-wording-note"]'),
).toBeVisible();
await expectNoSeriousViolations(page, `${locale} SCI result`);
});
}
test("nl: the switch shows the English original, and the result records it", async ({
page,
}) => {
test.setTimeout(90_000);
await openInLocale(page, "nl");
await page
.locator('[data-slot="instrument-card"][data-instrument="SCI"]')
.locator('[data-slot="instrument-card-start"]')
.click();
const wizard = page.locator(
'[data-slot="mental-health-check-in"][data-instrument="SCI"]',
);
const question = wizard.locator('[data-slot="check-in-question"]');
const translated = await question.textContent();
// The note and its switch travel with every step, not only the first.
for (const step of [1, 2]) {
const current = wizard.locator(
`[data-slot="check-in-step"][data-step="${step}"]`,
);
await expect(
current.locator('[data-slot="check-in-unvalidated-note"]'),
).toBeInViewport();
await current.locator('[role="radio"]').first().click();
}
await wizard.locator('[data-slot="check-in-back"]').click();
await wizard.locator('[data-slot="check-in-back"]').click();
await expect(question).toHaveText(translated ?? "");
const toggle = wizard.locator('[data-slot="check-in-wording-toggle"]');
await toggle.click();
await expect(toggle).toHaveAttribute("data-showing", "original_en");
await expect(question).toHaveAttribute("lang", "en");
await expect(question).not.toHaveText(translated ?? "");
const sci = caseOf("SCI");
const result = await answerAll(page, sci, 1);
await expect(
result.locator('[data-slot="result-wording-note"]'),
).toHaveCount(0);
});
test("pt-BR: PHQ-9 item 9 above zero leads the result with the crisis card", async ({
page,
}) => {
test.setTimeout(90_000);
await openInLocale(page, "pt-BR");
await page
.locator('[data-slot="instrument-card"][data-instrument="PHQ9"]')
.locator('[data-slot="instrument-card-start"]')
.click();
const phq = caseOf("PHQ9");
// Position 2 is "Vários dias" (value 1) on every item, item 9 included.
const result = await answerAll(page, phq, 2);
await expect(
result.locator('[data-slot="mental-health-crisis-card"]'),
).toBeVisible();
await expect(
result.locator('[data-slot="result-wording-note"]'),
).toHaveCount(0);
});
});
/**
* A WHO-5 check-in, word for word, in German and in Brazilian Portuguese,
* on the wellbeing journey's own account (one per project,
* `critical-fixture.ts`) against the real server.
*
* The journeys above anchor on data attributes so they read the same in every
* language; this one reads the words. The German text is the validated WHO
* translation, which the form has to reproduce exactly; the Brazilian one is
* HealthLog's own and has to say so. Either way the stored assessment records
* the language and the kind of wording it was answered in, and the score the
* server computed is the one on screen.
*
* The strings are written out here rather than imported from
* `src/lib/mental-health/wording/`: a test that read the expected text from
* the module it checks would pass on any edit to that module.
*/
const WHO5_WORDING = {
de: {
validated: true,
progress: "Frage 1 von 5",
stem: "In den letzten zwei Wochen …",
first: "… war ich froh und guter Laune",
last: "… war mein Alltag voller Dinge, die mich interessieren",
// In the order the form prints them, best first.
anchors: [
"Die ganze Zeit",
"Meistens",
"Etwas mehr als die Hälfte der Zeit",
"Etwas weniger als die Hälfte der Zeit",
"Ab und zu",
"Zu keinem Zeitpunkt",
],
},
"pt-BR": {
validated: false,
progress: "Pergunta 1 de 5",
stem: "Nas últimas duas semanas …",
first: "Eu me senti alegre e de bom humor",
last: "Meu dia a dia foi preenchido com coisas que me interessam",
anchors: [
"O tempo todo",
"A maior parte do tempo",
"Mais da metade do tempo",
"Menos da metade do tempo",
"Algumas vezes",
"Em nenhum momento",
],
},
} as const;
/** The account's stored assessments, as the list route answers them. */
async function storedAssessments(page: Page): Promise<
Array<{
instrument: string;
locale: string;
wording: string | null;
totalScore: number;
}>
> {
const res = await page.request.get("/api/mental-health/assessments");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: {
assessments: Array<{
instrument: string;
locale: string;
wording: string | null;
totalScore: number;
}>;
};
};
return data.assessments.map(
({ instrument, locale, wording, totalScore }) => ({
instrument,
locale,
wording,
totalScore,
}),
);
}
test.describe("WHO-5 check-in, word for word", () => {
test.use({ storageState: criticalStorageState("wellbeing") });
// Both locales write to the one account of the project, and each test
// empties it first; in order, so neither empties the other's mid-run.
test.describe.configure({ mode: "default" });
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("wellbeing", testInfo), [
"DELETE FROM mental_health_assessments WHERE user_id = $1",
]);
});
for (const [locale, words] of Object.entries(WHO5_WORDING)) {
test(`${locale}: the form reads the ${words.validated ? "validated" : "HealthLog"} translation and the result is stored in it @critical`, async ({
page,
}) => {
test.setTimeout(90_000);
await openInLocale(page, locale);
await expect(page.locator("html")).toHaveAttribute("lang", locale);
await page
.locator('[data-slot="instrument-card"][data-instrument="WHO5"]')
.locator('[data-slot="instrument-card-start"]')
.click();
const wizard = page.locator(
'[data-slot="mental-health-check-in"][data-instrument="WHO5"]',
);
const step = (n: number) =>
wizard.locator(`[data-slot="check-in-step"][data-step="${n}"]`);
await expect(step(1)).toBeVisible();
await expect(
wizard.locator('[data-slot="check-in-progress"]'),
).toContainText(words.progress);
await expect(step(1).locator('[data-slot="check-in-stem"]')).toHaveText(
words.stem,
);
await expect(
step(1).locator('[data-slot="check-in-question"]'),
).toHaveText(words.first);
await expect(step(1).locator('[role="radio"]')).toHaveText([
...words.anchors,
]);
await expect(
wizard.locator('[data-slot="check-in-unvalidated-note"]'),
).toHaveCount(words.validated ? 0 : 1);
// "Meistens" / "A maior parte do tempo" (value 4) on every item.
for (let n = 1; n <= 5; n++) {
await expect(step(n)).toBeVisible();
if (n === 5) {
await expect(
step(n).locator('[data-slot="check-in-question"]'),
).toHaveText(words.last);
}
await step(n).getByRole("radio", { name: words.anchors[1] }).click();
}
await expect(
wizard.locator('[data-slot="check-in-review"]'),
).toBeVisible();
const created = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/mental-health/assessments" &&
res.request().method() === "POST",
);
await wizard.locator('[data-slot="check-in-submit"]').click();
expect((await created).status()).toBe(201);
// Five items at 4, times WHO-5's multiplier of 4.
const result = page.locator(
'[data-slot="mental-health-result"][data-instrument="WHO5"]',
);
await expect(result.locator('[data-slot="result-score"]')).toContainText(
"80",
);
await expect(
result.locator('[data-slot="result-wording-note"]'),
).toHaveCount(words.validated ? 0 : 1);
expect(await storedAssessments(page)).toEqual([
{
instrument: "WHO5",
locale,
wording: words.validated ? "validated" : "unvalidated_translation",
totalScore: 80,
},
]);
});
}
});
+32 -66
View File
@@ -1,53 +1,39 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
/**
* Logging a mood through the form, against the real server: the time stays
* visible while the details are collapsed, the instant typed into it is the
* instant stored, and the entry is in the list afterwards.
*
* Until v1.46 this spec answered the POST and the list read itself, so the
* list was always empty and the POST always succeeded. The account is now the
* mood journey's own, one per project (`critical-fixture.ts`), emptied before
* the test; the empty state the form opens from is the account's real one.
*/
/** The account's mood entries, as the list route answers them. */
async function storedMoods(
page: Page,
): Promise<Array<{ mood: string; moodLoggedAt: string }>> {
const res = await page.request.get("/api/mood-entries");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: { entries: Array<{ mood: string; moodLoggedAt: string }> };
};
return data.entries.map(({ mood, moodLoggedAt }) => ({ mood, moodLoggedAt }));
}
import { STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("mood timestamp capture", () => {
test.use({ storageState: criticalStorageState("mood") });
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("mood", testInfo), [
"DELETE FROM mood_entry_rollups WHERE user_id = $1",
"DELETE FROM mood_entries WHERE user_id = $1",
]);
});
test("a mood logged at a chosen time is stored at that instant and listed @critical", async ({
test("keeps Time visible while details are collapsed and submits the selected instant", async ({
page,
}) => {
let submitted: Record<string, unknown> | null = null;
await page.route("**/api/mood-entries*", async (route) => {
if (route.request().method() === "POST") {
submitted = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ data: { id: "created-mood" }, error: null }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], meta: { total: 0, limit: 50, offset: 0 } },
error: null,
}),
});
});
await page.goto("/mood", { waitUntil: "domcontentloaded" });
// An empty list carries the add action in its empty state (the header
// drops its own while there is nothing to list).
// The list is mocked empty, so its empty state carries the add action
// (the header drops its own while there is nothing to list).
await page.getByRole("button", { name: "Log your first mood" }).click();
await page.getByRole("radio", { name: "Amazing" }).click();
@@ -67,32 +53,12 @@ test.describe("mood timestamp capture", () => {
await timeInput.fill("09:45");
await timeInput.blur();
const created = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/mood-entries" &&
res.request().method() === "POST",
);
await page.getByRole("button", { name: "Save", exact: true }).click();
expect((await created).status()).toBe(201);
// 09:45 in Berlin in January is 08:45 UTC.
expect(await storedMoods(page)).toEqual([
{ mood: "SUPER_GUT", moodLoggedAt: "2026-01-15T08:45:00.000Z" },
]);
// The list shows it, at either width (the table and the phone cards each
// carry the marker; one of them is on screen).
const rows = page.locator('[data-slot="mood-rows"]').filter({
visible: true,
await expect.poll(() => submitted).not.toBeNull();
expect(submitted).toMatchObject({
mood: "SUPER_GUT",
moodLoggedAt: "2026-01-15T08:45:00.000Z",
});
await expect(rows).toBeVisible();
await expect(rows).toContainText("Amazing");
await expect(rows).toContainText("09:45");
// And still after a reload, which reads the list from the server again
// rather than from the cache the mutation updated.
await page.reload({ waitUntil: "domcontentloaded" });
await expect(rows).toContainText("Amazing");
await expect(rows).toContainText("09:45");
});
});
+1 -1
View File
@@ -25,7 +25,7 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("offline, with the service worker", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test("an open page says it is offline, a new one opens the offline page, and both recover @critical", async ({
test("an open page says it is offline, a new one opens the offline page, and both recover", async ({
page,
context,
}) => {
+19 -82
View File
@@ -1,69 +1,29 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Lab results from a spreadsheet (#1215), against the real server: the
* mapping is suggested from the headers, the check reports a refused row by
* line and writes nothing, the import writes the rest, the imported row is on
* the labs page, and a second import of the same file only finds duplicates.
*
* The account is the labs journey's own, one per project
* (`critical-fixture.ts`), emptied of results first. Before v1.46 this ran on
* the shared account in both projects, so the "only duplicates" verdict of
* one project could be the other project's first import.
* Lab results from a spreadsheet (#1215), at phone width, against the real
* server: the mapping is suggested from the headers, the check reports a
* refused row by line and writes nothing, the import writes the rest, and a
* second import of the same file only finds duplicates.
*/
const ANALYTE = "E2E Marker CSV";
function csv(): string {
const day = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10);
return [
"Analyte;Value;Unit;Date",
// A decimal comma, as a German lab export writes it.
`${ANALYTE};5,4;mmol/L;${day}`,
// Either 1.234 or 1234: refused as ambiguous rather than guessed.
`${ANALYTE};1.234;mmol/L;${day}`,
`E2E Marker CSV;5,4;mmol/L;${day}`,
`E2E Marker CSV;1.234;mmol/L;${day}`,
].join("\n");
}
/** The account's lab results, as the list route answers them. */
async function storedResults(
page: Page,
): Promise<Array<{ analyte: string; value: number | null; unit: string }>> {
const res = await page.request.get("/api/labs");
expect(res.status()).toBe(200);
const { data } = (await res.json()) as {
data: {
results: Array<{ analyte: string; value: number | null; unit: string }>;
};
};
return data.results.map(({ analyte, value, unit }) => ({
analyte,
value,
unit,
}));
}
test.describe("record CSV import", () => {
test.use({ storageState: criticalStorageState("labs") });
test.beforeEach(async ({}, testInfo) => {
// Fixture hygiene: what a previous run or a retry left on the account.
await resetCriticalAccount(criticalAccount("labs", testInfo), [
"DELETE FROM lab_results WHERE user_id = $1",
]);
test.use({
storageState: STORAGE_STATE_PATH,
viewport: { width: 390, height: 844 },
});
test("checks, imports, lists the result and then only finds duplicates @critical", async ({
page,
}) => {
test("checks, imports and then only finds duplicates", async ({ page }) => {
await page.goto("/settings/export");
await page.locator('[data-slot="record-import-open-labs"]').click();
const sheet = page.locator('[data-slot="record-import-sheet"]');
@@ -88,42 +48,19 @@ test.describe("record CSV import", () => {
await expect(
page.locator('[data-slot="record-import-error-row"]'),
).toHaveCount(1);
// The check is a dry run.
expect(await storedResults(page)).toEqual([]);
await page.locator('[data-slot="record-import-run"]').click();
await expect(
page.locator('[data-slot="record-import-done"]'),
).toBeVisible();
expect(await storedResults(page)).toEqual([
{ analyte: ANALYTE, value: 5.4, unit: "mmol/L" },
]);
await test.step("the result is on the labs page", async () => {
await page.goto("/labs");
const analyte = page
.locator('[data-slot="lab-list-analyte"]')
.filter({ hasText: ANALYTE })
.filter({ visible: true });
await expect(analyte).toHaveCount(1);
// The cell the name heads carries the latest reading under it.
await expect(analyte.locator("xpath=../..")).toContainText("5.4");
});
await test.step("a second run of the same file", async () => {
await page.goto("/settings/export");
await page.locator('[data-slot="record-import-open-labs"]').click();
await page
.locator('[data-slot="record-import-file"]')
.setInputFiles(file);
await page.locator('[data-slot="record-import-check"]').click();
await expect(
page.locator('[data-slot="record-import-checked"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="record-import-run"]'),
).toBeDisabled();
expect(await storedResults(page)).toHaveLength(1);
});
// Second run of the same file.
await page.keyboard.press("Escape");
await page.locator('[data-slot="record-import-open-labs"]').click();
await page.locator('[data-slot="record-import-file"]').setInputFiles(file);
await page.locator('[data-slot="record-import-check"]').click();
await expect(
page.locator('[data-slot="record-import-run"]'),
).toBeDisabled();
});
});
-7
View File
@@ -261,17 +261,10 @@ test.describe("score histories", () => {
await expect(
page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`),
).toHaveCount(0);
// The choice is saved by a PUT; reloading before it answers cancels it.
const saved = page.waitForResponse(
(res) =>
res.url().includes("/api/dashboard/chart-overlay-prefs") &&
res.request().method() === "PUT",
);
await page
.locator(`${chart} [data-slot="chart-range-tab"][data-value="7"]`)
.click();
await expectSharedTabs(page, chart, "7");
expect((await saved).status()).toBe(200);
await page.reload();
await expectSharedTabs(page, chart, "7");
await resetRange(page, "scoreRecovery");
+1 -1
View File
@@ -160,7 +160,7 @@ test.describe("Settings → Export & Import", () => {
).toContainText("2/12");
await expect(
page.getByTestId("report-group-row-cardio-export"),
).toContainText("2/16");
).toContainText("2/15");
await expect(
page.getByTestId("report-group-row-mobility-export"),
).toContainText("0/11");
+2 -2
View File
@@ -8,7 +8,7 @@ import { settleBeforeMeasure } from "./utils/settle";
* v1.25.11 settings fix): the scroll viewport must never extend far past
* the last content element. The rule the guard pins is structural — only
* the AuthShell owns the scroll height and the bottom padding
* (`pt-6 pb-20` on the wrapper, `pb-24` in the desktop shell, plus the `<md` bottom-nav clearance on
* (`pt-6 pb-20` on the wrapper, plus the `<md` bottom-nav clearance on
* `<main>`); a page/section must never add its own viewport-height
* reserve or bottom gutter, because every such nested reserve stacks on
* the shell's own budget and reopens the dark-band-below-the-last-card
@@ -45,7 +45,7 @@ const ROUTES = [
] as const;
const VIEWPORTS = [
{ name: "desktop", width: 1440, height: 900, expectedPad: 96 }, // wrapper shell-desktop:pb-24
{ name: "desktop", width: 1440, height: 900, expectedPad: 80 }, // wrapper pb-20
{ name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16
] as const;
-82
View File
@@ -1,82 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
criticalAccount,
criticalStorageState,
resetCriticalAccount,
} from "./setup/critical-fixture";
/**
* A settings change is the server's, not the tab's.
*
* The time-format choice is changed through the account settings, then read
* back three ways that a client-side cache could not fake: after a reload, in
* a second browser context that shares the session but none of the first
* one's storage, and on `/api/auth/me`, which every client renders from.
*
* The account is the settings journey's own, one per project
* (`critical-fixture.ts`), and is put back to the default first.
*/
async function openTimeFormat(page: Page) {
await page.goto("/settings/account");
const select = page.getByTestId("settings-time-format-select");
await expect(select).toBeVisible({ timeout: 30_000 });
// The control is disabled until the account payload has landed.
await expect(select).toBeEnabled();
return select;
}
async function publishedTimeFormat(page: Page): Promise<string> {
const res = await page.request.get("/api/auth/me");
expect(res.status()).toBe(200);
return ((await res.json()) as { data: { timeFormat: string } }).data
.timeFormat;
}
test.describe("settings persistence", () => {
test.use({ storageState: criticalStorageState("settings") });
test.beforeEach(async ({}, testInfo) => {
await resetCriticalAccount(criticalAccount("settings", testInfo), [
"UPDATE users SET time_format = 'AUTO' WHERE id = $1",
]);
});
test("a time-format change survives a reload and reaches a second browser @critical", async ({
page,
browser,
}, testInfo) => {
const select = await openTimeFormat(page);
await expect(select).toHaveValue("AUTO");
const saved = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/user/profile" &&
res.request().method() === "PATCH",
);
await select.selectOption("H12");
expect((await saved).status()).toBe(200);
expect(await publishedTimeFormat(page)).toBe("H12");
await page.reload();
await expect(page.getByTestId("settings-time-format-select")).toHaveValue(
"H12",
);
// Same session cookie, nothing else: no local storage, no IndexedDB
// query snapshot, no in-memory cache.
const other = await browser.newContext({
storageState: criticalAccount("settings", testInfo).storageStatePath,
baseURL: testInfo.project.use.baseURL,
});
try {
const second = await other.newPage();
await expect(await openTimeFormat(second)).toHaveValue("H12");
} finally {
await other.close();
}
});
});
-253
View File
@@ -1,253 +0,0 @@
/**
* Accounts for the `@critical` journeys: one per journey AND per project.
*
* The `@critical` journeys run against the real server with no `/api` stubs
* (`src/__tests__/e2e-critical-no-api-stub-guard.test.ts` holds that line), so
* every verdict they read is a statement about a real record. Two things would
* make those verdicts somebody else's:
*
* - a sibling spec writing into the same account. The shared fixture account
* is read and written by sixty files, so "the dashboard tile shows the
* weight I just entered" is only true of an account nobody else writes;
* - the same journey running in the other project. Every other journey that
* owns its account answers that by running in ONE project (the long list in
* `playwright.config.ts`). These journeys are the release gate and are
* meant to hold at both widths, so instead of dropping a project each
* project gets its own account: `chromium-desktop` and `chromium-mobile`
* run the same file in parallel and never meet.
*
* The accounts are seeded and signed in by `global-setup.ts`, like every
* other fixture account; this module only names them and resets what the
* journeys write. It talks to Postgres through `pg` for the reason
* `global-setup.ts` gives (Playwright's loader cannot load the generated
* Prisma client).
*/
import { resolve } from "node:path";
import type { TestInfo } from "@playwright/test";
import pg from "pg";
/** The journeys that own an account pair. */
export const CRITICAL_JOURNEYS = [
"auth",
"measurement",
"mood",
"labs",
"wellbeing",
"settings",
// The fresh account a full backup is imported into. The backup journey
// runs in the desktop project only (`playwright.config.ts` says why), so
// only the desktop account of this pair is ever written.
"restore",
] as const;
export type CriticalJourney = (typeof CRITICAL_JOURNEYS)[number];
/**
* Journeys that sign in through the form in every test and so get no jar.
* The sign-in journey ends the sessions it opens; a jar captured once in
* global setup would be dead for a retry, and a retry has to start from the
* same place as the first attempt.
*/
export const FORM_SIGN_IN_JOURNEYS: ReadonlySet<CriticalJourney> = new Set([
"auth",
]);
/** The projects the journeys run in; each gets its own account per journey. */
export const CRITICAL_PROJECTS = {
"chromium-desktop": "d",
"chromium-mobile": "m",
} as const;
type CriticalProject = keyof typeof CRITICAL_PROJECTS;
export interface CriticalAccount {
username: string;
email: string;
password: string;
storageStatePath: string;
}
function accountFor(
journey: CriticalJourney,
suffix: (typeof CRITICAL_PROJECTS)[CriticalProject],
): CriticalAccount {
const username = `e2e-critical-${journey}-${suffix}`;
return {
username,
email: `${username}@healthlog.test`,
// Fixture credential for a throwaway test database, like every other
// account in `global-setup.ts`. Long and mixed so it would also clear the
// registration strength gate.
password: `Cq7!${journey}-${suffix}-Wz4pLx`,
storageStatePath: resolve(
process.cwd(),
`e2e/setup/storageStateCritical-${journey}-${suffix}.json`,
),
};
}
/** Every critical account, for `global-setup.ts` to seed and sign in. */
export function allCriticalAccounts(): Array<
CriticalAccount & { journey: CriticalJourney }
> {
return CRITICAL_JOURNEYS.flatMap((journey) =>
Object.values(CRITICAL_PROJECTS).map((suffix) => ({
journey,
...accountFor(journey, suffix),
})),
);
}
/**
* The account a journey uses in the project the test is running in. A project
* outside the map has no account on purpose: a critical journey that grows a
* third project has to say which account it reads.
*/
export function criticalAccount(
journey: CriticalJourney,
testInfo: TestInfo,
): CriticalAccount {
const suffix = CRITICAL_PROJECTS[testInfo.project.name as CriticalProject];
if (!suffix) {
throw new Error(
`[critical-fixture] no ${journey} account for project ${testInfo.project.name}`,
);
}
return accountFor(journey, suffix);
}
/**
* A `test.use({ storageState })` value that picks the jar of the running
* project's account.
*/
export function criticalStorageState(journey: CriticalJourney) {
return async (
{}: object,
use: (path: string) => Promise<void>,
testInfo: TestInfo,
): Promise<void> => {
await use(criticalAccount(journey, testInfo).storageStatePath);
};
}
async function withPool<T>(fn: (pool: pg.Pool) => Promise<T>): Promise<T> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[critical-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
return await fn(pool);
} finally {
await pool.end();
}
}
/**
* Run statements against one critical account, with `$1` bound to its id.
* Fixture hygiene only: what a previous run or a retry left behind. Nothing a
* journey asserts is written here.
*/
export async function resetCriticalAccount(
account: CriticalAccount,
statements: readonly string[],
): Promise<void> {
await withPool(async (pool) => {
const res = await pool.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[account.username],
);
const id = res.rows[0]?.id;
if (!id) {
throw new Error(
`[critical-fixture] ${account.username} not seeded — global-setup must run first`,
);
}
for (const sql of statements) await pool.query(sql, [id]);
});
}
/**
* Clear the login buckets. The ceiling is five attempts per IP per quarter
* hour, the suite shares one IP, and global setup alone signs in more than
* twenty times; a journey that signs in through the form would otherwise meet
* the fixture's 429 rather than the product. Only the auth surfaces' buckets
* are touched, exactly as global setup does between its own logins.
*/
export async function clearLoginBuckets(): Promise<void> {
await withPool(async (pool) => {
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
});
}
/**
* Delete a critical account and create it again, empty, under the same id,
* username and password. The backup journey restores a person's file into
* the account the way the runbook brings one back on a new host, where the
* file names its owner by id; a browser cannot delete and recreate a row
* under a chosen id, so this is the fixture's act, not the journey's.
*/
export async function recreateCriticalAccount(
account: CriticalAccount,
): Promise<void> {
await withPool(async (pool) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const res = await client.query<{
id: string;
password_hash: string;
module_preferences_json: unknown;
}>(
`SELECT id, password_hash, module_preferences_json
FROM users WHERE username = $1`,
[account.username],
);
const row = res.rows[0];
if (!row) {
throw new Error(`[critical-fixture] ${account.username} not seeded`);
}
await client.query("DELETE FROM users WHERE id = $1", [row.id]);
await client.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', NOW(), NOW(), NOW(), true, $5::jsonb)`,
[
row.id,
account.username,
account.email,
row.password_hash,
JSON.stringify(row.module_preferences_json),
],
);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
});
}
/** How many measurements and mood entries a critical account holds. */
export async function accountRowCounts(
account: CriticalAccount,
): Promise<{ measurements: number; moodEntries: number }> {
return withPool(async (pool) => {
const res = await pool.query<{ measurements: string; mood: string }>(
`SELECT
(SELECT count(*) FROM measurements m
JOIN users u ON u.id = m.user_id WHERE u.username = $1)
AS measurements,
(SELECT count(*) FROM mood_entries e
JOIN users u ON u.id = e.user_id WHERE u.username = $1) AS mood`,
[account.username],
);
return {
measurements: Number(res.rows[0]?.measurements ?? 0),
moodEntries: Number(res.rows[0]?.mood ?? 0),
};
});
}
-187
View File
@@ -33,7 +33,6 @@ import {
} from "@playwright/test";
import pg from "pg";
import { allCriticalAccounts, FORM_SIGN_IN_JOURNEYS } from "./critical-fixture";
import { seedDayDoorsRecord } from "./day-doors-fixture";
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
@@ -251,41 +250,6 @@ export const TIMELINE_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateTimeline.json",
);
/**
* The vault specs' own accounts (`documents.spec.ts`, and the documents
* journeys of `v142-qol.spec.ts`).
*
* The vault's layout (cards or a list, months stacked or side by side) is a
* per-account preference. On the shared account the layout journey turned it
* while the month-grid tests read the default, so either could fail depending
* on which ran first. Each file seeds its own vault into its own account
* (`ensureVaultFixture(username)`).
*/
export const E2E_VAULT = {
email: "e2e-vault@healthlog.test",
username: "e2e-vault",
password: "Vt4!Hq8mZr2wKx6N",
// The upload-cap test sets the instance's cap through the admin console.
role: "ADMIN",
} as const;
export const VAULT_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateVault.json",
);
export const E2E_VAULT_LAYOUT = {
email: "e2e-vault-layout@healthlog.test",
username: "e2e-vault-layout",
password: "Vl7!Bn3qXw9pRt5M",
role: "USER",
} as const;
export const VAULT_LAYOUT_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateVaultLayout.json",
);
/**
* The day-doors journey's own account (`day-doors.spec.ts`).
*
@@ -325,25 +289,6 @@ export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateScoreHistory.json",
);
/**
* The header-height sweep's own account (`header-height-locales.spec.ts`).
*
* It moves the account's language through every shipped locale; the server
* honours `User.locale` wherever a request carries no locale cookie, so on a
* shared account a parallel spec could render in Korean halfway through.
*/
export const E2E_HEADER_LOCALES = {
email: "e2e-header-locales@healthlog.test",
username: "e2e-header-locales",
password: "Hl8!Tq2vNx6mWr4Z",
role: "USER",
} as const;
export const HEADER_LOCALES_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateHeaderLocales.json",
);
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateAiOptional.json",
@@ -1045,32 +990,6 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// The vault specs' accounts — see `E2E_VAULT`. The vault fixture turns
// the documents module on and seeds the documents.
for (const account of [E2E_VAULT, E2E_VAULT_LAYOUT]) {
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed)
VALUES ($1, $2, $3, $4, $6, $5, $5, $5, true)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
[
cuid(),
account.username,
account.email,
await hashPassword(account.password),
now,
account.role,
],
);
}
// The day-doors journey's account. Every module the journey reaches is
// on; its record is written after the login capture.
await pool.query(
@@ -1121,33 +1040,6 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// The header-height sweep's account. Default modules and layout, so the
// dashboard header carries what a fresh account's does; the language is
// put back to English because the sweep leaves it on its last locale.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json, locale)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL, 'en')
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = NULL,
dashboard_widgets_json = NULL,
locale = 'en'`,
[
cuid(),
E2E_HEADER_LOCALES.username,
E2E_HEADER_LOCALES.email,
await hashPassword(E2E_HEADER_LOCALES.password),
now,
],
);
// The notification-dispatch journey's account. Seeded like the others;
// its channels, devices, ledger rows and preferences are reset by
// `e2e/setup/notification-fixture.ts` before every test, because the
@@ -1472,28 +1364,6 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// surfaces' own buckets are cleared; nothing else in the table is touched.
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
// Every other bucket the suite can fill, for the same reason. A bucket is
// keyed on the account it meters (`<surface>:<id>`, sometimes with a
// further segment) or, on an anonymous surface, on the caller's address,
// and several of them outlive a run by up to an hour: the check-in
// ceiling, the per-account write bucket, the export and import buckets.
// A second run on the same database then met the first run's counts.
// Cleared are the buckets of the fixture accounts (every one of them is
// an `@healthlog.test` address or an `e2e-` record) and those of the
// loopback addresses the suite calls from; a developer's own accounts
// and their buckets are left alone.
await pool.query(
`DELETE FROM rate_limits r
USING users u
WHERE (u.email LIKE '%@healthlog.test' OR u.username LIKE 'e2e-%')
AND position(u.id IN r.key) > 0`,
);
await pool.query(
`DELETE FROM rate_limits
WHERE key LIKE '%:127.0.0.1' OR key LIKE '%:::1'
OR key LIKE '%:::ffff:127.0.0.1' OR key LIKE '%:unknown'`,
);
// One marker weight per record. Re-seeded by delete-then-insert so a
// re-run cannot accumulate duplicates and so an edited constant takes
// effect rather than sitting beside the old value.
@@ -1669,49 +1539,6 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
[cuid(), now, E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username],
);
// ── the @critical journeys' accounts ─────────────────────────────────
//
// One per journey and per project; `critical-fixture.ts` says why. Every
// module a journey reaches is switched on (wellbeing and labs are opt-in),
// and the passkeys the sign-in journey registers go with the row's reset,
// so a re-run registers into an empty list rather than beside the last
// run's credential.
for (const account of allCriticalAccounts()) {
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json,
totp_confirmed_at = NULL`,
[
cuid(),
account.username,
account.email,
await hashPassword(account.password),
now,
JSON.stringify({
mood: true,
labs: true,
mentalHealth: true,
medications: true,
}),
],
);
await pool.query(
`DELETE FROM passkeys
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
[account.username],
);
}
// Console (instead of structured logging) is intentional here —
// global-setup runs outside the app's logging context, and the
// line is useful when debugging a CI failure where the seed didn't
@@ -1819,10 +1646,6 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// The timeline journey's jar — see `E2E_TIMELINE`.
await capture(E2E_TIMELINE, TIMELINE_STORAGE_STATE_PATH);
// The vault specs' jars — see `E2E_VAULT`.
await capture(E2E_VAULT, VAULT_STORAGE_STATE_PATH);
await capture(E2E_VAULT_LAYOUT, VAULT_LAYOUT_STORAGE_STATE_PATH);
// The day-doors journey's jar, then its record — see `E2E_DAY_DOORS`.
await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH);
await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH);
@@ -1830,9 +1653,6 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// The score-history journey's jar — see `E2E_SCORE_HISTORY`.
await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH);
// The header-height sweep's jar — see `E2E_HEADER_LOCALES`.
await capture(E2E_HEADER_LOCALES, HEADER_LOCALES_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
@@ -1888,13 +1708,6 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
[E2E_SETUP_CHILD.username, now],
);
// The @critical journeys' jars. The sign-in journey signs in through the
// form in every test, so its account gets none.
for (const account of allCriticalAccounts()) {
if (FORM_SIGN_IN_JOURNEYS.has(account.journey)) continue;
await capture(account, account.storageStatePath);
}
} finally {
await pool.end();
}
+1 -2
View File
@@ -57,8 +57,7 @@ export async function resetSetupFlow(username: string): Promise<void> {
WHERE key IN (
'onboarding-answers:' || $1,
'onboarding-restart:' || $1,
'managed-profile:create:' || $1,
'record-write:' || $1
'managed-profile:create:' || $1
)`,
[id],
);
+16 -60
View File
@@ -188,39 +188,6 @@ function jpegWithGps(marker: string): Buffer {
export const KNIE_EPISODE_ID = "e2evaultknie000000000001";
export const MRT_DOC_ID = "e2evaultmrt0000000000001";
/**
* The vault corpus's ids for one account. The shared account keeps the ids
* above; an account of its own gets the same corpus under ids of the same
* length with its own five-letter tag in place of `vault`, so two accounts'
* vaults never claim one row. Only `ensureVaultFixture` and
* `seedNamespaceDocs` take an account.
*/
const VAULT_TAGS: Record<string, string> = {
"e2e-vault": "vspec",
"e2e-vault-layout": "vqolx",
};
function vaultTag(username: string): string {
if (username === E2E_USER.username) return "vault";
const tag = VAULT_TAGS[username];
if (!tag) throw new Error(`[vault-fixture] no id tag for ${username}`);
return tag;
}
function inVault(id: string, username: string): string {
return id.replace(/^e2evault/, `e2e${vaultTag(username)}`);
}
export function vaultFixtureIds(username: string): {
knieEpisodeId: string;
mrtDocId: string;
} {
return {
knieEpisodeId: inVault(KNIE_EPISODE_ID, username),
mrtDocId: inVault(MRT_DOC_ID, username),
};
}
/** A stored PDF the AI-assist specs drive (mocked provider); title/kind are
* bland so an applied suggestion is visibly different. */
export const AI_PROBE_DOC_ID = "e2evaultaiprobe000000001";
@@ -301,10 +268,10 @@ async function withVaultSeedLock(
}
}
async function getUserId(pool: pg.Pool, username: string): Promise<string> {
async function getUserId(pool: pg.Pool): Promise<string> {
const res = await pool.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[username],
[E2E_USER.username],
);
const id = res.rows[0]?.id;
if (!id) {
@@ -377,7 +344,7 @@ export async function resetEncounters(): Promise<void> {
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool, E2E_USER.username);
const userId = await getUserId(pool);
await pool.query(
"DELETE FROM encounter_document_links WHERE user_id = $1",
[userId],
@@ -393,15 +360,13 @@ export async function resetEncounters(): Promise<void> {
* corpus. Safe to call from every spec's beforeAll — every statement
* upserts.
*/
export async function ensureVaultFixture(
username: string = E2E_USER.username,
): Promise<void> {
export async function ensureVaultFixture(): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
await withVaultSeedLock(pool, async () => {
const userId = await getUserId(pool, username);
const userId = await getUserId(pool);
// Opt-in module: merge, never overwrite other module preferences.
await pool.query(
@@ -420,7 +385,7 @@ export async function ensureVaultFixture(
VALUES ($1, $2, 'Knie', 'INJURY', 'ACUTE', '2025-09-01T00:00:00Z', NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
label = EXCLUDED.label, deleted_at = NULL, updated_at = NOW()`,
[inVault(KNIE_EPISODE_ID, username), userId],
[KNIE_EPISODE_ID, userId],
);
// 60 background documents spread over 36 months (newest ~current).
@@ -428,10 +393,7 @@ export async function ensureVaultFixture(
for (let i = 0; i < 60; i++) {
const monthsBack = Math.floor((i * 36) / 60);
const anchor = new Date(Date.UTC(2026, 5 - monthsBack, 1 + (i % 25)));
const id = inVault(
`e2evaultdoc${String(i).padStart(12, "0")}`,
username,
);
const id = `e2evaultdoc${String(i).padStart(12, "0")}`;
docs.push({
id,
kind: FIXTURE_KINDS[i % FIXTURE_KINDS.length],
@@ -444,17 +406,17 @@ export async function ensureVaultFixture(
}
// The MRT report + two adjacent images, same date, all linked to Knie.
docs.push({
id: inVault(MRT_DOC_ID, username),
id: MRT_DOC_ID,
kind: "IMAGING",
title: "MRT Knie",
filename: "mrt-knie.pdf",
mimeType: "application/pdf",
documentDate: MRT_DOC_DATE,
bytes: tinyPdf(inVault(MRT_DOC_ID, username)),
bytes: tinyPdf(MRT_DOC_ID),
});
for (const n of [1, 2]) {
docs.push({
id: inVault(`e2evaultimg000000000000${n}`, username),
id: `e2evaultimg000000000000${n}`,
kind: "IMAGING",
title: `MRT Aufnahme ${n}`,
filename: `mrt-aufnahme-${n}.png`,
@@ -470,19 +432,14 @@ export async function ensureVaultFixture(
MRT_DOC_ID,
"e2evaultimg0000000000001",
"e2evaultimg0000000000002",
].map((id) => inVault(id, username));
];
for (const [i, docId] of linked.entries()) {
await pool.query(
`INSERT INTO document_condition_links
(id, document_id, episode_id, user_id, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (document_id, episode_id) DO NOTHING`,
[
inVault(`e2evaultlink00000000000${i + 1}`, username),
docId,
inVault(KNIE_EPISODE_ID, username),
userId,
],
[`e2evaultlink00000000000${i + 1}`, docId, KNIE_EPISODE_ID, userId],
);
}
});
@@ -505,7 +462,7 @@ export async function ensureVaultAiFixture(): Promise<void> {
const pool = new pg.Pool({ connectionString: url });
try {
await withVaultSeedLock(pool, async () => {
const userId = await getUserId(pool, E2E_USER.username);
const userId = await getUserId(pool);
await upsertDocs(pool, userId, [
{
@@ -565,13 +522,12 @@ export async function ensureVaultAiFixture(): Promise<void> {
export async function seedNamespaceDocs(
prefix: string,
count: number,
username: string = E2E_USER.username,
): Promise<void> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool, username);
const userId = await getUserId(pool);
const docs: DocRow[] = [];
for (let i = 0; i < count; i++) {
const id = `e2e${prefix}${String(i).padStart(8, "0")}`;
@@ -613,7 +569,7 @@ export async function ensureProcedureFixture(): Promise<void> {
await seedNamespaceDocs(PROCEDURE_DOC_PREFIX, 3);
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool, E2E_USER.username);
const userId = await getUserId(pool);
const procedures = [
{
id: PROCEDURE_KNEE_ID,
@@ -680,7 +636,7 @@ export async function ensureShareDocFixture(): Promise<void> {
if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
const userId = await getUserId(pool, E2E_USER.username);
const userId = await getUserId(pool);
await pool.query(
`UPDATE users
SET module_preferences_json =
-740
View File
@@ -1,740 +0,0 @@
import type { Locator, Page } from "@playwright/test";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import { mockDay } from "./setup/day-mock";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The app shell on a tablet, under a finger, at every iPad size in both
* orientations: the rail reaches every destination, `<main>` scrolls to its
* last line, and closing a sheet or a dialog hands the scroll back.
*
* Runs in `webkit-tablet` (iPad, the engine an iPad runs; CI's WebKit job)
* and `chromium-tablet` (an Android tablet, in the Chromium shards). Both
* report `pointer: coarse`, so the rail keeps its 44 px rows and its list
* overflows in landscape, which is the case that broke.
*
* What a browser under test cannot show is the iPadOS toolbar moving the
* visible height away from `100dvh`; `shell-viewport.test.ts` holds the
* decisions that take that disagreement out, and this file measures that the
* shell they produce fills the window exactly and leaves the document
* nothing to scroll or bounce.
*
* Stable hooks only: `sidebar-nav-region`, `sidebar-nav-scroll-cue`,
* `sidebar-footer`, `main-content-wrapper`, `bottom-nav`,
* `bottom-nav-more` (test id), `command-palette-trigger`,
* `command-palette-input`, `day-panel`.
*/
/** iPad and Android tablet sizes, CSS px, with the browser's own bars where they take room. */
const VIEWPORTS = [
{ name: "iPad mini portrait", width: 744, height: 1133 },
{ name: "iPad mini landscape", width: 1133, height: 744 },
{ name: "iPad mini landscape, Safari bars", width: 1133, height: 670 },
{ name: "iPad landscape, Safari bars", width: 1000, height: 690 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Pro 11 portrait", width: 834, height: 1194 },
{ name: "iPad Pro 11 landscape", width: 1194, height: 834 },
{ name: "iPad Pro 13 portrait", width: 1024, height: 1366 },
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
{ name: "Split View two thirds", width: 694, height: 1024 },
{ name: "Split View half, landscape", width: 590, height: 820 },
{ name: "Slide Over", width: 375, height: 1024 },
{ name: "Android tablet landscape", width: 1138, height: 712 },
] as const;
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
async function openDashboard(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.locator('#main-content [data-slot="main-content-wrapper"]'),
).toBeVisible();
// The module-gated destinations join the rail once `/api/auth/me` lands.
await expect(page.locator('aside nav a[href="/medications"]')).toHaveCount(1);
}
/** The shell fills the window exactly and the document has nothing to scroll. */
async function expectShellPinned(page: Page) {
const m = await page.evaluate(() => {
const shell = document.querySelector<HTMLElement>(".shell-safe-area")!;
const rect = shell.getBoundingClientRect();
const root = document.scrollingElement!;
const html = getComputedStyle(document.documentElement);
return {
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
innerHeight,
innerWidth,
docScroll: root.scrollHeight - root.clientHeight,
overflowY: html.overflowY,
overscroll: html.overscrollBehaviorY,
};
});
expect(m.top).toBe(0);
expect(m.left).toBe(0);
expect(Math.abs(m.bottom - m.innerHeight)).toBeLessThanOrEqual(0.5);
expect(Math.abs(m.right - m.innerWidth)).toBeLessThanOrEqual(0.5);
expect(m.docScroll).toBeLessThanOrEqual(0);
expect(m.overflowY).toBe("hidden");
// No rubber band on the document: on iOS it held the next swipe.
expect(m.overscroll).toBe("none");
}
function railNav(page: Page): Locator {
return page.locator('aside [data-slot="sidebar-nav-region"] > nav');
}
/** Scrolls `<main>` to its end, waiting out content that grows on the way. */
async function scrollMainToEnd(page: Page) {
let last = -1;
for (let i = 0; i < 8; i += 1) {
const height = await page.evaluate(() => {
const main = document.getElementById("main-content")!;
main.scrollTop = main.scrollHeight;
return main.scrollHeight;
});
if (height === last) return;
last = height;
await page.waitForTimeout(250);
}
}
/** Where the page's lowest content ends, and what covers the window's bottom edge. */
async function measureMainEnd(page: Page) {
return page.evaluate(() => {
const main = document.getElementById("main-content")!;
const wrapper = main.querySelector<HTMLElement>(
'[data-slot="main-content-wrapper"]',
)!;
let contentBottom = 0;
for (const el of wrapper.querySelectorAll<HTMLElement>("*")) {
const cs = getComputedStyle(el);
if (cs.position === "fixed" || cs.visibility === "hidden") continue;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
let p = el.parentElement;
let nested = false;
while (p && p !== main) {
const ps = getComputedStyle(p);
if (
/(auto|scroll|hidden)/.test(ps.overflowY) &&
p.scrollHeight > p.clientHeight + 1
) {
nested = true;
break;
}
p = p.parentElement;
}
if (nested) continue;
contentBottom = Math.max(contentBottom, rect.bottom);
}
const visible = (el: Element | null): el is HTMLElement =>
!!el &&
getComputedStyle(el).display !== "none" &&
el.getBoundingClientRect().height > 0;
const bar = document.querySelector('[data-slot="bottom-nav"]');
const mainRect = main.getBoundingClientRect();
return {
atEnd: main.scrollTop + main.clientHeight >= main.scrollHeight - 1,
scrolls: main.scrollHeight > main.clientHeight,
contentBottom,
mainBottom: mainRect.bottom,
barTop: visible(bar) ? bar.getBoundingClientRect().top : null,
};
});
}
for (const vp of VIEWPORTS) {
test.describe(`${vp.name} (${vp.width}x${vp.height})`, () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
});
test("every rail destination scrolls into view and takes a tap", async ({
page,
}) => {
await openDashboard(page);
await expectShellPinned(page);
const aside = page.locator("aside");
const railShown = vp.width >= 768;
if (!railShown) {
// Split View and Slide Over get the phone shell: the bar, no rail.
await expect(aside).toBeHidden();
await expect(page.locator('[data-slot="bottom-nav"]')).toBeVisible();
return;
}
await expect(aside).toBeVisible();
// Nothing in the rail is clipped by the rail: the logo band, the list
// region, the pinned footer and the account row all sit inside it, in
// that order, and the list region alone gives up height.
const parts = await page.evaluate(() => {
const asideEl = document.querySelector("aside")!;
const box = asideEl.getBoundingClientRect();
const pick = (sel: string) =>
asideEl.querySelector(sel)!.getBoundingClientRect();
const column = asideEl.querySelector('[data-slot="sidebar-column"]')!;
const user = column.lastElementChild!.getBoundingClientRect();
return {
clipped: asideEl.scrollHeight - asideEl.clientHeight,
asideTop: box.top,
asideBottom: box.bottom,
header: pick('[data-slot="sidebar-header"]'),
region: pick('[data-slot="sidebar-nav-region"]'),
footer: pick('[data-slot="sidebar-footer"]'),
user,
overscroll: getComputedStyle(
asideEl.querySelector('[data-slot="sidebar-nav-region"] > nav')!,
).overscrollBehaviorY,
};
});
expect(parts.clipped).toBeLessThanOrEqual(0);
expect(parts.header.top).toBeGreaterThanOrEqual(parts.asideTop - 0.5);
expect(parts.region.top).toBeGreaterThanOrEqual(
parts.header.bottom - 0.5,
);
expect(parts.footer.top).toBeGreaterThanOrEqual(
parts.region.bottom - 0.5,
);
expect(parts.user.top).toBeGreaterThanOrEqual(parts.footer.bottom - 0.5);
expect(parts.user.bottom).toBeLessThanOrEqual(parts.asideBottom + 0.5);
expect(parts.overscroll).toBe("contain");
await expect(
aside.locator('[data-slot="sidebar-collapse-toggle"]'),
).toBeInViewport({ ratio: 1 });
await expect(
aside.locator('[data-slot="nav-settings-link"]'),
).toBeInViewport({ ratio: 1 });
// Every destination: scrolled into the list's view, whole, on top
// (not under the cue, the footer or anything else), and actionable.
const nav = railNav(page);
const links = nav.locator("a[href]");
const count = await links.count();
expect(count).toBeGreaterThanOrEqual(10);
for (let i = 0; i < count; i += 1) {
const link = links.nth(i);
await link.scrollIntoViewIfNeeded();
await expect
.poll(() =>
link.evaluate((el) => {
const navEl = el.closest("nav")!;
const r = el.getBoundingClientRect();
const n = navEl.getBoundingClientRect();
const hit = document.elementFromPoint(
r.left + r.width / 2,
r.top + r.height / 2,
);
return (
r.top >= n.top - 0.5 &&
r.bottom <= n.bottom + 0.5 &&
!!hit &&
el.contains(hit)
);
}),
)
.toBe(true);
await link.click({ trial: true });
}
// A list taller than its region shows the touch cue at the edge that
// has more, and a tap on it is a way there.
const overflows = await nav.evaluate(
(el) => el.scrollHeight > el.clientHeight + 1,
);
const down = page.locator(
'[data-slot="sidebar-nav-scroll-cue"][data-direction="down"]',
);
const up = page.locator(
'[data-slot="sidebar-nav-scroll-cue"][data-direction="up"]',
);
await nav.evaluate((el) => (el.scrollTop = 0));
if (overflows) {
await expect(down).toBeVisible();
await expect(up).toHaveCount(0);
await down.tap();
await expect
.poll(() => nav.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
await expect(up).toBeVisible();
await nav.evaluate((el) => (el.scrollTop = el.scrollHeight));
await expect(down).toHaveCount(0);
} else {
await expect(down).toHaveCount(0);
await expect(up).toHaveCount(0);
}
// And the last destination is a real tap away.
const lastLink = links.nth(count - 1);
const href = await lastLink.getAttribute("href");
await lastLink.scrollIntoViewIfNeeded();
await lastLink.tap();
await expect(page).toHaveURL(new RegExp(`${href}$`));
});
// The Coach button's clearance is held by `shell-viewport.test.ts`: the
// button renders only with an AI provider, which the suite's accounts
// do not have, so a check here would never run.
test("main scrolls to its last line, clear of the bottom bar", async ({
page,
}) => {
await openDashboard(page);
await scrollMainToEnd(page);
const end = await measureMainEnd(page);
expect(end.scrolls).toBe(true);
expect(end.atEnd).toBe(true);
expect(end.contentBottom).toBeLessThanOrEqual(end.mainBottom + 0.5);
if (end.barTop !== null) {
expect(end.contentBottom).toBeLessThanOrEqual(end.barTop);
}
await expectShellPinned(page);
});
});
}
/** After a modal closes, the page behind takes pointer and wheel again and scrolls. */
async function expectPageScrollsAgain(page: Page, browserName: string) {
await expect
.poll(() =>
page.evaluate(() => ({
locked: document.body.hasAttribute("data-scroll-locked"),
pointer: getComputedStyle(document.body).pointerEvents,
})),
)
.toEqual({ locked: false, pointer: "auto" });
const before = await page.evaluate(() => {
const main = document.getElementById("main-content")!;
main.scrollTop = 0;
const r = main.getBoundingClientRect();
const x = r.left + r.width / 2;
const y = r.top + r.height / 2;
const hit = document.elementFromPoint(x, y);
const wheel = new WheelEvent("wheel", {
deltaY: 120,
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
});
hit?.dispatchEvent(wheel);
return {
onMain: !!hit && main.contains(hit),
blocked: wheel.defaultPrevented,
x,
y,
};
});
// Nothing invisible lies over `<main>`, and no scroll lock swallows the
// wheel (react-remove-scroll cancels it outside its own layer).
expect(before.onMain).toBe(true);
expect(before.blocked).toBe(false);
// A real wheel where the engine has one. Mobile WebKit under Playwright
// has none (and no native touch scroll), so there the two checks above
// are the proof: nothing covers `<main>` and nothing cancels its events.
if (browserName === "webkit") return;
await page.mouse.move(before.x, before.y);
await page.mouse.wheel(0, 600);
await expect
.poll(() =>
page.evaluate(() => document.getElementById("main-content")!.scrollTop),
)
.toBeGreaterThan(0);
}
const MODAL_VIEWPORTS = [
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "Split View two thirds", width: 694, height: 1024 },
] as const;
for (const vp of MODAL_VIEWPORTS) {
test.describe(`${vp.name}: scroll after a modal`, () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
});
test("the page scrolls after the command palette dialog closes", async ({
page,
browserName,
}) => {
await openDashboard(page);
await page.locator('[data-slot="command-palette-trigger"]:visible').tap();
const input = page.locator('[data-slot="command-palette-input"]');
await expect(input).toBeVisible();
// The scroll lock makes the body `position: relative`; the shell
// behind the dialog keeps the window's box all the same.
await expectShellPinned(page);
await page.keyboard.press("Escape");
await expect(input).toBeHidden();
await expectPageScrollsAgain(page, browserName);
});
test("the page scrolls after a sheet closes", async ({
page,
browserName,
}) => {
await openDashboard(page);
if (vp.width < 768) {
// The phone shell's More sheet: the draggable bottom sheet.
await page.locator('[data-testid="bottom-nav-more"]').tap();
const sheet = page.locator('[data-testid="bottom-nav-more-sheet"]');
await expect(sheet).toBeVisible();
await expectShellPinned(page);
await sheet.locator('[data-slot="sheet-close-button"]').tap();
await expect(sheet).toBeHidden();
} else {
// The day, a sheet from the right below 1280 px.
await mockDay(page);
const day = new Date(Date.now() - 86_400_000)
.toISOString()
.slice(0, 10);
await page.goto(`/?day=${day}`, { waitUntil: "domcontentloaded" });
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toBeVisible();
await expectShellPinned(page);
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
}
await expectPageScrollsAgain(page, browserName);
});
});
}
test("a vertical swipe that starts on a tab strip is the page's", async ({
page,
}) => {
await page.setViewportSize({ width: 1180, height: 820 });
await page.goto("/checkups", { waitUntil: "domcontentloaded" });
const strip = page.locator('#main-content [data-slot="tabs-list"]').first();
await expect(strip).toBeVisible();
// `pan-x` here refused the vertical pan, so the page did not move.
const touchAction = await strip.evaluate(
(el) => getComputedStyle(el).touchAction,
);
expect(touchAction).toBe("auto");
});
/**
* An iPad without a home button reports a bottom inset of about 20 px, which
* no browser under test does. The shell reads the inset through
* `--safe-area-bottom` (globals.css), so setting the property is the same
* inset to every element that owns the bottom edge.
*/
const HOME_INDICATOR = 20;
async function setBottomInset(page: Page, px: number) {
await page.addStyleTag({
content: `:root { --safe-area-bottom: ${px}px !important; }`,
});
}
/** What paints the window's last row of pixels, sampled across its width. */
async function bottomRowOwners(page: Page) {
return page.evaluate(() => {
const y = innerHeight - 1;
const owners: string[] = [];
// 8 px in lands on the rail at its narrowest (64 px); the rest spread
// across the content column.
for (const x of [
8,
...Array.from({ length: 11 }, (_, i) => (innerWidth * (i + 1)) / 12),
]) {
const hit = document.elementFromPoint(x, y);
const owner = !hit
? "nothing"
: hit === document.documentElement || hit === document.body
? "page"
: hit.closest("aside")
? "rail"
: hit.closest("#main-content")
? "main"
: hit.closest('[data-slot="bottom-nav"]')
? "bar"
: hit.closest('[data-slot="coach-fab"]')
? "fab"
: `other:${hit.tagName}`;
owners.push(owner);
}
return owners;
});
}
const INSET_VIEWPORTS = [
{ name: "iPad mini portrait", width: 744, height: 1133 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
{ name: "Split View two thirds", width: 694, height: 1024 },
] as const;
for (const vp of INSET_VIEWPORTS) {
test(`${vp.name}: the shell paints to the edge under a home indicator`, async ({
page,
}) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await serveAiBlock(page, aiBlockAvailable());
await openDashboard(page);
await setBottomInset(page, HOME_INDICATOR);
await expectShellPinned(page);
// The last row of pixels belongs to the rail and `<main>` (or the bar),
// never to the page behind the shell: that was the band.
const owners = await bottomRowOwners(page);
expect(owners.filter((o) => o === "page" || o === "nothing")).toEqual([]);
const railShown = vp.width >= 768;
const edge = await page.evaluate(() => {
const aside = document.querySelector("aside")!;
const column = aside.querySelector('[data-slot="sidebar-column"]');
const bar = document.querySelector<HTMLElement>(
'[data-slot="bottom-nav"]',
)!;
return {
innerHeight,
asideBottom: aside.getBoundingClientRect().bottom,
lastRailRowBottom:
column?.lastElementChild?.getBoundingClientRect().bottom ?? 0,
barBottom: bar.getBoundingClientRect().bottom,
barPad: parseFloat(getComputedStyle(bar).paddingBottom),
// What a WKWebView browser paints where it keeps the page out: the
// root's own background.
root: getComputedStyle(document.documentElement).backgroundColor,
body: getComputedStyle(document.body).backgroundColor,
};
});
if (railShown) {
expect(owners).toContain("rail");
expect(owners).toContain("main");
// The rail's background runs to the edge; its account row stops
// above the inset.
expect(edge.asideBottom).toBe(edge.innerHeight);
expect(edge.lastRailRowBottom).toBeLessThanOrEqual(
edge.innerHeight - HOME_INDICATOR,
);
expect(edge.root).toBe(edge.body);
} else {
expect(owners).toContain("bar");
expect(edge.barBottom).toBe(edge.innerHeight);
expect(edge.barPad).toBe(HOME_INDICATOR);
expect(edge.root).toBe(edge.body);
}
// At the end of the page, the last line stands clear of the inset, the
// bar and the Coach button.
await scrollMainToEnd(page);
const end = await measureMainEnd(page);
expect(end.atEnd).toBe(true);
expect(end.contentBottom).toBeLessThanOrEqual(
edge.innerHeight - HOME_INDICATOR,
);
if (end.barTop !== null) {
expect(end.contentBottom).toBeLessThanOrEqual(end.barTop);
}
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible();
const fabTop = (await fab.boundingBox())!.y;
expect(end.contentBottom).toBeLessThanOrEqual(fabTop);
});
}
/**
* The Coach button over the last card of a nested layout (Insights, Settings,
* a values list): during the scroll it may sit over content, at the end it
* must not. Rendered by serving an `ai` block with the Coach available,
* since the suite has no provider.
*/
const NESTED_PAGES = [
"/insights/blood-pressure",
"/insights/values/BLOOD_PRESSURE_SYS",
"/insights",
"/settings/account",
"/settings/modules",
] as const;
for (const vp of [
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
] as const) {
for (const inset of [0, HOME_INDICATOR]) {
test(`${vp.name}, inset ${inset}: the Coach button clears the last line of nested layouts`, async ({
page,
}) => {
test.setTimeout(90_000);
await page.setViewportSize({ width: vp.width, height: vp.height });
await serveAiBlock(page, aiBlockAvailable());
for (const path of NESTED_PAGES) {
await page.goto(path, { waitUntil: "domcontentloaded" });
await expect(
page.locator('#main-content [data-slot="main-content-wrapper"]'),
).toBeVisible();
await page.waitForLoadState("networkidle");
if (inset) await setBottomInset(page, inset);
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible();
// A section near the end can still load once the scroll reaches it
// and show its skeleton for a moment; what has to hold is the page
// as it ends up, so scroll to the end and measure again until the
// last line is in place. Content that stays under the button fails.
const coveredAtEnd = async () => {
await scrollMainToEnd(page);
return page.evaluate(() => {
const f = document
.querySelector('[data-slot="coach-fab"]')!
.getBoundingClientRect();
const main = document.getElementById("main-content")!;
const under: string[] = [];
for (const el of main.querySelectorAll<HTMLElement>("*")) {
const cs = getComputedStyle(el);
if (cs.position === "fixed" || cs.visibility === "hidden")
continue;
const paints =
(el.childElementCount === 0 && !!el.textContent?.trim()) ||
cs.borderBottomWidth !== "0px" ||
cs.backgroundColor !== "rgba(0, 0, 0, 0)";
if (!paints) continue;
const b = el.getBoundingClientRect();
if (!b.width || !b.height) continue;
if (
b.right > f.left &&
b.left < f.right &&
b.bottom > f.top &&
b.top < f.bottom
) {
under.push(el.getAttribute("data-slot") ?? el.tagName);
}
}
return under;
});
};
await expect
.poll(coveredAtEnd, {
message: `${path}: content under the Coach button`,
timeout: 15_000,
})
.toEqual([]);
}
});
}
}
/**
* A side inset. An iPad in a Safari tab laid the shell out 10 px in from the
* left edge in portrait, which no browser under test reports. The shell and
* the rail read the side insets through `--safe-area-left` / `-right`
* (globals.css), so setting them is the same inset to both.
*/
const SIDE_INSET = 10;
async function setSideInsets(page: Page, px: number) {
await page.addStyleTag({
content: `:root { --safe-area-left: ${px}px !important; --safe-area-right: ${px}px !important; }`,
});
}
const SIDE_INSET_VIEWPORTS = [
{ name: "iPad Pro 11 portrait", width: 834, height: 1194 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
] as const;
for (const vp of SIDE_INSET_VIEWPORTS) {
test(`${vp.name}: the rail runs to the left edge over a side inset`, async ({
page,
}) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await openDashboard(page);
await setSideInsets(page, SIDE_INSET);
await setBottomInset(page, HOME_INDICATOR);
await expectShellPinned(page);
await expect(page.locator("aside")).toBeVisible();
const m = await page.evaluate(() => {
const aside = document.querySelector("aside")!;
const box = (el: Element | null) => {
const r = el!.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
};
// What paints a pixel: the rail, or the page behind the shell (the
// root, the body, or one of the shell's own unpainted boxes around the
// rail and `<main>`), or some other chrome that meets the edge.
const owner = (x: number, y: number) => {
const hit = document.elementFromPoint(x, y);
if (!hit) return "nothing";
if (hit.closest("aside")) return "rail";
const unpainted =
hit === document.documentElement ||
hit === document.body ||
(hit.id !== "main-content" &&
hit.contains(document.getElementById("main-content")));
return unpainted ? `page:${hit.tagName}` : "chrome";
};
const header = aside.querySelector('[data-slot="sidebar-header"]')!;
const account = aside.querySelector(
'[data-slot="sidebar-column"]',
)!.lastElementChild!;
const avatar = account.querySelector('[data-slot="avatar"]')!;
const footer = aside.querySelector('[data-slot="sidebar-footer"]')!;
const links = [...aside.querySelectorAll("nav a[href]")].map(
(a) => a.getBoundingClientRect().left,
);
const headerBox = box(header);
const accountBox = box(account);
return {
innerWidth,
innerHeight,
aside: box(aside),
header: headerBox,
account: accountBox,
avatar: box(avatar),
logoLeft: box(header.querySelector("svg")).left,
footerLeft: box(footer).left,
firstLinkLeft: Math.min(...links),
// Down the window's first column of pixels, and its last.
leftEdge: [
(headerBox.top + headerBox.bottom) / 2,
innerHeight / 2,
(accountBox.top + accountBox.bottom) / 2,
innerHeight - 2,
].map((y) => owner(1, y)),
rightEdge: [
(headerBox.top + headerBox.bottom) / 2,
innerHeight / 2,
].map((y) => owner(innerWidth - 1, y)),
};
});
// No strip: the rail paints the inset from the logo band to the bottom
// edge, and the top bar and `<main>` (or a docked panel) meet the right
// edge.
expect(m.leftEdge).toEqual(["rail", "rail", "rail", "rail"]);
expect(m.rightEdge).toEqual(["chrome", "chrome"]);
expect(m.aside.left).toBe(0);
// The band lines run to the edge: the logo band, the account band and
// the footer group (whose scroll edge draws a line) start at x = 0.
expect(m.header.left).toBe(0);
expect(m.account.left).toBe(0);
expect(m.footerLeft).toBe(0);
// And everything in the rail stays clear of the inset.
expect(m.logoLeft).toBeGreaterThanOrEqual(SIDE_INSET);
expect(m.firstLinkLeft).toBeGreaterThanOrEqual(SIDE_INSET);
expect(m.avatar.left).toBeGreaterThanOrEqual(SIDE_INSET);
// The account band mirrors the logo band: the same height, the avatar
// centred in it, and the home indicator's inset below it.
expect(m.account.bottom - m.account.top).toBe(
m.header.bottom - m.header.top,
);
const above = m.avatar.top - m.account.top;
const below = m.account.bottom - m.avatar.bottom;
expect(Math.abs(above - below)).toBeLessThanOrEqual(1);
expect(m.account.bottom).toBe(m.innerHeight - HOME_INDICATOR);
expect(m.aside.bottom).toBe(m.innerHeight);
});
}
+4 -195
View File
@@ -15,19 +15,12 @@
* to six lines.
* 6. On a phone the chronicle replaces the chart, says its empty months
* out loud, names each quarter's means once, and a row opens its day.
* 7. A lab marker (#1299) is picked from the values menu, drawn as its
* single readings with the range behind them, read on its own, kept
* across a reload, and listed on its lab day on a phone.
* 8. A symptom (#981) is picked from the values menu, drawn as a value
* line on the fixed 0 to 10 scale, read with the days behind a point,
* kept across a reload, and named in the phone chronicle.
*
* The timeline's server routes are mocked with the contract's shapes
* (`utils/mock-timeline.ts`); the module switch is the real one. Every
* assertion is addressed to a stable `data-*` attribute. The journey moves
* its account's module switch, so it runs serially and in one project.
*/
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -305,9 +298,7 @@ test("value lines: quarterly means, gaps kept, short ones bridged, as many as th
// The picker is one button that counts the lines; the chosen ones are
// not listed beside it.
const trigger = page.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
);
const trigger = page.locator('[data-slot="timeline-values-trigger"]');
await expect(trigger).toHaveAttribute("data-count", "3");
await expect(trigger).toHaveText(/\(3\)/);
const toolbarHeight = await trigger.evaluate(
@@ -820,188 +811,6 @@ test("a value point reads on its own, by pointer and by keyboard", async ({
await expect(tip).toHaveAttribute("data-series", series!);
});
test("a lab marker is picked, drawn as its readings, read on its own and kept", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY, { labs: true });
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/timeline?zoom=all");
await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({
timeout: 20_000,
});
await page
.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
)
.click();
const menu = page.locator('[data-slot="timeline-values-menu"]');
await expect(
menu.locator('[data-slot="timeline-values-labs"]'),
).toBeVisible();
await expect(menu.getByText("Eisen")).toBeVisible();
await menu
.locator('[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]')
.click();
await page.keyboard.press("Escape");
// The menu hands focus back to its trigger once its exit animation ends.
await expect(menu).toBeHidden();
await expect(
page.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
),
).toBeFocused();
const row = page.locator('[data-lab-series="lab:bm-ferritin"]');
await expect(row.locator('[data-slot="timeline-lab-mark"]')).toHaveCount(4);
// Below its range: a triangle, never only a colour.
await expect(row.locator('path[data-status="below"]')).toHaveCount(1);
await expect(row.locator('[data-slot="timeline-lab-band-fill"]')).toHaveCount(
1,
);
expect(log.timelineQueries.at(-1)?.get("values")?.split(",")).toContain(
"lab:bm-ferritin",
);
// The reading below its range, by keyboard: day, value, verdict, report.
const target = page.locator(
'[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]',
);
const tip = page.locator('[data-slot="timeline-point-tip"]');
// A pointer resting on another line's point, as the menu click can leave
// it: the focus takes the card, and that point losing the pointer (the
// focus scrolls the chart from under it) must not close the card.
await page
.locator('[data-slot="timeline-point-target"][data-series="WEIGHT"]')
.last()
.hover();
await expect(tip).toHaveAttribute("data-series", "WEIGHT");
await target.focus();
await expect(tip).toHaveAttribute("data-t", "fe-1");
await page.mouse.move(0, 0);
await expect(tip).toHaveAttribute("data-t", "fe-1");
await expect(tip).toContainText("22 ng/mL");
await expect(tip).toContainText("Laborbefund März");
const axe = await new AxeBuilder({ page })
.include('[data-slot="timeline-page"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]);
// The choice is kept: a reload asks for the marker again.
await page.reload();
await expect(row.locator('[data-slot="timeline-lab-mark"]')).toHaveCount(4, {
timeout: 20_000,
});
// On a phone: the same line, listed on its lab days, no sideways scroll.
await page.setViewportSize({ width: 390, height: 844 });
const labs = page.locator('[data-slot="timeline-chronicle-labs"]');
await expect(labs.first()).toBeVisible();
await expect(labs).toHaveCount(4);
await expect(labs.last()).toContainText("22 ng/mL");
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
// Unchosen, it is gone from the request and the page.
await page
.locator(
'[data-slot="timeline-mobile"] [data-slot="timeline-values-trigger"]',
)
.click();
await page
.locator('[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]')
.click();
await page.keyboard.press("Escape");
await expect(labs).toHaveCount(0);
});
test("a symptom is picked, drawn as a 0 to 10 line, read with its days and kept", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY, { symptoms: true });
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/timeline?zoom=all");
await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({
timeout: 20_000,
});
await page
.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
)
.click();
const menu = page.locator('[data-slot="timeline-values-menu"]');
await expect(
menu.locator('[data-slot="timeline-values-symptoms"]'),
).toBeVisible();
// A symptom hidden from entry is still offered, and says so.
await expect(
menu.locator(
'[data-slot="timeline-values-option"][data-key="symptom:sd-nausea"]',
),
).toBeVisible();
await menu
.locator(
'[data-slot="timeline-values-option"][data-key="symptom:sd-headache"]',
)
.click();
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
const row = page.locator('g[data-series="symptom:sd-headache"]');
const points = row.locator('[data-slot="timeline-series-point"]');
await expect(points).toHaveCount(3);
// The quarter resting on two days is hollow.
await expect(points.nth(1)).toHaveAttribute("data-thin", "true");
await expect(
page.locator('[data-slot="timeline-legend-symptom"]'),
).toBeVisible();
expect(log.timelineQueries.at(-1)?.get("values")?.split(",")).toContain(
"symptom:sd-headache",
);
const target = page.locator(
'[data-slot="timeline-point-target"][data-series="symptom:sd-headache"][data-t="2024-01-01"]',
);
const tip = page.locator('[data-slot="timeline-point-tip"]');
await target.focus();
await expect(tip).toHaveAttribute("data-t", "2024-01-01");
// Out of 10 in the account's own decimal mark, with the days behind it.
await expect(tip).toContainText(/6[.,]5\/10/);
await expect(tip).toContainText(/\b9\b/);
const axe = await new AxeBuilder({ page })
.include('[data-slot="timeline-page"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]);
// The choice is kept: a reload asks for the symptom again.
await page.reload();
await expect(points).toHaveCount(3, { timeout: 20_000 });
// On a phone: its means in the chronicle, no sideways scroll.
await page.setViewportSize({ width: 390, height: 844 });
const means = page.locator(
'[data-slot="timeline-mobile"] [data-slot="timeline-mean-part"][data-series="symptom:sd-headache"]',
);
await expect(means.first()).toBeVisible();
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
});
test("the timeline page ends where its content does, at every desktop width", async ({
page,
}) => {
@@ -1036,9 +845,9 @@ test("the timeline page ends where its content does, at every desktop width", as
expect(m.mainSw, where).toBeLessThanOrEqual(m.mainCw + 1);
expect(m.docSw, where).toBeLessThanOrEqual(m.docCw + 1);
expect(m.docSh, where).toBeLessThanOrEqual(m.docCh + 1);
// The shell's own buffer below the last card (the desktop shell's
// pb-24 and the top padding), nothing more.
expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 120 + 2);
// The shell's own buffer below the last card (pb-20 and the top
// padding), nothing more.
expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 104 + 2);
}
}
});
+1 -1
View File
@@ -255,7 +255,7 @@ test.describe("Today overview geometry", () => {
const hero = await openDashboard(page, digest);
const phone = viewport.width < 768;
// Late banners (the recovery kit nudge) insert above the hero; let
// Late banners (the key-backup nudge) insert above the hero; let
// the page settle so the capture shows one stable layout.
await page.waitForLoadState("networkidle");
mkdirSync(SHOTS, { recursive: true });
+2 -180
View File
@@ -512,150 +512,10 @@ export function fullTimeline(today: string) {
bucket: "quarter" as Bucket,
series: series("2019-01-01", today, "quarter"),
availableSeries: FULL_AVAILABLE,
labSeries: [],
availableLabs: [],
symptomSeries: [],
availableSymptoms: [],
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
};
}
/**
* Two lab markers of the full record (#1299): ferritin read a few times over
* the years against a 30 to 400 range, one reading below it and one read
* from a report, and HbA1c read once. Only what `values` names is sent as a
* line; the menu offers both.
*/
export const LAB_MARKERS = [
{
key: "lab:bm-ferritin",
biomarkerId: "bm-ferritin",
name: "Ferritin",
panel: "Eisen",
unit: "ng/mL",
},
{
key: "lab:bm-hba1c",
biomarkerId: "bm-hba1c",
name: "HbA1c",
panel: "Zucker",
unit: "%",
},
];
function labPoint(
id: string,
date: string,
value: number,
rangeStatus: "below" | "in-range" | "above" | "unknown",
report: { id: string; title: string } | null = null,
) {
const ranged = rangeStatus !== "unknown";
return {
id,
date,
value,
sourceValue: null,
rangeStatus,
referenceLow: ranged ? 30 : null,
referenceHigh: ranged ? 400 : null,
report: report ? { ...report, href: `/documents?doc=${report.id}` } : null,
};
}
export const LAB_LINES = [
{
...LAB_MARKERS[0],
band: { low: 30, high: 400 },
points: [
labPoint("fe-1", "2021-03-15", 22, "below", {
id: "doc-lab-1",
title: "Laborbefund März",
}),
labPoint("fe-2", "2022-09-20", 64, "in-range"),
labPoint("fe-3", "2024-02-12", 118, "in-range"),
labPoint("fe-4", "2025-11-04", 96, "in-range"),
],
excluded: 0,
},
{
...LAB_MARKERS[1],
band: null,
points: [labPoint("a1c-1", "2025-11-04", 5.4, "unknown")],
excluded: 0,
},
];
/** The lab days behind the lab lines, as the labs lane names them. */
function labsLane(keys: readonly string[]) {
const days = new Map<string, string[]>();
for (const line of LAB_LINES) {
if (!keys.includes(line.key)) continue;
for (const p of line.points) {
days.set(p.date, [...(days.get(p.date) ?? []), line.name]);
}
}
return {
key: "labs",
items: [...days]
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([date, names]) =>
item({
id: `lab-day:${date}`,
kind: "labDay",
start: date,
label: names.join(", "),
sub: String(names.length),
href: "/labs",
}),
),
};
}
/**
* Two of the person's own symptoms (#981): a headache logged over several
* quarters, one quarter resting on two days, and a nausea hidden from entry
* since, logged once. Their points are bucket means of day peaks on the
* fixed 0 to 10 scale; only quarters are drawn here, the bucket an `all`
* view of the full record uses.
*/
export const SYMPTOM_OPTIONS = [
{
key: "symptom:sd-headache",
definitionId: "sd-headache",
label: "Kopfschmerz",
icon: "brain",
isActive: true,
},
{
key: "symptom:sd-nausea",
definitionId: "sd-nausea",
label: "Übelkeit",
icon: null,
isActive: false,
},
];
const SYMPTOM_QUARTERS: Record<
string,
Array<{ t: string; mean: number; count: number }>
> = {
"symptom:sd-headache": [
{ t: "2024-01-01", mean: 6.5, count: 9 },
{ t: "2024-04-01", mean: 4, count: 2 },
{ t: "2025-01-01", mean: 3.25, count: 5 },
],
"symptom:sd-nausea": [{ t: "2023-07-01", mean: 2, count: 1 }],
};
function symptomLines(keys: readonly string[], bucket: Bucket) {
return SYMPTOM_OPTIONS.filter((o) => keys.includes(o.key)).map((o) => ({
...o,
scale: { min: 0, max: 10 },
points: bucket === "quarter" ? SYMPTOM_QUARTERS[o.key] : [],
}));
}
export function readyTimeline(today: string) {
return {
zoom: "all",
@@ -777,10 +637,6 @@ export function readyTimeline(today: string) {
(s) => s.key !== "RESTING_HEART_RATE",
),
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
labSeries: [],
availableLabs: [],
symptomSeries: [],
availableSymptoms: [],
notable: [],
};
}
@@ -978,14 +834,7 @@ export async function mockTimeline(
page: Page,
state: "full" | "ready",
today: string,
options: {
onlyOld?: boolean;
dataFrom?: string;
/** The record has lab markers to offer and draw (#1299). */
labs?: boolean;
/** The record has logged symptoms to offer and draw (#981). */
symptoms?: boolean;
} = {},
options: { onlyOld?: boolean; dataFrom?: string } = {},
): Promise<TimelineMockLog> {
const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] };
await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) =>
@@ -995,38 +844,13 @@ export async function mockTimeline(
const url = new URL(route.request().url());
log.timelineQueries.push(url.searchParams);
const body = state === "full" ? fullTimeline(today) : readyTimeline(today);
const named = (url.searchParams.get("values") ?? "")
const values = (url.searchParams.get("values") ?? "")
.split(",")
.filter(Boolean);
// A lab marker is a line of its own (`labSeries`), never a mean.
const labKeys = named.filter((key) => key.startsWith("lab:"));
// A symptom is a line of its own too (`symptomSeries`), on 0 to 10.
const symptomKeys = named.filter((key) => key.startsWith("symptom:"));
const values = named.filter(
(key) => !key.startsWith("lab:") && !key.startsWith("symptom:"),
);
const labs =
state === "full" && options.labs
? {
labSeries: LAB_LINES.filter((l) => labKeys.includes(l.key)),
availableLabs: LAB_MARKERS,
lanes:
labKeys.length > 0
? [...body.lanes, labsLane(labKeys)]
: body.lanes,
}
: {};
const zoom = url.searchParams.get("zoom") ?? "all";
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
const bucket = bucketFor(zoom, from, to);
const symptoms =
state === "full" && options.symptoms
? {
symptomSeries: symptomLines(symptomKeys, bucket),
availableSymptoms: SYMPTOM_OPTIONS,
}
: {};
const dataFrom =
options.dataFrom ?? (state === "full" ? "2019-01-01" : "2024-05-01");
if (options.onlyOld && (zoom === "quarter" || zoom === "year")) {
@@ -1048,8 +872,6 @@ export async function mockTimeline(
: series(chosen ? from : dataFrom, chosen ? to : today, bucket);
return json(route, 200, {
...body,
...labs,
...symptoms,
zoom,
bucket,
...(chosen
+10 -34
View File
@@ -16,42 +16,22 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
E2E_VAULT_LAYOUT,
STORAGE_STATE_PATH,
VAULT_LAYOUT_STORAGE_STATE_PATH,
} from "./setup/global-setup";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { ensureVaultFixture, seedNamespaceDocs } from "./setup/vault-fixture";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
test.use({ storageState: STORAGE_STATE_PATH });
async function putDocumentsLayout(
page: Page,
arrangement: "stacked" | "flow",
): Promise<void> {
async function resetDocumentsLayout(page: Page): Promise<void> {
const res = await page.request.put("/api/documents/inbound/layout", {
data: { version: 1, view: "cards", arrangement },
data: { version: 1, view: "cards", arrangement: "stacked" },
});
expect(res.status()).toBe(200);
}
/** The journey starts from the stacked arrangement so the flip is visible. */
const resetDocumentsLayout = (page: Page) =>
putDocumentsLayout(page, "stacked");
/** And leaves its account on the default, as a new account would read it. */
const restoreDefaultDocumentsLayout = (page: Page) =>
putDocumentsLayout(page, "flow");
// The vault's layout is account state, and this journey turns it, so these
// tests run on a vault account of their own (`E2E_VAULT_LAYOUT`); the month
// grid tests in `documents.spec.ts` read the default on theirs.
test.describe("documents", () => {
test.use({ storageState: VAULT_LAYOUT_STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture(E2E_VAULT_LAYOUT.username);
await ensureVaultFixture();
});
test("the display choice behind the wrench persists across visits", async ({
@@ -76,15 +56,11 @@ test.describe("documents", () => {
settings.locator('[data-slot="module-view-list"]'),
).toHaveAttribute("aria-pressed", "true");
const flow = settings.locator('[data-slot="documents-arrangement-flow"]');
// Wait for THIS write to land, so the reload below reads it back. The
// view switch above writes too, and its answer can arrive after this
// wait starts; matching on the arrangement keeps the two apart.
// Wait for the write to land, so the reload below reads it back.
const saved = page.waitForResponse(
(r) =>
r.url().includes("/api/documents/inbound/layout") &&
r.request().method() === "PUT" &&
(r.request().postDataJSON() as { arrangement?: string } | null)
?.arrangement === "flow",
r.request().method() === "PUT",
);
await flow.click();
expect((await saved).status()).toBe(200);
@@ -111,7 +87,7 @@ test.describe("documents", () => {
).toHaveAttribute("data-arrangement", "flow");
await second.close();
} finally {
await restoreDefaultDocumentsLayout(page);
await resetDocumentsLayout(page);
}
});
@@ -120,7 +96,7 @@ test.describe("documents", () => {
isMobile,
}) => {
test.skip(isMobile, "selection by checkbox is a pointer flow");
await seedNamespaceDocs("qoldel", 2, E2E_VAULT_LAYOUT.username);
await seedNamespaceDocs("qoldel", 2);
await page.goto("/documents?q=qoldel");
const box = page.getByRole("checkbox", { name: "Select qoldel 002" });
await expect(box).toBeVisible();
@@ -164,7 +140,7 @@ test.describe("documents", () => {
isMobile,
}) => {
test.skip(isMobile, "keyboard flow");
await seedNamespaceDocs("qolkey", 1, E2E_VAULT_LAYOUT.username);
await seedNamespaceDocs("qolkey", 1);
await page.goto("/documents?q=qolkey");
const open = page.getByRole("button", { name: "Open qolkey 001" });
await expect(open).toBeVisible();
@@ -182,7 +158,7 @@ test.describe("documents", () => {
isMobile,
}) => {
test.skip(isMobile, "selection by checkbox is a pointer flow");
await seedNamespaceDocs("qolclear", 2, E2E_VAULT_LAYOUT.username);
await seedNamespaceDocs("qolclear", 2);
await page.goto("/documents?q=qolclear");
await page.getByRole("checkbox", { name: "Select qolclear 001" }).click();
-6
View File
@@ -214,9 +214,6 @@ const eslintConfig = defineConfig([
"src/components/charts/chart-runtime.ts",
"src/components/admin/host-metrics-chart.tsx",
"src/components/charts/health-chart.tsx",
// HealthChart's plot layers, imported by health-chart.tsx alone, so
// they sit inside the same chart-runtime graph.
"src/components/charts/health-chart/chart-layers.tsx",
"src/components/charts/medication-compliance-chart.tsx",
"src/components/charts/mood-chart.tsx",
"src/components/charts/nutrient-daily-bar-chart.tsx",
@@ -239,9 +236,6 @@ const eslintConfig = defineConfig([
"src/components/charts/workout-elevation-chart.tsx",
"src/components/insights/coach-panel/result-chart.tsx",
"src/components/insights/derived/score-trend-chart.tsx",
// Browser tests draw a chart with Recharts itself to pin what it lays
// out; a test file is never bundled, so it mints no chunk.
"src/**/*.browser.test.tsx",
],
rules: {
"no-restricted-imports": [
-4
View File
@@ -100,9 +100,6 @@
"e2e/**/*.test.ts",
"tests/**/*.test.ts",
"tests/**/setup.ts",
// Reached only through the browser project's `next/navigation` alias in
// vitest.config.mts, which no import statement names.
"tests/browser/next-navigation.ts",
"vitest.config.ts",
"vitest.config.integration.ts",
"playwright.config.ts",
@@ -114,7 +111,6 @@
"src/**/*.{ts,tsx}!",
"scripts/**/*.{ts,mjs}!",
"tests/**/*.ts",
"tests/**/*.tsx",
"e2e/**/*.ts",
],
"ignore": [
Loaded 100 of 3681 files, more files were not shown because too many files have changed in this diff. Show more