---
phase: 16-ci-dependency-audit-and-security-checks
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
  - apps/api/src/lib/bootGuards.ts
  - apps/api/tests/lib/bootGuards.test.ts
  - apps/api/src/index.ts
  - apps/api/Dockerfile
autonomous: true
requirements: [IMG-01]
must_haves:
  truths:
    - "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"
  artifacts:
    - path: "apps/api/src/lib/bootGuards.ts"
      provides: "assertNotDevBypassInProduction() exported guard function"
      exports: ["assertNotDevBypassInProduction"]
    - path: "apps/api/tests/lib/bootGuards.test.ts"
      provides: "Unit tests for the boot guard (3 cases)"
    - path: "apps/api/Dockerfile"
      provides: "ENV NODE_ENV=production in the production stage"
      contains: "ENV NODE_ENV=production"
  key_links:
    - from: "apps/api/src/index.ts"
      to: "apps/api/src/lib/bootGuards.ts"
      via: "import + call as first statement in isMainModule()"
      pattern: "assertNotDevBypassInProduction\\(\\)"
---

<objective>
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).
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.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
</context>

<tasks>

<task type="tdd" tdd="true">
  <name>Task 1: RED — write failing unit tests for assertNotDevBypassInProduction()</name>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - 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
  </behavior>
  <action>
    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`.
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - 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)
  </acceptance_criteria>
  <done>The test file exists, encodes the 3 cases, and fails because src/lib/bootGuards.ts is absent.</done>
</task>

<task type="tdd" tdd="true">
  <name>Task 2: GREEN — implement bootGuards.ts and wire it into index.ts</name>
  <read_first>
    - 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)
  </read_first>
  <action>
    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`.
  </action>
  <verify>
    <automated>cd apps/api && pnpm test -- --run tests/lib/bootGuards.test.ts && pnpm typecheck</automated>
  </verify>
  <acceptance_criteria>
    - `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
  </acceptance_criteria>
  <done>The guard function exists, is imported and called first in isMainModule(), and all 3 unit tests pass with typecheck green.</done>
</task>

<task type="auto">
  <name>Task 3: Bake ENV NODE_ENV=production into the production Dockerfile stage</name>
  <read_first>
    - 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)
  </read_first>
  <action>
    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.
  </action>
  <verify>
    <automated>awk '/FROM base AS production/{p=1} p&&/ENV NODE_ENV=production/{print "FOUND"; exit}' apps/api/Dockerfile | grep -q FOUND && echo OK</automated>
  </verify>
  <acceptance_criteria>
    - `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
  </acceptance_criteria>
  <done>The production stage bakes NODE_ENV=production; no other stage is affected.</done>
</task>

</tasks>

<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>

<verification>
- `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
</verification>

<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>

<output>
Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-01-SUMMARY.md` when done.
</output>
