Files
HealthLog/docs/migration/v1.3-to-v1.4.md
T
Marc-André Bombeck 13db7935f6 fix(image): ship the operator scripts the runbooks run in the container
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.
2026-09-26 15:26:03 +02:00

11 KiB
Raw Blame History

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-overview is 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 the refresh_tokens table 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 TEXT to backfill an environment that was kept in sync via prisma db push rather than migrate 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, default all) — set to web to disable the pg-boss worker (jobs queue but don't run), or worker to refuse all HTTP traffic with 503 + X-HealthLog-Process-Type: worker. Default all keeps 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 with ENCRYPTION_KEY indefinitely.
  • ENCRYPTION_ACTIVE_KEY_ID (optional, new) — the key ID used for new writes. Must be a key in ENCRYPTION_KEYS. Defaults to the legacy single-key behaviour if either is unset.
  • ENCRYPTION_KEY (unchanged) — still honoured as a fallback when ENCRYPTION_KEYS is 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.ts does the same from a source checkout. Walk-through in docs/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 requests PutObject + GetObject — never DeleteObject. Retention is the bucket's lifecycle policy.
  • BACKUP_S3_REGION (default auto).
  • BACKUP_RETENTION_DAYS (default 30) — 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 new RefreshToken row 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 against https://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 configured BACKUP_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 matching ApiToken row to revoked.

All new admin endpoints are hidden from anonymous traffic by the proxy and rate-limited.

Behaviour-only changes

  • POST /api/ai/test now 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/summary localises the streak.label and greeting.salutation fields against the user's stored locale.
  • POST /api/insights/targets uses 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 to autoComplete="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 explicit autoComplete (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#anchor URLs 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.