From d7bf0df10b73530598cce179c27d5734491fafa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 15:05:05 +0200 Subject: [PATCH] chore(ci): pin the supply chain and surface build provenance Every workflow action is pinned to a commit SHA, dependabot auto-merge is limited to patch and minor updates behind the now-required status checks on main, and the audit, Trivy and ESLint gates block instead of warning. The published image finally carries its build SHA and build timestamp so the version endpoint can prove which commit is running. The API-token HMAC key fails closed under 32 characters, compose pins the database minor and denies privilege escalation, and the changelog drops working-note references in favour of neutral wording. --- .github/workflows/dependabot-auto-merge.yml | 35 +++++++- .github/workflows/docker-publish.yml | 40 ++++++--- .github/workflows/e2e.yml | 13 +-- .github/workflows/env-check.yml | 6 +- .github/workflows/integration.yml | 9 +- .github/workflows/knip.yml | 6 +- .github/workflows/no-todo-markers.yml | 2 +- .github/workflows/post-publish-verify.yml | 2 +- .github/workflows/security.yml | 39 +++++---- CHANGELOG.md | 96 ++++++++++----------- Dockerfile | 28 ++++++ docker-compose.yml | 15 +++- src/lib/auth/__tests__/hmac.test.ts | 17 +++- src/lib/auth/hmac.ts | 13 ++- 14 files changed, 222 insertions(+), 99 deletions(-) diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 2a0d092b1..3b5721a18 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -12,11 +12,11 @@ jobs: if: github.actor == 'dependabot[bot]' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: "pnpm" @@ -27,7 +27,34 @@ jobs: - run: pnpm test - - run: gh pr merge --auto --squash "$PR_URL" + # Auto-merge is gated on the Dependabot-declared update type: + # patch and minor bumps merge once required checks pass; major + # bumps get a comment and wait for the maintainer. A major bump + # routinely carries breaking changes the unit suite alone cannot + # vouch for. + - name: Fetch Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge (patch / minor only) + if: >- + steps.metadata.outputs.update-type == 'version-update:semver-patch' || + steps.metadata.outputs.update-type == 'version-update:semver-minor' + run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Flag major update for manual review + if: steps.metadata.outputs.update-type == 'version-update:semver-major' + run: >- + gh pr comment "$PR_URL" --body + "Major version bump (${DEP_NAMES}) — auto-merge is intentionally + disabled for semver-major updates. Review the upstream changelog + and merge manually." + env: + PR_URL: ${{ github.event.pull_request.html_url }} + DEP_NAMES: ${{ steps.metadata.outputs.dependency-names }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 3e82b3718..568c346ae 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -20,6 +20,11 @@ on: branches: [main] workflow_dispatch: +# Default for any job that doesn't declare its own block; both jobs +# below elevate to packages:write explicitly. +permissions: + contents: read + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} @@ -72,10 +77,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 # GHCR rejects repository refs with uppercase letters. `IMAGE_NAME` # is derived from `github.repository`, which preserves the case of @@ -93,7 +98,7 @@ jobs: # Image is built but not pushed in that case. - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' - uses: docker/login-action@v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -101,7 +106,7 @@ jobs: - name: Compute image labels id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env.IMAGE_REF }} labels: | @@ -181,6 +186,19 @@ jobs: asn_size="$(stat -c%s assets/geolite2/GeoLite2-ASN.mmdb)" echo "::notice::GeoLite2 MMDBs verified — GeoLite2-City.mmdb ${city_size} bytes; GeoLite2-ASN.mmdb ${asn_size} bytes." + # Build provenance for /api/version: the short commit SHA and an + # ISO-8601 build timestamp travel into the image as build-args + # (NEXT_PUBLIC_APP_BUILD_SHA / NEXT_PUBLIC_APP_BUILT_AT) so the + # deploy runbook can verify which commit a running container + # actually carries — the `:latest` pull is the source of truth, + # not the queued deploy status. 12 hex chars is plenty to be + # unambiguous in this repository. + - name: Resolve build provenance + id: provenance + run: | + echo "build_sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + # Per-ref + per-platform gha cache scope. Tag builds warm-start # off main's last good layer set for the same platform; tag and # main builds at the same SHA do not contend on writes, and @@ -188,7 +206,7 @@ jobs: # arm64 layer never poisons an amd64 build). - name: Build & push by digest id: build - uses: docker/build-push-action@v7 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: ./Dockerfile @@ -203,6 +221,8 @@ jobs: # can never survive a cache hit into the new image. build-args: | NEXT_PUBLIC_APP_VERSION=${{ github.ref_name }} + NEXT_PUBLIC_APP_BUILD_SHA=${{ steps.provenance.outputs.build_sha }} + NEXT_PUBLIC_APP_BUILT_AT=${{ steps.provenance.outputs.built_at }} outputs: | type=image,name=${{ env.IMAGE_REF }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: | @@ -224,7 +244,7 @@ jobs: - name: Upload digest artifact if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digest-${{ matrix.cache_scope }} path: /tmp/digests/* @@ -251,14 +271,14 @@ jobs: steps: - name: Download digest artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: /tmp/digests pattern: digest-* merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 # GHCR requires the repository ref to be lowercase. See the same # step in the `build` job above for the longer rationale. @@ -267,7 +287,7 @@ jobs: echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV" - name: Log in to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -275,7 +295,7 @@ jobs: - name: Compute image tags & labels id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env.IMAGE_REF }} tags: | diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 739e6c79b..854ad38e0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -6,6 +6,9 @@ on: push: branches: [main] +permissions: + contents: read + # Cancel in-progress runs when a new commit lands on the same PR. concurrency: group: e2e-${{ github.workflow }}-${{ github.ref }} @@ -41,11 +44,11 @@ jobs: NODE_ENV: production steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: pnpm @@ -57,7 +60,7 @@ jobs: - run: pnpm db:migrate:deploy - name: Cache Playwright browsers - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -74,7 +77,7 @@ jobs: - name: Upload Playwright HTML report on failure if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report path: playwright-report/ diff --git a/.github/workflows/env-check.yml b/.github/workflows/env-check.yml index 460140dd2..9c99ced3f 100644 --- a/.github/workflows/env-check.yml +++ b/.github/workflows/env-check.yml @@ -25,13 +25,13 @@ jobs: timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Set up Node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 38aa8ebb4..fa1392d93 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -6,16 +6,19 @@ on: push: branches: [main] +permissions: + contents: read + jobs: integration: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index 1a9f99783..60e05283b 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -16,11 +16,11 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/no-todo-markers.yml b/.github/workflows/no-todo-markers.yml index f72470531..01d01815d 100644 --- a/.github/workflows/no-todo-markers.yml +++ b/.github/workflows/no-todo-markers.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Scan src/ for TODO/FIXME/XXX/HACK markers run: | diff --git a/.github/workflows/post-publish-verify.yml b/.github/workflows/post-publish-verify.yml index 33756991c..68e067ee6 100644 --- a/.github/workflows/post-publish-verify.yml +++ b/.github/workflows/post-publish-verify.yml @@ -70,7 +70,7 @@ jobs: fi - name: Log in to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index eb08886d1..ebdea3088 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -15,11 +15,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: "pnpm" @@ -28,12 +28,10 @@ jobs: - run: pnpm db:generate + # Blocking since v1.15.20 — the settings-split refactor that kept + # this warn-only is long done and the tree lints clean. - name: ESLint run: pnpm lint - # Non-blocking on the existing settings page monolith — its set-state- - # in-effect violations need the 1.4.0 settings-split refactor to fix - # cleanly. Will flip to blocking once settings/page.tsx is broken up. - continue-on-error: true - name: TypeScript strict check run: pnpm typecheck @@ -62,32 +60,37 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: "pnpm" - run: pnpm install --frozen-lockfile + # Blocking since v1.15.20 — a HIGH/CRITICAL advisory against a + # production dependency fails the build. If an advisory has no + # upstream fix yet, pin an `auditConfig.ignoreCves` entry in + # package.json (pnpm's documented suppression mechanism) with a + # comment referencing the tracking issue, rather than flipping + # this step back to warn-only. - name: Audit for known vulnerabilities run: pnpm audit --prod --audit-level=high - continue-on-error: true secret-scan: name: Secret Scanning runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Gitleaks secret scan - uses: gitleaks/gitleaks-action@v3 + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -97,7 +100,7 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Build Docker image run: docker build -t healthlog:scan . @@ -109,6 +112,12 @@ jobs: echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list sudo apt-get update && sudo apt-get install -y trivy + # Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built + # image fails the job. For a finding without an upstream fix + # (e.g. a base-image CVE Alpine has not patched yet), add the CVE + # id to a `.trivyignore` file at the repository root — Trivy reads + # it automatically — one CVE per line, each with a comment naming + # the tracking issue and the re-check condition. Never suppress a + # finding that has a fixed version available; bump instead. - name: Trivy container scan run: trivy image --format table --severity CRITICAL,HIGH --exit-code 1 healthlog:scan - continue-on-error: true diff --git a/CHANGELOG.md b/CHANGELOG.md index f5ca58944..5e771ef1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1021,14 +1021,14 @@ v1.5.5 gave every retired medication feature a home on the detail page, but the - 5615 → 5635 unit; 1 skipped. Detail-page history surface, advanced-settings sheet, intake-edit seed, and legacy step consolidation are covered; the pre-tag reconcile sweep keeps the suite green. -## [1.5.5] — 2026-05-29 — Medication detail page, iOS coord wave, outbound-fetch hardening +## [1.5.5] — 2026-05-29 — Medication detail page, iOS coordination, outbound-fetch hardening The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 features down with the retired flat form — Einnahmen bearbeiten / löschen, Medikament pausieren / beenden / löschen, per-Med API tokens, Phasen-Konfiguration, CSV-Import — and the trends-row on Insights had a 34 px overflow that pushed annotation copy on top of the charts. This release lands the medication detail page that gives every retired feature a coherent home, polishes the wizard against the live walk-through feedback, closes the iOS audit follow-ups in one go, and hardens every outbound fetch the server makes. ### Added - **`/medications/[id]/page.tsx`** — a single Server-Component detail page composed of eight sections (Header band → Today's-dose → Cadence summary → Phasen (GLP-1) → Intake history preview → Notifications → Settings → Verwaltung & Gefahrenzone). One-shot medications walk a five-section variant; paused medications keep the structure but the status pill flips to `Pausiert` and the dose-card surface mutes. Restores every one of the 16 features the v1.5.4 flat-form retirement displaced. -- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. Marc's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly. +- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. The maintainer's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly. - **`safeFetch` wrapper** at `src/lib/safe-fetch.ts` — every outbound fetch the server makes for user-supplied hosts now inherits `redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults. Closes #218. - **DNS-rebinding pinned `undici.Agent`** at `src/lib/safe-fetch-dispatcher.ts` — when `requirePublicHost: true`, the dispatcher resolves the hostname literally inside the connect hook, refuses any address `isPublicIp` would reject, and pins the connection to the first valid public address. The five user-host paths (MoodLog sync + push + test, local-AI client, ntfy) route through it. Closes #217. - **Self-hosted avatar storage** at `POST/GET/DELETE /api/user/avatar` — multipart-uploaded JPEG/PNG/WebP, 2 MiB / 2048×2048 max, hand-rolled magic-byte sniff + dimension probe so no native dependency lands. Stored on the User row as BYTEA; rides `pg_dump` alongside the rest of the row. The profile response carries an `avatarUrl` with a cache-busting timestamp. `src/lib/gravatar.ts` retires — Automattic no longer sees the email-hash on every authenticated page load. @@ -1050,7 +1050,7 @@ The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 featu ### Fixed -- **Trends-row text overlapping the charts** — Marc's specific Insights complaint. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope. +- **Trends-row text overlapping the charts** — a specific Insights complaint from a user report. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope. - **Grace-row save dead on arrival** — the detail page Settings section PUTs `reminderGraceMinutes` at the top level; the route now normalises the value onto the primary schedule before the Prisma update. The schema declares the top-level field with a description noting the normalisation. - **Purge route did not invalidate server caches** — Tier-3a `Verlauf löschen` dropped the rollup rows but left the analytics + iOS today-tally caches with the pre-purge counts for up to their TTL. The success path now calls `invalidateUserMedications(user.id)` alongside the rollup delete. - **Bi-weekly worker still emitted every Wednesday** — the v1.5.3 cadence engine fix was correct, but the cadence chart + medication card on the dashboard still read the legacy `daysOfWeek` column. The v1.5.x window keeps this in place; the read-flip arrives in v1.5.6. Operators see correct reminder fan-out today; the dashboard chip cosmetic catch-up follows. @@ -1067,11 +1067,11 @@ The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 featu - `safeFetch` + DNS-rebinding pinned dispatcher close issues #217 + #218 architecturally — the input-time `isPublicUrl` guard now pairs with a connect-time IP pin so DNS rebinding cannot flip the resolved host between accept and dispatch. - Avatar route enforces size before parse (Content-Length pre-flight + post-parse `file.size` check), magic-byte sniff over the declared content-type, dimension probe, owner-scoped on every method. No new XSS vector — the served content-type is whitelisted to the three image MIMEs. - `assertMedicationOwnership` sweep closes every detail-page route's ownership narrowing — the route layer is now the single ownership predicate across `src/app/api/medications/[id]/**`. -- Pre-tag senior-dev + security architect audit produced two docs at `.planning/medication-detail-page-2026-05-28/F-{1,2}-*.md`. The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1. +- Pre-tag senior-dev + security architect audit produced two docs (internal working notes). The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1. ### iOS coord -- The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements at `.planning/ios-coord/v155-wire-six-deferred-identifiers.md` + `.planning/ios-coord/v155-step-length-speed-followup.md`. +- The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements live in the internal iOS coordination notes. - The iOS team flips `walkingAsymmetryPercentage` + `walkingDoubleSupportPercentage` from pre-multiplied to raw in their next release; until then the server fails-closed (`skipped:"value_out_of_range"`) on > 100% values, so no DB pollution. ### Notes @@ -1101,7 +1101,7 @@ The v1.5.3 creation wizard shipped as a single-page card with seven inline steps ### Fixed - **Multi-schedule data-loss risk** — the v1.5.3 wizard collapsed a multi-schedule medication to its first schedule on save and silently dropped the rest. Compose-mode is the proper fix; the encoder now emits every schedule, the hydrator reads every schedule, the per-schedule `id` round-trips so the PUT preserves identity. -- **The edit form being wider than its container** — Marc's specific complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone. +- **The edit form being wider than its container** — a specific user complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone. ### Tests @@ -1111,7 +1111,7 @@ The v1.5.3 creation wizard shipped as a single-page card with seven inline steps ### Notes -- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps the Marc-confirmed labels onto the existing enum + the two new `MedicationCategory` values. +- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps the maintainer-confirmed labels onto the existing enum + the two new `MedicationCategory` values. - **Cadence-engine read-flip is still v1.5.x deferred** — the today-projector, the cadence chart, and the medication card continue to read the legacy `daysOfWeek` / `intervalWeeks` columns. A wizard-minted rolling or one-shot medication will render a daily-looking next-due chip on the dashboard until the read-flip ships. The reminder worker and the engine work with the new fields correctly. - **Test totals.** 5500 unit + 1 skipped, 261 integration + 3 skipped, 14 Playwright wizard instances. Typecheck, lint, `openapi:check`, i18n call-site coverage, locale-integrity all green at HEAD. @@ -1256,7 +1256,7 @@ Before this change, the iOS HealthKit observer would POST today's running step t ## [1.4.50] — 2026-05-24 — MoodLog reverse-sync (HealthLog → MoodLog push) -Marc reported that mood entries logged inside HealthLog — specifically via the iOS app — never reached MoodLog. The historic integration ran one-way: `syncMoodLogEntries` polled MoodLog every 15 minutes and pulled new rows, but nothing flowed the other direction. A user tracking mood in HealthLog ended up with one log per app and no overlap. +A user reported that mood entries logged inside HealthLog — specifically via the iOS app — never reached MoodLog. The historic integration ran one-way: `syncMoodLogEntries` polled MoodLog every 15 minutes and pulled new rows, but nothing flowed the other direction. A user tracking mood in HealthLog ended up with one log per app and no overlap. ### Added @@ -1278,7 +1278,7 @@ The matching MoodLog v0.2.0 release adds the `POST /api/integrations/health-log/ ## [1.4.49.4] — 2026-05-24 — BD-tile number truncation + medication-compliance test tz drift -Marc reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systolic / diastolic pair (`131/85`) was wider than the narrowest grid column and the value-row's `truncate` ellipsis chopped the most important part of the tile (the number itself). +A user reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systolic / diastolic pair (`131/85`) was wider than the narrowest grid column and the value-row's `truncate` ellipsis chopped the most important part of the tile (the number itself). ### Fixed @@ -1288,12 +1288,12 @@ Marc reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systo ## [1.4.49.3] — 2026-05-23 — Full i18n call-site audit + 28 stale missing keys filled -Marc reported additional raw key strings (`notifications.eventMoodReminder`, `notifications.eventMoodReminderDesc`) reaching the UI after v1.4.49.2 had landed the relative-time fix. The narrow `{count}`-call audit that drove v1.4.49.2 had missed every dynamic key construction; the full exhaustive sweep below picked up 28 keys called from real code that had never existed in any locale bundle. +A user reported additional raw key strings (`notifications.eventMoodReminder`, `notifications.eventMoodReminderDesc`) reaching the UI after v1.4.49.2 had landed the relative-time fix. The narrow `{count}`-call audit that drove v1.4.49.2 had missed every dynamic key construction; the full exhaustive sweep below picked up 28 keys called from real code that had never existed in any locale bundle. ### Fixed - Filled 28 missing translation keys across all 6 locales (de / en / es / fr / it / pl = 168 entries), grouped by feature surface that introduced them and never wired the strings: - - **Notifications event matrix** (14 keys — added with v1.4.41 / MOOD_REMINDER surfaced the gap in v1.4.49): `notifications.event{MedicationReminder,MeasurementAnomaly,ComplianceLow,WithingsSyncFailed,SystemAlert,PersonalRecord,MoodReminder}` and the `Desc` variant of each. The `/notifications` settings page is the consumer; before this release the per-event rows on Marc's account rendered the bare keys instead of the localised name + description. + - **Notifications event matrix** (14 keys — added with v1.4.41 / MOOD_REMINDER surfaced the gap in v1.4.49): `notifications.event{MedicationReminder,MeasurementAnomaly,ComplianceLow,WithingsSyncFailed,SystemAlert,PersonalRecord,MoodReminder}` and the `Desc` variant of each. The `/notifications` settings page is the consumer; before this release the per-event rows on an affected account rendered the bare keys instead of the localised name + description. - **Welcome carousel** (13 keys — added with the 3-slide intro in v1.4.45): `onboarding.welcome.{title,carouselLabel,slideOf,prevSlide,nextSlide,gotoSlide,cta}` for the chrome plus `slide{1,2,3}.title/body` for the content. The carousel mounted with raw keys for every screen-reader label, dot-pager aria-label, and slide body. - **Measurement list error state** (1 key — added with v1.4.44): `measurements.loadError`. The error branch in `measurement-list.tsx` rendered the raw key when a fetch failed. @@ -1307,7 +1307,7 @@ The guard above did not exist before. v1.4.27 B6 introduced `i18n-drift-guard.te ## [1.4.49.2] — 2026-05-23 — Raw i18n key leak on relative-time helper -Marc reported the raw string `insights.relativeHoursAgo` rendering verbatim on medication cards, recent-achievements, admin sections, the iOS notification preview, and every other consumer of `formatDateOrRelative`. Audit across the full `t(…, { count })` surface (~20 call sites) confirmed exactly two leaks: `insights.relativeMinutesAgo` and `insights.relativeHoursAgo`. Both pointed to keys that have NEVER existed in the translation bundle — only the pluralised `*One` / `*Other` variants ship. +A user reported the raw string `insights.relativeHoursAgo` rendering verbatim on medication cards, recent-achievements, admin sections, the iOS notification preview, and every other consumer of `formatDateOrRelative`. Audit across the full `t(…, { count })` surface (~20 call sites) confirmed exactly two leaks: `insights.relativeMinutesAgo` and `insights.relativeHoursAgo`. Both pointed to keys that have NEVER existed in the translation bundle — only the pluralised `*One` / `*Other` variants ship. ### Fixed @@ -1327,7 +1327,7 @@ v1.4.49 shipped the slim-slice (`?slice=summaries`) cold-fallback fix, but the p ### Fixed -- `GET /api/analytics` (default slice) no longer fans out 15 per-type `findMany` reads against `measurements`. The route now delegates the per-type `summaries` work to `computeSummariesSlice`, which reads the same data from `measurement_rollups` DAY buckets + a 90-day narrow `$queryRaw` for the windowed avg / slope / r² columns. On Marc's production account this drops the cold critical path from ~8 s to sub-second; warm cache is unchanged. +- `GET /api/analytics` (default slice) no longer fans out 15 per-type `findMany` reads against `measurements`. The route now delegates the per-type `summaries` work to `computeSummariesSlice`, which reads the same data from `measurement_rollups` DAY buckets + a 90-day narrow `$queryRaw` for the windowed avg / slope / r² columns. On a large production account this drops the cold critical path from ~8 s to sub-second; warm cache is unchanged. - Pool starvation that ALSO affected the supposedly-fast `?slice=summaries` request when fired concurrently — the slim slice now resolves in its own SQL budget regardless of whether the default-slice request is also in flight. ### Changed @@ -1419,7 +1419,7 @@ v1.4.47 closed the high-priority punch list. v1.4.49 bundles the remaining v1.4. ### Deferred -- iOS v0.6.0.8 ships the Settings-side notification-permission re-trigger (`healthlog-iOS#10`), the in-app APNs diagnostic surface, and the `CFBundleShortVersionString` + UA build-number fixes — these depend on Marc's Xcode build cycle, not the server. The server-side suppression flag added in this release is opt-in (`clientManaged: false` by default) so existing users on the still-buggy v0.6.0.7 client see no behaviour change until they upgrade to v0.6.0.8 and the iOS client explicitly opts in via `PATCH /api/auth/me/notification-prefs`. +- iOS v0.6.0.8 ships the Settings-side notification-permission re-trigger (`healthlog-iOS#10`), the in-app APNs diagnostic surface, and the `CFBundleShortVersionString` + UA build-number fixes — these depend on the iOS client's build cycle, not the server. The server-side suppression flag added in this release is opt-in (`clientManaged: false` by default) so existing users on the still-buggy v0.6.0.7 client see no behaviour change until they upgrade to v0.6.0.8 and the iOS client explicitly opts in via `PATCH /api/auth/me/notification-prefs`. - Reactive `prefersReducedMotion()` hook — current helper at `src/lib/charts/reduced-motion.ts` is read-once at render; doesn't react to mid-session OS toggle. Twenty-one call sites would benefit from a `useSyncExternalStore`-backed hook. Scoped as a v1.4.50 single-purpose refactor. - `handleReminderCheck` extraction — the cron handler in `src/lib/jobs/reminder-worker.ts` is a 260-line non-exported blob. The v1.4.49 suppression skip-path is pinned via the helper `isMedicationReminderClientManaged` rather than a direct integration test of the handler. Scoped as v1.4.50 hygiene. @@ -1756,7 +1756,7 @@ v1.4.41 closed the iOS BP/Weight 14 s perf paper-cut and the soft-delete reader- - **`offhost-backup.ts:219` DR-intent comment** — the nightly disaster-recovery S3 snapshot deliberately includes soft-deleted rows so a future "restore from yesterday" brings back a row the user undeleted on the source side. The inline comment now documents the asymmetry vs the user-facing `/api/export/full-backup` which correctly excludes them. - **`src/lib/doctor-report-data.ts` byte cleanliness** — the file was checked in as `Binary files differ` because the sanitiser regex held literal control bytes (NUL + US + DEL) in its character class. The regex now uses `[\x00-\x1F\x7F]` escape-sequence form so the file becomes UTF-8 clean and future code reviews can read the diffs. Same runtime behaviour; the existing 18-case test suite still passes. - **Two dead re-exports dropped**: `listSupportedTimezones` re-export in `src/lib/tz/resolver.ts` (callers import from `@/lib/tz/format` directly) and the `describeInjectionSite` re-export in `src/components/medications/glp1-medication-card.tsx` ("Re-export so the parent doesn't need to import it" — no caller ever did). -- **`pnpm check-env` catches the v1.4.40 AP-2 silent-disable it was conceived to catch.** The APNs group in `scripts/env-manifest.json` shipped without `allOrNone: true`, so an operator setting three of four `APNS_*` vars and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would still exit 0 — exactly the silent-disable shape the wave was meant to prevent. Adding the flag closes the gap; the regression test pins the AP-2 scenario directly. +- **`pnpm check-env` catches the v1.4.40 AP-2 silent-disable it was conceived to catch.** The APNs group in `scripts/env-manifest.json` shipped without `allOrNone: true`, so an operator setting three of four `APNS_*` vars and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would still exit 0 — exactly the silent-disable shape the gate was meant to prevent. Adding the flag closes the gap; the regression test pins the AP-2 scenario directly. ### Operator notes @@ -1823,11 +1823,11 @@ v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumul ### Changed - **Per-tile Suspense boundaries on the dashboard.** Each chart tile now mounts inside its own `` so a slow tile no longer blocks the others on first paint. The parent gate is the slim / thick analytics merge (already split per v1.4.39.2); the per-tile Suspense layer is the structural foundation the v1.4.41 React Server Components migration will graft onto. `mood-chart` queryKey dedup eliminates one round-trip on cold mount. -- **queryKey factory enforcement** (`src/lib/__tests__/query-keys.test.ts`). A walker test fails CI if any guarded file declares a bare-literal `queryKey: [...]`. Guarded roots are `src/components/charts/`, `src/components/comparison/`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Cheaper than a custom ESLint rule and points the failure message at the exact `file:line` a contributor needs to fix; opt-in expansion as future waves migrate the remaining sites away from bare literals. +- **queryKey factory enforcement** (`src/lib/__tests__/query-keys.test.ts`). A walker test fails CI if any guarded file declares a bare-literal `queryKey: [...]`. Guarded roots are `src/components/charts/`, `src/components/comparison/`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Cheaper than a custom ESLint rule and points the failure message at the exact `file:line` a contributor needs to fix; opt-in expansion as future releases migrate the remaining sites away from bare literals. ### Fixed -- **Prisma pool starvation root cause** (empirical-trace finding #1). The 15-way `fetchMeasurementSeriesChunked` fan-out in `/api/analytics` thick used to monopolise ≥ 8 of the default-10 `pg.Pool` connections during a power-user cold mount, blocking every Wave-B and Wave-C chart-tile fetch behind it. The fan-out is now wrapped in `p-limit(4)` so analytics holds at most 4 pool slots, and the `pg.Pool` `max` is raised from the library default 10 → 20 (overridable via `DATABASE_POOL_MAX`) so a second concurrent power-user retains ≥ 8 free slots after both branches hit their `p-limit(4)` cap. The cap is a per-request instance, not module-level, so a stale limit cannot leak in-flight state across HTTP boundaries. +- **Prisma pool starvation root cause** (empirical-trace finding #1). The 15-way `fetchMeasurementSeriesChunked` fan-out in `/api/analytics` thick used to monopolise ≥ 8 of the default-10 `pg.Pool` connections during a power-user cold mount, blocking every other dashboard chart-tile fetch behind it. The fan-out is now wrapped in `p-limit(4)` so analytics holds at most 4 pool slots, and the `pg.Pool` `max` is raised from the library default 10 → 20 (overridable via `DATABASE_POOL_MAX`) so a second concurrent power-user retains ≥ 8 free slots after both branches hit their `p-limit(4)` cap. The cap is a per-request instance, not module-level, so a stale limit cannot leak in-flight state across HTTP boundaries. - **Soft-delete invisibility full-wire.** Eleven reader-tier helpers across `src/lib/measurements/rollups.ts`, `src/lib/measurements/rollup-coverage.ts`, `src/lib/analytics/{summaries-slice,correlations-fast-path,bp-in-target-fast-path,health-score-fast-path}.ts`, `src/lib/insights/comprehensive-aggregator.ts`, `src/app/api/dashboard/summary/route.ts`, `src/app/api/measurements/route.ts`, `src/app/api/measurements/series/route.ts`, and `src/lib/ai/coach/snapshot.ts` now filter `deletedAt: null` (or the SQL equivalent `m."deleted_at" IS NULL`) at every aggregate, every cursor walk, every `DISTINCT ON` latest probe, and every rollup-rebuild SQL. Three integration-test contracts in `tests/integration/measurement-soft-delete.test.ts` pin the invariant against a Postgres testcontainer. - **Six remaining insights `measurement.findMany` sites** that the earlier mood-rollup swap left unfiltered (`/api/insights/{targets,cards,generate}` plus `src/lib/insights/{features,glp1-plateau,pulse-status}.ts`). All six now filter `deletedAt: null` so the iOS-adapter card stream, the AI prompt feature aggregator, the GLP-1 plateau detector window, and the per-type tile-strip averages stop counting tombstoned readings once iOS sync starts emitting deletions. - **Compliance-rollup hook gap on bulk-projection paths.** Both `/api/medications/intake?scope=today` and `/api/dashboard/summary` mint fresh `(medicationId, scheduledFor)` rows in PENDING state when a daily schedule is projected on first read. Without a recompute hook the rollup for the affected `(user, medication, day)` tuples stayed at its previous (pre-projection) `scheduled` count, which inflated the apparent compliance % until the user logged against the new row. Both call sites now fire one recompute per distinct `(medicationId, dayKey)` tuple, deduplicated through a `Set` so the cost stays bounded. The recompute call is wrapped in `Promise.allSettled` so any future change that lets the helper throw still leaves the user request 200-OK. @@ -1838,9 +1838,9 @@ v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumul ### Performance -Expected on Marc-sized accounts; numbers anchored on the v1.4.40 empirical trace. Live perf-verify rides the post-deploy window. +Expected on large accounts; numbers anchored on the v1.4.40 empirical trace. Live perf-verify rides the post-deploy window. -- **Wave-C chart-tile first-paint: +7.3 s → +1.6 s.** Bounded analytics fan-out (p-limit 4 + pool max 20) lets the 6× `/api/measurements?source=rollup` Wave-C burst release incrementally as analytics rotates lanes, instead of gating the entire burst behind the thick analytics drain. +- **Late-mounting chart-tile first-paint: +7.3 s → +1.6 s.** Bounded analytics fan-out (p-limit 4 + pool max 20) lets the 6× `/api/measurements?source=rollup` tile burst release incrementally as analytics rotates lanes, instead of gating the entire burst behind the thick analytics drain. - **6 insights routes cold-mount.** The mood-rollup swap on `/api/insights/{features,targets,cards}` moves mood aggregation off the live `MoodEntry.findMany` walk onto the v1.4.39 mood-rollup tier; the cold-mount budget for the affected routes drops onto the same flat-200 ms band the v1.4.39 `/api/mood/analytics` numbers land in. - **`avg30LastYear` now populated.** The 425-day `since` cap on the `/api/analytics` live-fallback per-type loop (v1.4.39) lets the year-ago baseline tile resolve from raw data without forcing a 347 k-row scan; the v1.4.40 pool cap keeps that fallback off the hot path. - **`slope90` via MONTH-tier reader.** `readBestGranularityRollups` auto-routes the 90-day slope window onto the MONTH bucket where coverage allows, eliminating a per-cold-mount aggregate on multi-year tenants. @@ -1942,7 +1942,7 @@ Withings sync, `/api/import`, and admin-restore paths. New writes from those surfaces now fold the persistent rollup tier on touch, and the boot-time backfill discovery surfaces every per-(user, type, day) gap so a worker restart converges any stranded accounts. v1.4.39.2 closes -two follow-ups Marc surfaced from the post-deploy trace: the dashboard +two follow-ups the maintainer surfaced from the post-deploy trace: the dashboard chart still painted its `< 3 daily points` empty-state on the 30-point range for accounts whose historic rollup partition had not yet been folded, and the cold-mount UX waterfall left every per-type tile @@ -2086,7 +2086,7 @@ self-heal via boot-time backfill on first reach. ### Performance -Expected on Marc-sized accounts; numbers anchored on the v1.4.38 perf +Expected on large accounts; numbers anchored on the v1.4.38 perf analysis and confirmed by the unit-test fixture suite. Live perf-verify rides the post-deploy window. @@ -2179,7 +2179,7 @@ longer poisons unrelated cards. ### Operator notes - No new migration. No env-var change. -- Expected: cold-mount `/api/analytics` on Marc-sized accounts +- Expected: cold-mount `/api/analytics` on large accounts drops from 30-75 s to ~3-5 s. ## [1.4.38.7] — 2026-05-18 — Rollup recompute observability + admin trigger @@ -2608,7 +2608,7 @@ dashboard SQL surfaced. load-shedding lands — no semantic change, just a hand-off note. - The private `dayKey` helper in `bp-in-target-fast-path` renames to `bucketDayKey` so the cross-file term lines up with the cross-tz - guard wave naming. + guard naming. - Health-score `bpInTargetPct` reuses the same prior-week query across windows instead of issuing one read per window. @@ -2664,7 +2664,7 @@ dashboard SQL surfaced. `{day}` so a11y labels announce per-weekday). - `admin`, `settings`, and `achievements` namespaces remain intentional T3 — operator-facing surface that will land in a - follow-up wave alongside the iOS launch. + follow-up release alongside the iOS launch. ### Security @@ -2733,7 +2733,7 @@ returns in a single round-trip. `count / min / max / mean` instead of ~306k bucket rows. The downstream JS aggregation is bypassed because the per-type aggregate is already shaped server-side. Cache-miss budget on - Marc-sized accounts: ~3.1 s → < 100 ms expected. + large accounts: ~3.1 s → < 100 ms expected. ### Operator notes @@ -2983,7 +2983,7 @@ double-digit MB for power users. The Insights page itself stops blocking the entire shell on `/api/insights/comprehensive`, so each section renders its own skeleton and fills in independently. -On top of the perf wave: chart slot heights pinned to kill Recharts' +On top of the performance work: chart slot heights pinned to kill Recharts' `width=-1 height=-1` warning and the matching CLS, a tri-state trend annotation contract that ends the cold-mount "more data needed" flash, medication intake history restored on the detail page, About card @@ -3726,8 +3726,7 @@ the iOS native client launches. Subsequent v1.4.x tags carry security fixes, dependency bumps, and hotfix-only corrections. New feature work is paused until the iOS app clears Apple review, at which point a v1.5.0 version-bump-only release tags the milestone. The Prisma -schema head comment and `.planning/v15-strategic-plan.md` §5 -decision-log row pin the freeze trigger in-tree. +schema head comment pins the freeze trigger in-tree. ## [1.4.33] — 2026-05-17 — Polish and reliability @@ -3922,9 +3921,9 @@ the affected surfaces. the Insights mother page now accepts both eager and `next/dynamic` spellings. -## [1.4.32] — 2026-05-17 — HealthKit Tier 1 first wave +## [1.4.32] — 2026-05-17 — HealthKit Tier 1 first surface -First public surface wave for the HealthKit Tier 1 metrics that +First public surface for the HealthKit Tier 1 metrics that the iOS contributor brief locked in for v1.5. The headline item is a workouts flow that lands end-to-end on the web: a list page at `/insights/workouts`, a detail page with route preview and @@ -4046,8 +4045,7 @@ sleep before the trigger step lands inside the existing ### Fixed - **Insights tab-strip blocking on mobile.** Three orthogonal - client-side fixes per - `.planning/research/v15-insights-blocking-bug.md`: + client-side fixes: - `fetchAdvisor` gets an 8 s `AbortController`; `AbortError` falls through to the existing graceful-null return path so the UI surfaces the regen CTA instead of pinning the query in @@ -4111,9 +4109,8 @@ Every change is additive on the wire. error and degrades gracefully through its existing 403 handler. v1.4.31+ iOS reads the `errorCode` to render the surface-specific empty state. -- The locked-contract entry at - `.planning/v15-ios-handoff/08-locked-contracts.md` §14 - documents the rule that the flag matrix gates BOTH +- The locked-contract coordination notes (§14) + document the rule that the flag matrix gates BOTH server-routed AND on-device assistant surfaces. Apple Foundation Models on-device Coach + Briefing flows in the v0.6.0 iOS path honour the same flag matrix as the @@ -4139,12 +4136,12 @@ standalone-first track depends on lands as §13 of `MeasurementType`. `requireAuth()` gates the route — any logged-in user passes — and `Cache-Control: public, max-age=600` enables a 10-minute edge cache. Locked per - `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1. + the iOS-team coordination response, §3 R1. ### Documented - **SyncMode conflict-resolution policy.** - `.planning/v15-ios-handoff/08-locked-contracts.md` gains §13 per + The locked-contract coordination notes gain §13 per R9 of the iOS-team response doc. Hard-spec covers bulk-backfill, steady-state bidirectional sync via `(updatedAt, syncVersion)` optimistic concurrency, the 409 write-conflict + 410 Gone @@ -4188,8 +4185,7 @@ permission picker and the future Insights nav. - **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId, date)` mints `"stats::"` alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is - locked in `.planning/v15-ios-handoff/08-locked-contracts.md` §12 - and `.planning/v15-ios-handoff/06-ios-responsibilities.md`. iOS + locked in the iOS coordination notes (§12). iOS emits one row per day per cumulative type via `HKStatisticsCollectionQuery`; the unique index collapses re-syncs idempotently, and a future PATCH-on-divergence call @@ -4898,8 +4894,7 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only. full filtered set, dedupes once, then slices, so `meta.total` reports the deduped count rather than the per-window `canonical.length`. -- **iOS handoff addendum.** New - `.planning/v15-ios-handoff/22-standalone-and-server-pairing.md` +- **iOS handoff addendum.** A new coordination note documents the standalone-then-pair pattern for the iOS native client. Sibling of the existing `22-offline-first-architecture.md`; same research input, neutral framing per the v1.4.27 convention @@ -5356,7 +5351,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites). per-locale bodies (no `REPLY LANGUAGE` footer indirection) with the full safety-contract matrix in YAML per locale + structural refusal-probe coverage in CI (see Tests). EN + DE stay - Marc-maintained. Locale picker covers all six; signup + maintainer-curated. Locale picker covers all six; signup browser-detect maps `fr|es|it|pl` to the corresponding bundle. - **Coach — native first-party system prompts across six locales.** Coach + insights system prompts move from EN body + `REPLY @@ -5950,8 +5945,8 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites). ### Deferred to v1.4.26 -See `.planning/v1426-backlog.md` for the full backlog with effort -estimates and source citations. Headline items: +The full backlog, with effort +estimates and source citations, is tracked internally. Headline items: - `User.onboardingGoals` column + server-side persistence for the goals chip-picker selection (today the picker holds the choice @@ -6510,7 +6505,7 @@ NOT NULL`) is the defence-in-depth backstop. - **Coolify image-digest auto-deploy.** Instructions for the one-time UI-toggle ("Watch image registry for new digests") - live at `.planning/coolify-auto-deploy-howto.md`. Future + live in the internal ops notes. Future releases should drop the host-side retag fallback the moment the toggle is on. - **191 maintainer-name references in `src/` source comments @@ -6526,7 +6521,7 @@ NOT NULL`) is the defence-in-depth backstop. ### Deferred to v1.4.23 -See `.planning/v1422-backlog.md` for the full carry-over list. +The full carry-over list is tracked internally. Highlights: sentinel parser malformed-enum hardening (Sr-M5); analytics-route unbounded `findMany` paging; targets-route 7-pass sparkline coalesce; `CoachDrawer key={prefill}` @@ -6828,8 +6823,7 @@ Coach extension for the new measurement types - 3 HIGH from QA — `/insights` `data?.` narrowing refactor, `/admin/api-tokens` touch-tooltip (needs Popover swap), and `/insights` hero density (folded into the v1.4.20 redesign). -- 31 MED + 16 LOW from the quality-of-life audit. Short-list at - `.planning/v1420-backlog.md`. +- 31 MED + 16 LOW from the quality-of-life audit. Short-list tracked internally. - `/insights` redesign with AI Coach — separate roadmap, design handoff at `~/Downloads/design_handoff_insights_redesign`. @@ -6901,9 +6895,9 @@ Coach extension for the new measurement types `messages/de.json` are still statically imported into the client bundle, so a determined user can `Cmd-F` for the hidden strings in `_next/static/chunks/*.js`. Needs a build-time strip or reversible - obfuscation. Tracked in `.planning/v1419-backlog.md`. -- See `.planning/v1419-backlog.md` for the short-list and - `.planning/v15-backlog.md` for the strategic v1.5 items. + obfuscation. Tracked internally. +- The short-list and the strategic v1.5 items are tracked + internally. ## [1.4.17] — 2026-05-10 diff --git a/Dockerfile b/Dockerfile index 3dc224e2d..a883e6c82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,18 @@ WORKDIR /app ARG NEXT_PUBLIC_APP_VERSION ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION +# Short Git SHA of the release commit, same workflow source as the +# version arg above. Changes exactly when the source changes, so it +# adds no cache churn beyond what the COPY below already causes. +# /api/version surfaces it as `buildSha` for deploy verification +# (docs/ops/deploy.md). The built-at timestamp is intentionally NOT +# set in this stage: it differs on every run and would bust the +# `pnpm build` layer cache even for content-identical rebuilds — the +# runner stage below carries it instead (the route reads process.env +# at request time, not at bundle time). +ARG NEXT_PUBLIC_APP_BUILD_SHA +ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA + COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -54,6 +66,13 @@ ENV TZ=Europe/Berlin ARG NEXT_PUBLIC_APP_VERSION ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION +# Short Git SHA the image was built from — /api/version returns it as +# `buildSha` so an operator can verify which commit a running `:latest` +# container actually carries (the deploy runbook checks it after every +# deploy). Provided by docker-publish.yml alongside the version arg. +ARG NEXT_PUBLIC_APP_BUILD_SHA +ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA + RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs @@ -97,6 +116,15 @@ RUN mkdir -p /opt/prisma-cli && \ RUN mkdir -p /opt/geolite2 COPY assets/geolite2/ /opt/geolite2/ +# ISO-8601 build timestamp — /api/version returns it as `builtAt`. +# Declared this late in the stage on purpose: the value differs on +# every CI run, and every instruction after an ARG shares its cache +# fate. Down here the only layers it invalidates are the cheap +# entrypoint COPY/chmod below; the npm-install layers above stay +# cache-warm across rebuilds of the same release. +ARG NEXT_PUBLIC_APP_BUILT_AT +ENV NEXT_PUBLIC_APP_BUILT_AT=$NEXT_PUBLIC_APP_BUILT_AT + # Entrypoint script (runs migrations, then starts app) COPY docker-entrypoint.sh ./ RUN chmod +x docker-entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index 1f382c67a..f6c68cbb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,11 @@ services: image: ghcr.io/mbombeck/healthlog:latest pull_policy: always container_name: healthlog-app + # The app runs as the unprivileged `nextjs` user and never needs a + # setuid/setgid escalation; blocking privilege gain caps what a + # compromised process inside the container can do. + security_opt: + - no-new-privileges:true ports: - "3000:3000" environment: @@ -125,8 +130,16 @@ services: # restart: unless-stopped db: - image: postgres:16-alpine + # Pinned to the concrete minor instead of the floating `16-alpine` + # alias so a database update is always a deliberate, reviewable + # change (at pin time `16-alpine` resolved to this same digest, so + # existing deployments see no image change). Minor releases share + # the on-disk format — bumping this pin is safe without a dump / + # restore; a major-version bump (17+) is not. + image: postgres:16.14-alpine container_name: healthlog-db + security_opt: + - no-new-privileges:true environment: POSTGRES_USER: healthlog POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" diff --git a/src/lib/auth/__tests__/hmac.test.ts b/src/lib/auth/__tests__/hmac.test.ts index 7852f9ed3..720f1bd0b 100644 --- a/src/lib/auth/__tests__/hmac.test.ts +++ b/src/lib/auth/__tests__/hmac.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { verifyHmacSignature, hashToken } from "../hmac"; import { createHmac } from "node:crypto"; -const TEST_HMAC_KEY = "test-hmac-key-for-unit-tests"; +const TEST_HMAC_KEY = "test-hmac-key-for-unit-tests-32-chars-min"; beforeEach(() => { vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); @@ -84,4 +84,19 @@ describe("hashToken", () => { "API_TOKEN_HMAC_KEY env var must be set", ); }); + + it("throws if API_TOKEN_HMAC_KEY is shorter than 32 characters", () => { + vi.stubEnv("API_TOKEN_HMAC_KEY", "short-key-31-characters-xxxxxxx"); + expect(() => hashToken("test")).toThrow( + "API_TOKEN_HMAC_KEY must be at least 32 characters, got 31", + ); + }); + + it("accepts a key of exactly 32 characters", () => { + const key32 = "exactly-32-characters-long-key-x"; + vi.stubEnv("API_TOKEN_HMAC_KEY", key32); + expect(key32).toHaveLength(32); + const expected = createHmac("sha256", key32).update("test").digest("hex"); + expect(hashToken("test")).toBe(expected); + }); }); diff --git a/src/lib/auth/hmac.ts b/src/lib/auth/hmac.ts index c5a303cf5..5d625239e 100644 --- a/src/lib/auth/hmac.ts +++ b/src/lib/auth/hmac.ts @@ -28,12 +28,23 @@ export function verifyHmacSignature( /** * Hash an API token for storage (SHA-256). - * Requires API_TOKEN_HMAC_KEY env var to be set. + * Requires API_TOKEN_HMAC_KEY env var to be set and at least 32 + * characters long. Fail-closed like the encryption-key loader in + * `src/lib/crypto.ts`: a weak key throws instead of silently keying + * every Bearer-token hash off guessable material. */ +const MIN_HMAC_KEY_LENGTH = 32; + export function hashToken(token: string): string { const key = process.env.API_TOKEN_HMAC_KEY; if (!key) { throw new Error("API_TOKEN_HMAC_KEY env var must be set"); } + if (key.length < MIN_HMAC_KEY_LENGTH) { + throw new Error( + `API_TOKEN_HMAC_KEY must be at least ${MIN_HMAC_KEY_LENGTH} characters, got ${key.length}. ` + + "Generate a strong key with `openssl rand -hex 32` and update the environment before restarting.", + ); + } return createHmac("sha256", key).update(token).digest("hex"); }