feat: security headers, coverage gates, and web vitals #115

Merged
chihlasm merged 7 commits from feat/security-headers-coverage-performance into main 2026-03-18 03:34:04 +00:00
15 changed files with 1088 additions and 108 deletions

View File

@@ -48,7 +48,7 @@ jobs:
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- 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
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=80
- name: Display coverage summary
if: always()
@@ -89,8 +89,8 @@ jobs:
- name: Lint
run: cd frontend && npm run lint
- name: Test
run: cd frontend && npm test
- name: Test with coverage
run: cd frontend && npm run test:coverage
- name: Build
run: cd frontend && npm run build

1
.gitignore vendored
View File

@@ -219,6 +219,7 @@ frontend/stats.html
frontend/playwright-report/
frontend/test-results/
frontend/e2e/.auth/
frontend/coverage/
# Superpowers brainstorming mockups
.superpowers/

View File

@@ -1,6 +1,6 @@
# Development Roadmap
> **Last Updated:** March 1, 2026
> **Last Updated:** March 18, 2026
> **Product:** ResolutionFlow (repo: patherly)
> **Target Market:** MSP companies — IT service providers managing infrastructure and support for multiple clients
@@ -27,17 +27,44 @@
- Mobile-responsive design
- Security hardening (Phases A-D) — rate limiting, audit logs, soft delete, SQL escaping
### Phase 2.5: Step Library & Procedural Flows (Partially Complete)
- **Step Library backend** — CRUD, search, ratings, reviews, visibility filtering, verified-use badge
- **Procedural flows** — `procedural` tree type, step-by-step execution engine, intake forms, section headers, resume support, type-aware routing
### Phase 2.5: Step Library & Procedural Flows
- **Step Library** — CRUD, search, ratings, reviews, visibility filtering, verified-use badge (backend complete, frontend browse/search/rate pending)
- **Procedural flows** — `procedural` tree type, step-by-step execution engine, intake forms, section headers, resume support, type-aware routing, auto-start on page load
- **Maintenance flows** — `maintenance` tree type, batch session launch, saved target lists, APScheduler cron scheduling, maintenance detail page
- **Session sharing** — ShareSessionModal, SharedSessionPage, MySharesPage, share links with copy/manage
- **Export improvements** (Phases A-C) — step cutoff, summary block, detail levels, editable preview, sensitive data redaction
- **Rebrand** — Patherly → ResolutionFlow branding, dark-first purple gradient design system
- **Rebrand** — Patherly → ResolutionFlow branding, Slate & Ice glassmorphism design system
- **Flexible intake** — deferred variable resolution, prepared sessions for reuse
### Recently Completed (Feb-Mar 2026)
- **AI Flow Assist** — Conversational AI chat builder for generating troubleshooting and procedural flows. Multi-phase interview (scope → structure → details), progressive tree generation with live preview, save to flow library. Backend: Anthropic Claude API with streaming, AI tree validation, scaffold/refine pipeline. Frontend: ChatPanel, StaticTreePreview, ChatToolbar, Zustand store.
- **Cross-Reference / Loop-Back Support** — Ghost references allowing any node to link to any other node in the tree (loop-backs, re-verification patterns). Backend validation relaxed for cross-refs. Frontend: cross-reference edge rendering (dashed purple arrows), node picker dropdowns in action/decision forms, circular reference detection changed to warnings.
### Phase 3: Intelligence & Polish (Mar 2026)
- **AI Flow Assist** — Conversational AI chat builder for generating troubleshooting and procedural flows. Multi-phase interview, progressive tree generation with live preview, save to flow library
- **Cross-Reference / Loop-Back Support** — Ghost references, dashed purple arrows, node picker dropdowns, circular reference detection as warnings
- **Editor-Embedded Flow Assist** — 320px AI side panel in editor, ghost node suggestions, delta responses, model tier routing
- **Procedural Flow Assist** — AI suggestions for procedural flow editing with `[STEPS_UPDATE]` markers
- **AI Chat Conclusion** — Outcome tracking, AI-generated ticket summaries, resume flow
- **AI Copilot** — In-session copilot panel with RAG, standalone assistant chat
- **KB Accelerator** — Upload .md files and convert to troubleshooting/procedural flows with AI, tree builder with validation gate
- **Script Generator** — Backend engine with parameter detection, template editor with conditional/looping, PowerShell and Bash generation
- **Command Palette** — Quick flow navigation and actions (Ctrl+K)
- **Session-to-Flow Converter** — AI-powered conversion of finished sessions into reusable flows
- **Sidebar Redesign** — Activity feed, grouped navigation, improved flow discovery
- **Flow Export/Import** — JSON export, import with validation, cross-team flow transfer
- **Survey System** — Public survey page, admin invite tracking, response viewer with CSV export, email invitations, read/unread/archive/delete management
- **Email Verification** — Tokens, banner, admin toggle (platform setting)
- **Account Management** — Profile settings, delete/leave/transfer team, chat retention settings
- **Slate & Ice Aesthetic Redesign** — Glassmorphism with ice-cyan accents, Bricolage Grotesque + IBM Plex Sans + JetBrains Mono typography, orchestrated page-load animations
- **Tailwind CSS v3 → v4 Migration** — CSS custom properties, glass utilities, React Flow CSS integration
- **Landing Page + Beta Signup** — Marketing-ready landing page with CSS polish
- **PostHog Product Analytics** — 60+ events instrumented, PostHogProvider, identifyUser/resetAnalytics wired to auth, Dockerfile build args for Railway
- **Playwright E2E Tests** — 17 spec files, full CI job, auth storage state, both webServers managed in config
- **ConnectWise PSA Integration (Core)** — Provider pattern (`BasePsaProvider` / `ConnectWiseProvider`), connection CRUD, ticket context retrieval, session-to-ticket linking, member mapping, credential encryption, in-memory TTL cache
### Phase 3.5: Polish & Professional (Mar 2026 — PR #114)
- **Empty States** — Illustrative empty states across 8 pages, upgraded EmptyState component with illustration + learn-more support
- **Onboarding Checklist** — Backend status/dismiss endpoints, dashboard checklist widget with structured steps
- **Professional PDF Export** — WeasyPrint branded template, supporting data in all export formats
- **Team Branding** — CRUD endpoints + UI settings for export customization
- **Supporting Data Capture** — CRUD endpoints + UI for attaching evidence/context to sessions
---
@@ -45,14 +72,21 @@
| Task | Status | Notes |
|------|--------|-------|
| Step Library Frontend UI | In Progress | Backend complete, frontend browse/search/rate UI pending |
| Procedural Flows Lifecycle | In Progress | Resume done, run chooser/reuse pending |
| ConnectWise PSA Integration (Advanced) | In Progress | Core done — ticket linking, note posting, member mapping. Remaining: callback webhooks, deeper ticket context in sessions |
| PR #114 Merge | In Progress | Empty states, onboarding, PDF exports, branding, supporting data — ready for review |
---
## Phase 3: Intelligence & Polish
## What's Next
**Goal:** Make ResolutionFlow smarter — surface insights from usage data and make the day-to-day experience faster.
### Near-Term Priorities (from Stack Priorities Plan)
| Feature | Status | Description |
|---------|--------|-------------|
| Coverage gates in CI | ✅ Complete | Backend enforced at 80%, frontend coverage reporting enabled |
| Security headers | ✅ Complete | HSTS, CSP (report-only), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy |
| Web Vitals / performance budgets | ✅ Complete | LCP, INP, CLS, FCP, TTFB reported to PostHog via web-vitals |
| Search and recall improvements | ⬜ Not started | Search sessions by flow, tag, client, ticket context |
### 3A: Quick Wins & UX (Priority: Medium)
@@ -69,12 +103,6 @@
| Tree Effectiveness Dashboard | #61 | Usage stats, common paths, avg completion time, success rates per flow |
| Recurring Issue Detection | #60 | Identify repeat problems across sessions — surface patterns to team leads |
### 3C: Content Management (Priority: Medium)
| Feature | GitHub Issue | Description |
|---------|-------------|-------------|
| Tree Templates + Import/Export | #66 | Starter templates, JSON/YAML export, community sharing foundation |
### 3D: Remaining Infrastructure
- File attachments for sessions (S3-compatible storage, drag-and-drop, screenshot paste)
- Tree forking UI (backend schema exists — migration 022)
@@ -86,13 +114,12 @@
**Goal:** Connect ResolutionFlow to the MSP tools teams already use.
### 4A: PSA Integration (Priority: HIGH)
### 4A: PSA Integration (Priority: HIGH — Core Complete)
| Feature | GitHub Issue | Description |
|---------|-------------|-------------|
| ConnectWise / Autotask Integration | #63 | Create tickets from sessions, sync ticket numbers, update notes, pull client context |
This is the highest-priority strategic feature. It turns ResolutionFlow from a standalone tool into part of the MSP workflow.
| ConnectWise PSA — Advanced | #63 | ~~Core integration~~ ✅ — Remaining: callback webhooks, real-time ticket events, deeper session context |
| Autotask PSA | — | Second PSA provider using same `BasePsaProvider` pattern |
### 4B: Intelligence Layer (Priority: Strategic)
@@ -102,7 +129,7 @@ This is the highest-priority strategic feature. It turns ResolutionFlow from a s
| Intelligence Loop / Analytics Engine | #65 | Cross-session pattern analysis, auto-suggest flow improvements, team benchmarking |
### 4C: Automation
- PowerShell script execution framework with security sandbox
- PowerShell script execution framework with security sandbox (script generator foundation complete)
- Script library management
- Automation toggle at action nodes
@@ -128,9 +155,10 @@ This is the highest-priority strategic feature. It turns ResolutionFlow from a s
| Feature | GitHub Issue | Description |
|---------|-------------|-------------|
| AI Copilot — In-Session Intelligence | #69 | Real-time AI suggestions during troubleshooting based on context and history |
| Multi-Tree Sessions | #68 | Navigate across multiple flows in a single session, AI-suggested flow transitions |
> **Note:** AI Copilot (#69) completed in Phase 3. Session-to-Flow converter and KB Accelerator also delivered.
### 5C: Platform Growth
- Public API with key management and webhooks
- Community tree marketplace
@@ -145,23 +173,26 @@ This is the highest-priority strategic feature. It turns ResolutionFlow from a s
- Vertical-specific flow libraries (healthcare IT, financial services, education)
- Advanced compliance (SOC 2, ISO 27001)
- Voice-guided troubleshooting
- Evidence-rich sessions (screenshots, attachments, command output capture)
- Buyer-facing trust surfaces (changelog, status page, security page)
- Queue / worker architecture for AI, indexing, webhook fan-out
---
## Open GitHub Issues Summary
| # | Title | Priority | Phase |
|---|-------|----------|-------|
| #63 | PSA Integration (ConnectWise / Autotask) | HIGH | 4A |
| #70 | Quick Actions Dashboard | Medium | 3A |
| #66 | Tree Templates + Import/Export | Medium | 3C |
| #62 | Quick-Start from Clipboard | Medium | 3A |
| #61 | Tree Effectiveness Dashboard | Medium | 3B |
| #60 | Recurring Issue Detection | Medium | 3B |
| #58 | Step Feedback Flag | UX | 3A |
| #64 | Client Intelligence Sidebar | Strategic | 4B |
| #65 | Intelligence Loop / Analytics Engine | Strategic | 4B |
| #71 | Team Activity Feed + Collaboration | Low | 5A |
| #69 | AI Copilot — In-Session Intelligence | Low | 5B |
| #68 | Multi-Tree Sessions | Low | 5B |
| #67 | Push Steps to Active Sessions | Low | 5A |
| # | Title | Priority | Phase | Status |
|---|-------|----------|-------|--------|
| #63 | PSA Integration (ConnectWise / Autotask) | HIGH | 4A | Core complete, advanced in progress |
| #70 | Quick Actions Dashboard | Medium | 3A | Not started |
| #66 | Tree Templates + Import/Export | Medium | 3C | ✅ Export/Import done |
| #62 | Quick-Start from Clipboard | Medium | 3A | Not started |
| #61 | Tree Effectiveness Dashboard | Medium | 3B | Not started |
| #60 | Recurring Issue Detection | Medium | 3B | Not started |
| #58 | Step Feedback Flag | UX | 3A | Not started |
| #64 | Client Intelligence Sidebar | Strategic | 4B | Not started |
| #65 | Intelligence Loop / Analytics Engine | Strategic | 4B | Not started |
| #71 | Team Activity Feed + Collaboration | Low | 5A | Not started |
| #69 | AI Copilot — In-Session Intelligence | Low | 5B | ✅ Complete |
| #68 | Multi-Tree Sessions | Low | 5B | Not started |
| #67 | Push Steps to Active Sessions | Low | 5A | Not started |

View File

@@ -49,6 +49,11 @@ class Settings(BaseSettings):
# Security
BCRYPT_ROUNDS: int = 12
# 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
# Registration
REQUIRE_INVITE_CODE: bool = True # Set to False to allow open registration

View File

@@ -0,0 +1,80 @@
"""
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

View File

@@ -28,6 +28,7 @@ if settings.SENTRY_DSN:
from app.core.database import init_db, async_session_maker
from app.core.logging_config import setup_logging
from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
from app.core.security_headers import SecurityHeadersMiddleware
from app.core.rate_limit import limiter
from app.api.router import api_router
from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations
@@ -234,6 +235,9 @@ else:
expose_headers=["X-Redaction-Mode", "X-Redaction-Summary"],
)
# Add security headers middleware (after CORS so preflight responses work)
app.add_middleware(SecurityHeadersMiddleware)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_PREFIX)

View File

@@ -0,0 +1,41 @@
"""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

View File

@@ -1,9 +1,30 @@
# Stack Priorities And Playwright Plan
> **Date:** 2026-03-16
> **Updated:** 2026-03-17
> **Product:** ResolutionFlow
> **Purpose:** Turn the recent stack-gap review into a practical, sequenced execution plan
## Completion Status
| Item | Status | Notes |
|------|--------|-------|
| Product analytics (PostHog) | ✅ Complete | All 9 events tracked, identifyUser/resetAnalytics wired to auth, PostHogProvider in main.tsx |
| Playwright e2e | ✅ Complete | 17 spec files, full CI job, auth storage state, both webServers managed in config |
| Better empty states | ✅ Complete | Illustrative empty states rolled out across 8 pages, upgraded EmptyState component with illustration + learn-more support, 2 new guide entries |
| Onboarding checklist | ✅ Complete | Backend status/dismiss endpoints, dashboard checklist widget with structured steps |
| Professional exports | ✅ Complete | PDF export via WeasyPrint with branded template, supporting data in all export formats, team branding CRUD + UI settings, supporting data capture CRUD + UI |
| 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 |
| Search and recall improvements | ⬜ Not started | |
| Evidence-rich sessions | ⬜ Not started | |
| Smart PSA / client context | ⬜ Not started | |
| Queue / worker architecture | ⬜ Not started | |
| Buyer-facing trust surfaces | ⬜ Not started | |
---
---
## Summary

View File

@@ -0,0 +1,116 @@
# Security Headers, Coverage Gates & Web Vitals Design
> **Date:** 2026-03-18
> **Product:** ResolutionFlow
> **Branch:** `feat/security-headers-coverage-performance`
> **Purpose:** Add HTTP security headers, enforce test coverage gates, and instrument Core Web Vitals reporting
---
## Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Security headers priority | First | Highest trust signal for MSP buyers, real protection |
| CSP rollout strategy | Report-only first, then enforce | Avoids breaking third-party integrations (PostHog, Sentry, Google Fonts, React Flow) |
| Backend coverage gate | 80% fail threshold | Already near this level, prevents drift |
| Frontend coverage gate | Report-only (no gate yet) | Starting from zero — establish baseline first |
| Web Vitals destination | PostHog | 100% of sessions captured (vs Sentry's 20% sample), correlate with product analytics |
---
## 1. Security Headers Middleware
### New file: `backend/app/core/security_headers.py`
Starlette middleware that adds security headers to every response.
### Headers
| Header | Value | Purpose |
|--------|-------|---------|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | Force HTTPS for 1 year |
| `X-Content-Type-Options` | `nosniff` | Prevent MIME sniffing |
| `X-Frame-Options` | `DENY` | Block iframe embedding |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Limit referrer leakage |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | Disable unused browser APIs |
| `Content-Security-Policy-Report-Only` | *(see below)* | CSP in report-only mode |
### CSP Directive (report-only)
```
default-src 'self';
script-src 'self' https://us.i.posthog.com https://*.sentry.io;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: blob:;
connect-src 'self' https://us.posthog.com https://us.i.posthog.com https://*.sentry.io https://api.resolutionflow.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self'
```
### Wiring
- Add middleware in `main.py` after CORS middleware (so CORS preflight responses aren't affected)
- CSP directives configurable via `config.py` so promotion to enforcing mode is a config change, not a code change
- HSTS only sent when `DEBUG=false` (avoid locking localhost into HTTPS)
### Tests
Integration test that hits an endpoint and asserts all expected headers are present with correct values.
---
## 2. Coverage Gates
### Backend: Enforce at 80%
- Add `--cov-fail-under=80` to the pytest command in CI
- One-line change — reporting already wired up with `pytest-cov`
### Frontend: Report-only (establish baseline)
- Install `@vitest/coverage-v8` as dev dependency
- Add coverage config to `vite.config.ts`:
- Reporters: `text` + `json-summary` + `html`
- Include: `src/**/*.{ts,tsx}`
- Exclude: `src/test/`, `src/types/`, `**/*.d.ts`
- Add `test:coverage` script to `package.json`
- Update CI to run `npm run test:coverage` instead of `npm test`
- Display summary in CI output — no failure threshold yet
- Add `coverage/` to `.gitignore`
---
## 3. Web Vitals → PostHog
### Install
`web-vitals` npm package.
### New file: `frontend/src/lib/webVitals.ts`
- Import `onLCP`, `onINP`, `onCLS`, `onFCP`, `onTTFB` from `web-vitals`
- Each callback sends a PostHog event (`web_vitals`) with properties:
- `metric_name` — LCP, INP, CLS, FCP, TTFB
- `metric_value` — numeric value
- `metric_rating` — good / needs-improvement / poor
- `page_path` — current route
- Single `initWebVitals()` function that registers all observers
### Wiring
Call `initWebVitals()` in `main.tsx` after PostHog initialization.
---
## Scope Summary
| Area | Scope | Files |
|------|-------|-------|
| Security headers | New middleware + config + test | 3-4 backend files |
| Coverage gates | CI config + vitest coverage setup | CI workflow + 3 frontend config files |
| Web Vitals | New lib + dependency + main.tsx wiring | 2-3 frontend files |
Small, contained changes across all three. No architectural changes or new database models.

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

View File

@@ -36,6 +36,7 @@
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"web-vitals": "^4.2.4",
"zundo": "^2.3.0",
"zustand": "^5.0.10"
},
@@ -49,6 +50,7 @@
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
@@ -310,12 +312,12 @@
}
},
"node_modules/@babel/parser": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
"integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.6"
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -399,9 +401,9 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
"integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -411,6 +413,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz",
@@ -3313,18 +3325,49 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"node_modules/@vitest/coverage-v8": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz",
"integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.0",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.0",
"vitest": "4.1.0"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
"integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"@vitest/spy": "4.1.0",
"@vitest/utils": "4.1.0",
"chai": "^6.2.2",
"tinyrainbow": "^3.0.3"
},
"funding": {
@@ -3332,13 +3375,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz",
"integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"@vitest/spy": "4.1.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -3347,7 +3390,7 @@
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
@@ -3359,9 +3402,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz",
"integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3372,13 +3415,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz",
"integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"@vitest/utils": "4.1.0",
"pathe": "^2.0.3"
},
"funding": {
@@ -3386,13 +3429,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz",
"integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"@vitest/pretty-format": "4.1.0",
"@vitest/utils": "4.1.0",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -3401,9 +3445,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz",
"integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3411,13 +3455,14 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz",
"integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"@vitest/pretty-format": "4.1.0",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
@@ -3587,6 +3632,25 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
"integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -4420,9 +4484,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
@@ -5177,6 +5241,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -5452,6 +5523,45 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -5926,6 +6036,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
@@ -6989,6 +7140,12 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/posthog-js/node_modules/web-vitals": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
"license": "Apache-2.0"
},
"node_modules/preact": {
"version": "10.29.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz",
@@ -7605,9 +7762,9 @@
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"dev": true,
"license": "MIT"
},
@@ -8237,31 +8394,31 @@
}
},
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz",
"integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"@vitest/expect": "4.1.0",
"@vitest/mocker": "4.1.0",
"@vitest/pretty-format": "4.1.0",
"@vitest/runner": "4.1.0",
"@vitest/snapshot": "4.1.0",
"@vitest/spy": "4.1.0",
"@vitest/utils": "4.1.0",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0",
"why-is-node-running": "^2.3.0"
},
"bin": {
@@ -8277,12 +8434,13 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"@vitest/browser-playwright": "4.1.0",
"@vitest/browser-preview": "4.1.0",
"@vitest/browser-webdriverio": "4.1.0",
"@vitest/ui": "4.1.0",
"happy-dom": "*",
"jsdom": "*"
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
@@ -8311,6 +8469,9 @@
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
@@ -8328,9 +8489,9 @@
}
},
"node_modules/web-vitals": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {

View File

@@ -12,6 +12,7 @@
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
@@ -48,6 +49,7 @@
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"web-vitals": "^4.2.4",
"zundo": "^2.3.0",
"zustand": "^5.0.10"
},
@@ -60,6 +62,7 @@
"@types/node": "^24.10.9",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^4.0.18",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",

View File

@@ -0,0 +1,35 @@
/**
* 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)
}

View File

@@ -6,6 +6,7 @@ import { createRoot } from 'react-dom/client'
import { reactErrorHandler } from '@sentry/react'
import { HelmetProvider } from 'react-helmet-async'
import { PostHogProvider } from '@posthog/react'
import { initWebVitals } from './lib/webVitals'
import { Toaster } from 'sonner'
import './index.css'
import App from './App'
@@ -22,6 +23,9 @@ if (posthogKey) {
})
}
// Start Web Vitals reporting to PostHog
initWebVitals()
createRoot(document.getElementById('root')!, {
onUncaughtError: reactErrorHandler(),
onCaughtError: reactErrorHandler(),

View File

@@ -33,6 +33,18 @@ export default defineConfig({
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',
],
},
},
build: {
sourcemap: 'hidden', // Generate source maps but don't expose them publicly