---
phase: 06.5
plan: 01
subsystem: server/static-assets
tags: [static-mount, env-resolver, soft-fallback, symlink-swap, cold-start]
requires:
  - apps/server/src/log.ts (singleton pino logger)
  - apps/server/test/test-utils.ts (spawnServer harness)
  - apps/server/public/.gitignore (build-output-only convention)
provides:
  - apps/server/src/static-assets.ts (resolveStaticDir helper)
  - apps/server/test/static-assets.test.ts (5 unit branches + non-throw guard)
  - apps/server/test/static-assets.integ.test.ts (3 integ scenarios; swap is Linux-only)
affects:
  - apps/server/src/index.ts (static-mount call site refactored)
tech_stack_added: []
patterns_used:
  - "RESEARCH § Pattern 3 — Fallback-Resolver Helper"
  - "RESEARCH § Pitfall 3 — Cold-start race (soft-fallback, never throw)"
  - "RESEARCH § Pitfall 4 — Symlink swap under running Node process (no app-layer cache)"
  - "T-06.5-03 mitigation — explicit expect().not.toThrow() unit assertion"
key_files_created:
  - apps/server/src/static-assets.ts
  - apps/server/test/static-assets.test.ts
  - apps/server/test/static-assets.integ.test.ts
key_files_modified:
  - apps/server/src/index.ts
decisions:
  - "Resolver lives as a standalone module (`apps/server/src/static-assets.ts`), not inline — per Claude's Discretion in CONTEXT (helper module preferred for unit-test isolation)."
  - "Empty-string STATIC_ASSETS_DIR is treated as unset (log.info, not log.warn). Operators who leave the var blank are not misconfiguring; they're using the dev default."
  - "Resolver MUST NOT throw — wraps statSync in try/catch so permission errors and ENOENT races degrade to bundled fallback rather than crashloop (T-06.5-03 + Pitfall 3)."
  - "`dirname` / `join` / `fileURLToPath` removed from apps/server/src/index.ts imports — the static-mount block was their sole consumer (grep-confirmed); `pathToFileURL` retained for the entry guard at line 414."
  - "Symlink-swap integration test guards on `process.platform === 'win32'` and returns early. Windows lacks atomic rename(2) over a symlink without admin rights — Pitfall 4 verification path on Windows host is option (b) Linux CI runners (path forward when Plan 06.5-04 lands the GH Actions split) and option (c) Plan 06.5-05 operator-driven staging fast-path drill on the real Fly machine."
metrics:
  duration: "~25 min"
  completed: "2026-05-16T11:20Z"
  unit_tests_added: 6
  integ_tests_added: 3
  files_created: 3
  files_modified: 1
requirements: [REQ-DEP-01, REQ-CLI-08]
---

# Phase 06.5 Plan 01: Server static-assets resolver

Decouple "where static files live" from "what's compiled into the image":
new `resolveStaticDir(env, log)` helper picks `STATIC_ASSETS_DIR` when it
points at a real directory, otherwise soft-falls back to the bundled
`/app/public` layer baked into the Docker image. `apps/server/src/index.ts`
calls the helper once at boot and logs `static_mount_resolved {source, dir}`.
Five unit cases + three integration scenarios exercise the four
env/dir-presence permutations and the live symlink-swap behaviour that
client-only fast-path deploys rely on (Plan 06.5-03/04).

## What Was Built

### `apps/server/src/static-assets.ts` (NEW) [doc->REQ-DEP-01] [doc->REQ-CLI-08]

```ts
export interface StaticAssetsResolution {
  dir: string;
  source: 'STATIC_ASSETS_DIR' | 'bundled';
}

export function resolveStaticDir(
  env: NodeJS.ProcessEnv,
  log: Logger,
): StaticAssetsResolution;
```

- Branch 1: `env.STATIC_ASSETS_DIR === undefined || ''` → bundled, `log.info`.
- Branch 2: env set + `existsSync && statSync().isDirectory()` → env dir, `log.info`.
- Branch 3: env set + missing OR not-a-dir → bundled, `log.warn`.
- `statSync` is wrapped in `try/catch` so the resolver NEVER throws — Pitfall 3
  (cold-start fresh machine where `/data/client-assets/current` doesn't exist
  yet must NOT crashloop) + T-06.5-03 mitigation.
- ESM-compatible `__dirname` derivation via `dirname(fileURLToPath(import.meta.url))`
  preserves the dev (`<repo>/apps/server/src` → `<repo>/apps/server/public`)
  and prod docker (`/app/dist` → `/app/public`) paths the original mount used.
