diff --git a/.claude/TODO/2026-09-30-held-csp-customer-deployments.md b/.claude/TODO/2026-09-30-held-csp-customer-deployments.md
deleted file mode 100644
index 1b17763..0000000
--- a/.claude/TODO/2026-09-30-held-csp-customer-deployments.md
+++ /dev/null
@@ -1,32 +0,0 @@
----
-title: "Held — CSP enforcement notes for customer-deployed clones (report endpoint, browser walk)"
-severity: medium
-tags: [security, doc]
-area: routing
-files: [src/lib/security-headers.ts, src/proxy.ts]
-discovered: 2026-09-30
-discovered_by: playbook-audit (port-downstream-hardening.md F9)
-status: open
----
-
-## Problem
-Owner decision (2026-09-30): **skip for now; document for customer clones.** The nonce CSP (`security-headers.ts`,
-`proxy.ts:33-59`) is enforced by default via `CSP_ENFORCE`, but:
-- No CSP report endpoint is configured.
-- The enforced-CSP browser walk (sign-in, sign-out, images, every Radix surface) hasn't been done
- against a production build.
-
-## Notes for customer-deployed clones
-- **Report endpoint:** set `report-to`/`report-uri` to a collector the customer owns (Sentry, report-uri.com, or a
- small route that logs shape only). Run `CSP_ENFORCE=false` (report-only) for at least one release cycle first.
-- **Customer additions:** any analytics, chat widget or embedded MP widget a customer adds needs its own origin
- added — do it per deployment, never with `'unsafe-inline'` or a wildcard.
-- **Walk:** after any CSP change, `npm run build && npm start`, open DevTools console, walk every tool; zero CSP
- violations is the bar.
-
-## Impact if not fixed
-Violations go unseen without a report endpoint.
-
-## Update (2026-10-10)
-The GrapesJS font item is gone: the template editor was removed, and it was the only third-party
-origin the CSP blocked. What remains is the report endpoint and the enforced-CSP browser walk.
diff --git a/.claude/TODO/2026-09-30-step6-headers-csp-build-guards.md b/.claude/TODO/2026-09-30-step6-headers-csp-build-guards.md
deleted file mode 100644
index 5f110e1..0000000
--- a/.claude/TODO/2026-09-30-step6-headers-csp-build-guards.md
+++ /dev/null
@@ -1,27 +0,0 @@
----
-title: "Step 6 — Security headers, next.config hardening, server-only guards, prerender + build in CI"
-severity: medium
-tags: [security, drift]
-area: routing
-files: [next.config.ts, src/proxy.ts, src/lib/security-headers.ts, .github/workflows/test.yml, src/services/domainTimezoneService.ts]
-discovered: 2026-09-30
-discovered_by: playbook-audit (port-downstream-hardening.md F9, port-security-review-2026-09-28.md P11/P14)
-status: open
----
-
-## Problem
-- `next.config.ts:17-51` lacks COOP/CORP `same-origin`, `poweredByHeader:false`, `images.unoptimized`,
- `logging.serverFunctions:false` — the last matters most: `next dev` prints server-action arguments (household
- lists) while pointed at production MP.
-- `proxy.ts:22` `startsWith('/api/auth')` has no trailing slash; matcher (`:95`) unanchored; `originOf` doesn't
- validate scheme/host; CSP `base-uri 'self'` (upstream `'none'`).
-- No `server-only` anywhere (e.g. `domainTimezoneService`, services); no `scripts/check-prerender.mjs`; CI never
- runs `npm run build`.
-
-## Proposed fix
-Port per the playbooks. Verify `server-only` by planting a client import that must break `next build`.
-**The CSP enforcement walk is held** (the GrapesJS fonts question went away with the template editor, removed 2026-10-10) — see
-`2026-09-30-held-csp-customer-deployments.md`.
-
-## Impact if not fixed
-Server-action payloads in dev logs; weaker cross-origin isolation; server code can silently enter the client bundle.
diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md
index a4e22a0..8a74d61 100644
--- a/.claude/TODO/INDEX.md
+++ b/.claude/TODO/INDEX.md
@@ -18,10 +18,7 @@ Severity tiers:
- **medium**: doc drift, missing test, refactor with real cost
- **low**: nits, minor doc fixes, stylistic improvements
-Total: **2 open TODOs**.
-
-> **`step6`** is the remaining step from the 2026-09-30 MPNext playbook audit.
-> `held-*` items wait on an owner decision.
+Total: **0 open TODOs**.
---
@@ -33,11 +30,8 @@ _none open_
### High (0)
_none open_
-### Medium (2)
-| Area | Tags | Title | File |
-|---|---|---|---|
-| routing | security, drift | Step 6 — Headers, next.config, server-only, prerender + build in CI | [→](2026-09-30-step6-headers-csp-build-guards.md) |
-| routing | security, doc | Held — CSP notes for customer-deployed clones | [→](2026-09-30-held-csp-customer-deployments.md) |
+### Medium (0)
+_none open_
### Low (0)
_none open_
@@ -46,15 +40,14 @@ _none open_
## By tag
-### security (2)
-- step6-headers-csp-build-guards — medium
-- held-csp-customer-deployments — medium
+### security (0)
+_none open_
### bug (0)
_none open_
-### drift (1)
-- step6-headers-csp-build-guards — medium
+### drift (0)
+_none open_
### missing-test (0)
_none open_
@@ -62,8 +55,8 @@ _none open_
### refactor (0)
_none open_
-### doc (1)
-- held-csp-customer-deployments — medium
+### doc (0)
+_none open_
### perf (0)
_none open_
@@ -75,7 +68,7 @@ _none open_
| Area | Count |
|---|---|
| components | 0 |
-| routing | 2 |
+| routing | 0 |
| services | 0 |
| auth | 0 |
| commands | 0 |
diff --git a/.claude/references/GOTCHAS.md b/.claude/references/GOTCHAS.md
index b3e8b81..ea7da8f 100644
--- a/.claude/references/GOTCHAS.md
+++ b/.claude/references/GOTCHAS.md
@@ -48,6 +48,8 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs
- GOTCHA-027: Next.js errors "sync dynamic API usage" or undefined `params`
- GOTCHA-028: New public route redirects to `/signin` in a loop
- GOTCHA-029: CI runs `next lint` and fails — command unknown
+- GOTCHA-046: `This module cannot be imported from a Client Component module` from `server-only`
+- GOTCHA-047: CI `check:prerender` fails — a route was statically prerendered
### Frontend / components
- GOTCHA-030: `useUser()` throws "useUser must be used within a UserProvider"
@@ -323,6 +325,22 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs
---
+### GOTCHA-046: `This module cannot be imported from a Client Component module` from `server-only`
+
+**Symptom:** One of three, depending on where the import runs: `next build` fails with *"You're importing a module that depends on "server-only""* and a `[Client Component Browser]` import trace; `npm run mp:generate*` (or a hand-run `tsx` on the codegen scripts) throws from `node_modules/server-only/index.js`; or a new Vitest file throws the same error.
+**Root cause:** Every service, `src/lib/auth.ts`, `src/lib/env.ts`, `src/lib/tool-params.server.ts` and the MP client/credentials modules start with `import 'server-only'`. That package throws unless resolved under the `react-server` export condition, which Next applies to server code only.
+**Fix:** In the build case the guard is doing its job — the trace names the client component that reached a server module; use `import type` or call a server action instead. Codegen scripts must run as `tsx --conditions=react-server …` (the `package.json` scripts already do). Vitest aliases `server-only` to the package's `empty.js` in `vitest.config.mts`; do not remove that alias.
+**Enforced where:** `next build` (CI `Build` step); `vitest.config.mts` `resolve.alias`; `package.json` `mp:generate*` scripts.
+**Related:** [security/README.md](security/README.md), GOTCHA-047
+
+### GOTCHA-047: CI `check:prerender` fails — a route was statically prerendered
+
+**Symptom:** The `Check prerendered routes` CI step lists a route; locally, the build table shows it as `○ (Static)`.
+**Root cause:** The CSP nonce is read from the request at render time. A prerendered page has none, so under the enforced CSP it never hydrates — and it also skips server-side session checks.
+**Fix:** Give the route a dynamic signal: a `headers()`/session read, or `export const dynamic = "force-dynamic"` in a **server** module (segment config is ignored in a `"use client"` file, the same trap as GOTCHA-032). Only Next's `/_not-found` and `/_global-error` may be static.
+**Enforced where:** `scripts/check-prerender.mjs`, run by CI after `npm run build`.
+**Related:** [security/README.md](security/README.md) § Nonces force dynamic rendering
+
## Frontend / components
### GOTCHA-030: `useUser()` throws "useUser must be used within a UserProvider"
diff --git a/.claude/references/mp-provider/type-generation.md b/.claude/references/mp-provider/type-generation.md
index 74c147d..05cdccb 100644
--- a/.claude/references/mp-provider/type-generation.md
+++ b/.claude/references/mp-provider/type-generation.md
@@ -154,7 +154,7 @@ npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -o ./types
npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -d --sample-size 10
# Help
-npx tsx src/lib/providers/ministry-platform/scripts/generate-types.ts --help
+npx tsx --conditions=react-server src/lib/providers/ministry-platform/scripts/generate-types.ts --help
```
The canonical npm script:
diff --git a/.claude/references/security/README.md b/.claude/references/security/README.md
index ecc1e0e..1ae25e1 100644
--- a/.claude/references/security/README.md
+++ b/.claude/references/security/README.md
@@ -167,8 +167,24 @@ Split across two files, and the split is not arbitrary:
| Where | Headers | Why there |
|---|---|---|
-| `next.config.ts` on `/(.*)` | `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS (prod only) | Request-independent, and reaches `/api` plus the static paths the proxy matcher skips |
-| `src/proxy.ts` via `src/lib/security-headers.ts` | `Content-Security-Policy` | The nonce must be fresh per request; a build-time value is a constant an attacker reads off any page |
+| `next.config.ts` on `/(.*)` | `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy` / `Cross-Origin-Resource-Policy` (`same-origin`), HSTS (prod only) | Request-independent, and reaches `/api` plus the static paths the proxy matcher skips |
+| `src/proxy.ts` via `src/lib/security-headers.ts` | `Content-Security-Policy`, plus `Reporting-Endpoints` when `CSP_REPORT_URI` is set | The nonce must be fresh per request; a build-time value is a constant an attacker reads off any page |
+
+`next.config.ts` also sets `poweredByHeader: false`, `images.unoptimized: true`
+(nothing uses `next/image`, so the optimizer was only SSRF surface) and
+`logging.serverFunctions: false` (otherwise `next dev` prints every server
+action's arguments, i.e. household records, to the terminal).
+
+COOP `same-origin` is safe only because sign-in is a **full-page redirect**. A
+popup-based OAuth flow would lose `window.opener` and break.
+
+### Proxy path matching
+
+The public-path check matches `/api/auth` **as a segment** (`=== '/api/auth'` or
+`startsWith('/api/auth/')`), so a future `/api/authz` is not public by accident.
+The matcher's exclusions are anchored (`_next/static/`, `_next/image$`,
+`favicon\.ico$`, `assets/`) so a look-alike path such as `/favicon.ico-admin`
+still gets the session check and the CSP. `src/proxy.test.ts` pins both.
Anti-framing is expressed **twice on purpose** — `X-Frame-Options` reaches the
routes the proxy skips, `frame-ancestors` covers the rest. They are not both CSP
@@ -195,6 +211,13 @@ a browser-vendor list and the deploying church's call, not a repo default.
`form-action` to the **whole redirect chain**, not just its first hop.
3. **`img-src` includes the MP origins**, for contact photos served from MP.
+Those MP origins are accepted only over `https:` (`http:` to a loopback host in
+dev). `data:`, `javascript:`, plain-http real hosts and credentialed URLs are
+**dropped** from the policy rather than trusted — `new URL('data:,x').origin`
+is the string `"null"`, which would otherwise land in the directive.
+`base-uri` is `'none'`: nothing emits a ``, and `'self'` would still let
+an injected one re-root every relative URL.
+
### Nonces force dynamic rendering
Next reads the nonce off the **incoming request headers** at render time, so the
@@ -210,8 +233,12 @@ in `sign-in-content.tsx` and its `page.tsx` is a server component.
`src/app/signin/page.test.tsx` pins **both** halves — the export, and the absence
of the directive.
-`/_not-found` remains static and nonce-less. Accepted: it is Next's built-in 404
-with no interactivity to lose.
+`/_not-found` and `/_global-error` remain static and nonce-less. Accepted: the
+404 has no interactivity to lose, and the global error shell renders client-side.
+
+**CI enforces this.** After `npm run build`, `npm run check:prerender`
+(`scripts/check-prerender.mjs`) fails if any other route was prerendered. Fix
+the route; widen its allowlist only with a written justification.
### `CSP_ENFORCE`
@@ -225,6 +252,32 @@ policy can block things a clean report-only pass never flagged. Walk sign-in,
sign-out, images and **every Radix surface** (dropdown, dialog, select, tooltip)
against a production build before calling it done.
+Last walk: **2026-10-10**, enforced policy, production build — sign-in, every
+tool, Radix select/popover/command and dialog (scroll-lock `