Files
resolutionflow/docs/plans/2026-03-18-security-coverage-performance.md
2026-03-18 02:07:58 +00:00

13 KiB

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:

"""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

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):

    # 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:

"""
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):

from app.core.security_headers import SecurityHeadersMiddleware

Add middleware registration after the CORS block (after line 235, before # Include API router):

# Add security headers middleware (after CORS so preflight responses work)
app.add_middleware(SecurityHeadersMiddleware)

Step 6: Run tests to verify they pass

cd backend && python -m pytest tests/test_security_headers.py -v

Expected: 3 tests PASS.

Step 7: Run full backend test suite

cd backend && python -m pytest --override-ini="addopts=" -v

Expected: All tests pass (security headers don't break existing tests).

Step 8: Commit

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) <noreply@anthropic.com>"

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:

      - 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:

      - 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%

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

git add .github/workflows/ci.yml
git commit -m "ci: enforce 80% backend coverage gate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"

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

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:

  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):

    "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:

      - name: Test
        run: cd frontend && npm test

To:

      - name: Test with coverage
        run: cd frontend && npm run test:coverage

Step 6: Verify locally

cd frontend && npm run test:coverage

Expected: Tests pass and coverage summary prints to terminal. Note the baseline percentage.

Step 7: Commit

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) <noreply@anthropic.com>"

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

cd frontend && npm install web-vitals

Step 2: Create the web vitals module

Create frontend/src/lib/webVitals.ts:

/**
 * 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):

import { initWebVitals } from './lib/webVitals'

Add the init call after the PostHog init block (after line 23, before createRoot):

// Start Web Vitals reporting to PostHog
initWebVitals()

Step 4: Verify the build

cd frontend && npm run build

Expected: Build succeeds with no errors.

Step 5: Commit

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) <noreply@anthropic.com>"

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:

| 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

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

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) <noreply@anthropic.com>"