diff --git a/.dockerignore b/.dockerignore index 4239f361a..daa14cbc6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -49,6 +49,7 @@ public/sw-version.js # Editor / agent worktrees + caches. .claude +.worktrees .cursor .aider* .vscode diff --git a/.env.example b/.env.example index 2f200f2fd..f3e0196b2 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,17 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # Users bring their own API keys via Settings. No server-side key needed by # default. The admin can configure a fallback key in the Admin Panel. +# Local-AI SSRF opt-in (optional). The local AI provider rejects private/ +# internal hosts by default. Set to an exact host allowlist (comma-separated, +# e.g. "ollama.lan,10.0.0.5") or "true" for any private host — intended for a +# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md. +# ALLOW_LOCAL_AI_PRIVATE_HOSTS="" + +# Daily-briefing output-token ceiling (optional). Default 2500, clamped to +# 500-8000. Raise it when a verbose model's briefing JSON gets cut off +# ("AI response was cut off"). See docs/integrations/ai-providers.md. +# INSIGHTS_MAX_TOKENS="" + # Maximum number of comprehensive-insight generations a single user can # trigger per hour (POST /api/insights/generate). Default 10. The 24h # DB cache short-circuits this for read traffic; only force=true and diff --git a/.github/workflows/coach-eval.yml b/.github/workflows/coach-eval.yml index 356ee8432..e25dc6843 100644 --- a/.github/workflows/coach-eval.yml +++ b/.github/workflows/coach-eval.yml @@ -39,7 +39,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 17b28fe9e..2189a54c4 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -16,7 +16,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: "pnpm" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 955498c29..e53c95d72 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -37,6 +37,57 @@ concurrency: cancel-in-progress: true jobs: + release-policy: + name: Validate release policy + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: read + steps: + - name: Checkout full history + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - name: Validate release policy + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + echo "::notice::Pull-request image validation does not publish release tags." + exit 0 + fi + + if [ "$GITHUB_REF_TYPE" != "tag" ] || [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Release ref '$GITHUB_REF_NAME' is not a strict SemVer tag (expected vMAJOR.MINOR.PATCH with no leading zeroes or prerelease suffix)." >&2 + exit 1 + fi + + package_version="$(node -p "require('./package.json').version")" + tag_version="${GITHUB_REF_NAME#v}" + if [ "$package_version" != "$tag_version" ]; then + echo "::error::package.json version '$package_version' does not match release tag '$GITHUB_REF_NAME'." >&2 + exit 1 + fi + + git fetch --no-tags origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::Release commit $GITHUB_SHA is not an ancestor of origin/main." >&2 + exit 1 + fi + + successful_quality_runs="$( + gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$GITHUB_SHA&event=push&status=success&per_page=100" \ + --jq '[.workflow_runs[] | select(.name == "Security & Quality" and .head_branch == "main")] | length' + )" + if [ "$successful_quality_runs" -lt 1 ]; then + echo "::error::Release commit $GITHUB_SHA has no successful prior Security & Quality push run on main." >&2 + exit 1 + fi + + echo "Release policy passed for $GITHUB_REF_NAME at $GITHUB_SHA." + # ── Stage 1 ──────────────────────────────────────────────────────── # Build each architecture on its own native GitHub-hosted runner so # Next.js's static-page workers run on real silicon instead of QEMU @@ -54,6 +105,7 @@ jobs: # manifest in a third step, so consumers see a single tag that # auto-selects on `docker pull` per local platform. build: + needs: [release-policy] name: Build ${{ matrix.platform }} runs-on: ${{ matrix.runner }} # 25 min wall clock — native amd64 build finished inside 12 min @@ -265,6 +317,11 @@ jobs: if: github.event_name != 'pull_request' needs: [build] timeout-minutes: 10 + outputs: + image_ref: ${{ steps.published.outputs.image_ref }} + image_tag: ${{ steps.published.outputs.image_tag }} + image_digest: ${{ steps.published.outputs.image_digest }} + build_sha: ${{ steps.published.outputs.build_sha }} permissions: contents: read packages: write @@ -335,9 +392,21 @@ jobs: docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf '${{ env.IMAGE_REF }}@sha256:%s ' *) - - name: Inspect published manifest + - name: Resolve published image + id: published + env: + IMAGE_TAG: ${{ github.ref_name }} run: | - docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}' + image_digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')" + if [[ ! "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Could not resolve a valid manifest digest for ${IMAGE_REF}:${IMAGE_TAG}." >&2 + exit 1 + fi + echo "image_ref=$IMAGE_REF" >> "$GITHUB_OUTPUT" + echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT" + echo "image_digest=$image_digest" >> "$GITHUB_OUTPUT" + echo "build_sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + docker buildx imagetools inspect "${IMAGE_REF}@${image_digest}" # v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore). # Signing the INDEX digest covers both per-platform manifests in @@ -357,91 +426,59 @@ jobs: - name: Sign multi-arch manifest (keyless) env: - IMAGE_VERSION: ${{ steps.meta.outputs.version }} + IMAGE_DIGEST: ${{ steps.published.outputs.image_digest }} run: | - digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_VERSION}" --format '{{json .Manifest.Digest}}' | tr -d '"')" - if [ -z "$digest" ]; then - echo "::error::Could not resolve the manifest digest for ${IMAGE_REF}:${IMAGE_VERSION} — signing aborted." - exit 1 - fi - echo "Signing ${IMAGE_REF}@${digest}" - cosign sign --yes "${IMAGE_REF}@${digest}" - echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${digest}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${digest}" + echo "Signing ${IMAGE_REF}@${IMAGE_DIGEST}" + cosign sign --yes "${IMAGE_REF}@${IMAGE_DIGEST}" + echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${IMAGE_DIGEST}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${IMAGE_DIGEST}" - # v1.4.15: trigger the Coolify deploy automatically once GHCR has - # the new image. Replaces the former manual SSH + force-pull recipe - # on the deployment host. - # - # Runs only on the publish path (push to `main` or to a `v*` tag), never - # on PRs. `continue-on-error: true` because the image is *already* - # published to GHCR by the time we reach this step — a Coolify-side - # outage must not retroactively fail the build. The maintainer still - # has the manual recipe as fallback. - # - # Required GitHub repository secrets (set by the maintainer once): - # COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false - # COOLIFY_TOKEN = Bearer token from Coolify UI -> Keys & Tokens - # Both secrets are referenced exclusively here. If either is unset, the - # step short-circuits to a skip rather than calling Coolify with empty - # auth. - # - # v1.4.26 P6-3 — maintainer toggle. - # Three releases in a row shipped via the host-side SSH-retag fallback - # because the secrets above silently absent + the warn-only skip - # below let the workflow stay green. The `vars.COOLIFY_AUTO_DEPLOY` - # repository variable now distinguishes the two intended modes: - # - # COOLIFY_AUTO_DEPLOY=on → if secrets present, deploy; if absent, - # FAIL the step so the maintainer sees the - # missing-secret gap on the next release. - # COOLIFY_AUTO_DEPLOY=off → unconditional skip with a notice. Use - # this when the host-side SSH recipe is - # the intended deploy path. - # (unset / any other) → warn-and-skip (legacy behaviour). - # - # Set the variable under repo Settings → Secrets and variables → - # Actions → Variables (NOT Secrets — it is intentionally readable). + verify-published-image: + name: Verify exact published image + needs: merge + permissions: + contents: read + packages: read + uses: ./.github/workflows/post-publish-verify.yml + with: + image_ref: ${{ needs.merge.outputs.image_ref }} + image_tag: ${{ needs.merge.outputs.image_tag }} + image_digest: ${{ needs.merge.outputs.image_digest }} + build_sha: ${{ needs.merge.outputs.build_sha }} + secrets: inherit + + promote: + name: Promote verified image to Coolify + needs: [merge, verify-published-image] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: - name: Trigger Coolify deploy - continue-on-error: true env: COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }} COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }} COOLIFY_AUTO_DEPLOY: ${{ vars.COOLIFY_AUTO_DEPLOY }} - IMAGE_VERSION: ${{ steps.meta.outputs.version }} + IMAGE_TAG: ${{ needs.merge.outputs.image_tag }} + IMAGE_DIGEST: ${{ needs.merge.outputs.image_digest }} + BUILD_SHA: ${{ needs.merge.outputs.build_sha }} run: | mode="${COOLIFY_AUTO_DEPLOY:-}" if [ "$mode" = "off" ]; then - echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook. Maintainer uses the host-side SSH recipe instead." + echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook." + exit 0 + fi + if [ "$mode" != "on" ]; then + echo "::warning::COOLIFY_AUTO_DEPLOY is not 'on' or 'off' — skipping auto-deploy. Set an explicit mode in repository variables." exit 0 fi if [ -z "$COOLIFY_WEBHOOK" ] || [ -z "$COOLIFY_TOKEN" ]; then - if [ "$mode" = "on" ]; then - echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both under repo Settings → Secrets and variables → Actions, or set COOLIFY_AUTO_DEPLOY=off to silence." - exit 1 - fi - echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable, or set the COOLIFY_AUTO_DEPLOY variable to 'off' to silence this warning." - exit 0 + echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both credentials or disable promotion explicitly." >&2 + exit 1 fi - # v1.4.31 — wait for GHCR cache propagation before firing - # the Coolify webhook. Five releases in a row (v1.4.27 - # through v1.4.30.1) the webhook reported "finished" but - # Coolify still pulled the prior `:latest` digest. The - # `docker buildx imagetools create` + `inspect` steps - # above confirm the manifest exists at registry-write time, - # but GHCR's CDN edges can take 30-90 s to propagate the - # new digest. The webhook fires inside that window, so - # Coolify pulls "latest" while the edge still serves the - # prior digest, gets "no change", and returns success. - # Waiting out the propagation window before firing the - # webhook closes that gap. - echo "Waiting 90 s for GHCR cache propagation before triggering Coolify (image: $IMAGE_VERSION)…" + + echo "Waiting 90 s for registry propagation before promoting ${IMAGE_TAG}@${IMAGE_DIGEST}." sleep 90 - # v1.4.22 C3 — force=true tells Coolify to skip its image-cache - # and re-pull the registry digest. If the maintainer's secret - # URL already carries a `?force=` query, leave it alone; - # otherwise append `?force=true` (or `&force=true` if other - # query params are present) so doc-only deploys still trigger - # a registry-digest check. deploy_url="$COOLIFY_WEBHOOK" if ! echo "$deploy_url" | grep -q "force="; then if echo "$deploy_url" | grep -q "?"; then @@ -450,18 +487,18 @@ jobs: deploy_url="${deploy_url}?force=true" fi fi - echo "Triggering Coolify deploy for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7})" - response=$(curl --silent --show-error --max-time 30 \ + + echo "Triggering Coolify deploy for ${IMAGE_TAG}@${IMAGE_DIGEST} (source ${BUILD_SHA})." + response="$(curl --silent --show-error --max-time 30 \ --write-out "\n__HTTP_STATUS__:%{http_code}" \ --request GET "$deploy_url" \ - --header "Authorization: Bearer $COOLIFY_TOKEN") + --header "Authorization: Bearer $COOLIFY_TOKEN")" status="${response##*__HTTP_STATUS__:}" body="${response%__HTTP_STATUS__:*}" echo "Coolify response (HTTP $status):" echo "$body" if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then - echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Maintainer: deploy manually on the deployment host (SSH force-pull recipe, see docs/ops/deploy.md)." + echo "::error::Coolify deploy webhook returned HTTP $status for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}." >&2 exit 1 fi - echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, re-trigger the deploy from the Coolify UI or deploy manually on the deployment host (docs/ops/deploy.md)." - echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status." + echo "Coolify deploy queued for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}." diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 987fc94bd..526a6e57b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -37,10 +37,11 @@ jobs: DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' ENCRYPTION_ACTIVE_KEY_ID: v1 - API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x + API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb AUTH_RP_NAME: HealthLog AUTH_RP_ID: localhost AUTH_RP_ORIGIN: http://localhost:3000 + SESSION_COOKIE_SECURE: "false" NODE_ENV: production steps: @@ -48,7 +49,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: pnpm @@ -87,10 +88,11 @@ jobs: env: E2E_BASE_URL: http://localhost:3000 - - name: Upload Playwright HTML report on failure - if: failure() + - name: Upload Playwright HTML report + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report path: playwright-report/ retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/env-check.yml b/.github/workflows/env-check.yml index a68503a10..f169e8f58 100644 --- a/.github/workflows/env-check.yml +++ b/.github/workflows/env-check.yml @@ -31,7 +31,7 @@ jobs: uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Set up Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7d31a8b26..d1e16580b 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -18,7 +18,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: pnpm @@ -38,6 +38,6 @@ jobs: # string "0" — failing crypto's hex/base64 length check and breaking # the backups + integration-status suites for 50+ runs (regression # since v1.4.13). Quoting forces a literal scalar. - API_TOKEN_HMAC_KEY: "integration-tests-do-not-use-in-prod" + API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" SESSION_SECRET: "integration-tests" diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index 352fd29eb..908b17a84 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -20,7 +20,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/post-publish-verify.yml b/.github/workflows/post-publish-verify.yml index c2dc87df2..6f4347795 100644 --- a/.github/workflows/post-publish-verify.yml +++ b/.github/workflows/post-publish-verify.yml @@ -1,33 +1,42 @@ name: Post-publish verify -# Informational reachability gate that runs AFTER `Build & Publish Docker -# Image` finishes a `main` (or tag) build. Pulls the just-published image -# from GHCR and probes: -# - the image manifest is fetchable (i.e. the push actually completed) -# - the container starts and `/api/version` returns the expected version -# - the container's `/api/health` endpoint reports OK -# -# Does NOT block deploys — `continue-on-error` is set on every probe step -# so a transient registry blip doesn't go red. This is a smoke gate the -# v1.4.14 release would have caught: the main push appeared to "succeed" -# from the deploy host's POV, but the host had to pull the tag image -# because main's manifest never finished pushing. -# -# Triggered by `workflow_run` so it only runs after the build workflow -# actually completes — chaining via `needs:` would force the build into -# the same workflow file. - on: - workflow_run: - workflows: ["Build & Publish Docker Image"] - types: [completed] - branches: [main] + workflow_call: + inputs: + image_ref: + description: Exact registry repository to verify + required: true + type: string + image_tag: + description: Exact published release tag + required: true + type: string + image_digest: + description: Exact published multi-arch manifest digest + required: true + type: string + build_sha: + description: Expected short source commit SHA + required: true + type: string workflow_dispatch: inputs: + image_ref: + description: Exact registry repository to verify + required: true + type: string image_tag: - description: "GHCR image tag to verify (defaults to `main`)" - required: false - default: "main" + description: Exact published release tag + required: true + type: string + image_digest: + description: Exact published multi-arch manifest digest + required: true + type: string + build_sha: + description: Expected short source commit SHA + required: true + type: string permissions: contents: read @@ -35,14 +44,15 @@ permissions: jobs: verify: - name: Pull + smoke-test published image + name: Pull and smoke-test exact published image runs-on: ubuntu-latest timeout-minutes: 10 - # Skip if the upstream build itself failed — there's nothing to verify. - # Manual dispatch always runs. - if: >- - github.event_name == 'workflow_dispatch' || - github.event.workflow_run.conclusion == 'success' + env: + IMAGE_REF: ${{ inputs.image_ref }} + IMAGE_TAG: ${{ inputs.image_tag }} + IMAGE_DIGEST: ${{ inputs.image_digest }} + BUILD_SHA: ${{ inputs.build_sha }} + DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public services: postgres: @@ -60,15 +70,6 @@ jobs: --health-retries 10 steps: - - name: Resolve image tag - id: tag - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - echo "tag=${{ github.event.inputs.image_tag }}" >> "$GITHUB_OUTPUT" - else - echo "tag=main" >> "$GITHUB_OUTPUT" - fi - - name: Log in to GHCR uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: @@ -76,69 +77,97 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Pull image - id: pull - continue-on-error: true + - name: Pull exact image run: | - IMAGE="ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.tag }}" - echo "Pulling $IMAGE" - docker pull "$IMAGE" - echo "image=$IMAGE" >> "$GITHUB_OUTPUT" + TAG_REF="${IMAGE_REF}:${IMAGE_TAG}" + DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}" - - name: Start container - if: steps.pull.outcome == 'success' - id: run - continue-on-error: true + echo "Pulling published tag $TAG_REF" + docker pull "$TAG_REF" + resolved_digest="$(docker buildx imagetools inspect "$TAG_REF" --format '{{json .Manifest.Digest}}' | tr -d '"')" + if [ "$resolved_digest" != "$IMAGE_DIGEST" ]; then + echo "::error::Published tag digest mismatch: $TAG_REF resolved to '$resolved_digest', expected '$IMAGE_DIGEST'." >&2 + exit 1 + fi + + echo "Pulling immutable image $DIGEST_REF" + docker pull "$DIGEST_REF" + + - name: Run migrations run: | - # Minimal env to satisfy the strict-env validators on boot. + DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}" + docker run --rm \ + --network host \ + --entrypoint node \ + -e DATABASE_URL="$DATABASE_URL" \ + "$DIGEST_REF" \ + /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy + + - name: Start exact image + run: | + DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}" docker run -d --name healthlog-verify \ --network host \ - -e DATABASE_URL='postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public' \ + -e DATABASE_URL="$DATABASE_URL" \ -e ENCRYPTION_KEYS='{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \ -e ENCRYPTION_ACTIVE_KEY_ID=v1 \ - -e API_TOKEN_HMAC_KEY=verify-hmac-key-32-bytes-padding-x \ + -e API_TOKEN_HMAC_KEY=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc \ -e AUTH_RP_NAME=HealthLog \ -e AUTH_RP_ID=localhost \ -e AUTH_RP_ORIGIN=http://localhost:3000 \ -e NODE_ENV=production \ - -e HEALTHLOG_PROCESS_TYPE=web \ -e PORT=3000 \ - "${{ steps.pull.outputs.image }}" + "$DIGEST_REF" - # Wait up to 60 s for the server to start answering. + - name: Verify exact version + run: | + body="" for i in $(seq 1 30); do - if curl -sf -o /dev/null http://localhost:3000/api/version; then - echo "ready after ${i}*2s" + if body="$(curl -sf http://localhost:3000/api/version)"; then + echo "Version endpoint ready after $((i * 2))s" + break + fi + sleep 2 + done + if [ -z "$body" ]; then + echo "::error::Exact image did not expose /api/version within 60 seconds." >&2 + docker logs healthlog-verify || true + exit 1 + fi + + EXPECTED_VERSION="${IMAGE_TAG#v}" + echo "$body" | jq . + if ! jq -e --arg expected "$EXPECTED_VERSION" '.data.version == $expected' <<< "$body" >/dev/null; then + actual="$(jq -r '.data.version // ""' <<< "$body")" + echo "::error::Version mismatch: exact image reports '$actual', expected '$EXPECTED_VERSION' (release tag '$IMAGE_TAG')." >&2 + exit 1 + fi + if ! jq -e --arg expected "$BUILD_SHA" '.data.buildSha == $expected' <<< "$body" >/dev/null; then + actual="$(jq -r '.data.buildSha // ""' <<< "$body")" + echo "::error::Build SHA mismatch: exact image reports '$actual', expected '$BUILD_SHA'." >&2 + exit 1 + fi + + - name: Verify health + run: | + for i in $(seq 1 30); do + if body="$(curl -sf http://localhost:3000/api/health)" && jq -e '.status == "ok"' <<< "$body" >/dev/null; then + echo "$body" | jq . + echo "Health endpoint ready after $((i * 2))s" exit 0 fi sleep 2 done - echo "::warning::Container did not become ready within 60 s" + echo "::error::Exact image did not report healthy within 60 seconds." >&2 docker logs healthlog-verify || true exit 1 - - name: Probe /api/version - if: steps.run.outcome == 'success' + - name: Container logs + if: always() continue-on-error: true - run: | - set -e - BODY=$(curl -sf http://localhost:3000/api/version) - echo "$BODY" | jq . - VER=$(echo "$BODY" | jq -r '.data.version // .version') - echo "::notice::published image ${{ steps.tag.outputs.tag }} reports version=$VER" - - - name: Probe /api/health - if: steps.run.outcome == 'success' - continue-on-error: true - run: | - curl -sf http://localhost:3000/api/health | jq . - - - name: Container logs (always) - if: always() && steps.run.outcome == 'success' - continue-on-error: true - run: docker logs healthlog-verify | tail -100 + run: docker logs healthlog-verify - name: Tear down if: always() continue-on-error: true - run: docker rm -f healthlog-verify || true + run: docker rm -f healthlog-verify diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b5f939f24..93ac96718 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -19,7 +19,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: "pnpm" @@ -70,7 +70,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: "pnpm" diff --git a/.gitignore b/.gitignore index a0e44962e..37b97efa8 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,7 @@ next-env.d.ts # Local screenshot/audit harnesses (repo-root for module resolution; never committed) .audit-*.mjs + + +# Isolated development worktrees +/.worktrees/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b27a7b90..a15813119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,287 @@ ## [Unreleased] +## [1.32.0] — 2026-07-22 + +- **Provider ingestion and daily reactions now preserve the correct identity.** + Arrival reactions are bound to the exact marker revision, workout selection + matches field donors to the correct surviving session, long-range buckets + honor the user's timezone, MCP pages stay stable, and one Withings + subscription repair can no longer block fallback polling for other users. +- **Large reads and writes are bounded end to end.** Measurement exports stream + in pages without splitting sleep sessions, oversized projections cannot evict + the shared cache, dense reports and metric series use database aggregation, + upload reads have byte, time, and concurrency limits, full-history backfills + share admission control, and medication imports use durable chunks with + resumable, bounded final rollups. +- **Capture and history flows are complete on mobile and desktop.** Workout + history loads beyond the first page, metric pages open preselected capture + forms and return to their origin, failed water submissions retain their draft, + and confirmed discards clear it. +- **Interactive forms share one safe dismissal contract.** Dirty forms require + confirmation across tracked Back and Forward traversals, Apple Health setup + exposes successful native syncs, document selection is consumed on Coach + dismissal but preserved across full-page Coach navigation, conversation + titles can be renamed with rollback, and hidden tabs refresh meaningful + server state. +- **Accessibility coverage now exercises representative authenticated routes, + themes, dialogs, and sheets.** Small text keeps compliant contrast, OCR uses a + visible labeled control, workout sections use real headings, and compact + actions retain 44-pixel touch targets. +- **Client and API boundaries are explicit.** Localized stable error codes cover + measurement, mood, lab, and onboarding failures; client fetches and query + keys are lint-enforced; AI overrides are documented; and large Coach, + report, target, and provider modules now use one-way focused builders. + +- **Production image processing uses the patched Sharp 0.35 runtime.** This + closes the current high-severity libvips advisory chain inherited through + Next.js while retaining verified production-build compatibility. + +Migrations `0264_whoop_owner_identity`, `0265_withings_subscription_state`, and +`0266_medication_intake_import_jobs`. No breaking changes. + +## [1.31.8] — 2026-07-21 + +- **MCP search cursors keep lab results stable across pages.** Distinct analyte + names are ordered before offset pagination, preventing repeated or skipped + lab entries when a client follows `nextCursor`. +- **Opening the medication wizard no longer risks clearing entered fields.** + The one-shot URL flag is removed without starting a second page navigation, + so slower browsers keep the active wizard instance and its form state. + +No migrations. No breaking changes. + +## [1.31.7] — 2026-07-20 + +- **Current OpenAI reasoning models use their supported request contract.** + Official GPT-5, o1, o3, and o4 models receive `max_completion_tokens` + without unsupported `temperature` or `seed` fields; compatible custom + gateways retain the legacy parameters they advertise. +- **Apple Health XML cumulative totals no longer add overlapping sources.** + Imports aggregate each source/day independently and select the highest + source subtotal, while explicit provenance prevents XML estimates or late + legacy samples from overwriting authoritative native HealthKit statistics. + Existing legacy aggregates can be repaired by re-uploading their archive. +- **Every currently actionable medication reaches Today.** Equal-time doses no + longer collapse to one row, overdue doses win deterministic ties, and weekly + or custom intake windows use the scheduling engine's real availability + boundary. The shared digest fixes both the web start page and iOS Home. + +Migration `0263_apple_health_aggregate_authority`. No breaking changes. + +## [1.31.6] — 2026-07-21 + +- **The hardened runtime image can run database migrations again.** The + isolated Prisma CLI now exposes its pinned `dotenv` and `prisma/config` + dependencies to the copied production configuration while package managers + remain absent from the final image. + +No migrations. No breaking changes. + +## [1.31.5] — 2026-07-21 + +- **The production image no longer ships build-time package managers.** pnpm 11 + verifies the lockfile under explicit build-script policy, npm and Corepack + are removed after migrations are prepared, and linked worktrees stay out of + local Docker build contexts. + +No migrations. No breaking changes. + +## [1.31.4] — 2026-07-21 + +- **Web and background processes now start and recover predictably.** Invalid + encryption-key configuration fails before the service accepts traffic, + worker-only processes report their real health, connection limits are + divided safely across replicas, and provider backfills keep their bounded + queues during reconnects. +- **Published images are reproducible and verified before promotion.** The + release pipeline builds one multi-architecture image, validates its digest, + startup, migrations, health, and reported version, and only then promotes + that exact artifact to production. +- **CI now uses isolated, deterministic test environments.** Integration tests + receive their own database and cryptographic settings before application + code loads, worktrees install hooks consistently, and browser tests use the + intended application process on every platform. +- **Concurrent iOS registrations no longer fail intermittently.** Device and + APNs notification-channel reconciliation now share one database lock and + transaction, so simultaneous first registration remains idempotent. + +No migrations. No breaking changes. + +## [1.31.3] — 2026-07-20 + +- **Document summaries now stay in the language you chose.** The same account + language is used whether a summary is created in the background or requested + from the document sheet. A saved summary remains visible and can be replaced + with **Generate again** after you switch languages. +- **The Coach composer has more room on phones.** The writing field now gets + its own line, with document and conversation actions below it and Send on the + right. The unreliable browser dictation button and its dead-end permission + states are gone. + +No migrations. No breaking changes. + +## [1.31.2] — 2026-07-20 + +- **Provider imports now commit identity and progress atomically.** Withings, + WHOOP, Oura, Polar, Strava, and Apple Health reconcile external and natural + measurement identities under database locks, preserve tombstones, emit + arrivals only for genuine inserts, and advance cursors only after durable + writes. OAuth refresh-token rotation is serialized per user and provider. +- **Webhook and import success now means the source data is durable.** moodLog + writes before acknowledging delivery, while Withings ECG callbacks enter a + durable queue with bounded retry and replay-safe processing. WHOOP backfills + commit bounded chunks without advancing failed resource cursors, and legacy + mood imports use deterministic, collision-free retry identities. Operational + JSON import failures are reported as failures instead of misleading + duplicate skips. +- **Recovery is complete, exact, and owner-safe.** Canonical backups round-trip + every supported record class, including encrypted document content, + measurement reconciliation identities, and tombstones. Restore rejects + account mismatches before writing, validates backup enums at the boundary, + processes stable measurement IDs in bounded batches, and preserves recovery + integrity across both uploaded and off-host payloads. +- **Device and mood edits no longer lose related state.** Same-user APNs + registrations merge transactionally without crossing account boundaries, + refresh-token bindings follow the surviving device, and mood edits preserve + rated factors unless the caller explicitly replaces them. +- **Polar sync follows the current AccessLink collection API.** Collection + requests no longer include the obsolete user ID path segment, and current + activity, Nightly Recharge, cardio-load, sleep, and SpO2 response shapes are + normalized without losing stable measurement identity. +- **The July dependency bundle is included.** PDF.js 6 now releases its loading + task correctly; Canvas 1.0, Radix UI 1.6.2, Tailwind CSS 4.3.3, the Tailwind + Prettier plugin 0.8.1, and setup-node 7 are integrated. + +No migrations. No breaking changes. + +## [1.31.1] — 2026-07-19 + +- **Arrival events now follow committed inserts across every writer.** Provider + re-sync updates stay silent, partial Apple Health imports retain arrivals + from earlier committed batches, all newly inserted workouts emit, and sleep + refreshes distinguish new stages from updates. Web-only processes now retry + their bounded send-only queue connection instead of permanently losing + arrival jobs after one transient startup failure. +- **Reaction and workout generation are single-spend operations.** Durable + database claims close worker races and provider-retry gaps, daily token + reservations are atomic and reconciled on every terminal path, and both + generation and display honor the user's Insights and Workouts module choices + plus server-managed consent. +- **AI context is narrower and more accurate.** Arrival reactions use the + record that actually landed, completed sleep uses the reconstructed night + rather than one stage segment, user-authored lab fields are fenced as data, + workout comparisons use canonical sessions and dates, and every metric and + derived-score coach entry carries its exact supported scope. +- **Dashboard state survives old and partial clients.** Saved comparison range + points and newly supported metric widgets are preserved, while Today + freshness remains aligned across local-day boundaries, DST, tab focus, and + split web/worker deployments. + +Three migrations (0260, 0261, 0262). No breaking changes. + +## [1.31.0] — "The record that reacts" — 2026-07-19 + +A milestone release: the record now visibly responds when something lands, +instead of waiting for a daily rollup to notice. + +- **Today shows what just came in.** A completed night's sleep, a workout, a + first weigh-in of the day — the home screen now carries a calm "just in" + note for a few hours after each one, and at most one written line a day per + kind, grounded in your own numbers. Nothing changes if you run without an + AI provider: the sentence is the garnish, not the feature — the arrival + itself still appears, including on an otherwise-empty record, and an open + dashboard refreshes it as soon as the tab becomes visible again. The + underlying change that makes this possible without new polling or a flood of + background work: every write path that can produce a genuinely new reading + now emits a single typed event, day-scoped and de-duplicated, that a mass + import or a provider re-sync produces none of — a ten-year import or a + month of catch-up sync stays silent, by construction, not by luck. +- **A finished workout gets its own paragraph.** Opening a recent session now + shows a short, grounded read of that one activity — duration, effort, + where it sits against your own recent sessions for the sport — with no + training advice and no target zones. It is generated once, the first time + you open a fresh session, never on re-sync and never for older workouts + already in your record. +- **"Ask why" reaches every read-only card.** Every per-metric status card, + every derived-score page, and a finished workout now carries a way straight + into the coach, pre-loaded with just that card's own data so the + conversation starts already grounded — not with a blank prompt. +- **The lab-marker card and the offline metric texts speak with the same + voice as everything else** — meaning first, in your own language across + all six locales, checked by an automated tone pass that now runs on every + change to these surfaces so this kind of drift gets caught before it ships + again. +- **The dashboard layout stops discarding a comparison choice on save,** and + four clinical widgets stop disappearing from a saved layout — both from a + save made by a client that does not know those particular fields yet. +- **Mood and BMI reach the app's home screen**, and a medication pen can now + carry the maker and printed strength that show up in the native pen list. +- **Every new AI call this release can cause is metered and bounded**: a + daily cap per kind, a duplicate-content check that makes a re-post free, + and a token reservation that is always reconciled, including on every + failure path — the same discipline the rest of the AI surface already + holds to. + +Two migrations this release (0257, 0259); an additional dashboard-widget +migration carried over from the prior line. No breaking changes. + +## [1.30.35] — 2026-07-19 + +- **The metric texts you see without an AI provider now speak your + language.** They shipped in German and English only; French, Spanish, + Italian and Polish fell through to English. These are what nine metric + cards render whenever no provider is reachable — no key configured, an + outage, a spent budget — so for those readers this text was the app's + voice, in the wrong language. Written per language rather than + translated: where a sentence could not be formed naturally, that + language got its own sentence. +- **The lab-marker card leads with what a value means**, like every other + card already did. It was the last one still instructed to state the + number first. +- **The response length for a metric assessment is set in one place** + again, instead of one card carrying its own inline pair. +- **The fallback texts open on something honest** rather than on an + instruction, and the lab-marker card no longer shows the multi-metric + overview tip on a single value. + +No migrations. No breaking changes. + +## [1.30.34] — 2026-07-19 + +- **Rearranging your dashboard no longer wipes the comparison baseline.** + A layout save from a client that does not know about that setting — the + app never sends it — silently reset it to "none". Every field of the + layout now has to declare whether an absent value means "clear it" or + "leave it alone", so the next field added cannot repeat this. +- **Four clinical widgets stopped disappearing from a saved layout.** + Pain, grip strength, waist and waist-to-height were dropped by the save + as unknown, so a placed widget was lost every time. +- **Mood and BMI reach the app's home screen.** Both existed in the + record but were absent from the summary the app reads, so those two + tiles could not be shown while every other one worked. +- **A pen carries its manufacturer and printed strength.** Without them, + containers entered on the web could not be shown in the app's pen list. +- **Destructive controls ask first.** Signing every other device out, + revoking a session, and revoking a trusted device fired on a single + tap. A sweep found two more with neither a confirmation nor an undo: a + Coach reminder and a travel entry. Deleting a document still does not + ask — it can be undone, which covers the mis-tap. +- **Delete confirmations stop overclaiming.** Four of them promised the + deletion could not be undone while the same screen offered an undo. + Warnings that are wrong make the ones that are right easier to ignore. +- **The sleep-debt figure is described correctly again.** The calculation + changed to a running balance that credits surplus in v1.19.0; the label + still said "cumulative shortfall". The wrong description had spread to + the Coach, which was reasoning about the number on the old definition, + and to the published API description. +- **The glucose reference band can be chosen on the web.** The setting + already worked and already fed the Coach — nothing on the web set it. + It selects a reference range; it is not a diagnosis and is never + inferred from a reading. + +One migration (0256), additive. No breaking changes. + ## [1.30.33] — 2026-07-19 Security release. It ships on its own because it changes how a browser diff --git a/Dockerfile b/Dockerfile index aad0d0b4c..dca41b4ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,15 @@ # ── Stage 1: Dependencies ────────────────────────────────── -FROM node:22-alpine AS deps -RUN corepack enable && corepack prepare pnpm@latest --activate +FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps +RUN corepack enable && corepack prepare pnpm@11.15.1 --activate WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY scripts/prepare.mjs scripts/prepare.mjs RUN pnpm install --frozen-lockfile --prod=false # ── Stage 2: Build ───────────────────────────────────────── -FROM node:22-alpine AS builder -RUN corepack enable && corepack prepare pnpm@latest --activate +FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder +RUN corepack enable && corepack prepare pnpm@11.15.1 --activate WORKDIR /app # v1.4.43 B11 — version build-arg threaded into the layer cache key. @@ -45,17 +46,15 @@ ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm build # ── Stage 3: Production runner ───────────────────────────── -FROM node:22-alpine AS runner +FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner # `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware # timestamp formatting) resolve to the actual offset instead of silently # falling back to UTC on Alpine images that ship without it. -RUN apk add --no-cache tzdata && \ - corepack enable && corepack prepare pnpm@latest --activate +RUN apk add --no-cache tzdata WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -ENV NODE_PATH="/opt/pg-boss/node_modules" ENV TZ=Europe/Berlin # v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage @@ -80,6 +79,10 @@ RUN addgroup --system --gid 1001 nodejs && \ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# Next's standalone output must carry the externalized worker dependencies. +# Prisma's pure-JS adapter stays bundled; only modules loaded through native +# Node resolution belong in this runtime assertion. +RUN node -e "require.resolve('pg-boss'); require.resolve('pg')" # @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the # native binary's package into the standalone tree but NOT the pnpm symlinks that @@ -123,20 +126,19 @@ COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/src/generated ./src/generated -# Install all worker runtime dependencies in a shared prefix. -# NODE_PATH makes them available to Node.js module resolution. -# These packages are listed in next.config.ts serverExternalPackages -# and are NOT bundled by Next.js — they must exist at runtime. -RUN mkdir -p /opt/pg-boss && \ - cd /opt/pg-boss && \ - npm init -y && \ - npm install --omit=dev pg-boss@12.18 @prisma/adapter-pg@7.8 pg@8.20 - -# Install Prisma CLI + engines for runtime migrations (isolated from Next standalone tree) +# Install the migration CLI, its config dependency, and a pinned launcher for +# the maintenance scripts already shipped in the standalone tree. The Prisma +# config loads dotenv from /app, so expose the isolated copy there before +# stripping npm/Corepack and their caches from the runtime surface. RUN mkdir -p /opt/prisma-cli && \ cd /opt/prisma-cli && \ npm init -y && \ - npm install --omit=dev prisma@7.8 @prisma/engines@7.8 + npm install --omit=dev prisma@7.8 @prisma/engines@7.8 tsx@4.23.1 dotenv@17.4.2 && \ + ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \ + ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \ + ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \ + rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \ + rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx # v1.4.27 B3 — offline GeoLite2 databases for IP→location and IP→ASN # lookups. The MMDB files live in `/opt/geolite2/` and are read by diff --git a/bundle-budget.json b/bundle-budget.json index 80ad488e7..124e100a7 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,11 +1,11 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.27.15 + the mobile/PWA fix wave: / 417, /insights 402, /measurements 405, /insights/mood 419, total 2683, baseline 129 — budgets carry ~10-15% headroom so the gate catches step changes (a statically imported ~100 KB catalog, a ~90 KB duplicated library chunk), not noise. Raise a number consciously, in the PR that pays the cost, with the reason in the PR body.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.32.0 after the correctness/UX architecture wave: / 425, /insights 411, /measurements 412, /insights/mood 428, total 2982, baseline 129. Route caps retain focused headroom; the aggregate cap allows normal chunk noise while still catching a new ~90 KB duplicated library. Message catalogs and duplicate Recharts chunks have dedicated structural guards.", "routesKbGz": { "/page": 460, "/insights/page": 445, "/measurements/page": 445, "/insights/mood/page": 460 }, - "totalClientKbGz": 2950, + "totalClientKbGz": 3050, "maxRechartsChunks": 1 } diff --git a/docker-compose.yml b/docker-compose.yml index 469ab0a13..1987d0311 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -227,7 +227,7 @@ services: test: [ "CMD-SHELL", - "wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/version || exit 1", + "wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1", ] interval: 30s timeout: 5s @@ -245,7 +245,7 @@ services: # dockerfile: Dockerfile # container_name: healthlog-worker # environment: - # DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public" + # DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}" # NODE_ENV: production # ENCRYPTION_KEY: "${ENCRYPTION_KEY}" # ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 968c88d10..3386a8879 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -9,7 +9,7 @@ set -e # Validating here guarantees a clear, deploy-platform-agnostic # failure message before we touch the database. missing="" -for var in DATABASE_URL ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do +for var in DATABASE_URL API_TOKEN_HMAC_KEY; do eval "value=\$$var" if [ -z "$value" ]; then missing="$missing $var" @@ -21,6 +21,48 @@ if [ -n "$missing" ]; then exit 1 fi +# ENCRYPTION_KEYS supersedes the legacy single key, so validate the same +# fail-closed shape the runtime crypto loader accepts before touching the DB. +if ! node -e ' + const validKey = (value) => { + if (typeof value !== "string") return false; + if (/^[0-9a-fA-F]{64}$/.test(value)) return true; + if (!/^[A-Za-z0-9+/=]+$/.test(value)) return false; + try { return Buffer.from(value, "base64").length === 32; } + catch { return false; } + }; + const rawKeyring = (process.env.ENCRYPTION_KEYS || "").trim(); + if (rawKeyring) { + let keyring; + try { keyring = JSON.parse(rawKeyring); } + catch { throw new Error("ENCRYPTION_KEYS is invalid JSON"); } + if (!keyring || typeof keyring !== "object" || Array.isArray(keyring)) { + throw new Error("ENCRYPTION_KEYS is invalid: expected an object map"); + } + const entries = Object.entries(keyring); + if ( + entries.length === 0 || + entries.some(([id, value]) => + !/^[A-Za-z0-9_-]{1,32}$/.test(id) || !validKey(value) + ) + ) { + throw new Error("ENCRYPTION_KEYS is invalid: every entry must be a valid 32-byte key"); + } + const activeId = + (process.env.ENCRYPTION_ACTIVE_KEY_ID || "").trim() || + (entries.length === 1 ? entries[0][0] : ""); + if (!activeId || !Object.prototype.hasOwnProperty.call(keyring, activeId)) { + throw new Error("ENCRYPTION_ACTIVE_KEY_ID must name an entry in ENCRYPTION_KEYS"); + } + } else if (!validKey((process.env.ENCRYPTION_KEY || "").trim())) { + throw new Error("ENCRYPTION_KEY is missing or invalid"); + } +'; then + echo "HealthLog: ERROR — encryption configuration is invalid." >&2 + echo "HealthLog: set a valid ENCRYPTION_KEY or ENCRYPTION_KEYS with ENCRYPTION_ACTIVE_KEY_ID." >&2 + exit 1 +fi + # ── Wait for PostgreSQL ────────────────────────────────────── MAX_RETRIES=30 RETRY=0 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 11374f413..0349d5593 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.33 + version: 1.32.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -3054,11 +3054,11 @@ paths: - Measurements summary: Sleep rhythm — sleep-debt + chronotype (v1.17.0) description: "Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same - canonical night reconstruction the Sleep Score uses: cumulative `sleepDebt` over the rolling window (calm - `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` - state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone - (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement - type." + canonical night reconstruction the Sleep Score uses: the outstanding `sleepDebt` balance over the rolling window + (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, + `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the + user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no + new measurement type." responses: "200": description: Sleep-debt + chronotype DTO. @@ -3126,10 +3126,12 @@ paths: summary: Workout detail (v1.4.32) description: Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive - enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`, reserved `aiInsight`) are computed - server-side. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and - `route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32 - contract. Cross-user rows surface as 404 (existence channel sealed). + enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`) are computed server-side. `aiInsight` is a + pure read of a per-workout paragraph written by a background job when the workout landed; it is null for every + workout that predates the feature or was re-synced, and reading this endpoint never generates one. `compact=1` + (sent by the web client) drops the raw `samples.samples` HR blob and `route.sampleTimestamps` array from the + response; without it the payload is byte-identical to the v1.4.32 contract. Cross-user rows surface as 404 + (existence channel sealed). parameters: - in: path name: id @@ -5671,6 +5673,9 @@ paths: type: string minLength: 1 maxLength: 500 + workoutId: + type: string + maxLength: 64 required: - message responses: @@ -10309,13 +10314,13 @@ paths: post: tags: - Documents - summary: Summarise or transcribe a document (session-only) - description: 'On-demand, SESSION-ONLY description of a stored document. `?mode=summary` (default) returns a short - plain-language summary of WHAT the document is; `?mode=text` returns its raw transcribed text. The result is - transient — nothing is persisted except the AI-budget ledger + audit log; it never reaches coach memory, - snapshots, the structured stores, or the search index. The summary is descriptive only and never a diagnosis. - Same VISION (empty body) / TEXT (`application/json` `{ mode: "text", text }`, opt-in local OCR) dispatch as - extract. AI-consent / rate / budget gated. 422 with no provider.' + summary: Summarise or transcribe a document + description: 'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language + summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, + while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw + transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The + summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: + "text", text }`, opt-in local OCR) dispatch as extract. AI-consent / rate / budget gated. 422 with no provider.' parameters: - name: id in: path @@ -10332,6 +10337,20 @@ paths: - text default: summary description: "`summary` (plain-language description) or `text` (raw)." + - name: persist + in: query + required: false + schema: + type: boolean + default: false + description: "For summary mode only: persist into an empty stored-summary slot." + - name: replace + in: query + required: false + schema: + type: boolean + default: false + description: With `persist=true`, explicitly replace an existing stored summary. requestBody: required: false content: @@ -10361,7 +10380,7 @@ paths: - text responses: "200": - description: The session-only summary (`{ summary }`) or extracted text (`{ text }`). + description: The summary and its persistence outcome, or transient extracted text. content: application/json: schema: @@ -12648,6 +12667,19 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + manufacturer: + description: Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars. + anyOf: + - type: string + maxLength: 120 + - type: "null" + doseStrength: + description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split + into a number + unit because pens state strength per dose, per ml, or per cartridge. + anyOf: + - type: string + maxLength: 60 + - type: "null" notes: anyOf: - type: string @@ -12681,6 +12713,19 @@ components: type: number minimum: 0 maximum: 1000 + manufacturer: + description: Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars. + anyOf: + - type: string + maxLength: 120 + - type: "null" + doseStrength: + description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split + into a number + unit because pens state strength per dose, per ml, or per cartridge. + anyOf: + - type: string + maxLength: 60 + - type: "null" notes: anyOf: - type: string @@ -17710,6 +17755,10 @@ components: type: integer minimum: 0 maximum: 9007199254740991 + failed: + type: integer + minimum: 0 + maximum: 9007199254740991 skipped: type: array items: @@ -17734,6 +17783,7 @@ components: - inserted - updated - duplicates + - failed - skipped - entries additionalProperties: false @@ -17751,9 +17801,10 @@ components: - updated - duplicate - skipped - description: "`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate (per-day - cumulative total or hourly heart-rate bucket) overwrote an existing row. `skipped` — not stored (e.g. - unmappable identifier, out-of-range value, or `malformed_hr_bucket_id`); see `reason`." + - failed + description: "`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate overwrote + an existing row. `skipped` — validation no-op; see `reason`. `failed` — retryable database failure that must + not advance the entry cursor; the response is marked `Cache-Control: no-store`." reason: type: string required: @@ -18237,12 +18288,13 @@ components: - partial - ready description: "`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can - assert a cumulative debt." + assert a balance." debtMinutes: type: integer minimum: 0 maximum: 9007199254740991 - description: Cumulative sleep debt over the window, in minutes, after caps. + description: Outstanding sleep-debt balance over the window, in minutes, after caps. A night below need adds its + shortfall; a night above need pays the balance down (floored at 0). needMinutes: type: integer minimum: 0 @@ -18383,9 +18435,9 @@ components: - chronotype - averagePerNight additionalProperties: false - description: "Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype - (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work - calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type." + description: "Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ + chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone + (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type." ListWorkoutsResponse: type: object properties: @@ -18648,7 +18700,9 @@ components: - $ref: "#/components/schemas/WorkoutSportContext" - type: "null" aiInsight: - type: "null" + anyOf: + - $ref: "#/components/schemas/WorkoutActivityInsight" + - type: "null" canonicalId: type: string required: @@ -18847,6 +18901,19 @@ components: - avgDistanceM - avgAvgHr additionalProperties: false + WorkoutActivityInsight: + type: object + properties: + paragraph: + type: string + generatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - paragraph + - generatedAt + additionalProperties: false BatchWorkoutsResponse: type: object properties: @@ -19727,6 +19794,18 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + manufacturer: + anyOf: + - type: string + - type: "null" + description: Marketing-authorisation holder / maker as printed on the carton. NULL for a container registered before the + field existed, and for plain supply rows that never carried one. + doseStrength: + anyOf: + - type: string + - type: "null" + description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, not split into a number + unit — pens + state strength per dose, per ml, or per cartridge. NULL when unknown. notes: anyOf: - type: string @@ -19751,6 +19830,8 @@ components: - expiresAt - printedExpiry - purchasedAt + - manufacturer + - doseStrength - notes - createdAt - updatedAt @@ -25250,6 +25331,28 @@ components: anyOf: - type: string - type: "null" + dueCandidates: + type: array + items: + type: object + properties: + medicationId: + type: string + medicationName: + type: string + dueAt: + type: string + overdue: + type: boolean + availableFrom: + type: string + required: + - medicationId + - medicationName + - dueAt + - overdue + - availableFrom + additionalProperties: false required: - activeCount - scheduledToday @@ -25260,12 +25363,11 @@ components: - nextDueMedicationName - nextDueMedicationId additionalProperties: false - description: "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and - the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot - (anchor passed, still inside its catch-up band). `nextDueMedicationId` deep-links a consumer straight to the - overdue medication's card. Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past - with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day - summary, never an overdue state." + description: "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), every + active medication's current display-due candidate, and legacy scalar fields projected from candidate zero. + `overdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band); `availableFrom` + is the canonical cadence- and dose-window-derived attribution-band start. `dueCandidates` is optional so + older cached snapshots remain valid. Medication ids let consumers deep-link straight to the relevant card." healthScore: anyOf: - type: object @@ -30629,6 +30731,12 @@ components: type: string text: type: string + persistence: + type: string + enum: + - stored + - withheld + - failed additionalProperties: false DocumentIndexEnvelope: type: object @@ -31663,6 +31771,37 @@ components: items: $ref: "#/components/schemas/DailyPriorityItem" description: Bounded 0–3 rail items, never padded. + justIn: + anyOf: + - type: object + properties: + kind: + type: string + enum: + - sleep_night + - workout + - weight + - blood_pressure + - labs_panel + description: Closed arrival kind that landed. + at: + type: string + description: ISO-8601 instant of the newest sample. NEVER pre-formatted server-side — the client formats it in its own + locale and timezone. + required: + - kind + - at + additionalProperties: false + - type: "null" + description: The day's newest data arrival while it is still news (under three hours old), else null. Additive since + v1.31.0. + reactionLine: + anyOf: + - type: string + - type: "null" + description: One-sentence generated reaction to that arrival, standing for the rest of the local day. Null whenever no + line was generated (no provider, no consent, budget exhausted, or generation failed) — consumers fall back + to `briefingLead` / `line`. Additive since v1.31.0. required: - generatedAt - phase @@ -31672,6 +31811,8 @@ components: - briefingLead - line - worthALook + - justIn + - reactionLine additionalProperties: false description: The one data spine of the daily-value system — composed from already-cached data, never a fresh AI call. Consumed by the Today surface, the daily push, and a future iOS widget. diff --git a/docs/integrations/apple-health.md b/docs/integrations/apple-health.md index 4d80057fc..0b3c4209d 100644 --- a/docs/integrations/apple-health.md +++ b/docs/integrations/apple-health.md @@ -112,29 +112,50 @@ mapped set (workout routes, ECG voltage traces) is counted in ### Cumulative HK type → daily aggregate Cumulative quantity types — steps, active energy, walking/running -distance, flights climbed — get **collapsed into one row per -user-local day** to match the iOS daily-aggregation convention. +distance, flights climbed, time in daylight, and fall count — get +**collapsed into one estimated row per user-local day**. `export.xml` +contains source-specific records, not HealthKit's canonical daily +statistics, so the importer does not sum different devices or apps +into a source-blind total. +- Records are grouped by type, local day, and a SHA-256 hash of the + available source name, source version, and device attributes. + Values sum within each identified source-day; the largest + source-day subtotal is selected deterministically. If every source + attribute is absent, records remain separate rather than being + trusted as one source. Raw source and device labels are not + persisted. +- The row records `EXPORT_XML_SOURCE_MAX` provenance, its distinct + contributor count, and the selected non-identifying source hash. - External ID format: `stats::` (e.g. `stats:HKQuantityTypeIdentifierStepCount:2026-05-15`). - The day boundary respects the user's timezone preference. The worker defaults to `Europe/Berlin` when no preference is set - (`src/lib/jobs/apple-health-import-worker.ts:132-133`). -- A second import of the same archive re-folds the same days onto - the same external IDs — the values update in place rather than - duplicating. + (`src/lib/jobs/apple-health-import-worker.ts`). +- XML intervals are not reconstructed or split. A record crossing + midnight is assigned using its mapped timestamp and remains covered + by the explicit estimate contract. +- A second import re-folds the same days onto the same external IDs. + Equal-authority estimates update idempotently rather than duplicate. + +The terminal job result exposes +`cumulativeEstimates: { days, rows }`; the web import card warns when +one or more cumulative days used this estimate path. ### Re-import idempotency The two-axis idempotency story: 1. **File-level** — re-uploading the same `export.zip` (same SHA-256 - of bytes) short-circuits to the previous job before the parser - even starts. + of bytes) short-circuits to the previous job only when that job + used the current parser revision. Existing jobs are revision 1; + new jobs use revision 2, so a pre-fix successful archive can be + deliberately processed again under the corrected aggregation. 2. **Record-level** — when an older `export.zip` is re-exported from iOS (same history, slightly newer device timestamps), every - record's external ID stays stable. The upsert path matches on - `(userId, externalId)` and updates instead of inserting. + record's external ID stays stable. The serialized reconciliation + path matches the canonical identity and updates instead of + inserting. Practically: a user who exports monthly and re-imports each archive sees fresh data merged in without duplicates. The job result line @@ -150,14 +171,34 @@ is identical to the user-facing flow. The `ImportJob` row carries `triggeredByAdminId = admin.id` so the status endpoint admits both the target user and the triggering -admin. Idempotency is scoped by target user — an admin re-uploading -the same archive for the same user resolves to the previous job. +admin. Idempotency is scoped by target user and parser revision — an +admin re-uploading the same archive for the same user resolves to the +previous current-revision job. Typical use: migrating a friend's history into their HealthLog account when they cannot run the import themselves, or backfilling a household member's data after the admin received the `export.zip` out of band. +## Aggregate authority and recovery + +Native HealthKit daily statistics submitted by the iOS batch endpoint +are canonical and carry `HEALTHKIT_STATISTICS` provenance. XML +source-maximum estimates carry `EXPORT_XML_SOURCE_MAX`. Both use the +same serialized reconciler with this ordering: + +`LEGACY_UNKNOWN < EXPORT_XML_SOURCE_MAX < HEALTHKIT_STATISTICS` + +A later XML import cannot overwrite native statistics. Native +statistics repair an XML estimate or legacy row in either arrival +order, and every material reconciled update or resurrection increments +`syncVersion`. Existing Apple Health `stats:*` rows are migrated to +`LEGACY_UNKNOWN`; they are never guessed or heuristically promoted. +To recover an already-corrupted legacy total, the user must re-upload +the original archive under parser revision 2 or sync native HealthKit +statistics. Without either input, the correct per-source total cannot +be reconstructed from the stored aggregate. + ## Source-priority interaction HealthLog's source ladder is **APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ @@ -169,12 +210,12 @@ max). The defaults live in `src/lib/validations/source-priority.ts:205-220`. Concrete consequences: -- If you already sync steps from a Withings ScanWatch and also import - Apple Health, the Apple-Health-aggregated stream wins for the day — - iOS HealthKit folds the watch sensor + iPhone motion data into a - single canonical stream, so it is the most complete cumulative - source. The Withings rows stay in the database as an audit trail - but drop out of the per-day aggregation. +- Native HealthKit cumulative statistics are the canonical Apple + Health stream for a day and outrank both XML estimates and Withings + cumulative rows. An XML upload chooses the largest source subtotal + only; it never claims to reproduce HealthKit's device de-duplication. + Lower-priority rows stay in the database as an audit trail but drop + out of the displayed per-day aggregation. - If you weigh yourself on a Withings scale and also import Apple Health (which received the same reading via the Health Mate iOS app), the Withings row wins for display. Apple Health is diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index e96ce18a2..1d318164e 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -84,9 +84,9 @@ pnpm dlx tsx scripts/restore-backup.ts \ /tmp/restored.json ``` -The production standalone image strips `tsx`, so a bare -`pnpm tsx scripts/...` fails inside the container — always invoke -one-shot scripts via `pnpm dlx tsx`. +Run this command from a source checkout with the production backup variables +exported. The script imports the full application dependency graph, which the +minimal production image does not expose as an operator scripting environment. The script downloads the object, decrypts it, and writes the JSON dump to disk. Importing the JSON back into a HealthLog instance is left to diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md index ef77cb16c..c1a16cdc8 100644 --- a/docs/ops/intake-repair.md +++ b/docs/ops/intake-repair.md @@ -46,24 +46,23 @@ metadata (`medication_schedules`): Always. The default mode only reports and never mutates: ```bash -# inside the app container (or any checkout) — DATABASE_URL must point -# at the target database -pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts +# inside the production app container — DATABASE_URL is inherited +healthlog-tsx scripts/repair-intake-anomalies.ts # scoped to one account -pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user +healthlog-tsx scripts/repair-intake-anomalies.ts --user + +# source-checkout equivalent +pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts ``` The script is self-contained: its only import is `pg` and it reads `DATABASE_URL` straight from the environment (no dotenv, no `@/` path -alias, no `src/` imports). That matters in the production container — -the standalone image ships no project `node_modules`, so the previous -revision died there with `Cannot find module 'dotenv/config'`. Inside -the image `pg` resolves via `NODE_PATH=/opt/pg-boss/node_modules`; in a -checkout it resolves from the project `node_modules`; the -`--package pg --package tsx` pins cover any environment with neither. -Use `pnpm dlx`, not bare `pnpm tsx` — the standalone image also strips -`tsx`. +alias, no `src/` imports). The production image provides the pinned +`healthlog-tsx` launcher and resolves `pg` from its standalone runtime tree +without retaining a package manager. In a source checkout, `pg` resolves from +the project `node_modules`; `pnpm dlx --package pg --package tsx ...` also +works in an environment where neither dependency is installed. The dry-run prints every duplicate group (which row would be kept, which would be tombstoned), a table of implausible rows (id, medication, @@ -74,7 +73,7 @@ would write), and a table of stale-anchor pending rows. ## Applying the fix ```bash -pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix +healthlog-tsx scripts/repair-intake-anomalies.ts --fix ``` `--fix` does five things: @@ -92,7 +91,7 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix in explicitly: ```bash - pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible + healthlog-tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible ``` `--tombstone-implausible` is refused without `--fix`. @@ -139,7 +138,7 @@ slot anchors: The default run only **reports** the proposals. Apply them explicitly: ```bash -pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras +healthlog-tsx scripts/repair-intake-anomalies.ts --backfill-eras ``` Review the dry-run table first — the inference is a heuristic. A wrongly diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index fa433f35a..1fc1382d3 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -1,148 +1,887 @@ import AxeBuilder from "@axe-core/playwright"; -import { expect, test } from "@playwright/test"; +import { + expect, + test, + type Locator, + type Page, + type Route, +} from "@playwright/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot"; -/** - * Accessibility regression gate — fails the build if any WCAG 2.1 AA - * violation lands in the `serious` or `critical` bucket. v1.5 phase-5 - * re-enables the `/admin` surface that phase-3 had parked while phase-4b - * restructured the admin into per-section dynamic routes: - * - * - `/auth/login` (public) - * - `/` (dashboard, authenticated) - * - `/settings/integrations` (authenticated) - * - `/admin` (admin overview — ``) - * - `/admin/system-status` (representative admin sub-route) - * - `/admin/users` (admin users management — icon-only buttons) - * - * The seeded user (`e2e-tester`) is provisioned with `role: ADMIN` in - * `e2e/setup/global-setup.ts` so the same fixture covers both regular - * and admin surfaces without a second user. - */ +type AxeViolation = Awaited< + ReturnType +>["violations"][number]; -function reportBlocking( - blocking: Awaited>["violations"], -) { +type Theme = "light" | "dark"; + +type RouteCase = { + name: string; + path: string; + painted: (page: Page) => Locator; +}; + +const THEMES: readonly Theme[] = ["light", "dark"]; +const WCAG_TAGS: Record = { + wcag2a: true, + wcag2aa: true, + wcag21a: true, + wcag21aa: true, +}; +const SEMANTIC_RULES: Record = { + "heading-order": true, + "page-has-heading-one": true, + "landmark-one-main": true, + "landmark-unique": true, + region: true, +}; +const A11Y_WORKOUT_ID = "a11y-workout"; +const A11Y_CONVERSATION_ID = "a11y-conversation"; +const A11Y_DOCUMENT_ID = "a11y-document"; + +const fixedMeasurementRows = Array.from({ length: 10 }, (_, index) => ({ + id: `a11y-measurement-${index}`, + type: "WEIGHT", + value: 78.5 + (index % 3) - 1, + measuredAt: `2026-07-${String(20 - index).padStart(2, "0")}T08:00:00.000Z`, + source: "MANUAL", + notes: null, +})); + +const workoutListEntry = { + id: A11Y_WORKOUT_ID, + sportType: "running", + startedAt: "2026-07-20T07:00:00.000Z", + endedAt: "2026-07-20T07:30:00.000Z", + durationSec: 1800, + distanceM: 5200, + activeEnergyKcal: 320, + avgHr: 145, + maxHr: 170, + source: "APPLE_HEALTH", + externalId: "a11y-workout-external", + hasRoute: true, + hasHrSeries: true, +}; + +const workoutDetail = { + ...workoutListEntry, + minHr: 110, + stepCount: 5800, + elevationM: 12.5, + pauseDurationSec: null, + metadata: null, + route: { + geometry: { + type: "LineString", + coordinates: Array.from({ length: 20 }, (_, index) => [ + 11 + index * 0.0004, + 50 + index * 0.0003, + ]), + }, + sampleTimestamps: null, + }, + samples: { sampleCount: 20, samples: null }, + hrSeries: { + source: "pulse_window", + bucketSec: 8, + points: [ + { tSec: 0, mean: 130, min: 125, max: 135 }, + { tSec: 8, mean: 140, min: 135, max: 145 }, + { tSec: 16, mean: 150, min: 145, max: 155 }, + ], + envelope: false, + }, + zones: { + model: "tanaka", + hrMax: 180, + zones: [ + { zone: 1, lowBpm: 90, highBpm: 108, seconds: 300 }, + { zone: 2, lowBpm: 109, highBpm: 126, seconds: 420 }, + { zone: 3, lowBpm: 127, highBpm: 144, seconds: 540 }, + { zone: 4, lowBpm: 145, highBpm: 162, seconds: 360 }, + { zone: 5, lowBpm: 163, highBpm: null, seconds: 180 }, + ], + }, + splits: [ + { km: 1, durationSec: 340, paceSecPerKm: 340 }, + { km: 2, durationSec: 345, paceSecPerKm: 345 }, + ], + sportContext: { + count: 8, + avgDurationSec: 2040, + avgDistanceM: 5800, + avgAvgHr: 148, + }, + aiInsight: null, + canonicalId: A11Y_WORKOUT_ID, +}; + +const medication = { + id: "a11y-medication", + name: "A11y medication", + dose: "10 mg", + category: "OTHER", + active: true, + notificationsEnabled: true, + pausedAt: null, + lastTakenAt: null, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [ + { + id: "a11y-medication-schedule", + windowStart: "08:00", + windowEnd: "09:00", + label: null, + dose: null, + daysOfWeek: null, + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + reminderGraceMinutes: null, + }, + ], +}; + +const inboundDocument = { + id: A11Y_DOCUMENT_ID, + kind: "LAB_RESULT", + title: "A11y blood panel", + filename: "a11y-blood-panel.txt", + mimeType: "text/plain", + byteSize: 2048, + status: "CONFIRMED", + providerType: "mock", + reportDate: "2026-07-19", + documentDate: "2026-07-19", + errorReason: null, + factCount: 0, + pendingCount: 0, + conditionLinks: [], + servingClass: "attachment", + hasContentIndex: true, + contentIndexSource: "vision", + hasThumbnail: false, + createdAt: "2026-07-19T12:00:00.000Z", + updatedAt: "2026-07-19T12:00:00.000Z", +}; + +function fulfilJson(route: Route, data: unknown) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +function reportBlocking(label: string, blocking: AxeViolation[]) { if (blocking.length === 0) return; - // Pretty-print so failures are actionable in CI logs. console.log( - "axe violations:\n" + - blocking - .map( - (v) => - ` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.length} node(s)\n ${v.helpUrl}`, - ) - .join("\n"), + `axe violations for ${label}:\n${blocking + .map( + (violation) => + ` - [${violation.impact}] ${violation.id}: ${violation.help}\n` + + ` ${violation.helpUrl}\n` + + violation.nodes + .map( + (node, index) => + ` node ${index + 1}: ${node.target.join(" ")}\n` + + ` ${node.failureSummary ?? "No failure summary supplied."}\n` + + ` html: ${node.html}`, + ) + .join("\n"), + ) + .join("\n")}`, ); } -async function runAxe(page: import("@playwright/test").Page) { +async function runAxe(page: Page): Promise { const results = await new AxeBuilder({ page }) - .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "best-practice"]) .analyze(); - return results.violations.filter( - (v) => v.impact === "serious" || v.impact === "critical", + + return results.violations.filter((violation) => { + if (SEMANTIC_RULES[violation.id]) { + return ( + violation.impact === "moderate" || + violation.impact === "serious" || + violation.impact === "critical" + ); + } + const isWcagRule = violation.tags.some((tag) => WCAG_TAGS[tag]); + return ( + isWcagRule && + (violation.impact === "serious" || violation.impact === "critical") + ); + }); +} + +async function useExplicitTheme(page: Page, theme: Theme) { + await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" }); + await page.addInitScript((explicitTheme: Theme) => { + window.localStorage.setItem("healthlog-theme", explicitTheme); + }, theme); +} + +async function expectTheme(page: Page, theme: Theme) { + await expect(page.locator("html")).toHaveClass( + new RegExp(`(^|\\s)${theme}(\\s|$)`), + ); + await expect + .poll(() => page.evaluate(() => localStorage.getItem("healthlog-theme"))) + .toBe(theme); +} + +async function scanPaintedState( + page: Page, + theme: Theme, + label: string, + painted: Locator, +): Promise { + await expect(painted).toBeVisible({ timeout: 15_000 }); + await page.evaluate(async () => { + const finiteAnimations = document + .getAnimations() + .filter( + (animation) => animation.effect?.getTiming().iterations !== Infinity, + ); + const { promise: animationTimeout, resolve } = + Promise.withResolvers(); + window.setTimeout(resolve, 500); + await Promise.race([ + Promise.allSettled( + finiteAnimations.map((animation) => animation.finished), + ), + animationTimeout, + ]); + }); + await expectTheme(page, theme); + const blocking = await runAxe(page); + reportBlocking(`${theme} ${label}`, blocking); + return blocking; +} + +function expectNoViolations( + theme: Theme, + label: string, + blocking: AxeViolation[], +) { + expect(blocking, `${theme} ${label} accessibility violations`).toHaveLength( + 0, ); } +async function visitAndScan( + page: Page, + theme: Theme, + routeCase: RouteCase, +): Promise { + return test.step(`${theme} ${routeCase.name}`, async () => { + await page.goto(routeCase.path, { waitUntil: "domcontentloaded" }); + return scanPaintedState( + page, + theme, + routeCase.name, + routeCase.painted(page), + ); + }); +} + +async function installA11yMocks(page: Page) { + await page.route(/\/api\/analytics(\?|$)/, (route) => + fulfilJson(route, { + summaries: POPULATED_SUMMARIES, + bpInTargetPct: 80, + glucoseByContext: {}, + }), + ); + + await page.route("**/api/mood/analytics", (route) => + fulfilJson(route, { + entries: [{ date: "2026-07-20", score: 4 }], + summary: { count: 1 }, + }), + ); + + await page.route("**/api/mood/insights", (route) => + fulfilJson(route, { + summary: { count: 1 }, + tagBreakdown: [], + trends: [], + }), + ); + + await page.route("**/api/mood/tags*", (route) => + fulfilJson(route, { groups: [], tags: [] }), + ); + + await page.route("**/api/mood-entries*", (route) => + fulfilJson(route, { + entries: [ + { + id: "a11y-mood", + date: "2026-07-20", + mood: "GUT", + score: 4, + tags: [], + tagKeys: [], + source: "MANUAL", + moodLoggedAt: "2026-07-20T18:30:00.000Z", + note: null, + }, + ], + meta: { total: 1 }, + }), + ); + + await page.route(/\/api\/measurements\/series-batch(\?|$)/, (route) => { + const url = new URL(route.request().url()); + const types = (url.searchParams.get("types") ?? "WEIGHT").split(","); + const series = Object.fromEntries( + types.map((type) => [ + type, + fixedMeasurementRows.map((row) => ({ + type, + value: row.value, + measuredAt: row.measuredAt, + count: 1, + })), + ]), + ); + return fulfilJson(route, { series }); + }); + + await page.route("**/api/measurements*", (route) => + fulfilJson(route, { + measurements: fixedMeasurementRows, + meta: { total: fixedMeasurementRows.length }, + }), + ); + + await page.route("**/api/insights/comprehensive", (route) => + fulfilJson(route, { + totalMeasurements: fixedMeasurementRows.length, + moodSummary: { count: 1 }, + }), + ); + + await page.route("**/api/workouts**", (route) => { + const url = new URL(route.request().url()); + if (url.pathname === `/api/workouts/${A11Y_WORKOUT_ID}`) { + return fulfilJson(route, workoutDetail); + } + return fulfilJson(route, { + workouts: [workoutListEntry], + meta: { + total: 1, + limit: Number(url.searchParams.get("limit") ?? 100), + offset: Number(url.searchParams.get("offset") ?? 0), + droppedDuplicates: 0, + }, + }); + }); + + await page.route(/\/api\/insights\/chat(?:\/[^/?]+)?(?:\?.*)?$/, (route) => { + const url = new URL(route.request().url()); + if (url.pathname === `/api/insights/chat/${A11Y_CONVERSATION_ID}`) { + return fulfilJson(route, { + id: A11Y_CONVERSATION_ID, + title: "A11y evidence conversation", + createdAt: "2026-07-20T09:00:00.000Z", + updatedAt: "2026-07-20T09:01:00.000Z", + messageCount: 2, + fenced: false, + attachments: [], + documentTitle: null, + attachmentCount: 0, + summary: null, + messages: [ + { + id: "a11y-coach-user-message", + role: "user", + content: "How is my blood pressure trending?", + createdAt: "2026-07-20T09:00:00.000Z", + metricSource: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }, + { + id: "a11y-coach-assistant-message", + role: "assistant", + content: "Your recent readings are steady.", + createdAt: "2026-07-20T09:01:00.000Z", + metricSource: { + windows: ["last30days"], + metrics: ["bp", "pulse"], + counts: { bp: 12, pulse: 14 }, + keyValues: [ + { + label: "Average blood pressure", + value: "124/80", + unit: "mmHg", + window: "last 30 days", + }, + ], + }, + providerType: "mock", + promptVersion: "a11y", + tokensUsed: 42, + model: "mock", + }, + ], + }); + } + return fulfilJson(route, { + conversations: [ + { + id: A11Y_CONVERSATION_ID, + title: "A11y evidence conversation", + createdAt: "2026-07-20T09:00:00.000Z", + updatedAt: "2026-07-20T09:01:00.000Z", + messageCount: 2, + fenced: false, + attachments: [], + documentTitle: null, + }, + ], + nextCursor: null, + }); + }); + + await page.route("**/api/insights/coach/nudge-status*", (route) => + fulfilJson(route, { nudgedAt: null, unread: false }), + ); + await page.route("**/api/coach/about-me/questions*", (route) => + fulfilJson(route, { questions: [] }), + ); + await page.route("**/api/auth/me/coach-prefs", (route) => + fulfilJson(route, { + tone: "warm", + verbosity: "default", + excludeMetrics: [], + showEvidenceByDefault: false, + defaultWindow: "allTime", + }), + ); + + await page.route("**/api/medications**", (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/api/medications/compliance") { + return fulfilJson(route, []); + } + if (url.pathname === "/api/medications/layout") { + return fulfilJson(route, { version: 1, view: "cards", order: [] }); + } + if (url.pathname === "/api/medications") { + return fulfilJson(route, [medication]); + } + return fulfilJson(route, {}); + }); + await page.route("**/api/settings/reminder-thresholds", (route) => + fulfilJson(route, { + lowStockThreshold: null, + lowStockRunwayDays: null, + reorderLeadDays: null, + }), + ); + + await page.route("**/api/labs/ocr/capability", (route) => + fulfilJson(route, { + available: true, + mode: "vision", + reason: null, + pdfSupported: true, + }), + ); + await page.route("**/api/labs?*", (route) => + fulfilJson(route, { + results: [ + { + id: "a11y-lab", + biomarkerId: null, + panel: "A11y panel", + analyte: "A11y LDL", + value: 2.4, + valueText: null, + unit: "mmol/L", + referenceLow: 0, + referenceHigh: 3, + takenAt: "2026-07-19T12:00:00.000Z", + source: "MANUAL", + hasNote: false, + rangeStatus: "inRange", + createdAt: "2026-07-19T12:00:00.000Z", + updatedAt: "2026-07-19T12:00:00.000Z", + }, + ], + meta: { total: 1, limit: 500, offset: 0 }, + }), + ); + + await page.route("**/api/documents/inbound**", (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/api/documents/inbound/usage") { + return fulfilJson(route, { + usedBytes: inboundDocument.byteSize, + quotaBytes: 1_073_741_824, + maxFileBytes: 26_214_400, + acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"], + linkedEpisodes: [], + assistAvailable: true, + contentIndex: { enabled: true, indexedCount: 1, totalCount: 1 }, + }); + } + if (url.pathname === "/api/documents/inbound/capability") { + return fulfilJson(route, { + available: true, + mode: "vision", + reason: null, + pdfSupported: true, + egress: "local", + }); + } + if (url.pathname === `/api/documents/inbound/${A11Y_DOCUMENT_ID}/summary`) { + return fulfilJson(route, { + summary: "A deterministic summary of the A11y blood panel.", + }); + } + if (url.pathname === `/api/documents/inbound/${A11Y_DOCUMENT_ID}`) { + return fulfilJson(route, { + ...inboundDocument, + facts: [], + summary: "A stored summary of the A11y blood panel.", + summaryGeneratedAt: "2026-07-19T12:05:00.000Z", + summaryState: "READY", + }); + } + if (url.pathname === "/api/documents/inbound") { + return fulfilJson(route, { + documents: [inboundDocument], + nextCursor: null, + }); + } + return fulfilJson(route, {}); + }); + await page.route("**/api/auth/me/documents-auto-ai-read", (route) => + fulfilJson(route, { documentsAutoAiRead: false }), + ); + + await page.route("**/api/admin/app-logs*", (route) => + fulfilJson(route, { + events: [ + { + timestamp: "2026-07-20T10:00:00.000Z", + duration_ms: 24, + request_id: "a11y-request", + trace_id: "a11y-trace", + level: "info", + kind: "http", + service: "web", + environment: "test", + http: { + method: "GET", + path: "/api/version", + route: "/api/version", + status: 200, + }, + action: { name: "a11y.paint" }, + }, + ], + meta: { total: 1, bufferMax: 500 }, + }), + ); + await page.route("**/api/admin/backups", (route) => + fulfilJson(route, { + rows: [ + { + id: "a11y-backup", + userId: "a11y-user", + username: "a11y-backup-user", + type: "WEEKLY_AUTO", + sizeBytes: 4096, + createdAt: "2026-07-20T03:00:00.000Z", + }, + ], + retentionDays: 30, + }), + ); +} + +const INSIGHTS_ROUTES: readonly RouteCase[] = [ + { + name: "/insights overview", + path: "/insights", + painted: (page) => + page.locator('[data-slot="wellness-scores"]').filter({ + has: page.locator( + '[data-slot="wellness-scores-grid"]:not([aria-busy="true"])', + ), + }), + }, + { + name: "/insights/weight metric subpage", + path: "/insights/weight", + painted: (page) => page.locator(".recharts-wrapper").first(), + }, + { + name: "/insights/workouts list", + path: "/insights/workouts", + painted: (page) => + page + .locator( + '#main-content [data-slot="workout-list"], #main-content [role="status"]', + ) + .first(), + }, + { + name: "/insights/workouts/:id detail", + path: `/insights/workouts/${A11Y_WORKOUT_ID}`, + painted: (page) => page.locator('[data-slot="workout-detail-header"]'), + }, + { + name: "/coach", + path: "/coach", + painted: (page) => page.locator('[data-slot="coach-page"]').first(), + }, +]; + +const RECORD_ROUTES: readonly RouteCase[] = [ + { + name: "/measurements", + path: "/measurements", + painted: (page) => page.locator('[data-slot="filter-bar"]'), + }, + { + name: "/mood", + path: "/mood", + painted: (page) => page.locator('[data-slot="filter-bar"]'), + }, + { + name: "/medications", + path: "/medications", + painted: (page) => page.getByRole("heading", { level: 1 }).first(), + }, + { + name: "/labs", + path: "/labs", + painted: (page) => page.getByText("A11y LDL").first(), + }, + { + name: "/documents compact panel", + path: "/documents?view=compact", + painted: (page) => page.getByText("A11y blood panel").first(), + }, +]; + +const ADMIN_AND_BASELINE_ROUTES: readonly RouteCase[] = [ + { + name: "/admin/app-logs", + path: "/admin/app-logs", + painted: (page) => page.getByText("a11y.paint").first(), + }, + { + name: "/admin/backups", + path: "/admin/backups", + painted: (page) => + page.getByText("a11y-backup-user", { exact: true }).filter({ + visible: true, + }), + }, + { + name: "/ dashboard", + path: "/", + painted: (page) => page.locator("#main-content"), + }, + { + name: "/settings/integrations", + path: "/settings/integrations", + painted: (page) => page.getByRole("heading", { level: 1 }).first(), + }, + { + name: "/admin overview", + path: "/admin", + painted: (page) => page.getByRole("heading", { level: 1 }).first(), + }, + { + name: "/admin/system-status", + path: "/admin/system-status", + painted: (page) => page.getByRole("heading", { level: 1 }).first(), + }, + { + name: "/admin/users", + path: "/admin/users", + painted: (page) => page.getByRole("heading", { level: 1 }).first(), + }, +]; + test.describe("axe-core public surfaces", () => { - for (const path of ["/auth/login"]) { - test(`${path} has no serious or critical a11y violations`, async ({ - page, - }) => { - await page.goto(path); - await page.waitForLoadState("networkidle"); - - const blocking = await runAxe(page); - reportBlocking(blocking); - expect(blocking).toHaveLength(0); + for (const theme of THEMES) { + test(`theme=${theme} /auth/login`, async ({ page }) => { + await useExplicitTheme(page, theme); + await page.goto("/auth/login", { waitUntil: "domcontentloaded" }); + const blocking = await scanPaintedState( + page, + theme, + "/auth/login", + page.getByRole("main"), + ); + expectNoViolations(theme, "/auth/login", blocking); }); } }); -test.describe("axe-core authenticated surfaces", () => { - // Reuse the cookie jar captured by `e2e/setup/global-setup.ts`. Login - // ran once in global-setup so we don't burn auth rate-limit attempts. +test.describe("axe-core authenticated route and state matrix", () => { test.use({ storageState: STORAGE_STATE_PATH }); - // Stable mocks so the auth'd pages render deterministically — axe scans - // need fully-painted DOM, and a flaky API would otherwise stall the - // analyse() call until the 30s test timeout. - test.beforeEach(async ({ page }) => { - // v1.4.39.3 — match `/api/analytics` AND any sliced variant - // (`?slice=summaries`); the v1.4.39.2 dashboard split fires both - // in parallel and the literal string glob misses the query- - // string form. - await page.route(/\/api\/analytics(\?|$)/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} }, - error: null, - }), - }), - ); - await page.route("**/api/mood/analytics", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { entries: [], summary: { count: 0 } }, - error: null, - }), - }), - ); - }); - - for (const path of [ - "/", - "/settings/integrations", - "/admin", - "/admin/system-status", - "/admin/users", - ]) { - test(`${path} has no serious or critical a11y violations`, async ({ + for (const theme of THEMES) { + test(`theme=${theme} routes: /insights, metric, workouts list/detail, /coach`, async ({ page, }) => { - await page.goto(path, { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle"); + test.setTimeout(120_000); + await useExplicitTheme(page, theme); + await installA11yMocks(page); + const blocking: AxeViolation[] = []; + for (const routeCase of INSIGHTS_ROUTES) { + blocking.push(...(await visitAndScan(page, theme, routeCase))); + } + expectNoViolations(theme, "insights route matrix", blocking); + }); - const blocking = await runAxe(page); - reportBlocking(blocking); - expect(blocking).toHaveLength(0); + test(`theme=${theme} routes: /measurements, /mood, /medications, /labs, /documents`, async ({ + page, + }) => { + test.setTimeout(120_000); + await useExplicitTheme(page, theme); + await installA11yMocks(page); + const blocking: AxeViolation[] = []; + for (const routeCase of RECORD_ROUTES) { + blocking.push(...(await visitAndScan(page, theme, routeCase))); + } + expectNoViolations(theme, "record route matrix", blocking); + }); + + test(`theme=${theme} routes: retained baseline + /admin/app-logs and /admin/backups`, async ({ + page, + }) => { + test.setTimeout(150_000); + await useExplicitTheme(page, theme); + await installA11yMocks(page); + const blocking: AxeViolation[] = []; + for (const routeCase of ADMIN_AND_BASELINE_ROUTES) { + blocking.push(...(await visitAndScan(page, theme, routeCase))); + } + expectNoViolations(theme, "admin and retained route matrix", blocking); + }); + + test(`theme=${theme} open states: OCR picker, Coach evidence chips, medication dialog, document detail sheet`, async ({ + page, + }) => { + test.setTimeout(120_000); + await useExplicitTheme(page, theme); + await installA11yMocks(page); + const blocking: AxeViolation[] = []; + + await test.step(`${theme} /labs open OCR picker dialog`, async () => { + await page.goto("/labs", { waitUntil: "domcontentloaded" }); + await expect(page.getByText("A11y LDL").first()).toBeVisible({ + timeout: 15_000, + }); + await page + .locator('#main-content [data-slot="dropdown-menu-trigger"]') + .first() + .click(); + await page.getByRole("menuitem").first().click(); + const sheet = page.locator('[data-slot="responsive-sheet-content"]'); + await expect(sheet.locator('input[type="file"]')).toHaveCount(1); + blocking.push( + ...(await scanPaintedState( + page, + theme, + "/labs open OCR picker", + sheet, + )), + ); + }); + + await test.step(`${theme} /coach open evidence disclosure and source chips`, async () => { + await page.goto(`/coach?c=${A11Y_CONVERSATION_ID}`, { + waitUntil: "domcontentloaded", + }); + const evidence = page.locator('[data-slot="coach-evidence"]').first(); + await expect(evidence).toBeVisible({ timeout: 15_000 }); + await evidence.locator("summary").click(); + await expect(evidence).toHaveAttribute("open", ""); + await expect( + evidence.locator('[data-slot="coach-source-chips"]'), + ).toBeVisible(); + blocking.push( + ...(await scanPaintedState( + page, + theme, + "/coach open evidence disclosure and source chips", + evidence, + )), + ); + }); + + await test.step(`${theme} /medications open representative wizard dialog`, async () => { + await page.goto("/medications", { waitUntil: "domcontentloaded" }); + await page + .locator('#main-content [data-slot="dropdown-menu-trigger"]') + .first() + .click(); + await page.getByRole("menuitem").last().click(); + const dialog = page.locator('[data-slot="medication-wizard-dialog"]'); + blocking.push( + ...(await scanPaintedState( + page, + theme, + "/medications open wizard dialog", + dialog, + )), + ); + }); + + await test.step(`${theme} /documents compact detail sheet and summary panel`, async () => { + await page.goto(`/documents?view=compact&doc=${A11Y_DOCUMENT_ID}`, { + waitUntil: "domcontentloaded", + }); + const sheet = page.locator('[data-slot="responsive-sheet-content"]'); + await expect(sheet).toBeVisible({ timeout: 15_000 }); + await sheet.getByRole("button", { name: /summari/i }).click(); + const summary = sheet.locator('[data-slot="document-summary-panel"]'); + await expect(summary).toContainText("deterministic summary", { + timeout: 15_000, + }); + blocking.push( + ...(await scanPaintedState( + page, + theme, + "/documents compact detail sheet and summary panel", + sheet, + )), + ); + }); + expectNoViolations(theme, "open state matrix", blocking); }); } - // The keyboard-only "skip to content" link is fixed at the top-left - // corner of every authenticated page. The maintainer reported it intercepting - // logo clicks in v1.4.14 — the fix combines `pointer-events-none` - // with `-translate-y-full` so the element sits offscreen until - // focused. Regression guard: with no keyboard focus, the logo's own - // bounding box must accept the click. test("skip-link does not block logo click outside focus", async ({ page, viewport, }) => { - // The desktop sidebar (and its logo) is `hidden md:flex` — the - // mobile profile (Pixel 5) hides it by design. The skip-link - // regression the maintainer originally reported in v1.4.14 was a desktop- - // only artifact (mobile uses the topbar logo, which is a - // different DOM node). Skip the mobile project so we don't - // flake on a contract that doesn't apply there. test.skip( (viewport?.width ?? 0) < 768, "desktop sidebar logo is hidden on mobile by design", ); + await installA11yMocks(page); await page.goto("/", { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle"); - - // Find the desktop sidebar logo link — `` is rendered inside - // a `` and the only logo at the absolute top-left - // of the layout. We resolve it by role / accessible name to avoid - // brittle selectors. const logoLink = page .locator("a[href='/']") .filter({ hasText: "HealthLog" }) @@ -153,16 +892,9 @@ test.describe("axe-core authenticated surfaces", () => { expect(logoBox).not.toBeNull(); if (!logoBox) return; - // Click the centre of the logo. If the skip-link is intercepting - // pointer events, Playwright's auto-waiting click would either time - // out or report the wrong element. The defensive `force: false` - // (default) ensures we go through the full hit-test pipeline. await logoLink.click({ position: { x: logoBox.width / 2, y: logoBox.height / 2 }, }); - - // Logo points at "/", so we either stay on "/" or get a no-op - // reload. Either way the URL must remain on the dashboard. await page.waitForURL(/\/$/); }); }); diff --git a/e2e/achievements.spec.ts b/e2e/achievements.spec.ts index 160ff16ce..9786df03c 100644 --- a/e2e/achievements.spec.ts +++ b/e2e/achievements.spec.ts @@ -195,7 +195,11 @@ test.describe("achievements page renders all four card states", () => { // Page heading present await expect( - page.getByRole("heading", { name: "Achievements" }), + page.getByRole("main").getByRole("heading", { + name: "Achievements", + level: 1, + exact: true, + }), ).toBeVisible({ timeout: 5000 }); // Unlocked public — title visible diff --git a/e2e/ai-provider-dropdown.spec.ts b/e2e/ai-provider-dropdown.spec.ts index 757ca1d55..f12dd9b05 100644 --- a/e2e/ai-provider-dropdown.spec.ts +++ b/e2e/ai-provider-dropdown.spec.ts @@ -121,4 +121,222 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => { await expect(page.locator(`[data-chain-row="${slug}"]`)).toBeVisible(); } }); + test("provider editors submit once by Enter or click and isolate connection tests", async ({ + page, + }) => { + const patchBodies: Array> = []; + let releasePendingPatch: () => void = () => undefined; + let holdNextPatch = false; + let testConnectionRequests = 0; + let providerState: Record = { + provider: null, + model: null, + baseUrl: null, + responseTimeoutSeconds: null, + hasAnthropicKey: false, + anthropicKeyPreview: null, + hasLocalKey: false, + hasOpenaiKey: false, + openaiKeyPreview: null, + }; + + await page.route("**/api/insights/settings", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + codexStatus: "disconnected", + codexConnectedAt: null, + hasAdminKey: false, + codexOauthConfigured: true, + privacyMode: "aggregated", + lastInsightAt: null, + }, + error: null, + }), + }), + ); + await page.route("**/api/user/ai-provider", async (route) => { + if (route.request().method() === "PATCH") { + const body = route.request().postDataJSON() as Record; + patchBodies.push(body); + providerState = { ...providerState, ...body }; + if (holdNextPatch) { + holdNextPatch = false; + await new Promise((resolve) => { + releasePendingPatch = resolve; + }); + } + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: providerState, error: null }), + }); + }); + await page.route("**/api/insights/provider-chain", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + activeProvider: "codex", + cachedActiveProvider: null, + configuredChain: [ + { providerType: "codex", available: true }, + { providerType: "openai", available: true }, + { providerType: "anthropic", available: true }, + { providerType: "local", available: true }, + ], + }, + error: null, + }), + }), + ); + await page.route("**/api/ai/test", (route) => { + testConnectionRequests += 1; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { ok: true, providerType: "local", model: "test-model" }, + error: null, + }), + }); + }); + + const providerHydrated = page.waitForResponse("**/api/user/ai-provider"); + await page.goto("/settings/ai?provider=openai", { + waitUntil: "domcontentloaded", + }); + await providerHydrated; + + const select = page.getByTestId("ai-active-provider-select"); + + async function assertEnterAndClickSubmitOnce({ + provider, + formTestId, + inputId, + enterValue, + clickValue, + expectedField, + }: { + provider: "openai" | "anthropic" | "local"; + formTestId: string; + inputId: string; + enterValue: string; + clickValue: string; + expectedField: string; + }) { + await select.selectOption(provider); + const form = page.getByTestId(formTestId); + await expect(form).toHaveJSProperty("tagName", "FORM"); + const input = page.locator(`#${inputId}`); + const save = form.locator('button[type="submit"]'); + await expect(save).toHaveCount(1); + + await input.fill(enterValue); + const enterBaseline = patchBodies.length; + await input.press("Enter"); + await expect.poll(() => patchBodies.length).toBe(enterBaseline + 1); + await page.waitForTimeout(50); + expect(patchBodies).toHaveLength(enterBaseline + 1); + expect(patchBodies.at(-1)?.[expectedField]).toBe(enterValue); + + await input.fill(clickValue); + const clickBaseline = patchBodies.length; + await save.click(); + await expect.poll(() => patchBodies.length).toBe(clickBaseline + 1); + await page.waitForTimeout(50); + expect(patchBodies).toHaveLength(clickBaseline + 1); + expect(patchBodies.at(-1)?.[expectedField]).toBe(clickValue); + } + + await assertEnterAndClickSubmitOnce({ + provider: "openai", + formTestId: "ai-provider-config-openai", + inputId: "ai-openai-key", + enterValue: "sk-enter-openai", + clickValue: "sk-click-openai", + expectedField: "openaiKey", + }); + await assertEnterAndClickSubmitOnce({ + provider: "anthropic", + formTestId: "ai-provider-config-anthropic", + inputId: "ai-anthropic-key", + enterValue: "sk-ant-enter", + clickValue: "sk-ant-click", + expectedField: "anthropicKey", + }); + await assertEnterAndClickSubmitOnce({ + provider: "local", + formTestId: "ai-provider-config-local", + inputId: "ai-local-base-url", + enterValue: "http://localhost:11434/v1", + clickValue: "http://localhost:11435/v1", + expectedField: "baseUrl", + }); + + const timeoutForm = page.locator("form").filter({ + has: page.locator("#ai-response-timeout"), + }); + const timeoutInput = page.locator("#ai-response-timeout"); + const timeoutSave = timeoutForm.locator('button[type="submit"]'); + await expect(timeoutForm).toHaveCount(1); + await expect(timeoutSave).toHaveCount(1); + + await timeoutInput.fill("9"); + const invalidTimeoutBaseline = patchBodies.length; + await timeoutInput.press("Enter"); + await expect(timeoutForm.locator("p.text-destructive")).toBeVisible(); + expect(patchBodies).toHaveLength(invalidTimeoutBaseline); + + await timeoutInput.fill("180"); + const timeoutEnterBaseline = patchBodies.length; + await timeoutInput.press("Enter"); + await expect.poll(() => patchBodies.length).toBe(timeoutEnterBaseline + 1); + await page.waitForTimeout(50); + expect(patchBodies).toHaveLength(timeoutEnterBaseline + 1); + expect(patchBodies.at(-1)?.responseTimeoutSeconds).toBe(180); + + await timeoutInput.fill("240"); + const timeoutClickBaseline = patchBodies.length; + await timeoutSave.click(); + await expect.poll(() => patchBodies.length).toBe(timeoutClickBaseline + 1); + await page.waitForTimeout(50); + expect(patchBodies).toHaveLength(timeoutClickBaseline + 1); + expect(patchBodies.at(-1)?.responseTimeoutSeconds).toBe(240); + + holdNextPatch = true; + await page.locator("#ai-local-base-url").fill("http://localhost:11436/v1"); + const pendingBaseline = patchBodies.length; + await page + .getByTestId("ai-provider-config-local") + .evaluate((form: HTMLFormElement) => { + form.requestSubmit(); + form.requestSubmit(); + }); + await expect.poll(() => patchBodies.length).toBe(pendingBaseline + 1); + await expect( + page + .getByTestId("ai-provider-config-local") + .locator('button[type="submit"]'), + ).toBeDisabled(); + await page.waitForTimeout(50); + expect(patchBodies).toHaveLength(pendingBaseline + 1); + + const testConnection = page.getByTestId("ai-test-active-provider"); + await expect(testConnection).toHaveAttribute("type", "button"); + await testConnection.click(); + await expect.poll(() => testConnectionRequests).toBe(1); + expect(patchBodies).toHaveLength(pendingBaseline + 1); + + releasePendingPatch(); + await expect( + page + .getByTestId("ai-provider-config-local") + .locator('button[type="submit"]'), + ).toBeEnabled(); + }); }); diff --git a/e2e/coach-conversation-rename.spec.ts b/e2e/coach-conversation-rename.spec.ts new file mode 100644 index 000000000..97551287e --- /dev/null +++ b/e2e/coach-conversation-rename.spec.ts @@ -0,0 +1,115 @@ +import { expect, test } from "@playwright/test"; +import { setTimeout as delay } from "node:timers/promises"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +const CONVERSATION_ID = "conversation-rename-e2e"; +const INITIAL_TITLE = "Morning check-in"; +const RENAMED_TITLE = "Weekly health review"; + +test.describe("Coach conversation rename", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("renames by keyboard once and survives reload on desktop and mobile", async ({ + page, + }) => { + let persistedTitle = INITIAL_TITLE; + let patchCount = 0; + + await page.route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?|$)/, + async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if ( + request.method() === "PATCH" && + url.pathname === `/api/insights/chat/${CONVERSATION_ID}` + ) { + patchCount += 1; + const payload = JSON.parse(request.postData() ?? "{}") as { + title: string; + }; + persistedTitle = payload.title; + await delay(150); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { id: CONVERSATION_ID, title: persistedTitle }, + error: null, + }), + }); + return; + } + + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + conversations: [ + { + id: CONVERSATION_ID, + title: persistedTitle, + createdAt: "2026-07-20T10:00:00.000Z", + updatedAt: "2026-07-20T10:00:00.000Z", + messageCount: 2, + fenced: false, + attachments: [], + documentTitle: null, + }, + ], + nextCursor: null, + }, + error: null, + }), + }); + }, + ); + + await page.goto("/coach/conversations", { + waitUntil: "domcontentloaded", + }); + + const item = page.locator('[data-slot="coach-conversations-item"]'); + await expect(item).toContainText(INITIAL_TITLE, { timeout: 10_000 }); + + const renameButton = item.locator( + '[data-slot="coach-conversation-rename"]', + ); + await renameButton.click(); + const input = item.locator('[data-slot="coach-conversation-rename-input"]'); + await expect(input).toBeFocused(); + + await input.fill("Discard this draft"); + await input.press("Escape"); + await expect(input).toHaveCount(0); + await expect(item).toContainText(INITIAL_TITLE); + expect(patchCount).toBe(0); + + await renameButton.click(); + const activeInput = item.locator( + '[data-slot="coach-conversation-rename-input"]', + ); + await activeInput.fill(` ${RENAMED_TITLE} `); + await activeInput.evaluate((element) => { + element.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + element.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + }); + + await expect(activeInput).toBeDisabled(); + await expect(item).toContainText(RENAMED_TITLE); + await expect(activeInput).toHaveCount(0, { timeout: 10_000 }); + expect(patchCount).toBe(1); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect( + page.locator('[data-slot="coach-conversations-item"]'), + ).toContainText(RENAMED_TITLE, { timeout: 10_000 }); + expect(patchCount).toBe(1); + }); +}); diff --git a/e2e/codex-flow.spec.ts b/e2e/codex-flow.spec.ts index e7d2d6760..5e929066e 100644 --- a/e2e/codex-flow.spec.ts +++ b/e2e/codex-flow.spec.ts @@ -93,7 +93,14 @@ test.describe("settings AI codex device flow (mocked)", () => { }), ); + // Hydration gate — the settings query is fired by the same client + // boundary that renders the Codex form, so its response proves React + // has attached the CTA's click handler. On mobile the SSR-painted button + // can otherwise be clicked before hydration and the event is lost. + const codexFormHydrated = page.waitForResponse("**/api/insights/settings"); + await page.goto("/settings/ai", { waitUntil: "domcontentloaded" }); + await codexFormHydrated; // Step 1 — CTA visible const cta = page.getByRole("button", { diff --git a/e2e/documents-ai.spec.ts b/e2e/documents-ai.spec.ts index 8952d314b..f0f8abbbc 100644 --- a/e2e/documents-ai.spec.ts +++ b/e2e/documents-ai.spec.ts @@ -283,11 +283,18 @@ test.describe("document vault — AI assist + content search", () => { await expect(hint).toBeVisible(); const indexAll = page.locator('[data-slot="content-index-all"]'); await expect(indexAll).toBeVisible(); - await indexAll.click(); - - await expect( - page.getByText("Indexing 5 document(s) in the background."), - ).toBeVisible(); + const reindexRequest = page.waitForRequest( + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === "/api/documents/inbound/reindex", + ); + await Promise.all([ + reindexRequest, + expect( + page.getByText("Indexing 5 document(s) in the background."), + ).toBeVisible(), + indexAll.click(), + ]); expect(reindexCalls).toBe(1); }); diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index e11580fe6..470a67405 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -99,6 +99,42 @@ async function runAxe(page: Page) { return blocking; } +async function mockDocumentCoach(page: Page): Promise { + const fulfil = ( + route: Parameters[1]>[0], + data: unknown, + ) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); + + await page.route("**/api/documents/inbound/usage", (route) => + fulfil(route, { + usedBytes: 4096, + quotaBytes: 1_073_741_824, + maxFileBytes: DEFAULT_MAX_FILE_BYTES, + acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"], + linkedEpisodes: [], + assistAvailable: true, + contentIndex: { enabled: true, indexedCount: 1, totalCount: 1 }, + }), + ); + await page.route("**/api/documents/inbound/capability", (route) => + fulfil(route, { + available: true, + mode: "vision", + reason: null, + pdfSupported: true, + egress: "external", + }), + ); + await page.route("**/api/auth/me/documents-auto-ai-read", (route) => + fulfil(route, { documentsAutoAiRead: false }), + ); +} + test.describe("document vault", () => { test.use({ storageState: STORAGE_STATE_PATH }); @@ -191,6 +227,121 @@ test.describe("document vault", () => { await expect(page).not.toHaveURL(/doc=/); }); + test("ordinary card selection owns doc history and preserves unrelated query state", async ({ + page, + }) => { + await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`); + await expect(openButton(page, "MRT Knie")).toBeVisible(); + await openButton(page, "MRT Knie").click(); + const detail = page.getByRole("dialog", { name: "MRT Knie" }); + + await expect + .poll(() => new URL(page.url()).searchParams.get("doc")) + .toBe(MRT_DOC_ID); + expect(new URL(page.url()).searchParams.get("episode")).toBe( + KNIE_EPISODE_ID, + ); + expect(new URL(page.url()).searchParams.get("view")).toBe("compact"); + expect(new URL(page.url()).searchParams.get("q")).toBe("MRT"); + + await page.goBack(); + await expect(detail).not.toBeVisible(); + expect(new URL(page.url()).searchParams.get("doc")).toBeNull(); + expect(new URL(page.url()).searchParams.get("view")).toBe("compact"); + + await page.goForward(); + await expect(detail).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(detail).not.toBeVisible(); + await expect + .poll(() => new URL(page.url()).searchParams.get("doc")) + .toBeNull(); + expect(new URL(page.url()).searchParams.get("episode")).toBe( + KNIE_EPISODE_ID, + ); + expect(new URL(page.url()).searchParams.get("view")).toBe("compact"); + expect(new URL(page.url()).searchParams.get("q")).toBe("MRT"); + }); + + test("Coach close and overlay dismissal consume the document handoff", async ({ + page, + }) => { + await mockDocumentCoach(page); + await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`); + await expect(openButton(page, "MRT Knie")).toBeVisible(); + await openButton(page, "MRT Knie").click(); + await expect + .poll(() => new URL(page.url()).searchParams.get("doc")) + .toBe(MRT_DOC_ID); + + const detail = page.getByRole("dialog", { name: "MRT Knie" }); + await detail.locator('[data-slot="document-chat-open"]').click(); + const drawer = page.locator('[data-slot="coach-drawer"]'); + await expect(drawer).toBeVisible(); + await expect(detail).not.toBeVisible(); + + await drawer.locator('[data-slot="coach-drawer-close"]').click(); + await expect(drawer).not.toBeVisible(); + await expect + .poll(() => new URL(page.url()).searchParams.get("doc")) + .toBeNull(); + expect(new URL(page.url()).searchParams.get("episode")).toBe( + KNIE_EPISODE_ID, + ); + expect(new URL(page.url()).searchParams.get("view")).toBe("compact"); + expect(new URL(page.url()).searchParams.get("q")).toBe("MRT"); + await expect(detail).not.toBeVisible(); + + await page.reload(); + await expect(detail).not.toBeVisible(); + expect(new URL(page.url()).searchParams.get("doc")).toBeNull(); + + await expect(openButton(page, "MRT Knie")).toBeVisible(); + await openButton(page, "MRT Knie").click(); + await expect(detail).toBeVisible(); + await detail.locator('[data-slot="document-chat-open"]').click(); + await expect(drawer).toBeVisible(); + await page + .locator('[data-slot="sheet-overlay"]') + .last() + .click({ position: { x: 2, y: 2 } }); + await expect(drawer).not.toBeVisible(); + await expect + .poll(() => new URL(page.url()).searchParams.get("doc")) + .toBeNull(); + await expect(detail).not.toBeVisible(); + }); + + test("Ask Coach maximize then browser back restores the selected document", async ({ + page, + }) => { + await mockDocumentCoach(page); + await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`); + await expect(openButton(page, "MRT Knie")).toBeVisible(); + await openButton(page, "MRT Knie").click(); + await expect + .poll(() => new URL(page.url()).searchParams.get("doc")) + .toBe(MRT_DOC_ID); + + const detail = page.getByRole("dialog", { name: "MRT Knie" }); + await detail.locator('[data-slot="document-chat-open"]').click(); + const drawer = page.locator('[data-slot="coach-drawer"]'); + await expect(drawer).toBeVisible(); + await drawer.locator('[data-slot="coach-drawer-maximize"]').click(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/coach"); + expect(new URL(page.url()).searchParams.get("doc")).toBe(MRT_DOC_ID); + + await page.goBack(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/documents"); + await expect(detail).toBeVisible(); + const restoredUrl = new URL(page.url()); + expect(restoredUrl.searchParams.get("doc")).toBe(MRT_DOC_ID); + expect(restoredUrl.searchParams.get("episode")).toBe(KNIE_EPISODE_ID); + expect(restoredUrl.searchParams.get("view")).toBe("compact"); + expect(restoredUrl.searchParams.get("q")).toBe("MRT"); + }); + test("the condition page links into the vault and back", async ({ page }) => { await page.goto(`/illness/${KNIE_EPISODE_ID}`); const card = page.locator('[data-slot="episode-documents-card"]'); @@ -210,10 +361,16 @@ test.describe("document vault", () => { await page.goto("/documents"); await uploadViaPicker(page, `${title}.pdf`, uniquePdf(title)); await page.getByRole("searchbox", { name: "Search documents" }).fill(title); + // The uploaded card is optimistic and can paint before the 200 ms search + // draft is committed to the URL. Opening it earlier races that pending + // replace against the detail's `?doc=` history entry and closes the sheet. + await expect + .poll(() => new URL(page.url()).searchParams.get("q")) + .toBe(title); await expect(openButton(page, `${title}.pdf`)).toBeVisible(); await openButton(page, `${title}.pdf`).click(); - const sheet = page.getByRole("dialog"); + const sheet = page.getByRole("dialog", { name: `${title}.pdf` }); await expect(sheet).toBeVisible(); await sheet.getByRole("button", { name: "Delete" }).click(); diff --git a/e2e/insights-scroll-restoration.spec.ts b/e2e/insights-scroll-restoration.spec.ts index 554ccaa0d..b5be7995a 100644 --- a/e2e/insights-scroll-restoration.spec.ts +++ b/e2e/insights-scroll-restoration.spec.ts @@ -29,6 +29,12 @@ test.describe("v1.4.28 — insights scroll restoration", () => { }) => { await page.goto("/insights", { waitUntil: "domcontentloaded" }); + // A visible server-rendered link is not yet an interactive React link. + // Wait for this client component's hydration boundary before scrolling or + // clicking so the pointer gesture cannot race React replacing the anchor. + const tabStrip = page.locator("[data-slot='insights-tab-strip']"); + await expect(tabStrip).toHaveAttribute("data-hydrated", "true"); + // v1.25.1 — the app-wide scroll-to-top resets the `#main-content` // scroll container on every route change (auth-shell), with a // `window.scrollTo(0,0)` fallback for the body-scrolled shells. The @@ -53,18 +59,24 @@ test.describe("v1.4.28 — insights scroll restoration", () => { window.scrollTo({ top: 600, behavior: "auto" }); } }); + await expect.poll(readScrollTop).toBeGreaterThan(100); - // Click the first available sub-page pill. - const subPagePill = page - .locator("[data-slot='insights-tab-strip-pill']") - .nth(1); + // Recovery is always present and remains a direct link across responsive + // pill grouping. Target its route contract instead of a mutable list index. + const subPagePill = tabStrip.locator( + "[data-slot='insights-tab-strip-pill'][href='/insights/recovery']", + ); await expect(subPagePill).toBeVisible(); await subPagePill.click(); - await page.waitForURL(/\/insights\//); + await expect(page).toHaveURL(/\/insights\/recovery\/?$/); - // Back to the mother page via the overview pill (pill index 0). - await page.locator("[data-slot='insights-tab-strip-pill']").first().click(); - await page.waitForURL(/\/insights\/?$/); + // Back to the mother page through the overview pill. + const overviewPill = tabStrip.locator( + "[data-slot='insights-tab-strip-pill'][href='/insights']", + ); + await expect(overviewPill).toBeVisible(); + await overviewPill.click(); + await expect(page).toHaveURL(/\/insights\/?$/); // The shell's route-change effect resets the scroll on the next tick; // `useScrollResetOnRoute` defers to `requestAnimationFrame`. Wait for it @@ -91,6 +103,12 @@ test.describe("v1.4.28 — insights scroll restoration", () => { }) => { await page.goto("/insights", { waitUntil: "domcontentloaded" }); const nav = page.locator("[data-slot='insights-tab-strip']"); + const innerScroller = nav.locator( + "[data-slot='insights-tab-strip-scroller']", + ); + await expect(nav).toHaveAttribute("data-hydrated", "true"); + await expect(innerScroller).toBeVisible(); + const navOverflowX = await nav.evaluate( (el) => getComputedStyle(el).overflowX, ); @@ -100,7 +118,6 @@ test.describe("v1.4.28 — insights scroll restoration", () => { expect(navOverflowX).not.toBe("auto"); expect(navOverflowX).not.toBe("scroll"); - const innerScroller = nav.locator("> div > div").first(); const innerOverflowX = await innerScroller.evaluate( (el) => getComputedStyle(el).overflowX, ); diff --git a/e2e/medications-wizard-daily.spec.ts b/e2e/medications-wizard-daily.spec.ts index 5bdb3855a..e6d773dd4 100644 --- a/e2e/medications-wizard-daily.spec.ts +++ b/e2e/medications-wizard-daily.spec.ts @@ -20,8 +20,9 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; /** * v1.5.4 — Medication wizard end-to-end — daily cadence. * - * Walks the 7-step daily path through the modal dialog. The dialog - * mounts when `/medications/new` redirects to `/medications?new=1`. + * Walks the 7-step daily path through the modal dialog. The shared helper + * opens `/medications?new=1` directly so cadence coverage is independent of + * the legacy `/medications/new` redirect. * The DE label surface (the maintainer's primary locale) is the assertion * surface — "Schritt N von 7" tracks the visible counter. */ diff --git a/e2e/medications-wizard-helpers.ts b/e2e/medications-wizard-helpers.ts index c2c236e17..d411cea39 100644 --- a/e2e/medications-wizard-helpers.ts +++ b/e2e/medications-wizard-helpers.ts @@ -120,8 +120,9 @@ export async function stubDashboardAnalytics(page: Page): Promise { } /** - * Open the wizard dialog by hitting `/medications/new` (which - * redirects to `/medications?new=1` and opens the create dialog). + * Open the wizard directly on `/medications?new=1`. The redirect from the + * legacy `/medications/new` route is a separate concern; these cadence specs + * exercise the wizard without racing that server navigation. */ export async function openCreateWizard(page: Page): Promise { // Pin the German locale for the wizard flow. The app default locale is @@ -136,12 +137,14 @@ export async function openCreateWizard(page: Page): Promise { { name: "healthlog-locale", value: "de", - url: page.url().startsWith("http") - ? new URL(page.url()).origin - : "http://localhost:3000", + url: new URL(process.env.E2E_BASE_URL ?? "http://localhost:3000").origin, }, ]); - await page.goto("/medications/new", { waitUntil: "domcontentloaded" }); + await page.goto("/medications?new=1", { waitUntil: "load" }); + await page.waitForURL( + (url) => url.pathname === "/medications" && !url.searchParams.has("new"), + { timeout: 10_000 }, + ); await expect( page.locator('[data-slot="medication-wizard-dialog"]'), ).toBeVisible({ timeout: 10_000 }); diff --git a/e2e/medications-wizard-oneshot.spec.ts b/e2e/medications-wizard-oneshot.spec.ts index e341811ba..d851f9a4a 100644 --- a/e2e/medications-wizard-oneshot.spec.ts +++ b/e2e/medications-wizard-oneshot.spec.ts @@ -33,6 +33,29 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; test.describe("medication wizard — one-shot", () => { test.use({ storageState: STORAGE_STATE_PATH }); + test("does not replay the one-shot URL flag after history traversal", async ({ + page, + }) => { + await stubDashboardAnalytics(page); + await openCreateWizard(page); + + await page.keyboard.press("Escape"); + await expect( + page.locator('[data-slot="medication-wizard-dialog"]'), + ).toHaveCount(0); + + await page.locator('a[href="/"]:visible').first().click(); + await page.waitForURL((url) => url.pathname === "/"); + await page.goBack(); + await page.waitForURL( + (url) => url.pathname === "/medications" && !url.searchParams.has("new"), + ); + + await expect( + page.locator('[data-slot="medication-wizard-dialog"]'), + ).toHaveCount(0); + }); + test("creates a single-dose medication end-to-end", async ({ page }) => { test.slow(); await stubDashboardAnalytics(page); diff --git a/e2e/mobile-viewport.spec.ts b/e2e/mobile-viewport.spec.ts index a29965b07..af1e46888 100644 --- a/e2e/mobile-viewport.spec.ts +++ b/e2e/mobile-viewport.spec.ts @@ -206,7 +206,7 @@ test.describe("mobile-viewport smoke", () => { // 1) The center capture action opens the capture picker. await page.getByTestId("bottom-nav-capture").click(); await expect(page.getByTestId("capture-picker-options")).toBeVisible(); - for (const kind of ["measurement", "medication", "mood"]) { + for (const kind of ["measurement", "medication", "mood", "water"]) { await expect(page.getByTestId(`capture-picker-${kind}`)).toBeVisible(); } // Dismiss the picker before opening the hub. diff --git a/e2e/mood-timestamp.spec.ts b/e2e/mood-timestamp.spec.ts new file mode 100644 index 000000000..3aeca210c --- /dev/null +++ b/e2e/mood-timestamp.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +test.describe("mood timestamp capture", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("keeps Time visible while details are collapsed and submits the selected instant", async ({ + page, + }) => { + let submitted: Record | null = null; + + await page.route("**/api/mood-entries*", async (route) => { + if (route.request().method() === "POST") { + submitted = route.request().postDataJSON() as Record; + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ data: { id: "created-mood" }, error: null }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { entries: [], meta: { total: 0, limit: 50, offset: 0 } }, + error: null, + }), + }); + }); + + await page.goto("/mood", { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: "Add", exact: true }).click(); + await page.getByRole("radio", { name: "Amazing" }).click(); + + const details = page.getByRole("button", { name: "Note & details" }); + await expect(details).toHaveAttribute("aria-expanded", "false"); + + const timestamp = page.locator('[data-slot="date-time-field"]'); + await expect(timestamp).toBeVisible(); + const dateInput = timestamp.locator( + '[data-slot="date-field"] input[type="text"]', + ); + const timeInput = timestamp.locator( + '[data-slot="time-field"] input[type="text"]', + ); + await dateInput.fill("2026-01-15"); + await dateInput.blur(); + await timeInput.fill("09:45"); + await timeInput.blur(); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + + await expect.poll(() => submitted).not.toBeNull(); + expect(submitted).toMatchObject({ + mood: "SUPER_GUT", + moodLoggedAt: "2026-01-15T08:45:00.000Z", + }); + }); +}); diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index e8a04d1af..b116e6a16 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -94,9 +94,27 @@ test.describe("Settings → Export & Import", () => { }) => { // v1.18.0 (S5) — the panel (and its disclosure) live on the // dedicated Gesundheitsakte section now. + await page.route("**/api/share-links", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: [], error: null, meta: null }), + }); + }); + const sharingDataReady = page.waitForResponse( + (response) => + response.status() === 200 && + response.request().method() === "GET" && + new URL(response.url()).pathname === "/api/share-links", + ); await page.goto("/settings/gesundheitsakte", { waitUntil: "domcontentloaded", }); + await sharingDataReady; const toggle = page.getByTestId("health-record-included-data-toggle"); await expect(toggle).toBeVisible({ timeout: 10_000 }); // Collapsed on first render — the checklist panel is absent. diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index 3fe949756..22cf88593 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -198,3 +198,44 @@ test.describe("/settings/integrations Pixel-5 layout", () => { } }); }); + +test.describe("Apple Health guidance", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("opens on Integrations and reaches the one-shot import fallback", async ({ + page, + }) => { + await page.route("**/api/integrations/healthkit", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { entries: [], lastSyncedAt: null }, + error: null, + }), + }), + ); + + await page.goto("/settings/integrations"); + + const card = page.getByTestId("apple-health-card"); + await expect(card).toBeVisible(); + await expect(card).toContainText("HealthLog iOS app"); + await expect(card).toContainText("not a web or OAuth connection"); + await expect(page.getByTestId("apple-health-status")).toHaveAttribute( + "data-state", + "setup", + ); + + const importLink = page.getByTestId("apple-health-import-link"); + await expect(importLink).toHaveAttribute( + "href", + "/settings/export#settings-section-import-title", + ); + await page.goto("/settings/export#settings-section-import-title"); + await expect(page).toHaveURL( + /\/settings\/export#settings-section-import-title$/, + ); + await expect(page.getByTestId("import-card-apple-health")).toBeVisible(); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 111068f4a..247df5c4f 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -97,6 +97,8 @@ export default async function globalSetup(config: FullConfig): Promise { // that tries to click a header / sidebar / quick-add button times // out (50+ failed CI runs since v1.4.13). Specs that need the // tour can opt back in by mocking `/api/auth/me`. + // Nutrients is intentionally opt-in in production. The shared fixture + // enables it because the water-capture specs exercise that real gate. await pool.query( `INSERT INTO users (id, username, email, password_hash, role, @@ -105,14 +107,16 @@ export default async function globalSetup(config: FullConfig): Promise { codex_connection_status, insights_privacy_mode, onboarding_completed_at, - onboarding_tour_completed) + onboarding_tour_completed, + module_preferences_json) VALUES ($1, $2, $3, $4, $5, $6, $6, 180, $7, 'MALE', 'disconnected', 'aggregated', $6, - true) + true, + '{"nutrients":true}'::jsonb) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, @@ -129,7 +133,8 @@ export default async function globalSetup(config: FullConfig): Promise { insights_cached_at = NULL, insights_cached_text = NULL, onboarding_completed_at = EXCLUDED.onboarding_completed_at, - onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json`, [ cuid(), E2E_USER.username, @@ -141,6 +146,19 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // Repeated local E2E runs reuse the same seeded account and database. + // Its owner-scoped share-link bucket lasts an hour, so otherwise the third + // run can start above the 20-operation ceiling and fail before exercising + // the share flow. Reset only this fixture user's bucket; never touch + // unrelated rate-limit state in a developer database. + await pool.query( + `DELETE FROM rate_limits + WHERE key = 'share-link:' || ( + SELECT id::text FROM users WHERE username = $1 + )`, + [E2E_USER.username], + ); + // Opt the seed account into cycle tracking. The user is seeded as // `MALE`, and v1.18.0 resolves the `cycle` module through // `isCycleEnabled(gender, CycleProfile)` — NULL `cycleTrackingEnabled` diff --git a/e2e/share-documents.spec.ts b/e2e/share-documents.spec.ts index 1163bd826..d33cfe1de 100644 --- a/e2e/share-documents.spec.ts +++ b/e2e/share-documents.spec.ts @@ -57,7 +57,14 @@ interface CreatedShare { async function createShareWithDocs(page: Page): Promise { const label = `${SHARE_DOC_PREFIX} ${Date.now()}`; + const sharingDataReady = page.waitForResponse( + (response) => + response.status() === 200 && + response.request().method() === "GET" && + new URL(response.url()).pathname === "/api/share-links", + ); await page.goto("/settings/sharing"); + await sharingDataReady; await page.locator("#share-label").fill(label); // Open the document picker and isolate the seeded trio. @@ -69,13 +76,18 @@ async function createShareWithDocs(page: Page): Promise { // Exactly the three share-fixture documents match the prefix. const rows = list.getByRole("button"); await expect(rows).toHaveCount(3); - for (let i = 0; i < 3; i++) await rows.nth(i).click(); + for (let i = 0; i < 3; i++) { + const row = rows.nth(i); + await row.click(); + await expect(row).toHaveAttribute("aria-pressed", "true"); + } await page.getByRole("button", { name: "Done" }).click(); // Three chips staged before create. await expect( page.getByTestId("share-attached-chips").getByRole("listitem"), ).toHaveCount(3); + await expect(page.getByRole("button", { name: "Create link" })).toBeEnabled(); await page.getByRole("button", { name: "Create link" }).click(); @@ -150,9 +162,14 @@ test.describe("clinician document sharing", () => { clinician.getByRole("heading", { name: "Documents" }), ).toBeVisible(); // Class A image → inline at the token-scoped serve route. - await expect( - clinician.locator(`img[src*="/d/${SHARE_JPEG_DOC_ID}"]`), - ).toBeVisible(); + const inlineImage = clinician.getByRole("img", { + name: "Share e2e scan", + }); + await expect(inlineImage).toBeVisible(); + await expect(inlineImage).toHaveAttribute( + "src", + new RegExp(`/d/${SHARE_JPEG_DOC_ID}$`), + ); // Class A PDF → inline