diff --git a/.env.production.example b/.env.production.example index e4f73447e..28072371e 100644 --- a/.env.production.example +++ b/.env.production.example @@ -68,6 +68,11 @@ API_TOKEN_HMAC_KEY="" # Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in # deploy-failure notifications. When unset the notification omits the link. # DEPLOY_LOGS_URL="" +# Optional replay hardening: require a fresh X-Deploy-Webhook-Timestamp +# header (unix seconds / ms or ISO-8601, +/- 5 minutes) on every webhook +# call. Leave unset for Coolify's stock sender (it attaches no +# timestamp); see docs/ops/deploy.md. +# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false" # ----------------------------------------------------------------------------- diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7b8585d2b..7089c65cf 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -268,6 +268,12 @@ jobs: permissions: contents: read packages: write + # v1.16.4 — keyless image signing. The job requests an OIDC + # token from GitHub's issuer; cosign exchanges it for a + # short-lived Fulcio certificate bound to this workflow identity + # and records the signature in the Rekor transparency log. No + # long-lived signing key exists anywhere in this pipeline. + id-token: write steps: - name: Download digest artifacts @@ -333,6 +339,35 @@ jobs: run: | docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}' + # v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore). + # Signing the INDEX digest covers both per-platform manifests in + # one signature, and the digest is the only stable identity — every + # tag (`:latest`, `:1.16.4`, …) is a mutable alias that a registry + # compromise could repoint. Operators verify with: + # + # cosign verify \ + # --certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \ + # --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + # ghcr.io/mbombeck/healthlog: + # + # See docs/ops/deploy.md ("Verify the image signature") for the + # digest-pinned deploy recommendation that pairs with this. + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Sign multi-arch manifest (keyless) + env: + IMAGE_VERSION: ${{ steps.meta.outputs.version }} + run: | + digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_VERSION}" --format '{{json .Manifest.Digest}}' | tr -d '"')" + if [ -z "$digest" ]; then + echo "::error::Could not resolve the manifest digest for ${IMAGE_REF}:${IMAGE_VERSION} — signing aborted." + exit 1 + fi + echo "Signing ${IMAGE_REF}@${digest}" + cosign sign --yes "${IMAGE_REF}@${digest}" + echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${digest}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${digest}" + # v1.4.15: trigger the Coolify deploy automatically once GHCR has # the new image. Replaces the former manual SSH + force-pull recipe # on the deployment host. diff --git a/CHANGELOG.md b/CHANGELOG.md index aabea1cce..724e42f02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## [Unreleased] +## [1.16.4] — 2026-06-11 — half the bundle, a typed client, and stock you can record + +### Added + +- **The inventory tab records stock** — packs and pens with optional expiry, corrections and withdrawals. The detail page gains a dedicated API tab, an editor entry under lifecycle, and a danger zone without the red drama. +- **Record the dose you actually took.** Intakes carry an optional actual dose, editable in the quick-add and log dialogs and shown as a quiet deviation in the history. +- **Undo for deletes.** Removing a measurement or mood entry can be taken back from the toast. +- **Pull-to-refresh** on the measurement and mood lists, and the customize entry on every insights sub-page. +- **Answered coach follow-ups can flow into your self-context** with one tap. + +### Changed + +- **The app got dramatically lighter and faster.** Only the active language ships to the browser — the dashboard route drops from 2.1 MB to under 1 MB parsed (288 KB gzip), with no language flash. The shell paints immediately while the session resolves, a redundant request stage is gone, and insight sub-pages prefetch on approach: a first tab switch lands in tens of milliseconds instead of nearly a second. +- **Every client call goes through one typed API client** with the envelope, error and ok-handling in a single place, enforced by lint. The route registry, query keys, background worker and dashboard page split into feature-located modules behind thin indexes. +- **Settings behave one way.** Provider and integration cards live in focused subcomponents, every setting persists server-side at exactly one home, save conventions are documented, and duplicated controls collapsed into single owners with links. +- **Published images are signed**, dependabot merges only behind the required checks, a pre-commit secret scan guards every commit, the deploy webhook can demand a fresh timestamp, and a monthly drill proves the newest backup actually decrypts. + +### Fixed + +- Server and first client render now agree everywhere query state used to leak into the chrome. +- Delete confirmations admit the undo they offer, step counts drop their decimals, streaks know their singular, the medication header speaks your date format, and a failed coach turn keeps your message and says what went wrong. +- A language switch updates pending status notes and only flips once the new language is actually loaded. + ## [1.16.3] — 2026-06-10 — schedules remember their history ### Fixed diff --git a/README.md b/README.md index 67ded7605..4d8f03720 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ iOS TestFlight

