diff --git a/docs/plans/2026-03-18-security-coverage-performance.md b/docs/plans/2026-03-18-security-coverage-performance.md new file mode 100644 index 00000000..ebe4ee94 --- /dev/null +++ b/docs/plans/2026-03-18-security-coverage-performance.md @@ -0,0 +1,466 @@ +# Security Headers, Coverage Gates & Web Vitals Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add HTTP security headers to every response, enforce backend test coverage at 80%, add frontend coverage reporting, and instrument Core Web Vitals via PostHog. + +**Architecture:** Three independent workstreams — (1) new Starlette middleware for security headers with configurable CSP, (2) CI pipeline updates for coverage gates, (3) new frontend lib for web-vitals → PostHog. No database changes, no new API endpoints. + +**Tech Stack:** FastAPI/Starlette middleware (Python), pytest-cov (backend coverage), @vitest/coverage-v8 (frontend coverage), web-vitals + posthog-js (frontend performance) + +--- + +## Task 1: Security Headers Middleware + +**Files:** +- Create: `backend/app/core/security_headers.py` +- Modify: `backend/app/core/config.py:49` (add CSP config settings) +- Modify: `backend/app/main.py:236` (register middleware after CORS) + +### Step 1: Write the failing test + +Create `backend/tests/test_security_headers.py`: + +```python +"""Tests for security headers middleware.""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_security_headers_present(client: AsyncClient): + """Every response should include security headers.""" + response = await client.get("/health") + assert response.status_code == 200 + + # Non-CSP headers always present + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["x-frame-options"] == "DENY" + assert response.headers["referrer-policy"] == "strict-origin-when-cross-origin" + assert "camera=()" in response.headers["permissions-policy"] + assert "microphone=()" in response.headers["permissions-policy"] + assert "geolocation=()" in response.headers["permissions-policy"] + + +@pytest.mark.asyncio +async def test_csp_report_only_header(client: AsyncClient): + """CSP should be in report-only mode.""" + response = await client.get("/health") + assert response.status_code == 200 + + csp = response.headers.get("content-security-policy-report-only") + assert csp is not None + assert "default-src 'self'" in csp + assert "script-src 'self'" in csp + assert "style-src 'self' 'unsafe-inline'" in csp + assert "frame-ancestors 'none'" in csp + + +@pytest.mark.asyncio +async def test_hsts_only_in_production(client: AsyncClient): + """HSTS should NOT be sent when DEBUG=true (test environment).""" + response = await client.get("/health") + assert response.status_code == 200 + assert "strict-transport-security" not in response.headers +``` + +### Step 2: Run test to verify it fails + +```bash +cd backend && python -m pytest tests/test_security_headers.py -v +``` + +Expected: FAIL — headers not present yet. + +### Step 3: Add CSP config to settings + +Modify `backend/app/core/config.py`. Add after the `BCRYPT_ROUNDS` line (line 50): + +```python + # Security Headers + CSP_REPORT_ONLY: bool = True # Set False to enforce CSP + CSP_EXTRA_SCRIPT_SOURCES: list[str] = [] # Additional script-src domains + CSP_EXTRA_CONNECT_SOURCES: list[str] = [] # Additional connect-src domains +``` + +### Step 4: Write the middleware + +Create `backend/app/core/security_headers.py`: + +```python +""" +Security headers middleware. + +Adds standard security headers to every HTTP response: +- HSTS (production only) +- X-Content-Type-Options +- X-Frame-Options +- Referrer-Policy +- Permissions-Policy +- Content-Security-Policy (report-only by default) +""" + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from typing import Callable + +from app.core.config import settings + + +def _build_csp_directive() -> str: + """Build the CSP directive string from config.""" + script_sources = "'self' https://us.i.posthog.com https://*.sentry.io" + if settings.CSP_EXTRA_SCRIPT_SOURCES: + script_sources += " " + " ".join(settings.CSP_EXTRA_SCRIPT_SOURCES) + + connect_sources = ( + "'self' https://us.posthog.com https://us.i.posthog.com " + "https://*.sentry.io https://api.resolutionflow.com" + ) + if settings.CSP_EXTRA_CONNECT_SOURCES: + connect_sources += " " + " ".join(settings.CSP_EXTRA_CONNECT_SOURCES) + + return "; ".join([ + "default-src 'self'", + f"script-src {script_sources}", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data: blob:", + f"connect-src {connect_sources}", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ]) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Add security headers to every response.""" + + def __init__(self, app): + super().__init__(app) + # Pre-build CSP at startup so we don't rebuild per-request + self._csp = _build_csp_directive() + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + response = await call_next(request) + + # Always set these headers + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = ( + "camera=(), microphone=(), geolocation=()" + ) + + # HSTS only in production (avoid locking localhost into HTTPS) + if not settings.DEBUG: + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains" + ) + + # CSP — report-only or enforcing based on config + csp_header = ( + "Content-Security-Policy-Report-Only" + if settings.CSP_REPORT_ONLY + else "Content-Security-Policy" + ) + response.headers[csp_header] = self._csp + + return response +``` + +### Step 5: Register middleware in main.py + +Modify `backend/app/main.py`. Add import at top with other core imports (after line 30): + +```python +from app.core.security_headers import SecurityHeadersMiddleware +``` + +Add middleware registration after the CORS block (after line 235, before `# Include API router`): + +```python +# Add security headers middleware (after CORS so preflight responses work) +app.add_middleware(SecurityHeadersMiddleware) +``` + +### Step 6: Run tests to verify they pass + +```bash +cd backend && python -m pytest tests/test_security_headers.py -v +``` + +Expected: 3 tests PASS. + +### Step 7: Run full backend test suite + +```bash +cd backend && python -m pytest --override-ini="addopts=" -v +``` + +Expected: All tests pass (security headers don't break existing tests). + +### Step 8: Commit + +```bash +git add backend/app/core/security_headers.py backend/app/core/config.py backend/app/main.py backend/tests/test_security_headers.py +git commit -m "feat: add security headers middleware with report-only CSP + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 2: Backend Coverage Gate (80%) + +**Files:** +- Modify: `.github/workflows/ci.yml:51` (add --cov-fail-under=80) + +### Step 1: Update the pytest CI command + +Modify `.github/workflows/ci.yml` line 51. Change: + +```yaml + - name: Run tests with coverage + run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json +``` + +To: + +```yaml + - name: Run tests with coverage + run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=80 +``` + +### Step 2: Verify locally that coverage is above 80% + +```bash +cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-fail-under=80 +``` + +Expected: PASS with total coverage >= 80%. + +### Step 3: Commit + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: enforce 80% backend coverage gate + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 3: Frontend Coverage Reporting + +**Files:** +- Modify: `frontend/package.json` (add test:coverage script) +- Modify: `frontend/vite.config.ts:31-36` (add coverage config) +- Modify: `.github/workflows/ci.yml:93` (change npm test → npm run test:coverage) +- Modify: `.gitignore` (add coverage/) + +### Step 1: Install @vitest/coverage-v8 + +```bash +cd frontend && npm install -D @vitest/coverage-v8 +``` + +### Step 2: Add coverage config to vite.config.ts + +Modify `frontend/vite.config.ts`. Replace the `test` block (lines 31-36) with: + +```typescript + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + include: ['src/**/*.{test,spec}.{ts,tsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary', 'html'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/test/**', + 'src/types/**', + 'src/**/*.d.ts', + 'src/instrument.ts', + 'src/main.tsx', + ], + }, + }, +``` + +### Step 3: Add test:coverage script to package.json + +Modify `frontend/package.json`. Add after the `"test"` script (line 14): + +```json + "test:coverage": "vitest run --coverage", +``` + +### Step 4: Add coverage/ to .gitignore + +Modify `.gitignore`. Add after the `frontend/e2e/.auth/` line (line 221): + +``` +frontend/coverage/ +``` + +### Step 5: Update CI to use test:coverage + +Modify `.github/workflows/ci.yml` line 93. Change: + +```yaml + - name: Test + run: cd frontend && npm test +``` + +To: + +```yaml + - name: Test with coverage + run: cd frontend && npm run test:coverage +``` + +### Step 6: Verify locally + +```bash +cd frontend && npm run test:coverage +``` + +Expected: Tests pass and coverage summary prints to terminal. Note the baseline percentage. + +### Step 7: Commit + +```bash +git add frontend/package.json frontend/package-lock.json frontend/vite.config.ts .github/workflows/ci.yml .gitignore +git commit -m "ci: add frontend coverage reporting via vitest/v8 + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 4: Web Vitals → PostHog + +**Files:** +- Create: `frontend/src/lib/webVitals.ts` +- Modify: `frontend/src/main.tsx:23` (call initWebVitals after PostHog init) + +### Step 1: Install web-vitals + +```bash +cd frontend && npm install web-vitals +``` + +### Step 2: Create the web vitals module + +Create `frontend/src/lib/webVitals.ts`: + +```typescript +/** + * Core Web Vitals reporting via PostHog. + * + * Tracks LCP, INP, CLS, FCP, and TTFB as PostHog events so we can + * build dashboards and correlate performance with product usage. + * + * Each metric fires once per page load. PostHog captures 100% of + * sessions (vs Sentry's 20% sample), giving better coverage. + */ +import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals' +import type { Metric } from 'web-vitals' +import posthog from 'posthog-js' + +function sendToPostHog(metric: Metric) { + // Only send if PostHog is loaded + if (!(posthog as unknown as { __loaded?: boolean }).__loaded) return + + posthog.capture('web_vitals', { + metric_name: metric.name, + metric_value: metric.value, + metric_rating: metric.rating, + metric_delta: metric.delta, + metric_id: metric.id, + page_path: window.location.pathname, + }) +} + +/** Register all Web Vitals observers. Call once after PostHog init. */ +export function initWebVitals() { + onLCP(sendToPostHog) + onINP(sendToPostHog) + onCLS(sendToPostHog) + onFCP(sendToPostHog) + onTTFB(sendToPostHog) +} +``` + +### Step 3: Wire into main.tsx + +Modify `frontend/src/main.tsx`. Add import after the PostHog import (after line 8): + +```typescript +import { initWebVitals } from './lib/webVitals' +``` + +Add the init call after the PostHog init block (after line 23, before `createRoot`): + +```typescript +// Start Web Vitals reporting to PostHog +initWebVitals() +``` + +### Step 4: Verify the build + +```bash +cd frontend && npm run build +``` + +Expected: Build succeeds with no errors. + +### Step 5: Commit + +```bash +git add frontend/src/lib/webVitals.ts frontend/src/main.tsx frontend/package.json frontend/package-lock.json +git commit -m "feat: add Core Web Vitals reporting to PostHog + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +## Task 5: Update stack priorities plan and verify + +**Files:** +- Modify: `docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md` (update completion status) + +### Step 1: Update the completion status table + +In `docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md`, update these rows: + +```markdown +| Coverage gates in CI | ✅ Complete | Backend enforced at 80%, frontend coverage reporting enabled (no gate yet) | +| Security headers | ✅ Complete | HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP report-only | +| Web Vitals / performance budgets | ✅ Complete | LCP, INP, CLS, FCP, TTFB reported to PostHog via web-vitals library | +``` + +### Step 2: Run the full test suite one more time + +```bash +cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-fail-under=80 +cd frontend && npm run test:coverage +cd frontend && npm run build +``` + +Expected: All pass. + +### Step 3: Commit + +```bash +git add docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md +git commit -m "docs: mark security headers, coverage gates, and web vitals complete + +Co-Authored-By: Claude Opus 4.6 (1M context) " +```