Merge pull request #571 from MBombeck/release/v1.31.4

release: v1.31.4 operational hardening
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-21 02:41:45 +02:00
commit e38179e854
40 files changed
+1782 -548

No files matched your search

+115 -78
View File
@@ -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}."
+5 -3
View File
@@ -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:
@@ -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
+1 -1
View File
@@ -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"
+108 -79
View File
@@ -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 // "<missing>"' <<< "$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 // "<missing>"' <<< "$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
+21
View File
@@ -2,6 +2,27 @@
## [Unreleased]
## [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
+11 -16
View File
@@ -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@10.31.0 --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@10.31.0 --activate
WORKDIR /app
# v1.4.43 B11 — version build-arg threaded into the layer cache key.
@@ -45,17 +46,16 @@ 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
corepack enable && corepack prepare pnpm@10.31.0 --activate
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 +80,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,15 +127,6 @@ 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)
RUN mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \
+2 -2
View File
@@ -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:-}"
+43 -1
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.31.3
version: 1.31.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+18
View File
@@ -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.
+21 -4
View File
@@ -57,7 +57,14 @@ interface CreatedShare {
async function createShareWithDocs(page: Page): Promise<CreatedShare> {
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<CreatedShare> {
// 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 <img> 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 <iframe> at the same route family.
await expect(
clinician.locator(`iframe[src*="/d/${SHARE_PDF_DOC_ID}"]`),
-1
View File
@@ -231,7 +231,6 @@ const nextConfig: NextConfig = {
serverExternalPackages: [
"@prisma/client",
"pg-boss",
"@prisma/adapter-pg",
"pg",
// Document PDF handling MUST run the real, un-bundled modules. When
// Turbopack bundles pdfjs-dist into the server chunks, its runtime
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.31.3",
"version": "1.31.4",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -24,7 +24,7 @@
"private": true,
"packageManager": "pnpm@10.31.0",
"scripts": {
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
"prepare": "node scripts/prepare.mjs",
"dev": "next dev",
"predev": "node scripts/generate-i18n-boot.mjs",
"prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs",
@@ -142,6 +142,9 @@
"@prisma/engines"
],
"overrides": {
"dompurify@<=3.4.10": "^3.4.11",
"postcss@<8.5.10": "^8.5.10",
"@hono/node-server@<1.19.13": "^1.19.13",
"hono@<4.12.25": "^4.12.25",
"picomatch@<4.0.4": "^4.0.4"
}
+4 -4
View File
@@ -22,13 +22,13 @@ export default defineConfig({
expect: { timeout: 10_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
failOnFlakyTests: !!process.env.CI,
// Two retries on CI: under a loaded shared runner, DOM-settle-gated
// assertions (wizard step transitions, list refetch → card provenance,
// disclosure toggles) intermittently sample a mid-transition frame. A
// rotating single test failed each run while 211 passed; a second retry
// absorbs that transient contention without masking a real failure (a true
// break fails all three attempts). The default expect timeout is also
// lifted from 5s → 10s for the same settle headroom.
// retry distinguishes transient contention from a persistent break, while
// failOnFlakyTests keeps either outcome visible to CI. The default expect
// timeout is lifted from 5s → 10s for the same settle headroom.
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI
+9 -33
View File
@@ -5,6 +5,9 @@ settings:
excludeLinksFromLockfile: false
overrides:
dompurify@<=3.4.10: ^3.4.11
postcss@<8.5.10: ^8.5.10
'@hono/node-server@<1.19.13': ^1.19.13
hono@<4.12.25: ^4.12.25
picomatch@<4.0.4: ^4.0.4
@@ -879,12 +882,6 @@ packages:
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@hono/node-server@1.19.11':
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4.12.25
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
@@ -4006,8 +4003,8 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
dompurify@3.3.3:
resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
dompurify@3.4.12:
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
dot-prop@6.0.1:
resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
@@ -5355,11 +5352,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.15:
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -5733,10 +5725,6 @@ packages:
resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==}
engines: {node: '>=4'}
postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.14:
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
engines: {node: ^10 || ^12 || >=14}
@@ -7777,10 +7765,6 @@ snapshots:
'@hexagon/base64@1.1.28': {}
'@hono/node-server@1.19.11(hono@4.12.25)':
dependencies:
hono: 4.12.25
'@hono/node-server@1.19.14(hono@4.12.25)':
dependencies:
hono: 4.12.25
@@ -8538,7 +8522,7 @@ snapshots:
'@electric-sql/pglite': 0.4.1
'@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1)
'@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1)
'@hono/node-server': 1.19.11(hono@4.12.25)
'@hono/node-server': 1.19.14(hono@4.12.25)
'@prisma/get-platform': 7.2.0
'@prisma/query-plan-executor': 7.2.0
'@prisma/streams-local': 0.1.2
@@ -10760,7 +10744,7 @@ snapshots:
dependencies:
esutils: 2.0.3
dompurify@3.3.3:
dompurify@3.4.12:
optionalDependencies:
'@types/trusted-types': 2.0.7
optional: true
@@ -11897,7 +11881,7 @@ snapshots:
optionalDependencies:
canvg: 3.0.11
core-js: 3.49.0
dompurify: 3.3.3
dompurify: 3.4.12
html2canvas: 1.4.1
jsx-ast-utils@3.3.5:
@@ -12236,8 +12220,6 @@ snapshots:
nanoid@3.3.12: {}
nanoid@3.3.15: {}
nanoid@3.3.16: {}
napi-postinstall@0.3.4: {}
@@ -12257,7 +12239,7 @@ snapshots:
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.38
caniuse-lite: 1.0.30001799
postcss: 8.4.31
postcss: 8.5.20
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.7)
@@ -12637,12 +12619,6 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss@8.4.31:
dependencies:
nanoid: 3.3.15
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.14:
dependencies:
nanoid: 3.3.12
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.31.3";
/* @sw-version-fallback */ "v1.31.4";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+128
View File
@@ -0,0 +1,128 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { parse } from "yaml";
import integrationConfig from "../../vitest.integration.config.mts";
function repositoryRoot(): string {
const testPath = expect.getState().testPath;
if (!testPath) throw new Error("Vitest did not expose the current test path");
return join(dirname(testPath), "../..");
}
function readRepoFile(path: string): string {
return readFileSync(join(repositoryRoot(), path), "utf8");
}
type WorkflowStep = {
if?: string;
uses?: string;
with?: Record<string, string | number>;
};
type WorkflowJob = {
env?: Record<string, string>;
steps?: WorkflowStep[];
};
type Workflow = {
jobs: Record<string, WorkflowJob>;
};
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
describe("Playwright CI determinism", () => {
it("fails CI when a retry resolves a flaky test", async () => {
vi.stubEnv("CI", "true");
// Re-import after stubbing CI so config conditionals are evaluated as CI.
const { default: config } = await import("../../playwright.config");
expect(config.retries).toBe(2);
expect(config.failOnFlakyTests).toBe(true);
expect(config.reporter).toEqual([["github"], ["html", { open: "never" }]]);
});
it("retains the Playwright report after every workflow outcome", () => {
const workflow = parse(
readRepoFile(".github/workflows/e2e.yml"),
) as Workflow;
const upload = workflow.jobs.e2e?.steps?.find((step) =>
step.uses?.startsWith("actions/upload-artifact@"),
);
expect(upload).toBeDefined();
expect(upload?.if).toBe("always()");
expect(upload?.with).toMatchObject({
name: "playwright-report",
path: "playwright-report/",
"retention-days": 7,
"if-no-files-found": "ignore",
});
});
it("uses production-valid secrets and plain-HTTP cookies", () => {
const workflow = parse(
readRepoFile(".github/workflows/e2e.yml"),
) as Workflow;
const env = workflow.jobs.e2e?.env;
expect(env?.API_TOKEN_HMAC_KEY).toMatch(/^[0-9a-f]{64}$/);
expect(env?.SESSION_COOKIE_SECURE).toBe("false");
});
});
describe("integration environment isolation", () => {
it("registers a worker setup bridge and authoritative test secrets", () => {
const test = integrationConfig.test;
expect(test?.setupFiles ?? []).toContain(
"./tests/integration/environment-setup.ts",
);
expect(test?.env).toMatchObject({
TZ: "UTC",
ENCRYPTION_KEY:
"0000000000000000000000000000000000000000000000000000000000000000",
ENCRYPTION_KEYS: "",
ENCRYPTION_ACTIVE_KEY_ID: "",
API_TOKEN_HMAC_KEY:
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
SESSION_SECRET: "integration-test-session-secret-32-bytes",
});
expect(
existsSync(
join(repositoryRoot(), "tests/integration/environment-setup.ts"),
),
).toBe(true);
});
});
describe("container dependency installation", () => {
it("copies the prepare helper before the Git-free frozen install", () => {
const dockerfile = readRepoFile("Dockerfile");
expect(dockerfile).toContain(
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./\n" +
"COPY scripts/prepare.mjs scripts/prepare.mjs\n" +
"RUN pnpm install --frozen-lockfile --prod=false",
);
});
});
describe("production dependency advisory floors", () => {
it("pins vulnerable transitive ranges to compatible patched versions", () => {
const packageJson = JSON.parse(readRepoFile("package.json")) as {
pnpm?: { overrides?: Record<string, string> };
};
expect(packageJson.pnpm?.overrides).toMatchObject({
"dompurify@<=3.4.10": "^3.4.11",
"postcss@<8.5.10": "^8.5.10",
"@hono/node-server@<1.19.13": "^1.19.13",
"hono@<4.12.25": "^4.12.25",
});
});
});
+135
View File
@@ -0,0 +1,135 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import {
chmodSync,
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const path of temporaryDirectories.splice(0)) {
rmSync(path, { recursive: true, force: true });
}
});
type GitMarker = "absent" | "directory" | "file";
type PrepareResult = {
invocationLog: string | null;
result: SpawnSyncReturns<string>;
};
function runPrepareFixture(
gitMarker: GitMarker,
lefthookStatus = 0,
): PrepareResult {
const testPath = expect.getState().testPath;
if (!testPath) throw new Error("Vitest did not expose the current test path");
const root = join(dirname(testPath), "../..");
const directory = mkdtempSync(join(tmpdir(), "healthlog-prepare-"));
temporaryDirectories.push(directory);
const packageJson = JSON.parse(
readFileSync(join(root, "package.json"), "utf8"),
) as {
scripts: { prepare: string };
};
const prepareHelper = join(root, "scripts/prepare.mjs");
if (existsSync(prepareHelper)) {
mkdirSync(join(directory, "scripts"));
cpSync(prepareHelper, join(directory, "scripts/prepare.mjs"));
}
if (gitMarker === "directory") {
mkdirSync(join(directory, ".git"));
} else if (gitMarker === "file") {
writeFileSync(
join(directory, ".git"),
"gitdir: ../shared/.git/worktrees/fixture\n",
);
}
const binDirectory = join(directory, "bin");
const invocationLog = join(directory, "lefthook.log");
mkdirSync(binDirectory);
const lefthook = join(
binDirectory,
process.platform === "win32" ? "lefthook.cmd" : "lefthook",
);
writeFileSync(
lefthook,
process.platform === "win32"
? '@echo off\r\necho %*>>"%FIXTURE_LEFTHOOK_LOG%"\r\nexit /b %FIXTURE_LEFTHOOK_STATUS%\r\n'
: '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$FIXTURE_LEFTHOOK_LOG"\nexit "$FIXTURE_LEFTHOOK_STATUS"\n',
);
if (process.platform !== "win32") chmodSync(lefthook, 0o755);
const result = spawnSync(packageJson.scripts.prepare, {
cwd: directory,
encoding: "utf8",
env: {
...process.env,
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
FIXTURE_LEFTHOOK_LOG: invocationLog,
FIXTURE_LEFTHOOK_STATUS: String(lefthookStatus),
},
shell: true,
});
return {
result,
invocationLog: existsSync(invocationLog)
? readFileSync(invocationLog, "utf8").replaceAll("\r\n", "\n")
: null,
};
}
describe("package prepare hook installation", () => {
it("enables command-shim execution on Windows only", () => {
const testPath = expect.getState().testPath;
if (!testPath)
throw new Error("Vitest did not expose the current test path");
const source = readFileSync(
join(dirname(testPath), "../prepare.mjs"),
"utf8",
);
expect(source).toContain('shell: process.platform === "win32"');
});
it("skips hook installation only when Git metadata is absent", () => {
const { invocationLog, result } = runPrepareFixture("absent");
expect(result.status, result.stderr).toBe(0);
expect(invocationLog).toBeNull();
});
it.each(["directory", "file"] as const)(
"installs hooks when .git is a %s",
(gitMarker) => {
const { invocationLog, result } = runPrepareFixture(gitMarker);
expect(result.status, result.stderr).toBe(0);
expect(invocationLog).toBe("install --force\n");
},
);
it.each(["directory", "file"] as const)(
"propagates Lefthook failure when .git is a %s",
(gitMarker) => {
const { invocationLog, result } = runPrepareFixture(gitMarker, 42);
expect(invocationLog).toBe("install --force\n");
expect(result.status).toBe(42);
},
);
});
+454
View File
@@ -0,0 +1,454 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parse } from "yaml";
function readRepoFile(path: string): string {
const testPath = expect.getState().testPath;
if (!testPath) throw new Error("Vitest did not expose the current test path");
return readFileSync(join(dirname(testPath), "../..", path), "utf8");
}
type WorkflowStep = {
name?: string;
run?: string;
"continue-on-error"?: boolean;
};
type WorkflowJob = {
needs?: string | string[];
outputs?: Record<string, string>;
steps?: WorkflowStep[];
uses?: string;
with?: Record<string, string>;
};
type Workflow = {
on?: {
workflow_call?: { inputs?: Record<string, { required?: boolean }> };
};
jobs: Record<string, WorkflowJob>;
};
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const path of temporaryDirectories.splice(0)) {
rmSync(path, { recursive: true, force: true });
}
});
function loadWorkflow(name: string): Workflow {
return parse(readRepoFile(join(".github/workflows", name))) as Workflow;
}
function stepScript(
workflow: Workflow,
jobName: string,
stepName: string,
): string {
const step = workflow.jobs[jobName]?.steps?.find(
(candidate) => candidate.name === stepName,
);
if (!step?.run) {
throw new Error(`Missing ${jobName}/${stepName} run step`);
}
return step.run;
}
function runReleasePolicy(
overrides: Record<string, string> = {},
): SpawnSyncReturns<string> {
const directory = mkdtempSync(join(tmpdir(), "healthlog-release-policy-"));
temporaryDirectories.push(directory);
const binDirectory = join(directory, "bin");
mkdirSync(binDirectory);
writeFileSync(
join(directory, "package.json"),
JSON.stringify({ version: overrides.FIXTURE_PACKAGE_VERSION ?? "1.31.4" }),
);
const gitPath = join(binDirectory, "git");
writeFileSync(
gitPath,
`#!/bin/sh
if [ "$1" = "fetch" ]; then exit 0; fi
if [ "$1" = "merge-base" ] && [ "$FIXTURE_MAIN_ANCESTRY" = "yes" ]; then exit 0; fi
exit 1
`,
);
chmodSync(gitPath, 0o755);
const ghPath = join(binDirectory, "gh");
writeFileSync(
ghPath,
`#!/bin/sh
if [ "$FIXTURE_PRIOR_CI" = "yes" ]; then printf '1\\n'; else printf '0\\n'; fi
`,
);
chmodSync(ghPath, 0o755);
const workflow = loadWorkflow("docker-publish.yml");
return spawnSync(
"bash",
[
"-euo",
"pipefail",
"-c",
stepScript(workflow, "release-policy", "Validate release policy"),
],
{
cwd: directory,
encoding: "utf8",
env: {
...process.env,
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
GITHUB_EVENT_NAME: "push",
GITHUB_REF_TYPE: "tag",
GITHUB_REF_NAME: "v1.31.4",
GITHUB_SHA: "0123456789abcdef0123456789abcdef01234567",
GITHUB_REPOSITORY: "MBombeck/HealthLog",
GH_TOKEN: "fixture-token",
FIXTURE_MAIN_ANCESTRY: "yes",
FIXTURE_PRIOR_CI: "yes",
...overrides,
},
},
);
}
function runExactPull(resolvedDigest: string): {
result: SpawnSyncReturns<string>;
commands: string;
} {
const directory = mkdtempSync(join(tmpdir(), "healthlog-image-pull-"));
temporaryDirectories.push(directory);
const dockerPath = join(directory, "docker");
const commandLog = join(directory, "commands.log");
writeFileSync(
dockerPath,
`#!/bin/sh
printf '%s\\n' "$*" >> "$FIXTURE_COMMAND_LOG"
if [ "$1" = "pull" ]; then exit 0; fi
if [ "$1" = "buildx" ] && [ "$2" = "imagetools" ] && [ "$3" = "inspect" ]; then
printf '\"%s\"\\n' "$FIXTURE_RESOLVED_DIGEST"
exit 0
fi
exit 1
`,
);
chmodSync(dockerPath, 0o755);
const workflow = loadWorkflow("post-publish-verify.yml");
const result = spawnSync(
"bash",
[
"-euo",
"pipefail",
"-c",
stepScript(workflow, "verify", "Pull exact image"),
],
{
encoding: "utf8",
env: {
...process.env,
PATH: `${directory}:${process.env.PATH ?? ""}`,
IMAGE_REF: "ghcr.io/mbombeck/healthlog",
IMAGE_TAG: "v1.31.4",
IMAGE_DIGEST:
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
FIXTURE_COMMAND_LOG: commandLog,
FIXTURE_RESOLVED_DIGEST: resolvedDigest,
},
},
);
return {
result,
commands: readFileSync(commandLog, "utf8"),
};
}
function runPromotion(
overrides: Record<string, string> = {},
): SpawnSyncReturns<string> {
const directory = mkdtempSync(join(tmpdir(), "healthlog-promotion-"));
temporaryDirectories.push(directory);
const sleepPath = join(directory, "sleep");
writeFileSync(sleepPath, "#!/bin/sh\nexit 0\n");
chmodSync(sleepPath, 0o755);
const curlPath = join(directory, "curl");
writeFileSync(
curlPath,
`#!/bin/sh
printf '{\"deployment_uuid\":\"fixture\"}\\n__HTTP_STATUS__:%s' "$FIXTURE_HTTP_STATUS"
`,
);
chmodSync(curlPath, 0o755);
const workflow = loadWorkflow("docker-publish.yml");
return spawnSync(
"bash",
[
"-euo",
"pipefail",
"-c",
stepScript(workflow, "promote", "Trigger Coolify deploy"),
],
{
encoding: "utf8",
env: {
...process.env,
PATH: `${directory}:${process.env.PATH ?? ""}`,
COOLIFY_AUTO_DEPLOY: "on",
COOLIFY_WEBHOOK: "https://coolify.example/deploy?uuid=fixture",
COOLIFY_TOKEN: "fixture-token",
IMAGE_TAG: "v1.31.4",
IMAGE_DIGEST:
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
BUILD_SHA: "0123456789ab",
FIXTURE_HTTP_STATUS: "200",
...overrides,
},
},
);
}
describe("release container inputs", () => {
it("pins every Node stage to one reviewed multi-arch digest", () => {
const dockerfile = readRepoFile("Dockerfile");
const fromLines = dockerfile.match(/^FROM node:[^\n]+$/gm) ?? [];
expect(fromLines).toHaveLength(3);
expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual(
new Set([
"FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2",
]),
);
});
it("uses the packageManager pnpm version instead of a moving tag", () => {
const dockerfile = readRepoFile("Dockerfile");
const packageJson = JSON.parse(readRepoFile("package.json")) as {
packageManager: string;
};
expect(packageJson.packageManager).toBe("pnpm@10.31.0");
expect(dockerfile).not.toContain("pnpm@latest");
expect(
dockerfile.match(/corepack prepare pnpm@10\.31\.0 --activate/g),
).toHaveLength(3);
});
it("bundles Prisma's adapter and verifies only runtime externals", () => {
const dockerfile = readRepoFile("Dockerfile");
const nextConfig = readRepoFile("next.config.ts");
expect(dockerfile).not.toContain("/opt/pg-boss");
expect(dockerfile).not.toContain('NODE_PATH="/opt/pg-boss/node_modules"');
expect(dockerfile).toMatch(
/require\.resolve\(['"]pg-boss['"]\)[\s\S]*require\.resolve\(['"]pg['"]\)/,
);
expect(dockerfile).not.toContain("require.resolve('@prisma/adapter-pg')");
expect(dockerfile).not.toContain("require.resolve('@prisma/client')");
expect(nextConfig).not.toMatch(
/serverExternalPackages:\s*\[[\s\S]*["']@prisma\/adapter-pg["']/,
);
});
});
describe("release pre-build policy", () => {
it("accepts a strict SemVer tag that matches package.json on main with prior CI", () => {
const result = runReleasePolicy();
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("Release policy passed");
});
it.each(["v1.2", "v01.2.3", "v1.2.3-rc.1", "1.2.3"])(
"rejects non-strict release tag %s",
(tag) => {
const result = runReleasePolicy({ GITHUB_REF_NAME: tag });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("strict SemVer");
},
);
it("rejects a tag that does not match package.json", () => {
const result = runReleasePolicy({ FIXTURE_PACKAGE_VERSION: "1.31.3" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("package.json version");
});
it("rejects a release commit outside main ancestry", () => {
const result = runReleasePolicy({ FIXTURE_MAIN_ANCESTRY: "no" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("ancestor of origin/main");
});
it("rejects a release commit without successful prior quality CI", () => {
const result = runReleasePolicy({ FIXTURE_PRIOR_CI: "no" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("successful prior Security & Quality");
});
it("gates every image build on the release policy", () => {
const workflow = loadWorkflow("docker-publish.yml");
const needs = workflow.jobs.build?.needs;
expect(Array.isArray(needs) ? needs : [needs]).toContain("release-policy");
});
});
describe("exact post-publish verification", () => {
it("passes the published ref, tag, and digest into the blocking verifier", () => {
const workflow = loadWorkflow("docker-publish.yml");
const merge = workflow.jobs.merge;
const verify = workflow.jobs["verify-published-image"];
expect(merge?.outputs).toEqual({
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 }}",
});
expect(verify?.uses).toBe("./.github/workflows/post-publish-verify.yml");
expect(verify?.needs).toBe("merge");
expect(verify?.with).toEqual({
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 }}",
});
});
it("requires exact image identity inputs for every invocation", () => {
const workflow = loadWorkflow("post-publish-verify.yml");
const inputs = workflow.on?.workflow_call?.inputs;
expect(inputs?.image_ref?.required).toBe(true);
expect(inputs?.image_tag?.required).toBe(true);
expect(inputs?.image_digest?.required).toBe(true);
expect(inputs?.build_sha?.required).toBe(true);
});
it("pulls the exact tag and digest when the registry resolves correctly", () => {
const digest =
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const { result, commands } = runExactPull(digest);
expect(result.status, result.stderr).toBe(0);
expect(commands).toContain("pull ghcr.io/mbombeck/healthlog:v1.31.4");
expect(commands).toContain(
"pull ghcr.io/mbombeck/healthlog@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
});
it("fails closed when the published tag resolves to another digest", () => {
const { result } = runExactPull(
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("digest mismatch");
});
it("keeps pull, migration, start, version, and health checks blocking", () => {
const workflow = loadWorkflow("post-publish-verify.yml");
const steps = workflow.jobs.verify?.steps ?? [];
const requiredSteps = [
"Pull exact image",
"Run migrations",
"Start exact image",
"Verify exact version",
"Verify health",
];
for (const name of requiredSteps) {
const step = steps.find((candidate) => candidate.name === name);
expect(step, `missing ${name}`).toBeDefined();
expect(step?.["continue-on-error"], name).not.toBe(true);
}
expect(stepScript(workflow, "verify", "Run migrations")).toContain(
"${IMAGE_REF}@${IMAGE_DIGEST}",
);
const startScript = stepScript(workflow, "verify", "Start exact image");
expect(startScript).toContain("${IMAGE_REF}@${IMAGE_DIGEST}");
expect(startScript).toMatch(/API_TOKEN_HMAC_KEY=[0-9a-f]{64}(?:\s|\\)/);
const versionScript = stepScript(
workflow,
"verify",
"Verify exact version",
);
expect(versionScript).toContain('EXPECTED_VERSION="${IMAGE_TAG#v}"');
expect(versionScript).toContain('jq -e --arg expected "$EXPECTED_VERSION"');
expect(stepScript(workflow, "verify", "Verify health")).toContain(
"jq -e '.status == \"ok\"'",
);
});
});
describe("Coolify promotion policy", () => {
it("waits for exact-image verification and keeps enabled promotion blocking", () => {
const workflow = loadWorkflow("docker-publish.yml");
const promotion = workflow.jobs.promote;
const needs = promotion?.needs;
const trigger = promotion?.steps?.find(
(step) => step.name === "Trigger Coolify deploy",
);
expect(Array.isArray(needs) ? needs : [needs]).toEqual(
expect.arrayContaining(["merge", "verify-published-image"]),
);
expect(trigger).toBeDefined();
expect(trigger?.["continue-on-error"]).not.toBe(true);
expect(
workflow.jobs.merge?.steps?.some(
(step) => step.name === "Trigger Coolify deploy",
),
).toBe(false);
});
it("accepts an enabled promotion only after a successful webhook response", () => {
const result = runPromotion();
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("Coolify deploy queued");
});
it.each([
["webhook", { COOLIFY_WEBHOOK: "" }],
["token", { COOLIFY_TOKEN: "" }],
])("rejects enabled promotion with missing %s credentials", (_name, env) => {
const result = runPromotion(env);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("COOLIFY_AUTO_DEPLOY=on");
});
it("fails enabled promotion when Coolify rejects the webhook", () => {
const result = runPromotion({ FIXTURE_HTTP_STATUS: "503" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("returned HTTP 503");
});
it("allows explicit disabled promotion without credentials", () => {
const result = runPromotion({
COOLIFY_AUTO_DEPLOY: "off",
COOLIFY_WEBHOOK: "",
COOLIFY_TOKEN: "",
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("COOLIFY_AUTO_DEPLOY=off");
});
});
+17
View File
@@ -0,0 +1,17 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
if (!existsSync(".git")) {
console.log("Skipping Lefthook install: Git metadata is absent");
} else {
const result = spawnSync("lefthook", ["install", "--force"], {
stdio: "inherit",
shell: process.platform === "win32",
});
if (result.error) {
console.error(`Failed to run Lefthook: ${result.error.message}`);
process.exitCode = 1;
} else {
process.exitCode = result.status ?? 1;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => false,
}));
import { proxy } from "../proxy";
function request(pathname: string): NextRequest {
return new NextRequest(`http://localhost${pathname}`);
}
describe("worker-only process HTTP boundary", () => {
it("allows the process-aware health endpoint through", () => {
const response = proxy(request("/api/health"));
expect(response.status).toBe(200);
expect(response.headers.get("X-HealthLog-Process-Type")).toBeNull();
});
it("continues to refuse non-health HTTP traffic", () => {
const response = proxy(request("/api/version"));
expect(response.status).toBe(503);
expect(response.headers.get("X-HealthLog-Process-Type")).toBe("worker");
});
});
+16 -16
View File
@@ -274,6 +274,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: { id: true },
});
// Keep channel reconciliation under the same identity locks as the device
// row. Moving it after commit lets two first-time registrations race the
// compound unique key and intermittently turns one valid request into 500.
if (apnsToken) {
await tx.notificationChannel.upsert({
where: { userId_type: { userId: user.id, type: "APNS" } },
create: {
userId: user.id,
type: "APNS",
enabled: true,
config: encrypt("{}"),
},
update: {},
});
}
return { id: device.id };
});
@@ -292,22 +308,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { id } = registration;
// Reconcile APNs channel state only after the canonical device transaction
// commits. Config is an encrypted empty record by design — the per-device
// token and environment live on the Device row.
if (apnsToken) {
await prisma.notificationChannel.upsert({
where: { userId_type: { userId: user.id, type: "APNS" } },
create: {
userId: user.id,
type: "APNS",
enabled: true,
config: encrypt("{}"),
},
update: {},
});
}
await auditLog("devices.register", {
userId: user.id,
details: {
+119
View File
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
interface WorkerStatusFixture {
running: boolean;
startedAt: string | null;
lastHeartbeat: string | null;
lastReminderCheck: string | null;
lastWithingsSync: string | null;
lastInsightsRun: string | null;
jobsProcessed: number;
errors: number;
}
const { queryRaw, getWorkerStatus, getGlobalBoss, getSession } = vi.hoisted(
() => ({
queryRaw: vi.fn(async () => 1),
getWorkerStatus: vi.fn((): WorkerStatusFixture => ({
running: false,
startedAt: null,
lastHeartbeat: null,
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
})),
getGlobalBoss: vi.fn((): object | null => null),
getSession: vi.fn(async () => null),
}),
);
vi.mock("@/lib/db", () => ({
prisma: { $queryRaw: queryRaw },
}));
vi.mock("@/lib/jobs/worker-status", () => ({ getWorkerStatus }));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss }));
vi.mock("@/lib/auth/session", () => ({ getSession }));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { GET } from "../route";
async function health() {
const response = await (GET as unknown as () => Promise<Response>)();
return { response, body: (await response.json()) as { status: string } };
}
beforeEach(() => {
vi.clearAllMocks();
queryRaw.mockResolvedValue(1);
getWorkerStatus.mockReturnValue({
running: false,
startedAt: null,
lastHeartbeat: null,
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
});
getGlobalBoss.mockReturnValue(null);
getSession.mockResolvedValue(null);
});
describe("GET /api/health process topology", () => {
it("web mode requires DB and producer readiness, not an in-process worker", async () => {
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
getGlobalBoss.mockReturnValue({});
const { response, body } = await health();
expect(response.status).toBe(200);
expect(body).toEqual({ status: "ok" });
});
it("worker mode requires DB and worker readiness, not web producer readiness", async () => {
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "worker");
getWorkerStatus.mockReturnValue({
running: true,
startedAt: "2026-07-20T00:00:00.000Z",
lastHeartbeat: "2026-07-20T00:00:00.000Z",
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
});
const { response, body } = await health();
expect(response.status).toBe(200);
expect(body).toEqual({ status: "ok" });
});
it("all mode requires both producer and worker readiness", async () => {
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "all");
getWorkerStatus.mockReturnValue({
running: true,
startedAt: "2026-07-20T00:00:00.000Z",
lastHeartbeat: "2026-07-20T00:00:00.000Z",
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
});
const missingProducer = await health();
expect(missingProducer.response.status).toBe(503);
expect(missingProducer.body).toEqual({ status: "degraded" });
getGlobalBoss.mockReturnValue({});
const ready = await health();
expect(ready.response.status).toBe(200);
expect(ready.body).toEqual({ status: "ok" });
});
});
+9 -1
View File
@@ -1,5 +1,7 @@
import { prisma } from "@/lib/db";
import { getWorkerStatus } from "@/lib/jobs/worker-status";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { shouldRunWeb, shouldRunWorker } from "@/lib/process-type";
import { getSession } from "@/lib/auth/session";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -19,7 +21,13 @@ export const GET = apiHandler(async () => {
}
const worker = getWorkerStatus();
const status = dbOk && worker.running ? "ok" : "degraded";
const producerReady = getGlobalBoss() !== null;
const status =
dbOk &&
(!shouldRunWeb() || producerReady) &&
(!shouldRunWorker() || worker.running)
? "ok"
: "degraded";
const statusCode = status === "ok" ? 200 : 503;
const cacheHeaders = {
+49 -45
View File
@@ -1,65 +1,69 @@
/**
* v1.4.40 W-POOL — pool-config contract for `src/lib/db.ts`.
*
* The v1.4.39 empirical cold-mount trace
* (`.planning/round-v1439-empirical-trace.md` § B2) traced the
* Wave-B/C dashboard-query stall to the Prisma `pg.Pool` falling
* back to the library default ceiling of 10 connections. W-POOL
* pinned the ceiling to 20 via `getPoolMax()`; this test prevents a
* future refactor from silently dropping the override and re-introducing
* the saturation regression on a power-user cold mount.
*
* The test only exercises the `getPoolMax()` env-resolver. Constructing
* a real `PrismaClient` requires a live `DATABASE_URL`, which would
* make this an integration test instead of a unit test — the helper
* isolation is intentional.
* Pool configuration stays unit-tested here so connection ceilings and wait
* timeouts cannot silently fall back to separate library defaults.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildSessionOptions, getPoolMax, getStatementTimeoutMs } from "../db";
import {
buildSessionOptions,
getConnectionBudget,
getPgBossPoolMax,
getPoolConnectionTimeoutMs,
getPrismaPoolMax,
getStatementTimeoutMs,
} from "../db";
describe("getPoolMax", () => {
const originalEnv = process.env.DATABASE_POOL_MAX;
describe("per-process database connection budget", () => {
const originalDatabaseUrl = process.env.DATABASE_URL;
const originalConnectionLimit = process.env.DB_CONNECTION_LIMIT;
const originalPoolTimeout = process.env.DB_POOL_TIMEOUT;
const originalLegacyPoolMax = process.env.DATABASE_POOL_MAX;
beforeEach(() => {
process.env.DATABASE_URL =
"postgresql://healthlog:test@db:5432/healthlog?connection_limit=20&pool_timeout=20";
delete process.env.DB_CONNECTION_LIMIT;
delete process.env.DB_POOL_TIMEOUT;
delete process.env.DATABASE_POOL_MAX;
});
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.DATABASE_POOL_MAX;
} else {
process.env.DATABASE_POOL_MAX = originalEnv;
}
const restore = (name: string, value: string | undefined) => {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
};
restore("DATABASE_URL", originalDatabaseUrl);
restore("DB_CONNECTION_LIMIT", originalConnectionLimit);
restore("DB_POOL_TIMEOUT", originalPoolTimeout);
restore("DATABASE_POOL_MAX", originalLegacyPoolMax);
});
it("defaults to 20 when DATABASE_POOL_MAX is unset", () => {
expect(getPoolMax()).toBe(20);
it("splits the URL connection_limit across Prisma and pg-boss", () => {
expect(getConnectionBudget()).toBe(20);
expect(getPrismaPoolMax()).toBe(18);
expect(getPgBossPoolMax()).toBe(2);
expect(getPrismaPoolMax() + getPgBossPoolMax()).toBe(getConnectionBudget());
});
it("honours DATABASE_POOL_MAX when it parses to a positive int", () => {
it("uses the existing pool_timeout seconds for both pool constructors", () => {
expect(getPoolConnectionTimeoutMs()).toBe(20_000);
});
it("prefers explicit DB_CONNECTION_LIMIT and DB_POOL_TIMEOUT values", () => {
process.env.DB_CONNECTION_LIMIT = "12";
process.env.DB_POOL_TIMEOUT = "7";
expect(getConnectionBudget()).toBe(12);
expect(getPrismaPoolMax()).toBe(10);
expect(getPgBossPoolMax()).toBe(2);
expect(getPoolConnectionTimeoutMs()).toBe(7_000);
});
it("keeps DATABASE_POOL_MAX as a backward-compatible explicit override", () => {
process.env.DATABASE_POOL_MAX = "30";
expect(getPoolMax()).toBe(30);
});
it("falls back to 20 on a malformed DATABASE_POOL_MAX", () => {
process.env.DATABASE_POOL_MAX = "not-a-number";
expect(getPoolMax()).toBe(20);
});
it("falls back to 20 on a non-positive DATABASE_POOL_MAX", () => {
process.env.DATABASE_POOL_MAX = "0";
expect(getPoolMax()).toBe(20);
process.env.DATABASE_POOL_MAX = "-5";
expect(getPoolMax()).toBe(20);
});
it("never returns the library default of 10 on the happy path", () => {
// The whole point of the W-POOL change. If anybody removes the
// `max:` config from `createPrismaClient` and reverts to the
// library default, this assertion stops the regression before the
// next saturated cold mount.
expect(getPoolMax()).toBeGreaterThanOrEqual(20);
expect(getConnectionBudget()).toBe(30);
expect(getPrismaPoolMax()).toBe(28);
});
});
@@ -0,0 +1,99 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const entrypoint = join(repoRoot, "docker-entrypoint.sh");
const key = "a".repeat(64);
let fakeBin: string;
beforeAll(() => {
fakeBin = mkdtempSync(join(tmpdir(), "healthlog-entrypoint-"));
const fakeNode = join(fakeBin, "node");
writeFileSync(
fakeNode,
`#!/bin/sh
case "$1" in
-e)
case "$2" in
*ENCRYPTION_KEYS*) exec "$REAL_NODE" "$@" ;;
*) exit 0 ;;
esac
;;
*) exit 0 ;;
esac
`,
);
chmodSync(fakeNode, 0o755);
});
afterAll(() => {
rmSync(fakeBin, { recursive: true, force: true });
});
function runEntrypoint(overrides: Record<string, string | undefined>) {
const env: NodeJS.ProcessEnv = {
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
REAL_NODE: process.execPath,
NODE_ENV: "test",
DATABASE_URL: "postgresql://healthlog:test@db:5432/healthlog",
API_TOKEN_HMAC_KEY: "test-hmac-key",
...overrides,
};
for (const [name, value] of Object.entries(env)) {
if (value === undefined) delete env[name];
}
return spawnSync("sh", [entrypoint, "true"], {
cwd: repoRoot,
env,
encoding: "utf8",
});
}
describe("docker entrypoint crypto configuration", () => {
it("accepts the legacy ENCRYPTION_KEY configuration", () => {
const result = runEntrypoint({
ENCRYPTION_KEY: key,
ENCRYPTION_KEYS: undefined,
ENCRYPTION_ACTIVE_KEY_ID: undefined,
});
expect(result.status, result.stderr).toBe(0);
}, 20_000);
it("accepts a keyring-only configuration", () => {
const result = runEntrypoint({
ENCRYPTION_KEY: undefined,
ENCRYPTION_KEYS: JSON.stringify({ v2: key }),
ENCRYPTION_ACTIVE_KEY_ID: undefined,
});
expect(result.status, result.stderr).toBe(0);
}, 20_000);
it("fails closed when ENCRYPTION_KEYS is invalid even if a legacy key exists", () => {
const result = runEntrypoint({
ENCRYPTION_KEY: key,
ENCRYPTION_KEYS: JSON.stringify({ v2: "not-a-32-byte-key" }),
ENCRYPTION_ACTIVE_KEY_ID: "v2",
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("ENCRYPTION_KEYS");
expect(result.stderr).toContain("invalid");
});
it("fails closed when the active key is absent from the keyring", () => {
const result = runEntrypoint({
ENCRYPTION_KEY: undefined,
ENCRYPTION_KEYS: JSON.stringify({ v2: key }),
ENCRYPTION_ACTIVE_KEY_ID: "v3",
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("ENCRYPTION_ACTIVE_KEY_ID");
});
});
+49 -28
View File
@@ -5,34 +5,54 @@ const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
/**
* v1.4.40 W-POOL — Prisma `pg.Pool` ceiling raised from the library
* default of 10 → 20.
*
* The v1.4.39 empirical cold-mount trace
* (`.planning/round-v1439-empirical-trace.md` § B2) showed thick
* `/api/analytics` holding ≥ 8 of the 10 default pool slots for
* 6.5 s on the maintainer's 347k-row tenant, starving every other Wave-B and
* Wave-C useQuery fan-out for the duration. The production Postgres runs a
* `max_connections` of 200 (raised from the stock 100), so a 20-slot
* Node-side pool sits well under the server's hard cap with plenty of
* headroom for concurrent power-users. Note the web + worker containers
* share this one Postgres, so the effective ceiling covers both pools.
* Complements the W-POOL `p-limit(4)` cap on the analytics fan-out itself —
* the bounded concurrency keeps any single analytics call to ≤ 4 slots, so
* the remaining 16 stay available for every other dashboard query.
*
* Env-overridable via `DATABASE_POOL_MAX` so an operator running
* with a smaller Postgres `max_connections` can dial down without a
* code change.
*/
export function getPoolMax(): number {
const raw = process.env.DATABASE_POOL_MAX;
if (raw) {
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
const DEFAULT_CONNECTION_BUDGET = 20;
const DEFAULT_POOL_TIMEOUT_SECONDS = 20;
const PG_BOSS_CONNECTIONS = 2;
function positiveInteger(raw: string | undefined): number | null {
if (!raw) return null;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function databaseUrlParameter(name: string): string | undefined {
const rawUrl = process.env.DATABASE_URL;
if (!rawUrl) return undefined;
try {
return new URL(rawUrl).searchParams.get(name) ?? undefined;
} catch {
return undefined;
}
return 20;
}
/**
* Total PostgreSQL connections this process may own. Compose already exposes
* this as DB_CONNECTION_LIMIT through DATABASE_URL's `connection_limit`.
* DATABASE_POOL_MAX remains a backward-compatible explicit override.
*/
export function getConnectionBudget(): number {
const configured =
positiveInteger(process.env.DB_CONNECTION_LIMIT) ??
positiveInteger(process.env.DATABASE_POOL_MAX) ??
positiveInteger(databaseUrlParameter("connection_limit")) ??
DEFAULT_CONNECTION_BUDGET;
return Math.max(2, configured);
}
export function getPgBossPoolMax(): number {
return Math.min(PG_BOSS_CONNECTIONS, getConnectionBudget() - 1);
}
export function getPrismaPoolMax(): number {
return getConnectionBudget() - getPgBossPoolMax();
}
export function getPoolConnectionTimeoutMs(): number {
const seconds =
positiveInteger(process.env.DB_POOL_TIMEOUT) ??
positiveInteger(databaseUrlParameter("pool_timeout")) ??
DEFAULT_POOL_TIMEOUT_SECONDS;
return seconds * 1_000;
}
/**
@@ -79,7 +99,8 @@ function createPrismaClient() {
const sessionOptions = buildSessionOptions();
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
max: getPoolMax(),
max: getPrismaPoolMax(),
connectionTimeoutMillis: getPoolConnectionTimeoutMs(),
...(sessionOptions ? { options: sessionOptions } : {}),
});
return new PrismaClient({ adapter });
@@ -16,8 +16,6 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {},
buildSessionOptions: () => null,
getPoolMax: () => 5,
toJson: (value: unknown) => value,
}));
+97 -25
View File
@@ -1,44 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
const { start, PgBoss } = vi.hoisted(() => {
const start = vi.fn(async () => undefined);
const stop = vi.fn(async () => undefined);
const on = vi.fn();
const PgBoss = vi.fn(function MockPgBoss(this: Record<string, unknown>) {
this.start = start;
this.stop = stop;
this.on = on;
interface MockBoss {
start: Mock<() => Promise<unknown>>;
stop: Mock<(options?: { graceful?: boolean }) => Promise<void>>;
on: Mock<(event: string, handler: (error: Error) => void) => MockBoss>;
emitError(error: Error): void;
}
const { PgBoss, instances, startOutcomes } = vi.hoisted(() => {
const instances: MockBoss[] = [];
const startOutcomes: Array<() => Promise<unknown>> = [];
const PgBoss = vi.fn(function MockPgBoss(this: MockBoss) {
let errorHandler: ((error: Error) => void) | undefined;
this.start = vi.fn(() =>
(startOutcomes.shift() ?? (() => Promise.resolve(undefined)))(),
);
this.stop = vi.fn(async () => undefined);
this.on = vi.fn((event: string, handler: (error: Error) => void) => {
if (event === "error") errorHandler = handler;
return this;
});
this.emitError = (error: Error) => errorHandler?.(error);
instances.push(this);
});
return { start, PgBoss };
return { PgBoss, instances, startOutcomes };
});
vi.mock("pg-boss", () => ({ PgBoss }));
import { getGlobalBoss, startGlobalBossProducer } from "../boss-instance";
import {
createWorkerBoss,
getGlobalBoss,
startGlobalBossProducer,
} from "../boss-instance";
const globalState = globalThis as Record<string, unknown>;
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
instances.length = 0;
startOutcomes.length = 0;
delete globalState.__healthlog_pgboss__;
delete globalState.__healthlog_pgboss_start__;
vi.spyOn(console, "error").mockImplementation(() => undefined);
});
describe("web-process pg-boss producer", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("waits for delayed pg-boss schema readiness with capped exponential backoff", async () => {
vi.useFakeTimers();
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
startOutcomes.push(
() => Promise.reject(new Error("schema not ready")),
() => Promise.reject(new Error("schema still not ready")),
() => Promise.reject(new Error("schema still not ready")),
() => Promise.resolve(undefined),
);
it("retries startup and caps the send-only connection pool", async () => {
start
.mockRejectedValueOnce(new Error("schema not ready"))
.mockResolvedValueOnce(undefined);
const boss = await startGlobalBossProducer("postgres://healthlog@test/db", {
maxAttempts: 2,
retryDelayMs: 0,
const starting = startGlobalBossProducer("postgres://healthlog@test/db", {
maxAttempts: 4,
retryBaseDelayMs: 10,
retryMaxDelayMs: 20,
});
await vi.runAllTimersAsync();
const boss = await starting;
expect(PgBoss).toHaveBeenCalledTimes(2);
expect(PgBoss).toHaveBeenCalledWith({
expect(PgBoss).toHaveBeenCalledTimes(4);
expect(timeoutSpy.mock.calls.map((call) => call[1])).toEqual([10, 20, 20]);
expect(PgBoss).toHaveBeenLastCalledWith({
connectionString: "postgres://healthlog@test/db",
max: 2,
connectionTimeoutMillis: 20_000,
migrate: false,
schedule: false,
supervise: false,
});
expect(start).toHaveBeenCalledTimes(2);
expect(boss).toBe(getGlobalBoss());
});
it("keeps supervised reconnect active after a bounded failed batch", async () => {
vi.useFakeTimers();
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
startOutcomes.push(() => Promise.resolve(undefined));
await startGlobalBossProducer("postgres://healthlog@test/db", {
maxAttempts: 2,
retryBaseDelayMs: 10,
retryMaxDelayMs: 20,
});
startOutcomes.push(
() => Promise.reject(new Error("db unavailable 1")),
() => Promise.reject(new Error("db unavailable 2")),
() => Promise.resolve(undefined),
);
instances[0].emitError(new Error("connection lost"));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(PgBoss).toHaveBeenCalledTimes(4);
expect(timeoutSpy.mock.calls.map((call) => call[1])).toEqual([10, 20]);
expect(instances[0].stop).toHaveBeenCalledWith({ graceful: false });
expect(getGlobalBoss()).toBe(instances[3]);
});
});
describe("worker pg-boss pool", () => {
it("uses the pg-boss share of the process connection budget", () => {
createWorkerBoss("postgres://healthlog@test/db");
expect(PgBoss).toHaveBeenCalledWith({
connectionString: "postgres://healthlog@test/db",
max: 2,
connectionTimeoutMillis: 20_000,
});
});
});
+3 -17
View File
@@ -25,9 +25,8 @@
* Locks per `.planning/research/v1434-r-1-xml-import.md` §5.1.
*/
import { unlinkSync } from "node:fs";
import { PrismaClient } from "@/generated/prisma/client";
import { buildSessionOptions, getPoolMax, toJson } from "@/lib/db";
import { PrismaPg } from "@prisma/adapter-pg";
import type { PrismaClient } from "@/generated/prisma/client";
import { prisma, toJson } from "@/lib/db";
import type { Job } from "pg-boss";
import { extractExportXml } from "@/lib/import/unzip-export-xml";
@@ -94,20 +93,7 @@ export function _setWorkerPrismaForTests(client: PrismaClient | null): void {
}
function getWorkerPrisma(): PrismaClient {
if (!workerPrismaSingleton) {
// Inherit the web client's pool ceiling + per-session statement timeouts
// (`src/lib/db.ts`). The timeout is per-statement, not per-job, so the
// long-running bulk import stays safe while a single wedged UPSERT can no
// longer pin a worker connection indefinitely.
const sessionOptions = buildSessionOptions();
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
max: getPoolMax(),
...(sessionOptions ? { options: sessionOptions } : {}),
});
workerPrismaSingleton = new PrismaClient({ adapter });
}
return workerPrismaSingleton;
return workerPrismaSingleton ?? prisma;
}
/**
+131 -46
View File
@@ -7,15 +7,24 @@
*/
import { PgBoss } from "pg-boss";
import { getPgBossPoolMax, getPoolConnectionTimeoutMs } from "@/lib/db";
const BOSS_KEY = "__healthlog_pgboss__" as const;
const BOSS_START_KEY = "__healthlog_pgboss_start__" as const;
const PRODUCER_POOL_MAX = 2;
const PRODUCER_START_MAX_ATTEMPTS = 5;
const PRODUCER_START_RETRY_DELAY_MS = 1_000;
const PRODUCER_RETRY_BASE_DELAY_MS = 250;
const PRODUCER_RETRY_MAX_DELAY_MS = 2_000;
export interface ProducerStartOptions {
maxAttempts?: number;
retryDelayMs?: number;
retryBaseDelayMs?: number;
retryMaxDelayMs?: number;
}
interface ProducerRetryPolicy {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
}
function wait(ms: number): Promise<void> {
@@ -24,20 +33,127 @@ function wait(ms: number): Promise<void> {
return promise;
}
export function setGlobalBoss(boss: PgBoss) {
(globalThis as Record<string, unknown>)[BOSS_KEY] = boss;
function resolveRetryPolicy(
options: ProducerStartOptions,
): ProducerRetryPolicy {
const maxAttempts = Math.max(
1,
Math.trunc(options.maxAttempts ?? PRODUCER_START_MAX_ATTEMPTS),
);
const baseDelayMs = Math.max(
0,
Math.trunc(options.retryBaseDelayMs ?? PRODUCER_RETRY_BASE_DELAY_MS),
);
const maxDelayMs = Math.max(
baseDelayMs,
Math.trunc(options.retryMaxDelayMs ?? PRODUCER_RETRY_MAX_DELAY_MS),
);
return { maxAttempts, baseDelayMs, maxDelayMs };
}
function retryDelayMs(
failedAttempt: number,
policy: ProducerRetryPolicy,
): number {
return Math.min(
policy.maxDelayMs,
policy.baseDelayMs * 2 ** Math.max(0, failedAttempt - 1),
);
}
export function setGlobalBoss(boss: PgBoss | null): void {
const globalState = globalThis as Record<string, unknown>;
if (boss) globalState[BOSS_KEY] = boss;
else delete globalState[BOSS_KEY];
}
export function getGlobalBoss(): PgBoss | null {
return ((globalThis as Record<string, unknown>)[BOSS_KEY] as PgBoss) ?? null;
}
function databaseOptions(connectionString: string) {
return {
connectionString,
max: getPgBossPoolMax(),
connectionTimeoutMillis: getPoolConnectionTimeoutMs(),
} as const;
}
export function createWorkerBoss(connectionString: string): PgBoss {
return new PgBoss(databaseOptions(connectionString));
}
function producerOptions(connectionString: string) {
return {
...databaseOptions(connectionString),
migrate: false,
schedule: false,
supervise: false,
} as const;
}
async function startProducerAttempts(
connectionString: string,
policy: ProducerRetryPolicy,
): Promise<PgBoss> {
let lastError: unknown;
for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
const boss = new PgBoss(producerOptions(connectionString));
let ready = false;
boss.on("error", (error: unknown) => {
console.error("[pg-boss-producer] error", error);
if (ready) superviseProducerReconnect(boss, connectionString, policy);
});
try {
await boss.start();
ready = true;
setGlobalBoss(boss);
return boss;
} catch (error) {
lastError = error;
await boss.stop({ graceful: false }).catch(() => undefined);
if (attempt < policy.maxAttempts) {
await wait(retryDelayMs(attempt, policy));
}
}
}
throw lastError;
}
function superviseProducerReconnect(
failedBoss: PgBoss,
connectionString: string,
policy: ProducerRetryPolicy,
): void {
const globalState = globalThis as Record<string, unknown>;
if (getGlobalBoss() !== failedBoss || globalState[BOSS_START_KEY]) return;
setGlobalBoss(null);
const reconnecting = (async () => {
await failedBoss.stop({ graceful: false }).catch(() => undefined);
for (;;) {
try {
return await startProducerAttempts(connectionString, policy);
} catch (error) {
console.error("[pg-boss-producer] reconnect batch exhausted", error);
await wait(policy.maxDelayMs);
}
}
})();
globalState[BOSS_START_KEY] = reconnecting;
void reconnecting.finally(() => {
if (globalState[BOSS_START_KEY] === reconnecting) {
delete globalState[BOSS_START_KEY];
}
});
}
/**
* Start the web process's send-only pg-boss connection exactly once.
*
* `PgBoss.start()` is still required to open/check the database connection.
* Disabling migration, schedules, and supervision keeps this process from
* performing worker duties; `send()` remains available through the manager.
* `PgBoss.start()` is required to confirm the worker-owned schema exists.
* Migration, schedules, and pg-boss maintenance stay disabled in web mode;
* this module only supervises the producer connection itself.
*/
export async function startGlobalBossProducer(
connectionString: string | null | undefined,
@@ -53,47 +169,16 @@ export async function startGlobalBossProducer(
Promise<PgBoss | null> | undefined;
if (starting) return starting;
const maxAttempts = Math.max(
1,
Math.trunc(options.maxAttempts ?? PRODUCER_START_MAX_ATTEMPTS),
const startPromise = startProducerAttempts(
connectionString,
resolveRetryPolicy(options),
);
const retryDelayMs = Math.max(
0,
options.retryDelayMs ?? PRODUCER_START_RETRY_DELAY_MS,
);
const startPromise = (async () => {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const boss = new PgBoss({
connectionString,
max: PRODUCER_POOL_MAX,
migrate: false,
schedule: false,
supervise: false,
});
boss.on("error", (error: unknown) => {
console.error("[pg-boss-producer] error", error);
});
try {
await boss.start();
setGlobalBoss(boss);
return boss;
} catch (error) {
lastError = error;
await boss.stop({ graceful: false }).catch(() => {});
if (attempt < maxAttempts) {
await wait(retryDelayMs);
}
}
}
throw lastError;
})();
globalState[BOSS_START_KEY] = startPromise;
try {
return await startPromise;
} catch (error) {
delete globalState[BOSS_START_KEY];
throw error;
} finally {
if (globalState[BOSS_START_KEY] === startPromise) {
delete globalState[BOSS_START_KEY];
}
}
}
+2 -3
View File
@@ -14,9 +14,8 @@
* Usage: Run as a standalone process or call startReminderWorker() from a
* custom server setup. In dev, use: npx tsx src/lib/jobs/reminder-worker.ts
*/
import { PgBoss } from "pg-boss";
import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
import { createWorkerBoss, setGlobalBoss } from "@/lib/jobs/boss-instance";
import { assertSubsystemEnabled } from "@/lib/process-type";
import { DATABASE_URL, workerLog } from "./reminder/shared";
import {
@@ -50,7 +49,7 @@ export async function startReminderWorker() {
return;
}
const boss = new PgBoss(DATABASE_URL);
const boss = createWorkerBoss(DATABASE_URL);
boss.on("error", (error: unknown) => {
workerLog("error", "boss emitted error", error);
+12 -76
View File
@@ -1,15 +1,4 @@
/**
* F-DB-1 — the worker Prisma client must inherit the same pool ceiling +
* session timeouts as the web client (`src/lib/db.ts`).
*
* Before this fix the worker adapter was built with a bare
* `{ connectionString }`, so a pathological nightly drain on a heavy tenant
* could run unbounded, pin a connection indefinitely, and wedge the shared
* worker pool. This test pins the contract: `getWorkerPrisma()` constructs its
* `PrismaPg` adapter with `max` from `getPoolMax()` and the `options` startup
* string from `buildSessionOptions()`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const adapterCtor = vi.fn();
const clientCtor = vi.fn();
@@ -23,6 +12,7 @@ vi.mock("@prisma/adapter-pg", () => ({
}));
vi.mock("@/generated/prisma/client", () => ({
Prisma: {},
PrismaClient: class {
constructor(...args: unknown[]) {
clientCtor(...args);
@@ -30,78 +20,24 @@ vi.mock("@/generated/prisma/client", () => ({
},
}));
describe("getWorkerPrisma", () => {
describe("worker Prisma connection ownership", () => {
beforeEach(() => {
vi.resetModules();
adapterCtor.mockClear();
clientCtor.mockClear();
process.env.DATABASE_URL = "postgres://user:pass@localhost:5432/db";
delete process.env.DATABASE_POOL_MAX;
delete process.env.DATABASE_STATEMENT_TIMEOUT_MS;
process.env.DATABASE_URL =
"postgres://user:pass@localhost:5432/db?connection_limit=20&pool_timeout=20";
});
afterEach(() => {
delete process.env.DATABASE_POOL_MAX;
delete process.env.DATABASE_STATEMENT_TIMEOUT_MS;
});
it("builds the adapter with a pool cap and session timeouts", async () => {
const { getWorkerPrisma } = await import("../shared");
// Importing ../shared pulls in src/lib/db.ts, which constructs the web
// client's adapter at module load. Clear so we count only the worker's.
adapterCtor.mockClear();
clientCtor.mockClear();
getWorkerPrisma();
it("reuses the process Prisma client instead of opening a second pool", async () => {
const [{ prisma }, { getWorkerPrisma }] = await Promise.all([
import("@/lib/db"),
import("../shared"),
]);
expect(getWorkerPrisma()).toBe(prisma);
expect(getWorkerPrisma()).toBe(prisma);
expect(adapterCtor).toHaveBeenCalledTimes(1);
const config = adapterCtor.mock.calls[0][0] as {
connectionString: string;
max: number;
options?: string;
};
expect(config.connectionString).toBe(
"postgres://user:pass@localhost:5432/db",
);
// Default pool ceiling — must not fall back to the pg library default of 10.
expect(config.max).toBe(20);
// Both session timeouts applied at connection establishment.
expect(config.options).toContain("-c statement_timeout=60000");
expect(config.options).toContain(
"-c idle_in_transaction_session_timeout=60000",
);
});
it("honours the shared env knobs", async () => {
process.env.DATABASE_POOL_MAX = "12";
process.env.DATABASE_STATEMENT_TIMEOUT_MS = "30000";
const { getWorkerPrisma } = await import("../shared");
adapterCtor.mockClear();
getWorkerPrisma();
const config = adapterCtor.mock.calls[0][0] as {
max: number;
options?: string;
};
expect(config.max).toBe(12);
expect(config.options).toContain("-c statement_timeout=30000");
});
it("omits the options string when the timeout is disabled", async () => {
process.env.DATABASE_STATEMENT_TIMEOUT_MS = "0";
const { getWorkerPrisma } = await import("../shared");
adapterCtor.mockClear();
getWorkerPrisma();
const config = adapterCtor.mock.calls[0][0] as { options?: string };
expect(config.options).toBeUndefined();
});
it("reuses a single client across calls", async () => {
const { getWorkerPrisma } = await import("../shared");
clientCtor.mockClear();
const a = getWorkerPrisma();
const b = getWorkerPrisma();
expect(a).toBe(b);
expect(clientCtor).toHaveBeenCalledTimes(1);
});
});
+5 -27
View File
@@ -4,9 +4,8 @@
* Extracted from reminder-worker.ts, which owns the queue names, cron
* schedules, and boss.work registrations.
*/
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { buildSessionOptions, getPoolMax } from "@/lib/db";
import type { PrismaClient } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
export function parseTimeToMinutes(value: string): number {
const [h, m] = value.split(":").map(Number);
@@ -31,31 +30,10 @@ export const DATABASE_URL = process.env.DATABASE_URL!;
// immediate semantics.
export const BOOT_BACKFILL_STAGGER_SECONDS = 30;
// Reuse a single PrismaClient across all job handlers to avoid connection pool exhaustion
let workerPrisma: PrismaClient | null = null;
// Every queue handler shares the process-level client and its single bounded
// pg.Pool. Separate worker clients multiplied the configured connection limit.
export function getWorkerPrisma(): PrismaClient {
if (!workerPrisma) {
// Apply the same pool ceiling + session timeouts the web client uses
// (`src/lib/db.ts`). Without them the worker client ran unbounded: a
// pathological nightly drain on a heavy tenant could pin a connection
// indefinitely and wedge the shared worker pool. `getPoolMax()` caps the
// slot count; `buildSessionOptions()` applies `statement_timeout` +
// `idle_in_transaction_session_timeout` at connection establishment so
// every worker session is timeout-bounded from its first query. Both share
// the same `DATABASE_POOL_MAX` / `DATABASE_STATEMENT_TIMEOUT_MS` env knobs
// as the web tier, and the web + worker containers share one Postgres so a
// single ceiling covers both pools.
const sessionOptions = buildSessionOptions();
const adapter = new PrismaPg({
connectionString: DATABASE_URL,
max: getPoolMax(),
...(sessionOptions ? { options: sessionOptions } : {}),
});
workerPrisma = new PrismaClient({ adapter });
}
return workerPrisma;
return prisma;
}
/**
+1 -1
View File
@@ -194,7 +194,7 @@ export function proxy(request: NextRequest) {
// Worker-only container: refuse HTTP traffic with a clear hint instead of
// serving requests that would either crash (no DB pool ready for writes from
// the worker) or duplicate work that the dedicated web container is doing.
if (!shouldRunWeb()) {
if (!shouldRunWeb() && pathname !== "/api/health") {
return NextResponse.json(
{
data: null,
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { getPrismaClient } from "./setup";
const TEST_ENCRYPTION_KEY =
"0000000000000000000000000000000000000000000000000000000000000000";
const TEST_HMAC_KEY = "integration-test-hmac-key-32-bytes-minimum";
const TEST_SESSION_SECRET = "integration-test-session-secret-32-bytes";
describe("integration worker environment", () => {
it("replaces ambient database and encryption configuration with test values", async () => {
expect(process.env.DATABASE_URL).toContain("healthlog_test");
expect(process.env.DATABASE_URL).not.toContain("ambient_database");
expect(process.env.ENCRYPTION_KEY).toBe(TEST_ENCRYPTION_KEY);
expect(process.env.ENCRYPTION_KEYS).toBeUndefined();
expect(process.env.ENCRYPTION_ACTIVE_KEY_ID).toBeUndefined();
expect(process.env.API_TOKEN_HMAC_KEY).toBe(TEST_HMAC_KEY);
expect(process.env.SESSION_SECRET).toBe(TEST_SESSION_SECRET);
const rows = await getPrismaClient().$queryRaw<
Array<{ database_name: string }>
>`
SELECT current_database() AS database_name
`;
expect(rows).toEqual([{ database_name: "healthlog_test" }]);
});
});
+15
View File
@@ -0,0 +1,15 @@
import { inject } from "vitest";
declare module "vitest" {
export interface ProvidedContext {
integrationDatabaseUrl: string;
}
}
process.env.DATABASE_URL = inject("integrationDatabaseUrl");
process.env.ENCRYPTION_KEY =
"0000000000000000000000000000000000000000000000000000000000000000";
delete process.env.ENCRYPTION_KEYS;
delete process.env.ENCRYPTION_ACTIVE_KEY_ID;
process.env.API_TOKEN_HMAC_KEY = "integration-test-hmac-key-32-bytes-minimum";
process.env.SESSION_SECRET = "integration-test-session-secret-32-bytes";
+6 -4
View File
@@ -18,6 +18,7 @@ import { execSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { TestProject } from "vitest/node";
import {
PostgreSqlContainer,
type StartedPostgreSqlContainer,
@@ -29,7 +30,9 @@ const PROJECT_ROOT = resolve(__dirname, "..", "..");
let container: StartedPostgreSqlContainer | null = null;
export default async function setup(): Promise<() => Promise<void>> {
export default async function setup(
project: TestProject,
): Promise<() => Promise<void>> {
container = await new PostgreSqlContainer("postgres:16-alpine")
.withDatabase("healthlog_test")
.withUsername("healthlog")
@@ -38,10 +41,9 @@ export default async function setup(): Promise<() => Promise<void>> {
const url = container.getConnectionUri() + "?schema=public&pgbouncer=false";
process.env.DATABASE_URL = url;
project.provide("integrationDatabaseUrl", url);
// Provided so child processes (e.g. Prisma's CLI) inherit the URL.
// Vitest also forwards process.env to test workers, so the
// application Prisma singleton sees this URL when it loads.
// The explicit child environment keeps Prisma CLI on the same container.
execSync("pnpm db:migrate:deploy", {
cwd: PROJECT_ROOT,
stdio: "inherit",
+2 -30
View File
@@ -1,39 +1,11 @@
/**
* Per-test helpers for the integration suite. The Postgres testcontainer
* is started ONCE in `global-setup.ts` and torn down at the end of the
* run, so test files only need:
*
* import { getPrismaClient, truncateAllTables } from "./setup";
*
* beforeEach(async () => { await truncateAllTables(getPrismaClient()); });
*
* No beforeAll(startTestDb) / afterAll(stopTestDb) is needed — the
* container is alive whenever the test file is loaded, and Vitest's
* worker inherits `process.env.DATABASE_URL` from globalSetup so the
* application's Prisma singleton (`src/lib/db.ts`) connects to the
* testcontainer transparently.
* is started once in global-setup.ts and bridged into each worker by
* environment-setup.ts before application modules load.
*/
import { prisma } from "@/lib/db";
import type { PrismaClient } from "@/generated/prisma/client";
// v1.23 — every integration test imports this module. The free-text health-note
// columns (mood + measurement) are now AES-256-GCM at rest, so any test that
// writes/reads a note needs an encryption key. Crypto reads the key lazily on
// first `encrypt()`, so a `??=` default here covers every test without touching
// each file (individual tests that set their own key still win — `??=`).
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
// v1.30.32 — session cookies now carry a CSPRNG secret whose HMAC is what
// lands in `sessions.token_hash`, so `createSession` needs this key exactly
// like token issuance already did. It is a Core-required variable in
// `scripts/env-manifest.json` (the app refuses to boot without it), so
// defaulting it here matches production rather than papering over a gap.
// Same `??=` treatment as the encryption key above: files that set their own
// still win.
process.env.API_TOKEN_HMAC_KEY ??=
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
/**
* The application's Prisma singleton. Tests use this so any code
* imported via `await import("@/lib/...")` shares the exact same client
+13 -1
View File
@@ -15,7 +15,16 @@ export default defineConfig({
environment: "node",
// Same host-timezone pin as the unit config — CI runs UTC, so the
// integration contracts must be read against the same clock.
env: { TZ: "UTC" },
env: {
TZ: "UTC",
ENCRYPTION_KEY:
"0000000000000000000000000000000000000000000000000000000000000000",
ENCRYPTION_KEYS: "",
ENCRYPTION_ACTIVE_KEY_ID: "",
API_TOKEN_HMAC_KEY:
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
SESSION_SECRET: "integration-test-session-secret-32-bytes",
},
include: ["tests/integration/**/*.test.ts"],
// Container boot + migration apply is slow; give each test a generous
// budget and beforeAll/afterAll twice that.
@@ -39,6 +48,9 @@ export default defineConfig({
// expensive boot cost is unaffected.
isolate: true,
// One container for the whole run; tests truncate between them.
// Bridge the global Testcontainers URL into each isolated worker before
// application modules read DATABASE_URL at import time.
setupFiles: ["./tests/integration/environment-setup.ts"],
globalSetup: ["./tests/integration/global-setup.ts"],
},
resolve: {