mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix: harden pnpm supply chain and runtime image (#572)
This commit is contained in:
1 parent
e38179e854
commit
085aa3f5c5
15 files changed
+109
-82
No files matched your search
@@ -49,6 +49,7 @@ public/sw-version.js
|
||||
|
||||
# Editor / agent worktrees + caches.
|
||||
.claude
|
||||
.worktrees
|
||||
.cursor
|
||||
.aider*
|
||||
.vscode
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user