# 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 ```bash 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 `.` 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: `pnpm dlx tsx scripts/rotate-encryption-key.ts ` re-encrypts every ciphertext under the new key without downtime. Walk-through in `docs/ops/encryption-key-rotation.md` (the current playbook; the production image strips `tsx`, hence `pnpm dlx`). ### 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 `` 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 `` 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: ```bash 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.