Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,25 @@ jobs:
- name: Checkout
uses: actions/checkout@v6

# Issue #42: the runtime stage must mirror the lockfile's nested
# workspace layout (apps/api/node_modules) or the API cannot resolve
# express. Build the image on every PR so regressions ship nowhere.
# The runtime stage copies the pruned workspace dependency closure from
# the resolver's root. Build on every PR so hoisting or closure drift
# cannot ship unnoticed.
- name: Build image
run: docker build -t dockermap:ci .

- name: Assert runtime dependency boundary
run: |
set -euo pipefail
docker run --rm --entrypoint sh dockermap:ci -ec '
! command -v npm
! command -v npx
test ! -e /opt/dockermap/node_modules/.bin/tsx
test ! -e /opt/dockermap/node_modules/.bin/vite
test ! -d /opt/dockermap/node_modules/typescript
test ! -e /opt/dockermap/node_modules/@playwright/test/package.json
node -e "import(\"express\").then(() => import(\"@dockermap/contracts\"))"
'

- name: Smoke-test runtime image
run: |
set -euo pipefail
Expand Down
20 changes: 16 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ RUN npm run check:version && npm run check:contracts && npm run build
# Build and assert the entire package artifact, rather than relying on a
# source-tree module that happened to be copied into the image.
RUN test -f packages/contracts/dist/index.js && test -f packages/contracts/dist/nodeSchemas.js
# The runtime image needs the API's production dependency closure only. Prune
# after all builders have finished, so compiler/test tooling never crosses the
# runtime boundary. npm's workspace resolver may hoist that closure to the
# repository root, which is the only node_modules tree copied below.
RUN npm prune --omit=dev \
&& test ! -e node_modules/.bin/tsx && test ! -e node_modules/.bin/vite \
&& test ! -d node_modules/typescript && test ! -e node_modules/@playwright/test/package.json

# ---- Runtime image ----------------------------------------------------------
FROM node:22-bookworm-slim AS runtime
Expand All @@ -62,10 +69,6 @@ RUN groupadd --gid 10003 dockermap && \
WORKDIR /opt/dockermap

COPY --from=js-builder /src/node_modules ./node_modules
# npm nests workspace deps in the lockfile layout (apps/api/node_modules/express
# etc.); the runtime image must mirror that layout or the API cannot resolve
# its deps.
COPY --from=js-builder /src/apps/api/node_modules ./apps/api/node_modules
COPY --from=js-builder /src/package.json ./package.json
COPY --from=js-builder /src/apps/api/dist ./apps/api/dist
COPY --from=js-builder /src/apps/api/package.json ./apps/api/package.json
Expand All @@ -81,6 +84,15 @@ COPY deploy/docker/entrypoint.sh /entrypoint.sh
COPY deploy/docker/frontend-entrypoint.sh /frontend-entrypoint.sh
COPY deploy/docker/healthcheck.sh /usr/local/bin/dockermap-healthcheck
RUN chmod +x /entrypoint.sh /frontend-entrypoint.sh /usr/local/bin/dockermap-healthcheck
# The Node base image includes package-manager CLIs that DockerMap never uses
# at runtime. Remove them after staging the already-pruned closure; `node`
# remains available for the compiled API, while npm/npx cannot become an
# in-container mutation surface.
RUN rm -rf /usr/local/lib/node_modules/npm \
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
&& ! command -v npm && ! command -v npx \
&& test ! -e node_modules/.bin/tsx && test ! -e node_modules/.bin/vite \
&& test ! -d node_modules/typescript && test ! -e node_modules/@playwright/test/package.json

ENV NODE_ENV=production \
PORT=4000 \
Expand Down
4 changes: 2 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
"@dockermap/contracts": "0.1.0",
"ajv": "^8.20.0",
"cors": "^2.8.5",
"express": "^4.21.2",
"express": "^5.2.1",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle Express 5 listen errors

When startup encounters a bind failure such as EADDRINUSE or EACCES, Express 5 passes the error to the app.listen callback instead of throwing it. The callback in apps/api/src/index.ts ignores its argument and always logs that the API is listening, consuming the failure and allowing the process to exit successfully; because deploy/systemd/dockermap-api.service uses Restart=on-failure, the deployed API will remain down rather than restart. Update the Express type declarations to v5 and make the callback preserve a nonzero startup failure.

Useful? React with 👍 / 👎.

"helmet": "^8.2.0"
},
"devDependencies": {
"@seriousme/openapi-schema-validator": "^2.9.1",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express": "^5.0.6",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
}
Expand Down
10 changes: 8 additions & 2 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,13 @@ type ExpressLayer = {
};

export function registeredRoutes(appInstance: express.Express): RegisteredRoute[] {
const router = appInstance as express.Express & { _router?: { stack?: ExpressLayer[] } };
// Express 5 exposes the live router at `router`; Express 4 used the
// underscored `_router` property. This inspection is test/startup-policy
// evidence only: runtime request routing remains wholly Express-owned.
const router = appInstance as express.Express & {
router?: { stack?: ExpressLayer[] };
_router?: { stack?: ExpressLayer[] };
};
const routes: RegisteredRoute[] = [];
const unknownLayers: string[] = [];
const walk = (stack: readonly ExpressLayer[]) => {
Expand All @@ -701,7 +707,7 @@ export function registeredRoutes(appInstance: express.Express): RegisteredRoute[
}
}
};
walk(router._router?.stack ?? []);
walk(router.router?.stack ?? router._router?.stack ?? []);
if (unknownLayers.length) throw new Error(`Unknown Express layer(s): ${unknownLayers.join(", ")}`);
return routes;
}
Expand Down
4 changes: 2 additions & 2 deletions apps/api/test/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ test("route manifest completeness rejects every untracked response-capable layer
'app.use("/api/outside-path", (_req, res) => res.status(204).end());',
'app.use((_req, res, next) => process.env.DOCKERMAP_TEST_CONDITION === "respond" ? res.status(204).end() : next());',
'const router = express.Router(); router.get("/outside-mounted", (_req, res) => res.status(204).end()); app.use("/api", router);',
'app.use("/api/outside-preauth", (_req, res) => res.status(204).end()); const planted = app._router.stack.pop(); app._router.stack.splice(app._router.stack.findIndex((layer) => layer.handle?.name === "limitSessionAttempts"), 0, planted);'
'app.use("/api/outside-preauth", (_req, res) => res.status(204).end()); const stack = (app.router ?? app._router).stack; const planted = stack.pop(); stack.splice(stack.findIndex((layer) => layer.handle?.name === "limitSessionAttempts"), 0, planted);'
];
for (const mutation of mutations) {
const result = await inspectLiveRoutes(mutation);
Expand Down Expand Up @@ -2110,7 +2110,7 @@ test("SSE error payloads and invalid log service names cannot reflect hostile in
async function inspectLiveRoutes(mutation = "") {
const port = await freePort();
const script = `
import express from "./apps/api/node_modules/express/lib/express.js";
import express from "express";
import { app, registeredRoutes } from "./apps/api/src/index.ts";
import { assertRouteManifestComplete } from "./apps/api/src/routes.ts";
${mutation}
Expand Down
Loading
Loading