From f95397c3c89d3bbd1dabc2483607b28b2ae2c39c Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 16:21:15 -0700 Subject: [PATCH] Package production runtime consumers and verify actual images --- ...me-images-need-installed-consumer-proof.md | 46 ++++++ .agents/issues/INDEX.md | 1 + .agents/issues/security-audit-index.md | 2 + .github/workflows/ci.yml | 77 +++++++++- deployment/Dockerfile | 66 ++++---- deployment/docker-compose.http.yml | 2 +- deployment/docker-compose.yml | 2 +- deployment/self-host/Dockerfile | 29 +++- .../RUNTIME_IMAGE_PACKAGING_2026-10-27.md | 126 ++++++++++++++++ package-lock.json | 3 +- packages/server/Dockerfile | 53 ++++--- packages/server/package.json | 3 +- packages/server/src/index.ts | 8 - tests/runtime/check-scan.mjs | 9 ++ tests/runtime/image-contract.mjs | 142 ++++++++++++++++++ tests/runtime/image-inventory.mjs | 56 +++++++ tests/runtime/run-image.mjs | 71 +++++++++ 17 files changed, 615 insertions(+), 81 deletions(-) create mode 100644 .agents/inbox/2026-10-27-runtime-images-need-installed-consumer-proof.md create mode 100644 docs/quality/RUNTIME_IMAGE_PACKAGING_2026-10-27.md create mode 100644 tests/runtime/check-scan.mjs create mode 100644 tests/runtime/image-contract.mjs create mode 100644 tests/runtime/image-inventory.mjs create mode 100644 tests/runtime/run-image.mjs diff --git a/.agents/inbox/2026-10-27-runtime-images-need-installed-consumer-proof.md b/.agents/inbox/2026-10-27-runtime-images-need-installed-consumer-proof.md new file mode 100644 index 00000000..d5bb704c --- /dev/null +++ b/.agents/inbox/2026-10-27-runtime-images-need-installed-consumer-proof.md @@ -0,0 +1,46 @@ +# Runtime pruning needs actual consumer and persistence evidence + +Date: 2026-10-27. Issue #185. Searches of the symptom/security indices found +consumer-verified dependency and startup lessons, but no runtime-pruning contract. + +## Evidence + +`docker run --rm --entrypoint node -i graphdone-audit-patched-api:20261023 +--input-type=module < tests/runtime/image-inventory.mjs` failed on installed +Vite/Vitest/compiler/lint dependencies. A selected production install removed +them, but the first real compiled API launch failed `ERR_MODULE_NOT_FOUND` for +`graphql-tag`. The dependency had been supplied by the excluded web workspace. +Declaring the existing locked version in server restored the real consumer. + +Review also found a remaining npm-based startup shell call, proposed UID drift, +an SDK root entry that is intentionally absent, and auth volumes targeting a +directory different from the process's store. The final source removes the +redundant shell call, preserves runtime identities, validates SDK subpaths and +points production volumes at the actual store. Three final image contracts pass +real API/MCP/database readback and account recovery after container replacement. +Host server 211/211, harness 192/192, types and lint pass; final CI remains required. + +The full Trivy 0.76.1/dated-DB inventories show 0 npm findings in all images, 0 OS +findings in the Alpine variants, and 228 residual Bookworm OS entries after the +tzdata update. A raw critical MiniZip entry has a specific Debian applicability +explanation; this does not justify dismissing the rest. Full provenance, +commands, negative controls and limits are in the +[runtime packaging record](../../docs/quality/RUNTIME_IMAGE_PACKAGING_2026-10-27.md). + +## Cause and corrective rule + +A successful monorepo build can hide undeclared imports supplied by another +workspace. Copying a pruned root node_modules alone can lose nested dependencies, +native binaries, process identity or data ownership. Install selected production +consumers from the lock, preserve workspace-local trees, compile native code +against the runtime libc, and execute the actual compiled consumers. Test the +real data mount across container replacement; in-memory SQLite is insufficient. +Use the image's default production configuration for missing-secret tests. + +## Limits + +Present packages are not necessarily reachable surfaces. Scanner output and +package counts do not establish exploitability or full-image security clearance. +Retain raw findings and use primary distro evidence for scoped applicability. +No deployment, volume migration/deletion, identity change or protection change +is authorized by an image inventory or this lesson. diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index df3b31f2..209314dc 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -2,6 +2,7 @@ | Symptom | Canonical lesson and regression | | --- | --- | +| Pruned runtime starts failing imports, retains dev tools or loses auth across container replacement | [Installed consumer and persistence proof](../inbox/2026-10-27-runtime-images-need-installed-consumer-proof.md); `tests/runtime/run-image.mjs` | | A toolchain upgrade changes test counts, loses matcher types or drops unimported coverage | [Preserve discovery and consumer behavior](../inbox/2026-10-27-toolchain-upgrades-must-preserve-test-contracts.md); [migration evidence](../../docs/quality/TOOLCHAIN_MIGRATION_2026-10-27.md) | | OAuth links an unverified email, changes accounts after a provider email change, or leaves an orphan user after a link failure | [Provider proof and isolated atomic linking](../inbox/2026-10-27-oauth-linking-needs-provider-proof-and-atomicity.md); `oauth-identity`, `oauth-profiles` and actual `oauth-strategies` tests | | Login and reset links cross-use, or a reset/reactivation revives old sessions | [Bind purpose and session revision at the write](../inbox/2026-10-27-email-purpose-and-revision-are-auth-boundaries.md); real SQLite/HTTP/WebSocket `auth-link-boundary.test.ts` | diff --git a/.agents/issues/security-audit-index.md b/.agents/issues/security-audit-index.md index a6f68624..75c090a7 100644 --- a/.agents/issues/security-audit-index.md +++ b/.agents/issues/security-audit-index.md @@ -14,3 +14,5 @@ | A fresh audit reports Zod high severity after an earlier green scan; bounded collections still walk rejected elements | [Zod consumer and bounded-validation lesson](../inbox/2026-10-23-zod-constrained-collections.md); recheck the resolved SDK dependency and use an isolated audit cache without inferring why older metadata differed | | Browser login lockout is mistaken for server throttling; one-use email token succeeds concurrently | [Remaining authentication work](../../docs/quality/SECURITY_SCOPE_2026-10-23.md#remaining-authentication-work) | | A federation upgrade removes UUID findings but breaks Neo4j schema generation; an override leaves the old lock resolution | [Validate federation consumers and installed UUID paths](../inbox/2026-10-27-federation-consumers-bound-uuid-upgrades.md); `tests/lib/dependencies/uuid.test.mjs` | + +Runtime image pruning and actual OS/npm inventories: [2026-10-27 consumer/persistence lesson](../inbox/2026-10-27-runtime-images-need-installed-consumer-proof.md), [image provenance and residuals](../../docs/quality/RUNTIME_IMAGE_PACKAGING_2026-10-27.md). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d00fb8e..94ab3775 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -338,11 +338,83 @@ jobs: test-artifacts/ retention-days: 7 + runtime-images: + name: Runtime Images (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: api + dockerfile: packages/server/Dockerfile + target: runner + uid: '0' + - name: generic + dockerfile: deployment/Dockerfile + target: runner + uid: '1001' + - name: self-host + dockerfile: deployment/self-host/Dockerfile + target: api + uid: '1000' + services: + neo4j: + image: neo4j:5.26.12 + env: + NEO4J_AUTH: neo4j/graphdone_runtime_fixture + NEO4J_PLUGINS: '["apoc"]' + NEO4J_dbms_security_procedures_unrestricted: 'apoc.*' + options: >- + --health-cmd "cypher-shell -u neo4j -p graphdone_runtime_fixture 'RETURN 1'" + --health-interval 10s --health-timeout 5s --health-retries 30 + ports: + - 7687:7687 + env: + GRAPHDONE_DISPOSABLE_NEO4J: '1' + NEO4J_URI: bolt://127.0.0.1:7687 + NEO4J_USER: neo4j + NEO4J_PASSWORD: graphdone_runtime_fixture + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Build the final runtime image + run: | + mkdir -p test-artifacts/runtime + docker build --pull --target '${{ matrix.target }}' -f '${{ matrix.dockerfile }}' --iidfile test-artifacts/runtime/image-id . + - name: Verify installed packages, production startup, API, MCP and volume recovery + shell: bash + run: node tests/runtime/run-image.mjs "$(cat test-artifacts/runtime/image-id)" '${{ matrix.uid }}' | tee test-artifacts/runtime/contract.jsonl + - name: Record final image OS and npm inventory + shell: bash + run: | + docker save "$(cat test-artifacts/runtime/image-id)" -o "$RUNNER_TEMP/runtime-image.tar" + mkdir -p "$RUNNER_TEMP/runtime-trivy-cache" + docker run --rm --user "$(id -u):$(id -g)" \ + --mount "type=bind,source=$RUNNER_TEMP/runtime-image.tar,target=/image.tar,readonly" \ + --mount "type=bind,source=$RUNNER_TEMP/runtime-trivy-cache,target=/cache" \ + aquasec/trivy@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c \ + image --input /image.tar --cache-dir /cache --scanners vuln --list-all-pkgs --format json \ + > test-artifacts/runtime/trivy.json + cp "$RUNNER_TEMP/runtime-trivy-cache/db/metadata.json" test-artifacts/runtime/trivy-db.json + node tests/runtime/check-scan.mjs test-artifacts/runtime/trivy.json + - name: Retain image provenance and contract results + if: always() + uses: actions/upload-artifact@v4 + with: + name: runtime-image-${{ matrix.name }}-${{ github.sha }} + path: test-artifacts/runtime/ + retention-days: 7 + # ── Overall status ───────────────────────────────────────────────────────── ci-success: name: CI Success runs-on: ubuntu-latest - needs: [quality, unit-tests, mcp-contract, build, security-scan, smoke-gate] + needs: [quality, unit-tests, mcp-contract, build, security-scan, smoke-gate, runtime-images] if: always() steps: - name: Verify required jobs passed @@ -356,7 +428,8 @@ jobs: && "${{ needs.mcp-contract.result }}" == "success" \ && "${{ needs.build.result }}" == "success" \ && "${{ needs.security-scan.result }}" == "success" \ - && "${{ needs.smoke-gate.result }}" == "success" ]]; then + && "${{ needs.smoke-gate.result }}" == "success" \ + && "${{ needs.runtime-images.result }}" == "success" ]]; then echo "✅ CI green — lint, types, every unit suite, the build, and the user smoke gate all passed." else echo "❌ CI failed — see the failing job above." diff --git a/deployment/Dockerfile b/deployment/Dockerfile index 26ea6d33..0355faa3 100644 --- a/deployment/Dockerfile +++ b/deployment/Dockerfile @@ -1,50 +1,40 @@ -# Multi-stage build for production -FROM node:22-alpine AS base - -# Install dependencies only when needed -FROM base AS deps -RUN apk add --no-cache libc6-compat +FROM node:22-alpine AS build +RUN apk add --no-cache python3 make g++ WORKDIR /app - -# Install dependencies based on the preferred package manager -COPY package.json package-lock.json turbo.json ./ +COPY package.json package-lock.json turbo.json tsconfig.json ./ COPY packages/core/package.json ./packages/core/ COPY packages/server/package.json ./packages/server/ -COPY packages/web/package.json ./packages/web/ COPY packages/mcp-server/package.json ./packages/mcp-server/ - -RUN npm ci --legacy-peer-deps - -# Build the source code -FROM base AS builder -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/packages ./packages -COPY . . - -# Build all packages +COPY packages/web/package.json ./packages/web/ +RUN npm ci --legacy-peer-deps --no-audit --no-fund && npm rebuild sqlite3 --build-from-source +COPY packages ./packages RUN npm run build +RUN find packages/core/dist packages/server/dist packages/mcp-server/dist -type f \( -name '*.test.*' -o -name '*.spec.*' \) -delete -# Production image, copy all the files and run the app -FROM base AS runner +FROM node:22-alpine AS production-deps WORKDIR /app +COPY package.json package-lock.json ./ +COPY packages/core/package.json ./packages/core/ +COPY packages/server/package.json ./packages/server/ +COPY packages/mcp-server/package.json ./packages/mcp-server/ +COPY packages/web/package.json ./packages/web/ +RUN npm ci --legacy-peer-deps --omit=dev --omit=optional --ignore-scripts --no-audit --no-fund --workspace=@graphdone/core --workspace=@graphdone/server --workspace=@graphdone/mcp-server --include-workspace-root=true +COPY --from=build /app/node_modules/sqlite3/build/Release/node_sqlite3.node ./node_modules/sqlite3/build/Release/node_sqlite3.node +FROM node:22-alpine AS runner +RUN apk add --no-cache curl && rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /opt/yarn* /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg /usr/local/bin/pnpm /usr/local/bin/pnpx +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 graphdone +WORKDIR /app ENV NODE_ENV=production - -# Create non-root user -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 graphdone - -# Copy built application -COPY --from=builder --chown=graphdone:nodejs /app/package.json ./package.json -COPY --from=builder --chown=graphdone:nodejs /app/packages/core ./packages/core -COPY --from=builder --chown=graphdone:nodejs /app/packages/server ./packages/server -COPY --from=builder --chown=graphdone:nodejs /app/packages/web/dist ./web -COPY --from=deps --chown=graphdone:nodejs /app/node_modules ./node_modules - +COPY --from=production-deps /app/package.json ./package.json +COPY --from=production-deps /app/node_modules ./node_modules +COPY --from=production-deps /app/packages ./packages +COPY --from=build /app/packages/core/dist ./packages/core/dist +COPY --from=build /app/packages/server/dist ./packages/server/dist +COPY --from=build /app/packages/mcp-server/dist ./packages/mcp-server/dist +COPY --from=build /app/packages/web/dist ./web +RUN mkdir -p /app/packages/server/data && chown graphdone:nodejs /app/packages/server/data USER graphdone - -EXPOSE 4127 - WORKDIR /app/packages/server +EXPOSE 4127 CMD ["node", "dist/index.js"] diff --git a/deployment/docker-compose.http.yml b/deployment/docker-compose.http.yml index a7b3f20a..9d0fbaa1 100644 --- a/deployment/docker-compose.http.yml +++ b/deployment/docker-compose.http.yml @@ -66,7 +66,7 @@ services: volumes: - ../packages/server/.env:/app/.env - logs:/app/logs - - sqlite_auth_data:/app/data + - sqlite_auth_data:/app/packages/server/data # SSL certificates (uncomment when using HTTPS) # - ./certs:/app/certs:ro healthcheck: diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 8a685c24..0825965f 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -68,7 +68,7 @@ services: volumes: - ../packages/server/.env:/app/.env - logs:/app/logs - - sqlite_auth_data:/app/data + - sqlite_auth_data:/app/packages/server/data - ./certs:/app/certs:ro # Mount SSL certificates healthcheck: test: ["CMD", "curl", "-k", "-f", "https://localhost:4128/health"] diff --git a/deployment/self-host/Dockerfile b/deployment/self-host/Dockerfile index 05603646..3edd4c3f 100644 --- a/deployment/self-host/Dockerfile +++ b/deployment/self-host/Dockerfile @@ -2,17 +2,36 @@ FROM node:22-bookworm-slim AS build RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package.json package-lock.json turbo.json tsconfig.json ./ -COPY packages ./packages +COPY packages/core/package.json ./packages/core/ +COPY packages/server/package.json ./packages/server/ +COPY packages/mcp-server/package.json ./packages/mcp-server/ +COPY packages/web/package.json ./packages/web/ # Compile the native binding against this image's libc instead of accepting a # prebuilt SQLite binary that may require a newer glibc than the runtime image. RUN npm ci --legacy-peer-deps --no-audit --no-fund && npm rebuild sqlite3 --build-from-source -RUN npm run build -w @graphdone/core && npm run build -w @graphdone/server +COPY packages ./packages +RUN npm run build -w @graphdone/core && npm run build -w @graphdone/server && npm run build -w @graphdone/mcp-server RUN VITE_GRAPHQL_URL=/api/graphql npm run build -w @graphdone/web +RUN find packages/core/dist packages/server/dist packages/mcp-server/dist -type f \( -name '*.test.*' -o -name '*.spec.*' \) -delete +FROM node:22-bookworm-slim AS production-deps +WORKDIR /app +COPY package.json package-lock.json ./ +COPY packages/core/package.json ./packages/core/ +COPY packages/server/package.json ./packages/server/ +COPY packages/mcp-server/package.json ./packages/mcp-server/ +COPY packages/web/package.json ./packages/web/ +RUN npm ci --legacy-peer-deps --omit=dev --omit=optional --ignore-scripts --no-audit --no-fund --workspace=@graphdone/core --workspace=@graphdone/server --workspace=@graphdone/mcp-server --include-workspace-root=true +COPY --from=build /app/node_modules/sqlite3/build/Release/node_sqlite3.node ./node_modules/sqlite3/build/Release/node_sqlite3.node FROM node:22-bookworm-slim AS api +ENV NODE_ENV=production +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --only-upgrade tzdata && rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /opt/yarn* /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg /usr/local/bin/pnpm /usr/local/bin/pnpx WORKDIR /app -COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/packages/core ./packages/core -COPY --from=build /app/packages/server ./packages/server +COPY --from=production-deps /app/package.json ./package.json +COPY --from=production-deps /app/node_modules ./node_modules +COPY --from=production-deps /app/packages ./packages +COPY --from=build /app/packages/core/dist ./packages/core/dist +COPY --from=build /app/packages/server/dist ./packages/server/dist +COPY --from=build /app/packages/mcp-server/dist ./packages/mcp-server/dist RUN mkdir -p /app/packages/server/data && chown -R node:node /app/packages/server/data WORKDIR /app/packages/server USER node diff --git a/docs/quality/RUNTIME_IMAGE_PACKAGING_2026-10-27.md b/docs/quality/RUNTIME_IMAGE_PACKAGING_2026-10-27.md new file mode 100644 index 00000000..3efcbbb7 --- /dev/null +++ b/docs/quality/RUNTIME_IMAGE_PACKAGING_2026-10-27.md @@ -0,0 +1,126 @@ +# Runtime dependency packaging — 2026-10-27 + +This implements the runtime-packaging slice of #185, based on reviewed toolchain +source `569ad9f`. It does not publish or deploy images. Main/release gates, hosted +acceptance and base-OS triage remain separate. A green inventory step proves a +scan completed; it does not mean every recorded finding has been cleared. + +## Installed runtime contract + +API, generic and self-host API builds install the lock's selected production +workspaces (core/server/MCP plus root production declarations), omitting dev and +optional packages and install scripts. Each build compiles SQLite against its +own image libc and copies that exact native binding into the production install. +Workspace-local dependency trees are retained. Compiled application output and +package manifests are copied; workspace source/test trees, compiled test files, Vite, +Vitest, TypeScript, tsx, ESLint, Turbo, esbuild, node-gyp and global npm/Yarn/ +Corepack are excluded. The generic image still includes the built web assets. + +`graphql-tag` was used directly by server schemas but declared only by the web +workspace. The reduced image exposed that missing declaration. The server now +declares the existing locked 2.12.6; no installed dependency version changed. +Root `passport-openidconnect` remains resolved through the explicit root install. +The API no longer shells out to `npm run create-admin`: the awaited SQLite +initialization already creates/checks the initial account before Neo4j startup. + +Existing runtime identities remain API root (legacy behavior), generic +`graphdone:1001`, and self-host `node:1000`. This does not claim API non-root +hardening. Generic's existing UID is preserved for existing data owners. +Self-host now defaults to `NODE_ENV=production` even outside Compose. Production +HTTP/HTTPS Compose auth volumes target the actual server working directory, +`/app/packages/server/data`; the previous `/app/data` target did not persist the +store used by the process. No existing volumes are migrated or deleted here. + +## Acceptance and negative evidence + +Three clean final images passed `tests/runtime/run-image.mjs IMAGE EXPECTED_UID`. +The runner checks the actual image configuration/default command, missing-secret +failure without network access, installed consumer resolution (including SDK +subpaths/root imports), absent tools/tests, runtime UID and native SQLite. + +Each image then starts its compiled API against an explicitly owned loopback +Neo4j fixture and private named auth volume. The test rejects missing/replayed +CAPTCHA, solves a real production challenge, signs in, creates an owned graph, +uses the compiled MCP stdio process to create/read a node, observes it through +GraphQL, updates it through GraphQL and verifies MCP plus independent database +readback. Protocol errors and cleanup failures fail. Replacing the container +while retaining only its named volume must preserve the exact SQLite account ID. +The entire real boundary contract passes again after replacement. Owned graphs, +containers and named volumes are removed strictly by the runner. + +Retained failures: + +- The old full-dependency API image fails the actual installed-tool inventory. +- The first reduced image fails real ESM startup on missing `graphql-tag`. +- Preliminary review caught the shell-based administrator check and proposed + generic UID drift before acceptance. The final source removes the redundant + shell call and preserves existing identities. +- Initial inventory drafts incorrectly treated `typescript-memoize` as the + compiler and resolved the MCP SDK's nonexistent root entry. The corrected + contract checks exact tool names and actual exported consumer subpaths; it + still launches the real compiled API/MCP processes. + +Local clean host install, all-package typecheck/lint and harness 192/192 pass. +The affected server suite passes 211/211. Docker builds compile all relevant +packages. Existing lint/bundle warnings remain. These checks use owned Linux +fixtures, not a public deployment, Windows test or historical-volume migration. + +## Actual image and scanner provenance + +Final local manifest digests: + +| Image | Manifest digest | Runtime UID | +| --- | --- | --- | +| API | `sha256:4deb93462ed64371cc64a425f099449ad7561f3e4df2a46f336658b44caa8a00` | 0 | +| Generic | `sha256:43202783142bbde44333518f451e39b67495a06ffa25ee783871f7c9c81b4535` | 1001 | +| Self-host API | `sha256:8d464a8965e84b815e0c5172ccac982b9942a50b07a474e5dac83ce72c637ea4` | 1000 | + +The inventory records 393 named manifests per image, including module entrypoint +manifests without versions; this is not a distinct-package or exploit count. +Trivy reports 377 Node package entries per image. It also records 28 Alpine OS +packages or 88 Debian OS packages. Separate counting schemes are not conflated. + +Scanner: Trivy 0.76.1, immutable scanner image +`aquasec/trivy@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c`. +DB version 2, updated2026-10-27T19:17:44.708952708Z and downloaded23:01:37Z. +Scanner containers receive only the owned image archive and dedicated cache; +no Docker socket, credentials or broad workspace mounts are required. + +Commands use `docker save` and `trivy image --input ... --scanners vuln +--list-all-pkgs --format json`. Full JSON and DB metadata are retained under the +owned dated evidence directory and by the new required CI image matrix. Scans +are inventories of known findings in supported analyzers, not proof of absence +of vulnerabilities or of package reachability. + +| Final scan | OS findings | npm findings | +| --- | ---: | ---: | +| Alpine API | 0 | 0 | +| Alpine generic | 0 | 0 | +| Debian Bookworm self-host API | 228 | 0 | + +Bookworm initially reported 229 OS entries. Upgrading tzdata from 2026c to +2026e-0+deb12u1 removes the one entry with an available distro fix. The final raw +inventory retains 1 critical, 56 high, 87 medium, 83 low and 1 unknown; statuses are +170 affected, 17 will_not_fix and 41 fix_deferred. These counts are not distinct +reachable exploits. Base-OS triage is tracked separately under #209. + +The critical raw entry, CVE-2023-45853 on zlib1g 1:1.2.13.dfsg-1, concerns MiniZip. +[Debian's primary assessment](https://security-tracker.debian.org/tracker/CVE-2023-45853) +states the affected contrib/minizip component is not built for Bookworm's package. +Actual `dpkg-query -L zlib1g` lists libz.so.1.2.13; no minizip/libminizip1 package +is installed. This specific applicability evidence does not dismiss other +findings or alter the raw scan. [Debian tzdata inventory](https://security-tracker.debian.org/tracker/source-package/tzdata) +identifies the installed security update. + +## CI and remaining limits + +The required `Runtime Images` matrix builds each final image and runs the same +real contract, including replacement-container recovery. `CI Success` rejects +failed/skipped matrix outcomes. The inventory step requires valid OS and Node +package evidence and retains all findings; it is deliberately not represented +as a zero-vulnerability policy. Final exact-head review and CI remain required. + +Turbo/Mermaid tooling updates, base-OS remediation/applicability review, historical +mis-mounted data recovery, hosted acceptance and main/release/deployment clearance +are unfinished. Existing TLS mount semantics are preserved; these local image +contracts exercise HTTP API behavior and do not replace production TLS acceptance. diff --git a/package-lock.json b/package-lock.json index 0a4069ab..95d9d03a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14863,7 +14863,8 @@ "passport-linkedin-oauth2": "^2.0.0", "sqlite3": "^6.0.1", "uuid": "^11.1.0", - "ws": "^8.14.0" + "ws": "^8.14.0", + "graphql-tag": "^2.12.6" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index 177dce26..c949d1e1 100644 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -1,32 +1,37 @@ -FROM node:22-alpine - -# Install curl for health checks -RUN apk add --no-cache curl - +FROM node:22-alpine AS build +RUN apk add --no-cache python3 make g++ WORKDIR /app - -# Copy all package files for monorepo workspace -COPY package.json package-lock.json turbo.json ./ +COPY package.json package-lock.json turbo.json tsconfig.json ./ COPY packages/core/package.json ./packages/core/ COPY packages/server/package.json ./packages/server/ -COPY packages/web/package.json ./packages/web/ COPY packages/mcp-server/package.json ./packages/mcp-server/ +COPY packages/web/package.json ./packages/web/ +RUN npm ci --legacy-peer-deps --no-audit --no-fund && npm rebuild sqlite3 --build-from-source +COPY packages ./packages +RUN npm run build +RUN find packages/core/dist packages/server/dist packages/mcp-server/dist -type f \( -name '*.test.*' -o -name '*.spec.*' \) -delete -# Install all dependencies (monorepo workspace) -RUN npm ci --legacy-peer-deps - -# Copy all source code (needed for monorepo dependencies) -COPY packages/ ./packages/ -COPY tsconfig.json ./ - -# Build core package first (dependency) -RUN npm run build --workspace=@graphdone/core - -# Build server package -RUN npm run build --workspace=@graphdone/server +FROM node:22-alpine AS production-deps +WORKDIR /app +COPY package.json package-lock.json ./ +COPY packages/core/package.json ./packages/core/ +COPY packages/server/package.json ./packages/server/ +COPY packages/mcp-server/package.json ./packages/mcp-server/ +COPY packages/web/package.json ./packages/web/ +RUN npm ci --legacy-peer-deps --omit=dev --omit=optional --ignore-scripts --no-audit --no-fund --workspace=@graphdone/core --workspace=@graphdone/server --workspace=@graphdone/mcp-server --include-workspace-root=true +COPY --from=build /app/node_modules/sqlite3/build/Release/node_sqlite3.node ./node_modules/sqlite3/build/Release/node_sqlite3.node +FROM node:22-alpine AS runner +RUN apk add --no-cache curl && rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /opt/yarn* /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg /usr/local/bin/pnpm /usr/local/bin/pnpx +WORKDIR /app ENV NODE_ENV=production - +COPY --from=production-deps /app/package.json ./package.json +COPY --from=production-deps /app/node_modules ./node_modules +COPY --from=production-deps /app/packages ./packages +COPY --from=build /app/packages/core/dist ./packages/core/dist +COPY --from=build /app/packages/server/dist ./packages/server/dist +COPY --from=build /app/packages/mcp-server/dist ./packages/mcp-server/dist +RUN mkdir -p /app/packages/server/data +WORKDIR /app/packages/server EXPOSE 4127 - -CMD ["npm", "run", "start", "--workspace=@graphdone/server"] +CMD ["node", "dist/index.js"] diff --git a/packages/server/package.json b/packages/server/package.json index 0bdb7271..21840122 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -48,7 +48,8 @@ "passport-linkedin-oauth2": "^2.0.0", "sqlite3": "^6.0.1", "uuid": "^11.1.0", - "ws": "^8.14.0" + "ws": "^8.14.0", + "graphql-tag": "^2.12.6" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e4750502..c551fe7b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -290,14 +290,6 @@ async function startServer() { throw new Error('Neo4j connection timeout - falling back to auth-only mode'); } - // Create default admin user for testing if none exists - try { - await execAsync('npm run create-admin', { cwd: process.cwd() }); - } catch (error) { - // Admin creation is optional - may already exist - console.log('ℹ️ Default admin setup completed'); // eslint-disable-line no-console - } - // Welcome graphs are now created per-user on first login (handled by frontend) // Merge type definitions (Neo4j schema + auth schema) diff --git a/tests/runtime/check-scan.mjs b/tests/runtime/check-scan.mjs new file mode 100644 index 00000000..f0c5ad72 --- /dev/null +++ b/tests/runtime/check-scan.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +const report = JSON.parse(readFileSync(process.argv[2], 'utf8')); +assert.equal(report.SchemaVersion, 2); +assert.ok(report.Metadata?.ImageID?.startsWith('sha256:')); +assert.ok(report.Results?.some(result => result.Class === 'os-pkgs' && result.Packages?.length)); +assert.ok(report.Results?.some(result => result.Type === 'node-pkg' && result.Packages?.length)); +const findings = report.Results.flatMap(result => result.Vulnerabilities || []); +console.log(JSON.stringify({ image: report.Metadata.ImageID, knownFindings: findings.length, severity: Object.fromEntries(['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'].map(severity => [severity, findings.filter(finding => finding.Severity === severity).length])) })); diff --git a/tests/runtime/image-contract.mjs b/tests/runtime/image-contract.mjs new file mode 100644 index 00000000..681f21aa --- /dev/null +++ b/tests/runtime/image-contract.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { setTimeout as delay } from 'node:timers/promises'; + +const root = process.env.GRAPHDONE_IMAGE_ROOT || '/app'; +const uri = process.env.NEO4J_URI; +assert.equal(process.env.GRAPHDONE_DISPOSABLE_NEO4J, '1'); +assert.ok(uri && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(uri).hostname)); +assert.ok(process.env.NEO4J_USER && process.env.NEO4J_PASSWORD && process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD); +const cwd = `${root}/packages/server`; +const require = createRequire(`${cwd}/package.json`); +const neo4j = require('neo4j-driver'); +const { Client } = require('@modelcontextprotocol/sdk/client/index.js'); +const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js'); +const driver = neo4j.driver(uri, neo4j.auth.basic(process.env.NEO4J_USER, process.env.NEO4J_PASSWORD), { connectionTimeout: 5000, maxTransactionRetryTime: 1000 }); +const childEnv = { ...process.env, NODE_ENV: 'production', PORT: process.env.GRAPHDONE_IMAGE_TEST_PORT || '28767', MCP_HEALTH_PORT: '0', CI: 'true' }; +for (const invalid of [{ JWT_SECRET: '', SESSION_SECRET: '' }, { JWT_SECRET: 'your-dev-secret-key' }]) { + const result = spawnSync(process.execPath, ['dist/index.js'], { cwd, env: { ...childEnv, ...invalid }, encoding: 'utf8', timeout: 10000, maxBuffer: 1024 * 1024 }); + assert.ifError(result.error); + assert.notEqual(result.status, 0); + assert.match(result.stdout + result.stderr, /Invalid production secrets: JWT_SECRET/); +} +const server = spawn(process.execPath, ['dist/index.js'], { cwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] }); +let diagnostics = ''; +for (const stream of [server.stdout, server.stderr]) stream.on('data', chunk => { diagnostics += chunk.toString().slice(0, Math.max(0, 32000 - diagnostics.length)); }); +const endpoint = `http://127.0.0.1:${childEnv.PORT}`; +let token = ''; +let graphId; +let transport; +let client; +const protocolErrors = []; +async function request(query, variables) { + const response = await fetch(`${endpoint}/graphql`, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(20000) }); + assert.equal(response.status, 200); + return response.json(); +} +async function gql(query, variables) { + const body = await request(query, variables); + assert.equal(body.errors, undefined, 'GraphQL operation failed'); + return body.data; +} +try { + await driver.verifyConnectivity(); + let health; + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + assert.equal(server.exitCode, null, 'Compiled API exited during startup'); + try { + const response = await fetch(`${endpoint}/health`, { signal: AbortSignal.timeout(2000) }); + if (response.ok) { health = await response.json(); break; } + } catch {} + await delay(200); + } + assert.ok(health, 'Compiled API did not become ready'); + assert.equal(health.services.graphql.status, 'healthy'); + assert.equal(health.services.neo4j.status, 'healthy'); + assert.equal(diagnostics.includes('Default admin setup completed'), false, 'Startup hid a missing shell dependency'); + const loginQuery = 'mutation($input:LoginInput!){login(input:$input){token user{id role}}}'; + const input = { emailOrUsername: process.env.ADMIN_EMAIL, password: process.env.ADMIN_PASSWORD }; + const denied = await request(loginQuery, { input }); + assert.equal(denied.errors?.[0]?.extensions.code, 'CAPTCHA_FAILED'); + const challengeResponse = await fetch(`${endpoint}/api/captcha/challenge?action=login`, { signal: AbortSignal.timeout(5000) }); + assert.equal(challengeResponse.status, 200); + const challenge = await challengeResponse.json(); + assert.equal(challenge.algorithm, 'SHA-256'); + assert.equal(challenge.maxnumber, 100000); + let captchaPayload; + for (let number = 0; number <= challenge.maxnumber; number++) { + if (createHash('sha256').update(challenge.salt + number).digest('hex') === challenge.challenge) { + captchaPayload = Buffer.from(JSON.stringify({ ...challenge, number })).toString('base64'); + break; + } + } + assert.ok(captchaPayload, 'Owned challenge was not solved'); + const loggedIn = await gql(loginQuery, { input: { ...input, captchaPayload } }); + token = loggedIn.login.token; + assert.equal(loggedIn.login.user.role, 'ADMIN'); + const statePath = `${cwd}/data/image-contract-state.json`; + if (process.env.GRAPHDONE_REQUIRE_RECOVERY === '1') assert.ok(existsSync(statePath), 'Owned volume recovery marker is missing'); + if (existsSync(statePath)) assert.equal(JSON.parse(readFileSync(statePath, 'utf8')).userId, loggedIn.login.user.id, 'SQLite account did not survive container replacement'); + else writeFileSync(statePath, JSON.stringify({ userId: loggedIn.login.user.id }), { mode: 0o600, flag: 'wx' }); + const replay = await request(loginQuery, { input: { ...input, captchaPayload } }); + assert.equal(replay.errors?.[0]?.extensions.code, 'CAPTCHA_FAILED'); + const me = await gql('{me{id}}'); + assert.equal(me.me.id, loggedIn.login.user.id); + const graph = await gql('mutation($input:[GraphCreateInput!]!){createGraphs(input:$input){graphs{id}}}', { input: [{ name: `Owned image contract ${randomUUID()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: me.me.id, isShared: true }] }); + graphId = graph.createGraphs.graphs[0].id; + transport = new StdioClientTransport({ command: process.execPath, args: [`${root}/packages/mcp-server/dist/index.js`], cwd: `${root}/packages/mcp-server`, env: childEnv, stderr: 'pipe' }); + transport.stderr?.on('data', () => {}); + client = new Client({ name: 'graphdone-image-contract', version: '1.0.0' }, { capabilities: {} }); + client.onerror = error => protocolErrors.push(error.message); + const options = { timeout: 10000, maxTotalTimeout: 10000 }; + await client.connect(transport, options); + const listed = await client.listTools(undefined, options); + assert.ok(listed.tools.some(tool => tool.name === 'create_node')); + const created = await client.callTool({ name: 'create_node', arguments: { title: 'Owned compiled image item', type: 'TASK', graph_id: graphId } }, undefined, options); + assert.notEqual(created.isError, true); + const nodeId = JSON.parse(created.content[0].text).node.id; + assert.ok(nodeId); + const visible = await gql('query($id:ID!){workItems(where:{id:$id}){id title}}', { id: nodeId }); + assert.deepEqual(visible.workItems, [{ id: nodeId, title: 'Owned compiled image item' }]); + await gql('mutation($id:ID!){updateWorkItems(where:{id:$id},update:{title:"Updated by compiled API"}){workItems{id}}}', { id: nodeId }); + const read = await client.callTool({ name: 'get_node_details', arguments: { node_id: nodeId } }, undefined, options); + assert.equal(JSON.parse(read.content[0].text).node.title, 'Updated by compiled API'); + const unknown = await client.callTool({ name: 'owned_unknown_tool', arguments: {} }, undefined, options); + assert.equal(unknown.isError, true); + const recovered = await client.callTool({ name: 'get_graph_details', arguments: { graphId } }, undefined, options); + assert.equal(JSON.parse(recovered.content[0].text).graph.nodeCount, 1); + const session = driver.session(); + try { + const persisted = await session.run('MATCH (w:WorkItem {id:$id})-[:BELONGS_TO]->(g:Graph {id:$graphId}) RETURN w.title AS title', { id: nodeId, graphId }); + assert.deepEqual(persisted.records.map(record => record.get('title')), ['Updated by compiled API']); + } finally { await session.close(); } + assert.deepEqual(protocolErrors, []); + console.log(JSON.stringify({ secretRejection: 'passed', api: 'passed', captcha: 'passed', compiledMcpStdio: 'passed', independentNeo4jReadback: 'passed' })); +} finally { + try { + try { await client?.close(); } finally { await transport?.close(); } + if (server.exitCode === null) { + const exited = new Promise(resolve => server.once('exit', resolve)); + server.kill('SIGTERM'); + const stopped = await Promise.race([exited.then(() => true), delay(5000).then(() => false)]); + if (!stopped) server.kill('SIGKILL'); + assert.equal(stopped, true, 'Owned compiled API did not stop'); + } + } finally { + try { + if (graphId) { + const session = driver.session(); + try { + await session.run('MATCH (g:Graph {id:$graphId})<-[:BELONGS_TO]-(w:WorkItem) OPTIONAL MATCH (w)<-[:EDGE_SOURCE|EDGE_TARGET]-(e:Edge) DETACH DELETE e', { graphId }); + await session.run('MATCH (g:Graph {id:$graphId}) OPTIONAL MATCH (g)<-[:BELONGS_TO]-(w:WorkItem) DETACH DELETE w,g', { graphId }); + const remaining = await session.run('MATCH (g:Graph {id:$graphId}) RETURN count(g) AS count', { graphId }); + assert.equal(remaining.records[0].get('count').toNumber(), 0); + } finally { await session.close(); } + } + } finally { await driver.close(); } + } +} diff --git a/tests/runtime/image-inventory.mjs b/tests/runtime/image-inventory.mjs new file mode 100644 index 00000000..25c452c1 --- /dev/null +++ b/tests/runtime/image-inventory.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +assert.equal(process.env.NODE_ENV, 'production'); +const root = process.env.GRAPHDONE_IMAGE_ROOT || '/app'; +const forbidden = /^(?:vite|vitest|vite-node|tinypool|esbuild|typescript|tsx|prettier|turbo|node-gyp|playwright|playwright-core)$|^(?:eslint(?:$|-)|@vitest\/|@vitejs\/|@playwright\/|@esbuild\/)/; +const packages = []; +const visited = new Set(); +function inspect(directory) { + const actual = realpathSync(directory); + if (visited.has(actual)) return; + visited.add(actual); + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) inspect(path); + else if (entry.name === 'package.json') { + const value = JSON.parse(readFileSync(path, 'utf8')); + if (value.name && path.includes('/node_modules/')) packages.push({ name: value.name, version: value.version, path }); + } + } +} +inspect(join(root, 'node_modules')); +for (const name of ['core', 'server', 'mcp-server']) { + const directory = join(root, 'packages', name); + if (existsSync(join(directory, 'node_modules'))) inspect(join(directory, 'node_modules')); +} +assert.deepEqual(packages.filter(item => forbidden.test(item.name)), [], 'Runtime contains unused build/test packages'); +for (const name of ['core', 'server', 'mcp-server']) { + const directory = join(root, 'packages', name); + assert.ok(existsSync(join(directory, 'dist/index.js')), `Missing compiled ${name} entry`); + for (const entry of readdirSync(join(directory, 'dist'), { recursive: true })) assert.equal(/\.(test|spec)\./.test(entry), false, `Compiled test remains in ${name}`); + assert.equal(existsSync(join(directory, 'src')), false, `Unexpected ${name} source tree`); + assert.equal(existsSync(join(directory, 'tests')), false, `Unexpected ${name} test tree`); + const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')); + const require = createRequire(join(directory, 'package.json')); + for (const dependency of Object.keys(manifest.dependencies || {})) { + if (dependency === '@modelcontextprotocol/sdk') { + for (const subpath of ['server/index.js', 'server/stdio.js', 'types.js']) require.resolve(`${dependency}/${subpath}`); + } else require.resolve(dependency.startsWith('@types/') ? `${dependency}/package.json` : dependency); + } +} +assert.equal(process.getuid(), Number(process.env.GRAPHDONE_EXPECTED_UID)); +for (const path of ['/usr/local/lib/node_modules/npm', '/usr/local/lib/node_modules/corepack', '/usr/local/bin/npm', '/usr/local/bin/npx', '/usr/local/bin/yarn', '/usr/local/bin/corepack']) assert.equal(existsSync(path), false, `Unexpected global tool ${path}`); +const rootRequire = createRequire(join(root, 'package.json')); +for (const dependency of Object.keys(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).dependencies || {})) rootRequire.resolve(dependency); +const require = createRequire(join(root, 'packages/server/package.json')); +const sqlite = require('sqlite3'); +const database = await new Promise((resolve, reject) => { + const opened = new sqlite.Database(':memory:', error => error ? reject(error) : resolve(opened)); +}); +await new Promise((resolve, reject) => database.get('SELECT 7 AS value', (error, row) => error ? reject(error) : row.value === 7 ? resolve() : reject(new Error('Native SQLite result mismatch')))); +await new Promise((resolve, reject) => database.close(error => error ? reject(error) : resolve())); +console.log(JSON.stringify({ node: process.version, installedPackages: packages.sort((a, b) => a.path.localeCompare(b.path)), nativeSQLite: 'passed' })); diff --git a/tests/runtime/run-image.mjs b/tests/runtime/run-image.mjs new file mode 100644 index 00000000..99c061fd --- /dev/null +++ b/tests/runtime/run-image.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { randomUUID, randomBytes } from 'node:crypto'; +import { readFileSync } from 'node:fs'; + +const [image, expectedUid] = process.argv.slice(2); +assert.ok(image && ['0', '1000', '1001'].includes(expectedUid), 'Usage: node tests/runtime/run-image.mjs IMAGE EXPECTED_UID'); +assert.equal(process.env.GRAPHDONE_DISPOSABLE_NEO4J, '1'); +for (const name of ['NEO4J_URI', 'NEO4J_USER', 'NEO4J_PASSWORD']) assert.ok(process.env[name], `Missing ${name}`); +assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(new URL(process.env.NEO4J_URI).hostname)); +const suffix = randomUUID(); +const volume = `graphdone-image-contract-${suffix}`; +const containers = []; +const env = { + ...process.env, + JWT_SECRET: randomBytes(32).toString('hex'), SESSION_SECRET: randomBytes(32).toString('hex'), + ADMIN_EMAIL: `image-${suffix}@example.invalid`, ADMIN_PASSWORD: randomBytes(24).toString('hex'), + GRAPHDONE_EXPECTED_UID: expectedUid, SSL_ENABLED: 'false', +}; +function docker(args, { input, expectedFailure = false, timeout = 150000 } = {}) { + return new Promise((resolve, reject) => { + const child = spawn('docker', args, { env, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('Owned image command timed out')); }, timeout); + child.once('error', error => { clearTimeout(timer); reject(error); }); + child.stdout.on('data', chunk => { stdout += chunk.toString(); }); + child.stderr.on('data', chunk => { stderr += chunk.toString(); }); + child.once('close', code => { + clearTimeout(timer); + if ((expectedFailure && code === 0) || (!expectedFailure && code !== 0)) reject(new Error(`Owned image command exited ${code}: ${stderr.slice(-6000)}${stdout.slice(-6000)}`)); + else resolve({ stdout, stderr, code }); + }); + child.stdin.on('error', error => { if (error.code !== 'EPIPE') reject(error); }); + child.stdin.end(input); + }); +} +try { + const config = JSON.parse((await docker(['image', 'inspect', image, '--format', '{{json .Config}}'])).stdout); + assert.deepEqual(config.Cmd, ['node', 'dist/index.js']); + assert.equal(config.WorkingDir, '/app/packages/server'); + assert.ok(config.Env.includes('NODE_ENV=production')); + const rejectedName = `graphdone-image-secret-${suffix}`; + containers.push(rejectedName); + const rejected = await docker(['run', '--rm', '--name', rejectedName, '--network', 'none', image], { expectedFailure: true, timeout: 15000 }); + assert.match(rejected.stdout + rejected.stderr, /Invalid production secrets: JWT_SECRET, SESSION_SECRET/); + const inventoryName = `graphdone-image-inventory-${suffix}`; + containers.push(inventoryName); + const inventory = await docker(['run', '--rm', '--name', inventoryName, '-e', 'GRAPHDONE_EXPECTED_UID', '--entrypoint', 'node', '-i', image, '--input-type=module'], { input: readFileSync(new URL('./image-inventory.mjs', import.meta.url)) }); + const installed = JSON.parse(inventory.stdout); + assert.equal(installed.nativeSQLite, 'passed'); + await docker(['volume', 'create', volume]); + for (const restart of [false, true]) { + const name = `graphdone-image-runtime-${restart ? 'restart' : 'initial'}-${suffix}`; + containers.push(name); + env.GRAPHDONE_REQUIRE_RECOVERY = restart ? '1' : '0'; + const keys = ['NEO4J_URI', 'NEO4J_USER', 'NEO4J_PASSWORD', 'GRAPHDONE_DISPOSABLE_NEO4J', 'JWT_SECRET', 'SESSION_SECRET', 'ADMIN_EMAIL', 'ADMIN_PASSWORD', 'SSL_ENABLED', 'GRAPHDONE_REQUIRE_RECOVERY']; + if (env.GRAPHDONE_IMAGE_TEST_PORT) keys.push('GRAPHDONE_IMAGE_TEST_PORT'); + const result = await docker(['run', '--rm', '--name', name, '--network', 'host', '--mount', `type=volume,source=${volume},target=/app/packages/server/data`, ...keys.flatMap(key => ['-e', key]), '--entrypoint', 'node', '-i', image, '--input-type=module'], { input: readFileSync(new URL('./image-contract.mjs', import.meta.url)) }); + const outcome = JSON.parse(result.stdout); + assert.equal(outcome.independentNeo4jReadback, 'passed'); + console.log(JSON.stringify({ image, restart, ...outcome })); + } + console.log(JSON.stringify({ image, uid: expectedUid, installedPackageCount: installed.installedPackages.length, nativeSQLite: 'passed', defaultSecretRejection: 'passed', replacementContainerRecovery: 'passed' })); +} finally { + for (const name of containers) { + const existing = await docker(['container', 'ls', '--all', '--filter', `name=^/${name}$`, '--format', '{{.Names}}']); + if (existing.stdout.trim()) await docker(['container', 'rm', '--force', name]); + } + const existing = await docker(['volume', 'ls', '--filter', `name=^${volume}$`, '--format', '{{.Name}}']); + if (existing.stdout.trim()) await docker(['volume', 'rm', volume]); +}