- File header carries `// [impl->REQ-DEP-01] [impl->REQ-CLI-08]` per the
  CLAUDE.md tagging contract.

### `apps/server/test/static-assets.test.ts` (NEW) [doc->REQ-DEP-01]

Six assertions (five behavioural branches + one non-throw guard):

| # | Scenario | Expected `source` | Expected log path |
|---|----------|-------------------|-------------------|
| 1 | env set + dir exists (tmpdir)         | `'STATIC_ASSETS_DIR'` | info (no warn) |
| 2 | env set + dir missing                  | `'bundled'`           | exactly 1 warn |
| 3 | env set + path is a file (not a dir)   | `'bundled'`           | exactly 1 warn |
| 4 | env unset (key absent)                 | `'bundled'`           | info (no warn) |
| 5 | env empty string                       | `'bundled'`           | info (no warn) — empty ≠ misconfig |
| 6 | `expect(() => resolver(...)).not.toThrow()` | — | T-06.5-03 guard |

`pino({level:'silent'})` is cast to `Logger` (= `Logger<string>`) because the
default inference under `exactOptionalPropertyTypes: true` yields
`Logger<never>`, which is narrower than the resolver's parameter type. The
test helper monkey-patches `log.warn` / `log.info` to capture call counts —
the resolver only invokes those two methods.

### `apps/server/test/static-assets.integ.test.ts` (NEW) [doc->REQ-DEP-01] [doc->REQ-CLI-08]

Three end-to-end scenarios against `spawnServer()` (the real `boot()`):

1. **`STATIC_ASSETS_DIR=/nonexistent/path-<rand>`** — server boots successfully
   (Pitfall 3 / T-06.5-01 mitigation), `GET /index.html` returns 200 with the
   seeded `BUNDLED-FALLBACK-MARKER` payload. Boot log confirms `source: 'bundled'`.
2. **Env unset control** — `delete process.env.STATIC_ASSETS_DIR`, identical
   bundled-fallback assertion, `source: 'bundled'`.
3. **Live symlink swap (Pitfall 4 mitigation)** — symlink `current → r1`,
   boot server with `STATIC_ASSETS_DIR=<current>`, GET → `R1-PAYLOAD`. Atomic
   swap via `symlinkSync(r2, current.new); renameSync(current.new, current)`
   (same `rename(2)` primitive `mv -T` uses). GET again without server
   restart → `R2-PAYLOAD`. Proves `express.static` does not cache `realpath()`.
   Skipped on `process.platform === 'win32'` (see Verification Path).

Bundled fallback is seeded via `beforeAll` writing
`<!doctype html><title>BUNDLED-FALLBACK-MARKER</title>` to `apps/server/public/index.html`
and restored (or unlinked) in `afterAll`. `apps/server/public/.gitignore`
already excludes that path so leftovers cannot accidentally land in git.

### `apps/server/src/index.ts` (MODIFIED) [doc->REQ-DEP-01]

- Removed orphaned `dirname`, `join`, `fileURLToPath` from `node:path` /
  `node:url` imports (their sole consumer was the static-mount block at
  lines 282-283 of the pre-change file). `pathToFileURL` stays — it's used
  at line ~414 in the cross-platform entry guard.
- Added `import { resolveStaticDir } from './static-assets.js';` adjacent to
  the existing relative-import block, with a `[impl->REQ-DEP-01]` annotation
  comment one line above per the in-file convention.
- Replaced lines 282-283 with:
  ```ts
  const { dir: staticDir, source: staticDirSource } = resolveStaticDir(
    process.env,
    log,
  );
  log.info(
    { source: staticDirSource, dir: staticDir },
    'static_mount_resolved',
  );
  app.use(express.static(staticDir));
  ```
- Extended the existing `[impl->REQ-CLI-01] [impl->REQ-CLI-08]` mount-block
  annotation with `[impl->REQ-DEP-01]` and a one-line rationale noting the
  symlink-redirect / cold-start-fallback intent.

## Verification

### Automated (this plan)

| Command | Result |
|---------|--------|
| `pnpm --filter @rebno/server typecheck` | green (exit 0) |
| `pnpm --filter @rebno/server exec vitest run --exclude 'test/**/*.integ.test.ts' static-assets` | 6 / 6 unit cases pass |
| `pnpm --filter @rebno/server exec vitest run static-assets.integ` | 3 / 3 integ scenarios pass (symlink scenario skipped on win32 with explicit log) |
| `pnpm trace:check` for REQ-DEP-01 / REQ-CLI-08 | `[OK] REQ-DEP-01 stages: +doc +impl +unit +int` / `[OK] REQ-CLI-08 stages: +doc +impl +unit +int` |

