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"); }