Merge pull request #115 from patherly/feat/security-headers-coverage-performance
feat: security headers, coverage gates, and web vitals
This commit was merged in pull request #115.
This commit is contained in:
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -219,6 +219,7 @@ frontend/stats.html
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
frontend/e2e/.auth/
|
||||
frontend/coverage/
|
||||
|
||||
# Superpowers brainstorming mockups
|
||||
.superpowers/
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
80
backend/app/core/security_headers.py
Normal file
80
backend/app/core/security_headers.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
41
backend/tests/test_security_headers.py
Normal file
41
backend/tests/test_security_headers.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
116
docs/plans/2026-03-18-security-coverage-performance-design.md
Normal file
116
docs/plans/2026-03-18-security-coverage-performance-design.md
Normal 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.
|
||||
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>"
|
||||
```
|
||||
293
frontend/package-lock.json
generated
293
frontend/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
35
frontend/src/lib/webVitals.ts
Normal file
35
frontend/src/lib/webVitals.ts
Normal 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)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user