Document the shadowing risk and the leftover dir; close two holes in the mount guard
CI / full-gate (push) Successful in 2m38s

This commit is contained in:
2026-08-05 19:17:07 +02:00
parent bcf4d7fb1f
commit 3d3313c0ee
4 changed files with 25 additions and 6 deletions
+2
View File
@@ -1,4 +1,6 @@
.git .git
# Load-bearing: deps live at /node_modules, so a builder's stray copy would bake in at
# /app/node_modules and shadow them for every consumer of the image, production included.
node_modules node_modules
npm-debug.log npm-debug.log
*.log *.log
+6 -1
View File
@@ -329,7 +329,12 @@ them. Revisit only if the stated reason stops holding.
as `<repo>/node_modules` no longer resolves, so `src/ui/icons.test.ts` locates lucide-static by as `<repo>/node_modules` no longer resolves, so `src/ui/icons.test.ts` locates lucide-static by
specifier via `import.meta.resolve`; and `npm install` must not run in `/app` or it recreates the specifier via `import.meta.resolve`; and `npm install` must not run in `/app` or it recreates the
problem — README → Extending the core documents `--package-lock-only` plus `--user` instead, which problem — README → Extending the core documents `--package-lock-only` plus `--user` instead, which
is also why the image sets `npm_config_cache` (the host uid has no home dir here). Decided 2026-08-05. is also why the image sets `npm_config_cache` (the host uid has no home dir here). Nothing masks
that path any more, so anything at `/app/node_modules` now **shadows `/node_modules` silently**
wrong dependency code, no warning, and CI stays green because a fresh clone has none. An empty
leftover dir is harmless (resolution falls through); a populated one from the superseded root
`npm install` needs `sudo` to remove, which is worse than the bug this replaced. `.dockerignore`'s
`node_modules` line is what keeps a builder's stray copy out of the image. Decided 2026-08-05.
## Docker only — no host tooling ## Docker only — no host tooling
+7
View File
@@ -1768,6 +1768,13 @@ so for now the error names the rule it tripped instead.
`users:`/`groups:`/`oauth2-clients:` × `read`/`write`, so a copy taken before this needs re-copying. `users:`/`groups:`/`oauth2-clients:` × `read`/`write`, so a copy taken before this needs re-copying.
`ADMIN_PERMISSIONS` is held to the same rule, but an unusable value there is dropped with a warning `ADMIN_PERMISSIONS` is held to the same rule, but an unusable value there is dropped with a warning
rather than failing the boot. See [Naming a permission](#naming-a-permission). rather than failing the boot. See [Naming a permission](#naming-a-permission).
- **Deps moved to `/node_modules`, above `/app`** (2026-08-05). The dev override no longer mounts an
anonymous volume at `/app/node_modules`, so after pulling, delete the empty directory the daemon
left behind: `rmdir node_modules` — no `sudo`, it is empty. `docker volume prune` reclaims the
orphaned volumes. Keep that path clear: anything there shadows the image's deps silently, and a
populated one is root-owned and needs `sudo` to remove. Add a dependency with the
`--package-lock-only` command in [Extending the core](#extending-the-core), never a bare
`npm install` in `/app`.
## Observability ## Observability
+10 -5
View File
@@ -94,14 +94,19 @@ test("deps live above WORKDIR, so no mount creates a root-owned dir in the check
// a volume at /app/node_modules leaves a root-owned node_modules/ in the developer's own checkout // a volume at /app/node_modules leaves a root-owned node_modules/ in the developer's own checkout
// (dev bind-mounts `.:/app`). Installing above /app lets Node resolve upward instead — nothing to // (dev bind-mounts `.:/app`). Installing above /app lets Node resolve upward instead — nothing to
// shadow, so nothing to mount over. // shadow, so nothing to mount over.
const beforeWorkdir = read("Dockerfile").split("WORKDIR /app")[0]!; const dockerfile = read("Dockerfile");
// Asserted, not assumed: split() returns the whole file when the marker is missing, which would
// silently widen "before WORKDIR" to "anywhere".
assert.ok(dockerfile.includes("WORKDIR /app"), "the app dir is /app");
const beforeWorkdir = dockerfile.split("WORKDIR /app")[0]!;
assert.match(beforeWorkdir, /npm ci/, "npm ci runs before WORKDIR /app"); assert.match(beforeWorkdir, /npm ci/, "npm ci runs before WORKDIR /app");
assert.match(beforeWorkdir, /mv\s+node_modules\s+\/node_modules/, "and its tree lands at /node_modules"); assert.match(beforeWorkdir, /mv\s+node_modules\s+\/node_modules/, "and its tree lands at /node_modules");
const composeFiles = readdirSync(new URL("../e2e-tests", import.meta.url)) const composeFiles = (dir: string) =>
.filter((f) => f.startsWith("compose.")) readdirSync(new URL(`../${dir}`, import.meta.url))
.map((f) => `e2e-tests/${f}`); .filter((f) => f.startsWith("compose.") && f.endsWith(".yml"))
for (const f of ["compose.yml", "compose.override.yml", ...composeFiles]) .map((f) => `${dir}${f}`);
for (const f of [...composeFiles(""), ...composeFiles("e2e-tests/")])
assert.ok(!read(f).includes("/app/node_modules"), `${f} mounts nothing at /app/node_modules`); assert.ok(!read(f).includes("/app/node_modules"), `${f} mounts nothing at /app/node_modules`);
}); });