A production image with DEV_AUTH_BYPASS=true refuses to boot (process exits non-zero) instead of silently no-op'ing
The production Docker stage bakes NODE_ENV=production so the devBypass hard guard is actually engaged in the shipped image
The boot guard is a unit-tested exported function, not inline startup logic
path
provides
exports
apps/api/src/lib/bootGuards.ts
assertNotDevBypassInProduction() exported guard function
assertNotDevBypassInProduction
path
provides
apps/api/tests/lib/bootGuards.test.ts
Unit tests for the boot guard (3 cases)
path
provides
contains
apps/api/Dockerfile
ENV NODE_ENV=production in the production stage
ENV NODE_ENV=production
from
to
via
pattern
apps/api/src/index.ts
apps/api/src/lib/bootGuards.ts
import + call as first statement in isMainModule()
assertNotDevBypassInProduction()
Implement the dev/prod image-boundary runtime enforcement (D-07 + D-08). Bake `ENV NODE_ENV=production` into the production Dockerfile stage so the existing `devBypass.ts` hard guard is actually engaged in the shipped image, and add a boot-time refuse-to-boot guard `assertNotDevBypassInProduction()` that exits non-zero when `NODE_ENV==='production'` AND `DEV_AUTH_BYPASS==='true'`.
Purpose: Today the production image sets no NODE_ENV, so the devBypass.ts hard guard (NODE_ENV==='production' first) is only safe by accident (the second DEV_AUTH_BYPASS !== 'true' check passes when unset). An operator who accidentally sets DEV_AUTH_BYPASS=true in the production compose would silently bypass auth. D-07 engages the guard; D-08 turns a silent misconfig into a loud, immediate failure. The app holds real family credentials — this is the highest-leverage, lowest-cost hardening in the phase.
Output: A testable exported guard function (the primary Wave 0 test asset), its unit tests, the index.ts wiring, and the Dockerfile ENV line. This plan is consumed by 16-06 (publish.yml boot-smoke verifies the guard fires in the actual built image).
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-CONTEXT.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md
Task 1: RED — write failing unit tests for assertNotDevBypassInProduction()
- apps/api/tests/lib/bootGuards.test.ts (file being created)
- apps/api/tests/auth/devBypass.test.ts (EXACT analog: afterEach env-restoration pattern at lines 18-29, three-case structure, vitest imports)
- apps/api/src/auth/devBypass.ts (the existing hard-guard idiom this mirrors — lines 58-76)
- apps/api/tests/fixtures/ (note: tests live in tests/, NEVER in src/ — see memory)
- Test 1: NODE_ENV='production' AND DEV_AUTH_BYPASS='true' → calls process.exit(1) (spy throws so the call is observable)
- Test 2: NODE_ENV='development' AND DEV_AUTH_BYPASS='true' → does NOT call process.exit
- Test 3: NODE_ENV='production' AND DEV_AUTH_BYPASS unset → does NOT call process.exit
Create apps/api/tests/lib/bootGuards.test.ts. Import { describe, it, expect, vi, afterEach } from 'vitest' and { assertNotDevBypassInProduction } from '../../src/lib/bootGuards.js'. Capture originalNodeEnv and originalBypassFlag at describe scope; restore both in afterEach exactly like devBypass.test.ts (delete DEV_AUTH_BYPASS when originalBypassFlag is undefined). In each test set process.env.NODE_ENV and process.env.DEV_AUTH_BYPASS, install vi.spyOn(process,'exit').mockImplementation(() => { throw new Error('process.exit called'); }), and assert: Test 1 expects the call to throw 'process.exit called' and expect(exitSpy).toHaveBeenCalledWith(1); Tests 2 and 3 expect it NOT to throw and exitSpy NOT to have been called. Call exitSpy.mockRestore() at the end of each test. Run the suite to confirm it FAILS because src/lib/bootGuards.ts does not exist yet. Commit: `test(16-01): add failing tests for boot-time dev-bypass guard`.
cd apps/api && pnpm test -- --run tests/lib/bootGuards.test.ts 2>&1 | grep -Eq 'Cannot find|Failed to load|No test files|failed|error' && echo RED-OK
- tests/lib/bootGuards.test.ts exists with exactly 3 `it(...)` cases matching the behavior block
- Running the suite fails (module under test does not yet exist) — RED state confirmed
- afterEach restores NODE_ENV and DEV_AUTH_BYPASS (delete when originally undefined)
The test file exists, encodes the 3 cases, and fails because src/lib/bootGuards.ts is absent.
Task 2: GREEN — implement bootGuards.ts and wire it into index.ts
- apps/api/src/lib/bootGuards.ts (file being created)
- apps/api/src/auth/devBypass.ts (JSDoc + env-at-call-time pattern to mirror; lines 1-25, 58-76)
- apps/api/src/index.ts (the isMainModule() block at lines 112-147 — guard call goes FIRST inside it; import block lines 1-18)
- apps/api/src/lib/ (sibling utilities follow this dir's conventions — listAccess.ts, rank.ts)
Create apps/api/src/lib/bootGuards.ts exporting `assertNotDevBypassInProduction(): void`. Logic: `if (process.env.NODE_ENV === 'production' && process.env.DEV_AUTH_BYPASS === 'true') { console.error('[FATAL] DEV_AUTH_BYPASS=true is set in a production environment. This configuration is forbidden. Refusing to start.'); process.exit(1); }`. Add a JSDoc block (mirror devBypass.ts style) stating it is D-08, exported for unit-testing without forking a process, and MUST be called as the FIRST statement inside the isMainModule() block. Then in apps/api/src/index.ts add `import { assertNotDevBypassInProduction } from './lib/bootGuards.js';` to the existing import block, and call `assertNotDevBypassInProduction();` as the FIRST statement inside `if (isMainModule()) {` — before the VAPID config (currently line 116), before startBrokerPoller/startOutboxWorker/startReminderScheduler, before serve(). Do NOT change the top-level `devBypassActive` computation (line 24). Commit: `feat(16-01): add boot-time refuse-to-boot guard for dev-bypass in production`.
cd apps/api && pnpm test -- --run tests/lib/bootGuards.test.ts && pnpm typecheck
- `assertNotDevBypassInProduction` is exported from src/lib/bootGuards.ts
- `grep -n "assertNotDevBypassInProduction()" apps/api/src/index.ts` shows the call inside the isMainModule() block, before VAPID/worker/serve lines
- `pnpm test -- --run tests/lib/bootGuards.test.ts` is GREEN (3/3)
- `pnpm typecheck` (apps/api) passes — guard placement does not break the startup module
The guard function exists, is imported and called first in isMainModule(), and all 3 unit tests pass with typecheck green.
Task 3: Bake ENV NODE_ENV=production into the production Dockerfile stage
- apps/api/Dockerfile (the production stage at lines 35-46; the gap D-07 fixes)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (Dockerfile section — exact placement: after WORKDIR /app/apps/api, before COPY --from=pwa-builder)
In apps/api/Dockerfile, in the `FROM base AS production` stage, add `ENV NODE_ENV=production` after the `WORKDIR /app/apps/api` line (currently line 41) and before the `COPY --from=pwa-builder` line (currently line 45). Add a one-line comment above it referencing D-07 ("Enforce production identity — engages the NODE_ENV=production hard guard in devBypass.ts"). Do NOT add ENV to the `base`, `builder`, `dev`, or `pwa-builder` stages — only `production`. Leave the CMD line unchanged.
awk '/FROM base AS production/{p=1} p&&/ENV NODE_ENV=production/{print "FOUND"; exit}' apps/api/Dockerfile | grep -q FOUND && echo OK
- `ENV NODE_ENV=production` appears within the `production` stage (after `FROM base AS production`), not in any other stage
- The line sits between `WORKDIR /app/apps/api` and `COPY --from=pwa-builder`
- `grep -c "ENV NODE_ENV=production" apps/api/Dockerfile` returns exactly 1
The production stage bakes NODE_ENV=production; no other stage is affected.
<threat_model>
Trust Boundaries
Boundary
Description
operator config → production container
An operator-supplied env (docker-compose, Unraid) crosses into the running process; DEV_AUTH_BYPASS is attacker-equivalent if it slips into prod
Docker image build → shipped artifact
The baked image environment (NODE_ENV) is the last line of defense before runtime
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-16-01
Elevation of Privilege
Production container with DEV_AUTH_BYPASS=true (misconfigured compose)
mitigate
D-07: ENV NODE_ENV=production baked into the production stage engages the devBypass.ts hard guard so the bypass middleware can never inject DEV_USER in the shipped image (Task 3)
T-16-02
Elevation of Privilege
Silent no-op of the dev-bypass guard hides the misconfiguration
mitigate
D-08: assertNotDevBypassInProduction() throws and process.exit(1) on NODE_ENV=production + DEV_AUTH_BYPASS=true, converting a silent bypass into an immediate crash (Tasks 1-2); verified in the built image by 16-06 boot-smoke
T-16-03
Tampering
Guard placed too late in startup (DB/port opened before it fires)
accept
Guard is the FIRST statement in isMainModule(), before VAPID/workers/serve — no port or DB connection precedes it (Task 2 placement rule). Residual risk nil given enforced placement
</threat_model>
- `pnpm --filter @familysync/api test -- --run tests/lib/bootGuards.test.ts` → 3/3 green
- `pnpm --filter @familysync/api typecheck` → green (startup module intact)
- Dockerfile production stage contains exactly one `ENV NODE_ENV=production`
- Full image-level proof (boot-smoke) is deferred to plan 16-06 against the built image
<success_criteria>
assertNotDevBypassInProduction() exists, is exported, imported in index.ts, and called first in isMainModule()
Unit tests cover all 3 env combinations and pass
The production Dockerfile stage bakes NODE_ENV=production
No change to dev/builder/pwa-builder stages or to the top-level devBypassActive logic
</success_criteria>
Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-01-SUMMARY.md` when done.