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 `