The password-reset runbook tells an operator to run `docker compose exec app node scripts/reset-password.mjs`, and the intake-repair runbook runs `healthlog-tsx scripts/repair-intake-anomalies.ts` inside the container. The runner stage copied nothing from scripts/, so both failed with "Cannot find module", the reset at the moment an admin is locked out. The runner stage now copies both scripts and the Argon2 parameter module the reset imports, and proves at build time that each reaches its own first check with every import resolved. A structural test holds every in-container command in docs/ and the Dockerfile copies in step. Key rotation is documented the way it works on the published image: the Rotate now button under Admin > Encryption is the primary path. The rotation script imports the application graph and runs from a source checkout of the same release; the runbook, the admin hint in all seven languages and the decrypt error now say so.
11 KiB
Migrating from HealthLog 1.3 to 1.4
v1.4.0 is drop-in for self-hosters running the GHCR image — the
two new migrations (0025_refresh_tokens, 0025_user_locale_drift_fix)
both apply on container start via prisma migrate deploy, and every
new env var in §Environment variables below has a safe default. This
guide documents what changes for operators and integrators, in case
you're extending the API or scaling the deployment.
Historical note: route paths on this page reflect v1.4. Several have since moved or gone:
/api/admin/status-overviewis now/api/admin/status, the Umami probe is/api/admin/monitoring/umami-test, and the moodLog integration was removed entirely.
TL;DR
docker compose pull
docker compose up -d
That's it. The migrations are forward-compatible (IF NOT EXISTS
guards), no env vars are required to be added, and no API contract
changes shape or status code.
Database
0025_refresh_tokens(new) — adds therefresh_tokenstable for the native-client 24h access-token / refresh-token rotation flow. Browser cookie sessions don't use it.0025_user_locale_drift_fix(new) —ADD COLUMN IF NOT EXISTS users.locale TEXTto backfill an environment that was kept in sync viaprisma db pushrather thanmigrate deploy. No-op on installations that were already current.- Both migrations are idempotent. A rollback to 1.3.3 keeps the schema usable; the dropped tables/columns are simply unused.
Environment variables
About surface
NEXT_PUBLIC_APP_BUILD_SHA(optional, new) — short Git SHA exposed through/api/version, rendered on Settings → About.NEXT_PUBLIC_APP_BUILT_AT(optional, new) — ISO-8601 build timestamp.
Both are optional; without them, the About surface renders "development" wording.
Worker / web split
HEALTHLOG_PROCESS_TYPE(optional, defaultall) — set towebto disable the pg-boss worker (jobs queue but don't run), orworkerto refuse all HTTP traffic with503 + X-HealthLog-Process-Type: worker. Defaultallkeeps the single-container behaviour. Only relevant if you're scaling HTTP and the worker independently.
Encryption-key versioning
ENCRYPTION_KEYS(optional, new) — JSON object mapping versioned key IDs to 64-char hex keys, e.g.{"v1": "...", "v2": "..."}. When present, ciphertexts are written with the<keyId>.<base64>format and decrypted by looking up the key ID. Existing 1.3-format ciphertexts (no key prefix) keep decrypting withENCRYPTION_KEYindefinitely.ENCRYPTION_ACTIVE_KEY_ID(optional, new) — the key ID used for new writes. Must be a key inENCRYPTION_KEYS. Defaults to the legacy single-key behaviour if either is unset.ENCRYPTION_KEY(unchanged) — still honoured as a fallback whenENCRYPTION_KEYSis empty. No action needed for existing installs.- Rotation: Admin → Encryption → Rotate now re-encrypts every
ciphertext under the new key without downtime; the script
scripts/rotate-encryption-key.tsdoes the same from a source checkout. Walk-through indocs/ops/encryption-key-rotation.md(the current playbook).
Off-host backup target (optional)
If unset, the existing local DataBackup storage continues to work.
BACKUP_S3_ENDPOINT— S3-compatible endpoint URL. Leave blank to disable off-host backup.BACKUP_S3_BUCKET— destination bucket name.BACKUP_S3_ACCESS_KEY,BACKUP_S3_SECRET_KEY— IAM credentials. Worker only requestsPutObject+GetObject— neverDeleteObject. Retention is the bucket's lifecycle policy.BACKUP_S3_REGION(defaultauto).BACKUP_RETENTION_DAYS(default30) — surfaced in the admin UI; the lifecycle rule on your bucket is the actual enforcer.BACKUP_ENCRYPTION_KEY— separate 64-char hex key for the AES-GCM envelope of each upload. Keep offline / in a vault. Lose this and the off-host backups are unreadable.
API token hashing (unchanged but worth re-confirming)
API_TOKEN_HMAC_KEY— 64-char hex string. Used to HMAC Bearer tokens before storage so a DB leak can't be reversed via rainbow table. Same key now also keys the newRefreshTokenrow hashes. Do not rotate without invalidating every native client.
API
New
GET /api/version— public, static, returns{ version, buildSha, builtAt, license, repository, changelog, docs }. No authentication.POST /api/integrations/withings/test— admin-only probe against the saved Withings OAuth connection. Returns{ ok, latency_ms }on success.POST /api/integrations/moodlog/test— admin-only HEAD request againsthttps://moodlog.app/api/health. SSRF-guarded.POST /api/notifications/web-push/test— sends a smoke push notification to the current user's subscribed devices.POST /api/monitoring/glitchtip/test— fires a benign event at the configured DSN.POST /api/monitoring/umami/test— verifies the script URL + website ID resolve and respond with the expected payload.POST /api/admin/backup/test— admin-only round-trip against the configuredBACKUP_S3_*credentials.GET /api/admin/status-overview— admin-only aggregator returning the six-card summary the new admin top strip renders in one batched query.POST /api/auth/refresh— native-client refresh-token rotation. Cookie-session browsers don't use this.POST /api/auth/refresh/revoke— paired logout-on-device that flips both the refresh row and the matchingApiTokenrow to revoked.
All new admin endpoints are hidden from anonymous traffic by the proxy and rate-limited.
Behaviour-only changes
POST /api/ai/testnow accepts an optional JSON body that mirrors the AI dropdown in/settings. Empty body keeps the pre-1.4 behaviour (test the saved provider). Non-empty body tests the unsaved selection without persisting plaintext keys. The existing rate limit (5/min) and error-leak shielding stay in place.GET /api/dashboard/summarylocalises thestreak.labelandgreeting.salutationfields against the user's stored locale.POST /api/insights/targetsuses a single batched query for per-type latest readings; returns identical payloads.
No /api/... route changed shape, status codes, or auth rules. iOS,
n8n, and Health Connect integrations need no change.
UI / UX
Dashboard
- Tile strip is always one row. Previously the four-to-five-tile grid wrapped to a second row when many widgets were enabled. v1.4 uses a horizontal-scroll strip — total width caps at the chart width below, snap points keep the touch experience deliberate. To reduce the visible tile count, open Settings → Dashboard Layout and toggle widgets off.
Forms
- Password managers no longer autofill health-data inputs.
Browsers and LastPass / 1Password were happily filling the user's
saved account password into measurement notes, AI tokens, and
similar free-text fields. The
<Input>primitive now defaults toautoComplete="off"plus the LastPass / 1Password ignore attributes whenever the caller doesn't pass a semantic value. Auth and profile forms (login, register, change-email) keep autofilling normally because they pass an explicitautoComplete(e.g."username","email","new-password").
Settings
- AI provider connection-test honours the unsaved selection. Pick a provider in the dropdown and click "Verbindung testen" without saving first — the test now runs against your in-form selection instead of the saved provider.
Notifications
- Telegram, ntfy, and Web Push reminders follow your locale. Switching the user's locale to English now produces English reminders; the previous build always sent German strings regardless. Telegram callback IDs stay stable (English) so the bot dispatcher keeps matching them.
Medical defaults
- Diastolic blood-pressure orange band no longer reaches 60 mmHg.
A reading of 60 mmHg now lands in red instead of yellow. The
user-override path is intact — if you set a personal threshold via
/settings#thresholds, it still applies and is audit-logged. - Body-fat classifier mirrors the ACE table — readings below the ACE essential floor (M < 2 / F < 10) now classify as "Below essential" (danger), the actual essential band (M 2–5 / F 10–13) as warning, and the realistic healthy range (M 14–24 / F 21–31) as the green band on the dashboard target tile.
- Step target unified at 8 000–15 000. The two surfaces that
previously disagreed (7 000–10 000 in
getStepsRange()versus 8 000–15 000 in the effective-range resolver) now use the same band, anchored on Saint-Maurice et al., JAMA 2020.
URL changes
- Settings has moved to per-section routes.
/settings#integrations→/settings/integrations, etc. The legacy/settings#anchorURLs still 308-redirect to the new routes; sidebar deep-links and the<a href="/settings#thresholds">patterns from 1.3 keep working.
What landed in v1.4 vs v1.4.1
The v1.3.3 ecosystem audit identified architectural rewrites tracked through v1.4. Status as of the v1.4.1 cycle:
| Bucket | Status |
|---|---|
Settings page split into /settings/[section] |
✓ shipped in v1.4.0 |
| Admin status-first card grid + aggregator endpoint | ✓ shipped in v1.4.0 |
| AI insights rework — severity hero + allowlisted inline charts | ✓ shipped in v1.4.0 |
| Multi-tenant prep (off-host backups, key versioning, worker/web split, refresh tokens) | ✓ shipped in v1.4.0 |
| Per-section admin component extraction (inner monolith → one file per panel) | ✓ shipped in v1.4.1 |
| Postgres-backed integration test suite (testcontainers) | ✓ shipped in v1.4.1 |
| Playwright + axe-core E2E foundation | ✓ shipped in v1.4.1 |
Rollback
If 1.4.0 surfaces a regression on your install, redeploy the previous 1.3.3 image:
docker compose down
docker pull ghcr.io/mbombeck/healthlog:1.3.3
sed -i 's/healthlog:1.4.0/healthlog:1.3.3/' docker-compose.yml
docker compose up -d
The schema is unchanged so a rollback does not require a database restore. File a GitHub issue with the symptom — the v1.4 cycle ran multi-pass QA per stage, but no migration is risk-free.