docs: add implementation plan for security headers, coverage, and web vitals
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
466
docs/plans/2026-03-18-security-coverage-performance.md
Normal file
466
docs/plans/2026-03-18-security-coverage-performance.md
Normal file
@@ -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) <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:
|
||||
|
||||
```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) <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
|
||||
|
||||
```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) <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
|
||||
|
||||
```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) <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:
|
||||
|
||||
```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) <noreply@anthropic.com>"
|
||||
```
|
||||
Reference in New Issue
Block a user