-HealthLog dashboard: health metric tiles, trend charts, and medication overview +HealthLog dashboard: health metric tiles with 7-day trends and a weight chart, dark theme ## Why HealthLog diff --git a/docker-compose.yml b/docker-compose.yml index f6c68cbb6..b173c7de4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,10 @@ services: # .env reach the container (the `environment:` block is a whitelist). DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}" DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}" + # Optional replay hardening: when "true", the deploy webhook + # requires a fresh X-Deploy-Webhook-Timestamp header (±5 min). + # See docs/ops/deploy.md. + DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}" # TLS leaf SPKI-change alarm baseline (optional). The native client # pins the served TLS leaf; this is the known-good pin set the monitor # alarms against on a rotation. Comma-separated for the dual-pin diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 18bb1967f..84cfcc443 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.3 + version: 1.16.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1735,6 +1735,31 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/measurements/restore: + post: + tags: + - Measurements + summary: Restore soft-deleted measurements + description: Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo + counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited + `measurements:restore:`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the + count of rows actually un-tombstoned. The restored rows re-surface in `/api/sync/changes` as upserts. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementsRestoreRequest" + responses: + "200": + description: Restore processed. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementsRestoreResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/sleep/night: get: tags: @@ -4229,6 +4254,20 @@ components: required: - ids description: 1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak). + MeasurementsRestoreRequest: + type: object + properties: + ids: + minItems: 1 + maxItems: 200 + type: array + items: + type: string + minLength: 1 + required: + - ids + description: 1..200 measurement ids, scoped to the caller. Forged / foreign / not-deleted ids are silently skipped (no + existence leak). DeviceRegisterRequest: type: object properties: @@ -4709,6 +4748,13 @@ components: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + doseTaken: + description: Dose actually consumed for THIS intake when it deviates from (or documents) the medication's configured + dose, e.g. a half tablet or a titration step. Free text, max 50 characters. Omit to record the take under + the medication's configured dose. + type: string + minLength: 1 + maxLength: 50 required: - medicationId description: Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second @@ -7316,6 +7362,33 @@ components: required: - deleted additionalProperties: false + MeasurementsRestoreResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementsRestoreResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementsRestoreResult: + type: object + properties: + restored: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - restored + additionalProperties: false SleepNightResponse: type: object properties: diff --git a/docs/assets/dashboard-hero.png b/docs/assets/dashboard-hero.png new file mode 100644 index 000000000..f065f336c Binary files /dev/null and b/docs/assets/dashboard-hero.png differ diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 6884f70ce..af110f832 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -85,3 +85,30 @@ pnpm tsx scripts/restore-backup.ts \ The script downloads the object, decrypts it, and writes the JSON dump to disk. Importing the JSON back into a HealthLog instance is left to the operator (use `prisma db seed` or a custom script). + +## Monthly restore drill (automatic) + +Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` — +04:11 on the 1st of each month) exercises the read path end-to-end: +fetch the most recent backup object from the bucket, decrypt it under +the current `BACKUP_ENCRYPTION_KEY`, JSON-parse it, and sanity-check +the payload shape. It performs **no database restore** — it validates +the artefact, not the import path. + +Outcomes: + +- **Success** — record counts, object age, and sizes land in the + wide-event meta (`job.restore_drill`). +- **Stale chain** — the newest object is older than 3 days: the nightly + uploader has stalled (or the lifecycle rule is too aggressive). The + drill pages via the worker error reporter (stderr + GlitchTip). +- **Failure** — empty bucket, fetch error, decryption failure (wrong or + rotated key), malformed JSON: pages the same way. A decryption + failure right after a `BACKUP_ENCRYPTION_KEY` change means the new + key cannot read the existing objects — re-encrypt or accept that + pre-rotation backups are only readable with the retired key. +- **Not configured** — deployments without the `BACKUP_S3_*` vars skip + silently (wide-event warning only). + +The drill needs no IAM grant beyond the uploader's existing +`GetObject` + `ListBucket`. diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 822748a72..ad890d7d9 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -70,7 +70,7 @@ roll back: release pair shipped on the v1.4.x line. 3. If a column-drop migration ships in the rollback target, hand-roll the column back via a fresh forward migration (`pnpm prisma migrate - dev --create-only --name vX_Y_Z_rollback_`), edit the SQL, +dev --create-only --name vX_Y_Z_rollback_`), edit the SQL, commit, and re-deploy. Never `migrate resolve --rolled-back` against production unless you've taken a fresh `pg_dump` first. @@ -90,3 +90,76 @@ pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 Pass `--only=prod` / `--only=demo` to check a single target, or `--attempts=N --interval=ms` to widen the retry window while a slow pull settles. + +## Verify the image signature + +Every release image (the multi-arch manifest **index**, covering both +`amd64` and `arm64`) is signed keyless via Sigstore in the +`docker-publish.yml` merge job: GitHub's OIDC token is exchanged for a +short-lived Fulcio certificate bound to the workflow identity, and the +signature lands in the Rekor transparency log. There is no long-lived +signing key to leak or rotate. + +Verify before (or after) a deploy with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/): + +```sh +cosign verify \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \ + ghcr.io/mbombeck/healthlog:1.16.4 +``` + +A passing verification proves the image was built by this repository's +release workflow on a `v*` tag — not by anyone who merely obtained push +access to the GHCR package. + +### Prefer digest-pinned deploys + +Tags (`:latest`, `:1.16.4`) are mutable aliases: a registry-side +compromise or an accidental re-push can repoint them. The digest is the +image's only immutable identity, and it is what the signature actually +covers. For any deploy where supply-chain integrity matters more than +convenience: + +1. Resolve and verify the digest once: + + ```sh + cosign verify ... ghcr.io/mbombeck/healthlog:1.16.4 \ + | jq -r '.[0].critical.image."docker-manifest-digest"' + ``` + +2. Pin the compose `image:` (or the Coolify image reference) to + `ghcr.io/mbombeck/healthlog@sha256:` instead of the tag. + +The day-to-day `:latest` + `pull_policy: always` flow stays the +documented default for self-hosters; digest pinning is the recommended +hardening for operators with a threat model that includes the registry. + +## Deploy-status webhook: replay protection + +`POST /api/internal/deploy-webhook` authenticates via the static +`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally +enforces a freshness window: a request carrying an +`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds, +or ISO-8601) is rejected with `401 {"status":"stale"}` when the +timestamp lies more than **5 minutes** from server time — so a captured +request cannot be replayed later to re-page admins or forge deploy +outcomes in the audit log. + +Two modes: + +- **Default (tolerant).** Requests _without_ the header pass — Coolify's + stock notification sender does not attach one. A header that is + present but stale or unparseable is always rejected. +- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app + environment to make the header mandatory, closing the replay window + completely. Use this when the sender (or a reverse proxy in front of + the app) can attach the timestamp, e.g.: + + ```sh + curl -X POST https:///api/internal/deploy-webhook \ + -H "X-Deploy-Webhook-Secret: $SECRET" \ + -H "X-Deploy-Webhook-Timestamp: $(date +%s)" \ + -H "Content-Type: application/json" \ + -d '{"status":"success"}' + ``` diff --git a/docs/ui-guidelines.md b/docs/ui-guidelines.md index fcb14b166..3e58b9069 100644 --- a/docs/ui-guidelines.md +++ b/docs/ui-guidelines.md @@ -149,9 +149,38 @@ Wrap inputs in `