Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 0 additions & 32 deletions .claude/TODO/2026-09-30-held-csp-customer-deployments.md

This file was deleted.

27 changes: 0 additions & 27 deletions .claude/TODO/2026-09-30-step6-headers-csp-build-guards.md

This file was deleted.

27 changes: 10 additions & 17 deletions .claude/TODO/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

---

Expand All @@ -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_
Expand All @@ -46,24 +40,23 @@ _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_

### refactor (0)
_none open_

### doc (1)
- held-csp-customer-deployments — medium
### doc (0)
_none open_

### perf (0)
_none open_
Expand All @@ -75,7 +68,7 @@ _none open_
| Area | Count |
|---|---|
| components | 0 |
| routing | 2 |
| routing | 0 |
| services | 0 |
| auth | 0 |
| commands | 0 |
Expand Down
18 changes: 18 additions & 0 deletions .claude/references/GOTCHAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .claude/references/mp-provider/type-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 57 additions & 4 deletions .claude/references/security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<base>`, 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
Expand All @@ -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`

Expand All @@ -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 `<style>` applied),
zero violations. Sign-out could not be walked: `UserMenu` is not mounted
anywhere, so no sign-out control is reachable in a production build.

### `CSP_REPORT_URI` (opt-in reporting)

This repo ships **no collector**. Each deployment that wants reports sets
`CSP_REPORT_URI` to one it owns (Sentry's security endpoint, report-uri.com, or
a small route that logs shape only). When set, the policy gains
`report-uri <url>; report-to csp-endpoint` and responses carry
`Reporting-Endpoints: csp-endpoint="<url>"` — both spellings, because Firefox
still honours only `report-uri`. The value must be `https:` (loopback `http:` in
dev only) and may not contain `;`, `,`, `"` or whitespace; anything else is
ignored, never escaped.

### Customer-deployed clones

- **Adding an origin** (analytics, chat widget, an embedded MP widget): add it
to the specific directive, per deployment. Never `'unsafe-inline'` in
`script-src`, never a wildcard.
- **Roll-out order:** set `CSP_REPORT_URI`, run `CSP_ENFORCE=false` for at least
one release cycle, read the reports, then enforce.
- **After any CSP change:** `npm run build && npm start`, open the console, walk
every tool. Zero violations is the bar.

---

## Identity
Expand Down
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ MP_SECURITY_ROLES=*
# silent (no policy). Set to "false" only to diagnose a violation.
CSP_ENFORCE=

# Optional CSP violation collector (Sentry, report-uri.com, or your own route).
# Unset = no reporting. When set, the policy gains `report-uri` + `report-to`
# and responses carry a matching `Reporting-Endpoints` header. Must be https://
# (http://localhost is accepted in development only); a value that is not, or
# that contains `;`, `,`, `"` or whitespace, is ignored. When adding a new
# origin, run with CSP_ENFORCE=false (report-only) for one release cycle first.
CSP_REPORT_URI=

# Client IP for sign-in rate limiting (comma-separated header names, tried in
# order). Name ONLY a header your edge always OVERWRITES — a header clients can
# set themselves lets them dodge the limit or lock someone else out. Never use a
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,27 @@ jobs:
- name: Test with coverage
run: npm run test:coverage

# Catches what tests and tsc cannot: a `server-only` module pulled into
# a client component, Turbopack graph errors, and boot-time env guards.
# The values are placeholders that satisfy `src/lib/env.ts` and the
# secret guard in `src/lib/auth.ts`; the build makes no MP call (OIDC
# discovery is lazy), so nothing here needs to be real or secret.
- name: Build
run: npm run build
env:
BETTER_AUTH_SECRET: ci-build-only-placeholder-secret-0123456789abcdef
BETTER_AUTH_URL: http://localhost:3000
MINISTRY_PLATFORM_BASE_URL: https://mp.example.invalid/ministryplatformapi
MINISTRY_PLATFORM_CLIENT_ID: ci-build
MINISTRY_PLATFORM_CLIENT_SECRET: ci-build-placeholder
MP_SECURITY_ROLES: "*"
NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL: https://mp.example.invalid/ministryplatformapi/files

# A statically prerendered route has no CSP nonce, so it would never
# hydrate under the enforced policy. See scripts/check-prerender.mjs.
- name: Check prerendered routes
run: npm run check:prerender

- uses: codecov/codecov-action@v5
if: always()
with:
Expand Down
Loading
Loading