Architecture and data flow
This page describes how the Better Comply components connect, what security controls sit at each boundary, and where regulated evidence is persisted.
Operators who need to understand the system before configuring or auditing a deployment.
Component layout
Browser (React SPA on Netlify CDN)
│
│ HTTPS - read queries (RLS-gated)
▼
Supabase
├── Auth (GoTrue) JWT issuance and refresh
├── Postgres + pgvector All relational data + vector search
└── Object Storage Certificates, document blobs, quiz images
│
│ HTTPS - POST /v1/<route> state changes and AI operations
▼
Cloud Run (Fastify / Node 20)
├── auth middleware Verifies JWT, re-reads role from user_roles
├── /v1/generate-content AI content, admin-only
├── /v1/generate-quiz AI quiz generation
├── /v1/process-document RAG indexing trigger
├── /v1/recertification-scan Secret-gated cron
├── /v1/supervisor-report-scan Secret-gated cron
└── ... (all other routes)
│
│ service-role key or caller-scoped JWT
▼
Supabase (same project - Cloud Run reads/writes back)
Request paths
Browser to Supabase (direct)
Read-only SELECT queries run from the browser through the Supabase JS client using the user's JWT. Row Level Security (RLS) policies on every table ensure each user sees only the data their role permits.
The browser never writes directly to regulated tables (evidence, training_versions, audit_logs, document_versions, document_approvals). Those writes go through Cloud Run routes that validate input, enforce authorization, and emit fail-loud audit events before touching the database.
Browser to Cloud Run
The browser calls POST /v1/<route> via the invokeBackendFunction helper (packages/web/src/lib/backendApi.ts). Every call forwards the user's Supabase session token as Authorization: Bearer <access_token>.
The Cloud Run backend:
- Verifies the JWT with Supabase Auth.
- Re-reads the caller's role from
user_roles(never trusts the role embedded in the JWT, which can lag behind a role change). - Enforces per-route authorization rules (admin-only, owner-or-admin, etc.).
- Performs the operation.
- Emits an audit event via
logAuditEventbefore returning the response. For fail-loud actions, a failed audit RPC aborts the whole operation.
Cloud Run to Supabase
The backend uses the Supabase service-role key for administrative writes (certificate storage, seed operations, cron drains) and the caller's forwarded JWT for RAG retrieval - so that auth.uid() resolves inside the database function and the caller's department-level filter is applied (defense-in-depth against cross-tenant data access).
Security headers (Netlify)
All responses from Netlify include these headers, set in netlify.toml:
| Header | Value |
|---|---|
Content-Security-Policy | default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' https://*.supabase.co https://*.supabase.in wss://*.supabase.co; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; object-src 'none' |
Strict-Transport-Security | max-age=31536000; includeSubDomains; preload |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | geolocation=(), microphone=(), camera=(), payment=() |
Cross-Origin-Opener-Policy | same-origin |
style-src 'unsafe-inline' is intentional: Tailwind and shadcn/ui inject runtime style blocks. script-src is locked to 'self' with no unsafe-inline or unsafe-eval.
The Cloud Run backend uses @fastify/helmet for basic security defaults. CSP is the frontend's responsibility; the backend serves JSON and PDF only.
Where regulated data lives
| Table | What it stores | Key invariants |
|---|---|---|
audit_logs | Every logAuditEvent call from both browser and backend | Append-only; no UPDATE or DELETE policy; SECURITY DEFINER RPC |
evidence | Learner completion records (signed or unsigned) | Append-only; signed_ip written server-side by the record_training_evidence RPC from inet_client_addr() |
training_versions | Versioned training content, quiz, objectives | Immutability trigger once evidence references the version; DELETE blocked |
document_approvals | E-signature records for controlled documents | Append-only; signed_ip from inet_client_addr() inside the record_document_approval RPC |
document_versions | Versioned document content and lifecycle state | Requires edit_reason >= 10 chars; reason cannot be cleared after insert |
training_version_sources | Source attachments for training versions | Append-only; DELETE blocked |
CORS
CORS is configured on the Cloud Run backend via @fastify/cors. The allowed origins come from the ALLOWED_ORIGIN environment variable (comma-separated). An empty value falls back to * for local development only. In production you must set ALLOWED_ORIGIN to the exact frontend origin (e.g. https://app.better-comply.example).
Health probes
Cloud Run uses two HTTP probes:
GET /healthz- liveness: returns 200 when the process is up.GET /readyz- readiness: returns 200 when the service can accept traffic.
Neither probe requires authentication.
Related
- Environment variables - all env vars including
ALLOWED_ORIGINandVITE_BACKEND_URL - Scheduled jobs - the cron endpoints and their secret-gating
- Compliance and audit readiness - the regulatory model for audit evidence