# 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) " ```