Merge branch 'fix/compose-env-and-claims' into release/v1.39.3

This commit is contained in:
Marc-André Bombeck committed 2026-09-26 17:00:46 +02:00
commit 026e8fc1ed
64 files changed
+1059 -158

No files matched your search

+4
View File
@@ -833,6 +833,10 @@ paths:
"unknown"` and a machine-readable `reason`, so the UI can offer a retry instead of a red banner nobody can act
on. The one genuine error is a feed that answers successfully with no version tag in it. Nothing is cached
server-side; the client is expected to hold the result.
An operator who sets `UPDATE_CHECK_DISABLED` turns the outbound request off: the route then answers `status:
"unknown"` with `reason: "disabled"` and contacts nobody.
responses:
"200":
description: Up to date, an update is available, or the check could not be completed.
+4 -3
View File
@@ -70,10 +70,11 @@ worker`. Default `all` keeps the single-container behaviour.
- **`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 <newKeyId>`
re-encrypts every ciphertext under the new key without downtime.
- 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; the production image strips `tsx`, hence `pnpm dlx`).
playbook).
### Off-host backup target _(optional)_
+34 -14
View File
@@ -41,15 +41,33 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
Restart the app. New writes are now encrypted under `v2`; existing
`v1`-keyed and legacy bare rows still decrypt because the `v1` entry is
retained.
3. **Run the rotation script** to re-encrypt every registered encrypted column
under the new active key:
3. **Re-encrypt the stored data under the new key.** Open **Admin →
Encryption** and press **Rotate now**. The button needs an admin account
with a second factor, confirmed recently; it queues one background run
(`POST /api/admin/encryption/rotate`) on the app's own worker, so it
works on the published image with nothing else installed. The same page
shows the per-column counts and the result of the last run.
Without a second factor on any admin account, run the rotation script
instead. It imports the application code and the generated database
client, so it runs from a source checkout of the same release, not
inside the production container (the image has no package manager and
does not contain `scripts/rotate-encryption-key.ts`). It needs the same
`ENCRYPTION_KEYS` and `ENCRYPTION_ACTIVE_KEY_ID` as the app and a
`DATABASE_URL` that reaches the database:
```
git checkout vX.Y.Z # the release the instance runs
pnpm install && pnpm db:generate
pnpm dlx tsx scripts/rotate-encryption-key.ts v2
```
The script is idempotent — running it again is a no-op for rows already
prefixed with `v2.`. It rotates every column in the canonical registry
The bundled `db` service publishes no port. On a stock compose stack,
either publish `5432` for the duration, or run the checkout in a
throwaway `node:22` container attached to the compose network.
Both paths are idempotent: running again is a no-op for rows already
prefixed with `v2.`. Both rotate every column in the canonical registry
(`src/lib/crypto/encrypted-columns.ts`), which covers the `*Encrypted`
columns plus the ones whose names say nothing about their contents:
`IntegrationStatus.lastError`, `CoachMessage.encryptedContent`,
@@ -58,7 +76,8 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
`IdempotencyKey.responseBody`, and `DataBackup.data` — the whole-account
backup blob.
Read three things off the output before going further:
When you used the script, read three things off its output before going
further:
- the per-column line, `scanned` / `rotated` / `errors` / `dropped`.
`scanned` counts the rows that hold ciphertext in that column, not every
@@ -78,10 +97,10 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
4. **Confirm nothing is left on the old key, THEN drop it.** This is the step
that destroys data if it is taken on a bad signal, so check the corpus
rather than the absence of complaints. Either re-run the script — a clean
second pass reports `rotated=0`, `errors=0` and a full `Columns walked`
line — or open **Admin → Encryption** and confirm the status view reports
zero rows outside the active key. Both read the same registry the rotation
rather than the absence of complaints. Open **Admin → Encryption** and
confirm the status view reports zero rows outside the active key, or
re-run the script: a clean second pass reports `rotated=0`, `errors=0` and
a full `Columns walked` line. Both read the same registry the rotation
walks.
A zero only means "nothing left" when the run also says it walked every
@@ -117,13 +136,14 @@ remaining.
```
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
ENCRYPTION_ACTIVE_KEY_ID="v3"
pnpm dlx tsx scripts/rotate-encryption-key.ts v3
# restart, then Admin → Encryption → Rotate now
# (or: pnpm dlx tsx scripts/rotate-encryption-key.ts v3 from a checkout)
ENCRYPTION_KEYS='{"v3":"<new>"}'
```
## Rollback
> **Important.** Once the rotation script has run, ciphertexts in the
> **Important.** Once a rotation has run, ciphertexts in the
> database start with `v2.` (or whatever the active id is). The pre-PR
> v1.3.x image cannot read that prefix — it expects bare base64 — and
> calling `decrypt()` on those rows will throw.
@@ -133,7 +153,7 @@ If you need to revert to the pre-rotation image:
- Either keep the new image. The new code reads both formats, so most
rollback scenarios don't need to undo rotation.
- Or, if you must run the old code, restore a database backup taken
_before_ the rotation script ran. There is no script to convert
_before_ the rotation ran. There is no script to convert
`v2.`-prefixed rows back to legacy format — by design, rotation is a
forward-only operation.
@@ -145,10 +165,10 @@ at least 24 hours.
- `Encryption key id 'v1' is not configured` — the database still contains
`v1.`-prefixed rows but `v1` was removed from `ENCRYPTION_KEYS`. Re-add
the key, run the rotation script, then remove again.
the key, run the rotation again, then remove it.
- `Found a legacy-format ciphertext but no v1 key is configured` — same
cause for legacy bare-base64 rows. Restore `ENCRYPTION_KEY` (or add a
`v1` entry to `ENCRYPTION_KEYS`) and run the rotation script before
`v1` entry to `ENCRYPTION_KEYS`) and run the rotation again before
removing it.
- `Refusing to rotate: argv key id ... does not match the currently active
id ...` — pass the same id you set in `ENCRYPTION_ACTIVE_KEY_ID`. The
+11 -7
View File
@@ -18,10 +18,12 @@ instance).
## Run it
The reset CLI ships in the image and runs under plain `node` against the built
runtime (the standalone image strips `tsx`, so this is a `.mjs` that uses the
in-image `pg` and `@node-rs/argon2` directly and reuses the app's exact Argon2id
parameters):
The reset CLI ships in the image from v1.39.3 on. It runs under plain `node`
against the built runtime: a `.mjs` file that uses the in-image `pg` and
`@node-rs/argon2` directly and reuses the app's exact Argon2id parameters.
Images up to and including v1.39.2 did not contain the script, so on those
the command below fails with "Cannot find module"; use the source-checkout
form at the end of this page instead.
```sh
docker compose exec app node scripts/reset-password.mjs <username-or-email>
@@ -32,7 +34,7 @@ confirmation that names only the user — never the password:
```
New password (input hidden):
reset-password: password updated for "alice"
reset-password: password updated for "alice"; revoked 2 sessions, 0 API tokens, 1 refresh tokens, 0 trusted devices, and 0 step-up elevations
```
You can also pass the password as a second argument for non-interactive use,
@@ -57,8 +59,10 @@ docker compose exec app node scripts/reset-password.mjs alice 'a-strong-passphra
## Source checkout
From a source checkout (not the production image) the same script runs under
Node directly:
From a source checkout of the same release (after `pnpm install`) the same
script runs under Node directly. It needs a `DATABASE_URL` that reaches the
database; the bundled `db` service publishes no port, so on a stock compose
stack run it inside the compose network or publish the port temporarily:
```sh
DATABASE_URL='postgres://…' node scripts/reset-password.mjs <username-or-email>
+3
View File
@@ -127,6 +127,9 @@ addresses they could not place. To remove that remainder too:
IP_GEO_LOOKUP_DISABLED="1"
```
`true` and `yes` work as well. Any other value, including an empty one, leaves
the lookup on.
The resolver then uses only the local databases and returns nothing when
they miss, rather than asking anyone. An instance handling other people's
health data is a reasonable place to make that trade.
+12 -3
View File
@@ -21,6 +21,13 @@ already installed, longer if a TLS-fronted public hostname is in scope
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
GitHub releases API for the update badge, and the online IP
geolocation lookup for login events. `PASSWORD_BREACH_CHECK_DISABLED`,
`UPDATE_CHECK_DISABLED` and `IP_GEO_LOOKUP_DISABLED` turn each off; see
`.env.production.example`.
- **Optional but recommended:** a reverse proxy that terminates TLS
and forwards to `http://localhost:3000` (Caddy, Traefik, Nginx
Proxy Manager, Coolify, or bare Nginx — see `reverse-proxy.md`).
@@ -33,9 +40,11 @@ cd HealthLog
cp .env.example .env
```
`.env.example` is the canonical reference for every env var the app
reads. Anything you do not uncomment falls back to a sane default or
disables the optional subsystem.
`.env.example` is a short starter with the variables most self-hosts
touch. The complete reference, with every variable the app reads and what
it does, is `.env.production.example`; CI checks that file against
`scripts/env-manifest.json`. Anything you do not set falls back to a sane
default or leaves the optional subsystem off.
## 2. Generate the three required secrets