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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 15:05:05 +02:00
1 parent aabc7915cb
commit d7bf0df10b
14 files changed
+222 -99

No files matched your search

+31 -4
View File
@@ -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 }}
+30 -10
View File
@@ -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: |
+8 -5
View File
@@ -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/
+3 -3
View File
@@ -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
+6 -3
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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 }}
+24 -15
View File
@@ -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
+45 -51
View File
@@ -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 `<Suspense>` 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:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
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
+28
View File
@@ -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
+14 -1
View File
@@ -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}"
+16 -1
View File
@@ -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);
});
});
+12 -1
View File
@@ -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");
}