### Verification Path for Pitfall 4 (symlink swap on Windows host)

Per PLAN acceptance criterion: "On Windows the symlink swap test `return`s
early — Windows lacks atomic ext4 `rename(2)` semantics, so local Windows
runs cannot prove this. The assertion MUST fire on Linux. Verification path
on Windows host: (a) WSL2/Linux, (b) CI Ubuntu, (c) Plan 06.5-05 staging
drill."

This plan's local verification was performed on a Windows host. The
symlink-swap assertion (Pitfall 4) will be exercised on:

- **(b) CI Ubuntu runners** — once Plan 06.5-04 ships the GH Actions
  fast-path workflow; the existing `pnpm test:integration` jobs already
  run on `ubuntu-latest`. **PLANNED — primary path.**
- **(c) Plan 06.5-05 operator-driven staging fast-path drill** — first
  real fast-path deploy on Fly (Linux/ext4) exercises live `ln -s … && mv -T`
  on the persistent volume; the drill's 3-check probe validates served-bytes
  change after the swap. **PLANNED — backup confirmation.**

Option (a) WSL2 was not taken in this plan cycle — CI coverage is sufficient.

### Trace check output (REQ-DEP-01 + REQ-CLI-08 stages)

```
[OK] REQ-CLI-08  required: [doc, int]              stages: +doc +impl +unit +int
[OK] REQ-DEP-01  required: [doc, impl, int]        stages: +doc +impl +unit +int
```

Other `pnpm trace:check` findings are pre-existing template-tag drift in
older phase docs (`REQ-CLI-XX`, `REQ-SRV-XX`, `REQ-DEP-NN`, `REQ-X` placeholders
in 04 / 05 / 06.1 / 06.4 phase research/plan markdown) — none introduced by
Plan 06.5-01. CI hard-gate is deferred to Phase 5 / DEP-04 per CLAUDE.md.

## Deviations from Plan

None. The plan executed exactly as written, with two minor implementation
choices documented as decisions above:

1. The test-side `pino({level:'silent'})` cast to `Logger` was added to
   satisfy `exactOptionalPropertyTypes: true`. The cast is sound — the
   resolver only invokes `log.info` / `log.warn`, both identical between
   `Logger<never>` and `Logger<string>`.
2. The bundled fallback marker file (`apps/server/public/index.html`) is
   created in `beforeAll` and restored in `afterAll` rather than inlined
   per-test. Per-file scoping avoids ordering issues if both bundled-fallback
   scenarios run in parallel.

## Deferred Issues

`apps/server/test/admin-stubs.test.ts` has two pre-existing failures
asserting that `apps/server/src/admin-stubs.ts` does not export
`handleExecuteString` / `handleClipboardRun` / `handleModExecute`.
Verified pre-existing at plan base commit `43d98ef` via `git stash`-and-rerun
— **not caused by this plan**. Logged in `deferred-items.md` for follow-up
under a separate plan (likely Phase 7 PAR-07 cleanup).

## Commits

| Task | Hash | Message |
|------|------|---------|
| 1 — resolver helper + unit tests | `c1f5c23` | feat(06.5-01): add resolveStaticDir helper + unit tests |
| 2 — wire into index.ts + integ tests | `e956675` | feat(06.5-01): wire resolveStaticDir into server boot + integration test |

## Next Plans

- **Plan 06.5-02** — fly.toml env injection (`STATIC_ASSETS_DIR = "/data/client-assets/current"`
  in staging + prod `[env]` tables, plus `scripts/check-fly-env.mjs` lint).
- **Plan 06.5-03** — release script + rollback runbook (stage → check → atomic swap → GC).
- **Plan 06.5-04** — GitHub Actions client-only fast-path with `dorny/paths-filter@v3`.
- **Plan 06.5-05** — E2E UAT on real Fly staging machine (provides Pitfall 4 backup verification).

## Self-Check

Verified after writing this SUMMARY:

- File `apps/server/src/static-assets.ts` exists.
- File `apps/server/test/static-assets.test.ts` exists.
- File `apps/server/test/static-assets.integ.test.ts` exists.
- File `apps/server/src/index.ts` modified (resolveStaticDir wired; `express.static(join(__dirname,...))` removed).
- Commits `c1f5c23` and `e956675` exist on `worktree-agent-a9dff7628e8a735bb`.
- `pnpm trace:check` reports REQ-DEP-01 + REQ-CLI-08 stages `+doc +impl +unit +int`.

## Self-Check: PASSED
