diff --git a/README.md b/README.md index edf748bbb..4c880ab87 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ curl -s http://localhost:3000/api/version # returns version + buildSha + built `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/)). -Persistent state lives in exactly one named volume (`postgres-data`, the Postgres data); the app container is stateless, so backing up the database backs up everything. An install from v1.45 or earlier moves its Postgres 16 database into it on the first start ([details](docs/ops/postgres-major-upgrade.md)). +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). 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). diff --git a/docs/ops/postgres-major-upgrade.md b/docs/ops/postgres-major-upgrade.md index 670316b99..ff1c236da 100644 --- a/docs/ops/postgres-major-upgrade.md +++ b/docs/ops/postgres-major-upgrade.md @@ -36,8 +36,10 @@ 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 is read and never changed, apart from the marker -file. That is the way back. +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 diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 0cd793b9a..859616446 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -433,7 +433,9 @@ 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, a new major (16 to 18) is a dump and restore, described in +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: diff --git a/docs/self-hosting/portainer/templates.json b/docs/self-hosting/portainer/templates.json index bfa70a388..c0cebc054 100644 --- a/docs/self-hosting/portainer/templates.json +++ b/docs/self-hosting/portainer/templates.json @@ -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 16 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 18 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 encryption key backup step on the Portainer instructions.", + "description": "Opens the admin's recovery kit on the Portainer instructions.", "default": "portainer" }, { diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md index 314b65aa2..1fcaec46a 100644 --- a/docs/self-hosting/reproductive-health-data.md +++ b/docs/self-hosting/reproductive-health-data.md @@ -95,19 +95,31 @@ 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 outright, tombstones +- **Settings → Advanced → Delete all data** removes the rows, tombstones included. Everything the account owns goes except what you sign in - with; the confirmation dialog lists it. -- **Deleting the account** removes the account row, and the database - cascades every record with it. + 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. -Two consequences follow, and they matter for the seizure threat model +`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 above. A host imaged while a per-entry deletion is the only deletion you -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. +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)). ## Practical hardening diff --git a/docs/self-hosting/unraid/healthlog.xml b/docs/self-hosting/unraid/healthlog.xml index c68c70c58..895340e41 100644 --- a/docs/self-hosting/unraid/healthlog.xml +++ b/docs/self-hosting/unraid/healthlog.xml @@ -46,14 +46,14 @@ 3000 - + false - unraid + unraid enforce diff --git a/tests/integration/demo-seed-shape.test.ts b/tests/integration/demo-seed-shape.test.ts index d35aa6742..d7557b8bd 100644 --- a/tests/integration/demo-seed-shape.test.ts +++ b/tests/integration/demo-seed-shape.test.ts @@ -105,7 +105,9 @@ describe("demo seed, short-window shape", () => { it("leaves the median of the window in the settled band", () => { // The usual range is the median ± 3 MAD of the last 30 days. A stretch // that fills half the window moves that median onto the stretch and - // widens the band to something like 87-181 mmHg. + // widens the band to something like 87-181 mmHg. The seed's own spread + // reaches about ±22 in one run of 150, so the bound sits at 24, still far + // below what a stretch that long produces. const sorted = [...systolic].sort((a, b) => a - b); const median = sorted[Math.floor(sorted.length / 2)]; expect(median).toBeLessThanOrEqual(124); @@ -113,7 +115,7 @@ describe("demo seed, short-window shape", () => { .map((v) => Math.abs(v - median)) .sort((a, b) => a - b); const mad = deviations[Math.floor(deviations.length / 2)]; - expect(3 * 1.4826 * mad).toBeLessThanOrEqual(20); + expect(3 * 1.4826 * mad).toBeLessThanOrEqual(24); }); it("raises ramipril on the day the stretch turns", () => {