fix: harden pnpm supply chain and runtime image (#572)

This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-21 04:36:53 +02:00
1 parent e38179e854
commit 085aa3f5c5
15 files changed
+109 -82

No files matched your search

+1
View File
@@ -49,6 +49,7 @@ public/sw-version.js
# Editor / agent worktrees + caches.
.claude
.worktrees
.cursor
.aider*
.vscode
+4
View File
@@ -20,6 +20,10 @@
- **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.
- **The production image no longer ships build-time package managers.** pnpm 11
verifies the lockfile under explicit build-script policy, npm and Corepack
are removed after migrations are prepared, and linked worktrees stay out of
local Docker build contexts.
No migrations. No breaking changes.
+10 -6
View File
@@ -1,6 +1,6 @@
# ── Stage 1: Dependencies ──────────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps
RUN corepack enable && corepack prepare pnpm@10.31.0 --activate
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
@@ -9,7 +9,7 @@ RUN pnpm install --frozen-lockfile --prod=false
# ── Stage 2: Build ─────────────────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder
RUN corepack enable && corepack prepare pnpm@10.31.0 --activate
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
# v1.4.43 B11 — version build-arg threaded into the layer cache key.
@@ -50,8 +50,7 @@ FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb
# `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@10.31.0 --activate
RUN apk add --no-cache tzdata
WORKDIR /app
ENV NODE_ENV=production
@@ -127,11 +126,16 @@ 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 Prisma CLI + engines for runtime migrations (isolated from Next standalone tree)
# Install the migration CLI plus a pinned launcher for the maintenance scripts
# already shipped in the standalone tree. Then strip npm/Corepack and caches:
# package managers are build-time tools, not runtime surface.
RUN mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \
npm init -y && \
npm install --omit=dev prisma@7.8 @prisma/engines@7.8
npm install --omit=dev prisma@7.8 @prisma/engines@7.8 tsx@4.23.1 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \
rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx
# v1.4.27 B3 — offline GeoLite2 databases for IP→location and IP→ASN
# lookups. The MMDB files live in `/opt/geolite2/` and are read by
+3 -3
View File
@@ -84,9 +84,9 @@ pnpm dlx tsx scripts/restore-backup.ts \
/tmp/restored.json
```
The production standalone image strips `tsx`, so a bare
`pnpm tsx scripts/...` fails inside the container — always invoke
one-shot scripts via `pnpm dlx tsx`.
Run this command from a source checkout with the production backup variables
exported. The script imports the full application dependency graph, which the
minimal production image does not expose as an operator scripting environment.
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
+14 -15
View File
@@ -46,24 +46,23 @@ metadata (`medication_schedules`):
Always. The default mode only reports and never mutates:
```bash
# inside the app container (or any checkout) — DATABASE_URL must point
# at the target database
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
# inside the production app container — DATABASE_URL is inherited
healthlog-tsx scripts/repair-intake-anomalies.ts
# scoped to one account
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <userId>
healthlog-tsx scripts/repair-intake-anomalies.ts --user <userId>
# source-checkout equivalent
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
```
The script is self-contained: its only import is `pg` and it reads
`DATABASE_URL` straight from the environment (no dotenv, no `@/` path
alias, no `src/` imports). That matters in the production container —
the standalone image ships no project `node_modules`, so the previous
revision died there with `Cannot find module 'dotenv/config'`. Inside
the image `pg` resolves via `NODE_PATH=/opt/pg-boss/node_modules`; in a
checkout it resolves from the project `node_modules`; the
`--package pg --package tsx` pins cover any environment with neither.
Use `pnpm dlx`, not bare `pnpm tsx` — the standalone image also strips
`tsx`.
alias, no `src/` imports). The production image provides the pinned
`healthlog-tsx` launcher and resolves `pg` from its standalone runtime tree
without retaining a package manager. In a source checkout, `pg` resolves from
the project `node_modules`; `pnpm dlx --package pg --package tsx ...` also
works in an environment where neither dependency is installed.
The dry-run prints every duplicate group (which row would be kept, which
would be tombstoned), a table of implausible rows (id, medication,
@@ -74,7 +73,7 @@ would write), and a table of stale-anchor pending rows.
## Applying the fix
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
healthlog-tsx scripts/repair-intake-anomalies.ts --fix
```
`--fix` does five things:
@@ -92,7 +91,7 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
in explicitly:
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
healthlog-tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
```
`--tombstone-implausible` is refused without `--fix`.
@@ -139,7 +138,7 @@ slot anchors:
The default run only **reports** the proposals. Apply them explicitly:
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
healthlog-tsx scripts/repair-intake-anomalies.ts --backfill-eras
```
Review the dry-run table first — the inference is a heuristic. A wrongly
+4 -3
View File
@@ -13,10 +13,11 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// v1.4.41 — ignore local tool worktrees so a worktree on an older
// commit can't poison `pnpm lint` locally.
// CI never sees this path; the rule is for the dev environment.
// v1.31.4 — ignore local tool worktrees so a worktree on another
// commit cannot poison `pnpm lint` locally.
// CI never sees these paths; the rules protect the dev environment.
".claude/**",
".worktrees/**",
// The project-local ESLint rule plugin is CommonJS (require/module
// .exports) by ESLint convention; it is not application source and
// is not linted by the app's TypeScript ruleset.
+1 -14
View File
@@ -22,7 +22,7 @@
"passkey"
],
"private": true,
"packageManager": "pnpm@10.31.0",
"packageManager": "pnpm@11.15.1",
"scripts": {
"prepare": "node scripts/prepare.mjs",
"dev": "next dev",
@@ -135,18 +135,5 @@
"tw-animate-css": "^1.4.0",
"typescript": "^6",
"vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"prisma",
"@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"
}
}
}
+20 -3
View File
@@ -1,3 +1,20 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver
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"
allowBuilds:
"@prisma/engines": true
prisma: true
core-js: false
cpu-features: false
esbuild: false
lefthook: false
msw: false
protobufjs: false
sharp: false
ssh2: false
tesseract.js: false
unrs-resolver: false
+7 -1
View File
@@ -4,11 +4,17 @@ One-shot operator and build scripts. These are not part of the running applicati
## How to run them
From a source checkout:
```bash
pnpm dlx tsx scripts/<file>.ts
```
Use `pnpm dlx tsx`, not bare `pnpm tsx`. The production standalone image strips `tsx`, so the bare invocation fails inside the container. `scripts/` is excluded from the project typecheck.
The production image intentionally removes npm, pnpm, and Corepack. Only
container-safe scripts named by an operator runbook use its pinned
`healthlog-tsx` launcher; scripts that import the full application dependency
graph must run from a source checkout. `scripts/` is excluded from the project
typecheck.
## What lives here
+10 -3
View File
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { parse } from "yaml";
import integrationConfig from "../../vitest.integration.config.mts";
import unitConfig from "../../vitest.config.mts";
function repositoryRoot(): string {
const testPath = expect.getState().testPath;
@@ -75,6 +76,12 @@ describe("Playwright CI determinism", () => {
});
});
describe("local worktree isolation", () => {
it("keeps nested worktree suites out of the root unit run", () => {
expect(unitConfig.test?.exclude ?? []).toContain(".worktrees/**");
});
});
describe("integration environment isolation", () => {
it("registers a worker setup bridge and authoritative test secrets", () => {
const test = integrationConfig.test;
@@ -114,11 +121,11 @@ describe("container dependency installation", () => {
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> };
const workspace = parse(readRepoFile("pnpm-workspace.yaml")) as {
overrides?: Record<string, string>;
};
expect(packageJson.pnpm?.overrides).toMatchObject({
expect(workspace.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",
+16 -4
View File
@@ -232,16 +232,28 @@ describe("release container inputs", () => {
);
});
it("uses the packageManager pnpm version instead of a moving tag", () => {
it("pins pnpm for builds and removes package managers from runtime", () => {
const dockerfile = readRepoFile("Dockerfile");
const packageJson = JSON.parse(readRepoFile("package.json")) as {
packageManager: string;
};
expect(packageJson.packageManager).toBe("pnpm@10.31.0");
expect(packageJson.packageManager).toBe("pnpm@11.15.1");
expect(dockerfile).not.toContain("pnpm@latest");
expect(
dockerfile.match(/corepack prepare pnpm@10\.31\.0 --activate/g),
).toHaveLength(3);
dockerfile.match(/corepack prepare pnpm@11\.15\.1 --activate/g),
).toHaveLength(2);
expect(dockerfile).toContain(
"rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack",
);
expect(dockerfile).toContain("tsx@4.23.1");
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx",
);
});
it("keeps linked worktrees out of the Docker build context", () => {
const dockerignore = readRepoFile(".dockerignore");
expect(dockerignore.split(/\r?\n/)).toContain(".worktrees");
});
it("bundles Prisma's adapter and verifies only runtime externals", () => {
+12 -19
View File
@@ -64,32 +64,25 @@
*
* SELF-CONTAINED by design (v1.16.0)
* ----------------------------------
* The production standalone image ships neither the project
* `node_modules` tree nor the `@/` path alias, so the previous
* `dotenv/config` + `@/lib/db` import chain died with "Cannot find
* module" inside the container. This script therefore depends on
* exactly ONE package — `pg` — and reads `DATABASE_URL` straight from
* the environment (no dotenv). Inside the production image `pg` resolves
* via `NODE_PATH=/opt/pg-boss/node_modules` (Dockerfile installs
* `pg@8.x` there); in a repo checkout it resolves from the project
* `node_modules`. The `pnpm dlx` invocation below supplies `tsx` (the
* standalone image strips it) and pins `pg` for any environment where
* neither resolution path exists.
* The production standalone image does not expose the project package
* manager. It provides a pinned `healthlog-tsx` maintenance launcher, while
* `pg` resolves from the standalone runtime tree. In a repo checkout, run the
* same script through `pnpm dlx --package pg --package tsx tsx ...`.
*
* Usage
* -----
* Usage inside the production container
* -------------------------------------
* # dry-run (DATABASE_URL must point at the target database)
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
* healthlog-tsx scripts/repair-intake-anomalies.ts
*
* # scoped dry-run
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <id>
* healthlog-tsx scripts/repair-intake-anomalies.ts --user <id>
*
* # apply
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
* healthlog-tsx scripts/repair-intake-anomalies.ts --fix
* healthlog-tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
*
* # create the proposed schedule revisions + startsOn fixes (defect 5)
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
* healthlog-tsx scripts/repair-intake-anomalies.ts --backfill-eras
*/
import { Client, types } from "pg";
@@ -425,7 +418,7 @@ async function main(): Promise<void> {
if (!process.env.DATABASE_URL) {
console.error(
`${TAG} DATABASE_URL must be set (no dotenv — export it or prefix ` +
`the command: DATABASE_URL='postgresql://…' pnpm dlx …)`,
`the command: DATABASE_URL='postgresql://…' healthlog-tsx …)`,
);
process.exit(1);
}
+2 -3
View File
@@ -14,9 +14,8 @@
* BACKUP_S3_SECRET_KEY=... \
* BACKUP_S3_REGION=auto \
* BACKUP_ENCRYPTION_KEY=<hex64> \
* pnpm tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
* pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
*/
import "dotenv/config";
import { writeFileSync } from "node:fs";
import {
loadOffhostConfig,
@@ -82,7 +81,7 @@ async function main() {
?.slice("--user-id=".length);
if (!key) {
console.error(
"Usage: pnpm tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
"Usage: pnpm dlx tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
);
process.exit(1);
}
+2 -5
View File
@@ -1,11 +1,8 @@
/**
* Test script for notification delivery and reminder check.
*
* Usage (inside Docker container):
* npx tsx scripts/test-notifications.ts
*
* Or from host:
* docker compose exec app npx tsx scripts/test-notifications.ts
* Usage (from a source checkout with the target environment exported):
* pnpm dlx tsx scripts/test-notifications.ts
*
* What it does:
* 1. Lists all configured notification channels
+3 -3
View File
@@ -31,10 +31,10 @@ export default defineConfig({
"tests/integration/**",
// Playwright E2E suite — driven separately via `pnpm e2e`.
"e2e/**",
// Live agent worktrees create copies of `src/` under
// `.claude/worktrees/`; vitest would otherwise pick those copies up
// and run their tests twice — possibly against stale snapshots.
// Local agent worktrees create copies of `src/` under `.claude/`
// or `.worktrees/`; vitest must not collect stale duplicate suites.
".claude/worktrees/**",
".worktrees/**",
],
coverage: {
provider: "v8",