Skip to main content

Architecture

Navaid is a browser application backed by Supabase, with request-scoped Edge Functions for privileged orchestration and a separate long-running worker for multiplayer voice. This page describes the checked-in architecture; it does not assert that every optional integration is configured in a remote environment.

System Architecture Diagram

┌─────────────────────────────────────────────────────────────────────┐
│ Browser: React 19 / Vite │
│ │
│ Routes · Zustand · React Query · i18n · Sentry · Web Audio │
└───────────────┬─────────────────────┬───────────────────────┬───────┘
│ │ │
anon-key HTTPS/WSS function HTTPS/WSS presigned HTTPS
│ │ │
┌───────────────▼────────────┐ ┌────▼────────────────┐ ┌───▼──────────┐
│ Supabase platform │ │ 26 Edge Functions │ │ Cloudflare R2│
│ │ │ │ │ optional │
│ Postgres 17 + RLS │ │ Deno 2 │ └──────────────┘
│ Auth / OAuth 2.1 │ │ service-role client│
│ Realtime │ │ explicit auth/authz│
│ Storage: avatars │ └────┬──────┬─────┬──┘
└───────────────┬────────────┘ │ │ │
│ │ │ └──────── Resend
│ │ └────────────── Google Gemini
│ └───────────────────── LiveKit API

│ service-role HTTPS
┌───────────────▼─────────────────────────────────────────────────────┐
│ LiveKit Agents Node worker (`agent/`) │
│ Persistent room connection · Gemini Live · speaker attribution │
│ transcript persistence · periodic token accounting │
└───────────────┬─────────────────────────────────────────────────────┘


LiveKit Cloud rooms

Observability: browser → Sentry ingest; traces propagate to Supabase requests.
Hosting: Cloudflare Pages serves the SPA and its security headers.

Key Architectural Boundaries

  1. Browser to Supabase: the browser uses the anon key. Direct table, Storage, and RPC access is constrained by RLS, grants, and function-internal authorization.
  2. Browser to Edge Functions: bearer tokens are verified with supabase.auth.getUser(token). WebSocket upgrades carry a short-lived access token in the URL because browser WebSocket APIs cannot set an Authorization header.
  3. Edge Functions to data: the shared serviceClient() bypasses RLS. Functions must bind every requested company/session/document to the verified caller before reading or writing.
  4. AI boundary: Gemini keys remain in Supabase function secrets or the LiveKit agent environment. They never enter the browser.
  5. Voice boundary: single-user voice is relayed by chat-live; group voice uses LiveKit plus the persistent worker in agent/.
  6. Object boundary: document bytes move directly between the browser and R2 through five-minute presigned URLs. Edge Functions authorize and meter the operation but do not proxy file bodies.
  7. Email boundary: application links are composed from server-owned APP_URL; request-provided origins are ignored.

Conversation Data Flow

User submits text

├─ Browser validates the active session and attachment limits

└─ POST /functions/v1/chat with bearer token

├─ Verify user with Auth
├─ Load session and active company membership
├─ Enforce rate and input/output budget gates
├─ Load active v2 prompt, recent history, company facts,
│ shared maturity state, open tasks, partners, locale and scope
├─ Call Gemini text model
├─ Parse response, reasoning, maturity signals, facts and task signals
├─ Persist user/assistant messages and token audit row
├─ Atomically roll token totals into token_usage_monthly
├─ Apply maturity transitions using optimistic version persistence
├─ Generate/dedupe next-level tasks where needed
└─ Return response + transitions + task changes + budget remainder

The authoritative current maturity state is company_profiles.maturity_state. sessions.maturity_state is a session snapshot, while maturity_state_at_start supports completion diffs.

Persistence concurrency

Multiple sessions can update one company concurrently. The shared _shared/maturity-persist.ts helper:

  1. reads the profile and version,
  2. applies signals,
  3. writes only when the expected version still matches,
  4. retries against the new state after a conflict, and
  5. keeps the session snapshot synchronized.

Do not replace this with an unconditional UPDATE; doing so can lose evidence from a parallel session. See The Maturity Engine.

Summary Generation Flow

Client requests completion

└─ POST /functions/v1/generate-summary
├─ Verify owner/member access
├─ Claim session through claim_session_summary()
│ └─ competing completion receives the existing/in-progress result
├─ Enforce rate and budget limits
├─ Read transcript, maturity start/current state and company context
├─ Generate structured narrative with Gemini
├─ Create/dedupe recommended tasks
├─ Write maturity snapshot
├─ Persist token audit and atomic monthly roll-up
└─ Finalize pipeline-owned status/completed_at/summary fields

Database column guards prevent ordinary authenticated clients from finalizing a session directly.

Voice Conversation Flow

Single-user voice

Browser microphone
⇅ PCM/control frames over WebSocket
chat-live Edge Function
⇅ Gemini Live WebSocket
Gemini Live

chat-live additionally:
- verifies the query-string access token,
- verifies session ownership/membership,
- supports Gemini session-resumption handles,
- persists attributed transcripts,
- extracts maturity signals with a text-model side call,
- updates maturity state with the same concurrency helper,
- samples and records realtime token usage.

voice_enabled currently controls the browser affordance. It is not a server-side kill switch inside chat-live; operators who need a hard stop must also control function availability or add server enforcement.

Multiplayer text

participant message → messages table → Supabase Realtime

└─ chat-multi claims session turn
├─ drains unprocessed user turns
├─ composes speaker-aware prompt
├─ writes one assistant response
└─ releases/advances turn state

claim_session_turn() prevents duplicate AI responses when multiple clients observe the same new messages.

Multiplayer voice

Browser participants ⇄ LiveKit room ⇄ LiveKit Agents worker ⇄ Gemini Live
│ │
│ ├─ validates session and current membership
│ ├─ switches input to active speaker
│ ├─ persists user and assistant transcripts
│ └─ records realtime usage periodically

└─ room token minted by livekit-token
├─ requires active company/session access
├─ requires participant/owner status
├─ enforces group_voice_enabled for session rooms
└─ checks company budget

Leaving/removing a participant must go through livekit-session-control. Deleting only the database row would leave an already-connected media participant in the room.

See Realtime and Voice.

Component Architecture

App.tsx
├── Sentry.ErrorBoundary
├── QueryClientProvider
├── ThemeLoader
├── BrowserRouter
├── ConfirmDialogProvider
├── DocumentTitle
├── public routes
│ ├── / Marketing
│ ├── /welcome Welcome
│ ├── /login Login
│ ├── /create-company CreateCompany
│ ├── /invite AcceptInvite
│ ├── /shared/:token SharedSession
│ ├── /oauth/consent OAuthConsent
│ └── /privacy /terms /cookies legal pages
└── /app through AuthGuard
└── AppLayout
├── dashboard, overview, sessions and summaries
├── maturity, progression and tasks
├── competitors and Prospect Radar
├── company and system administration
├── settings
└── hidden /app/voice-lab prototype

All route components are lazy loaded. A Vite preloadError handler and the Sentry fallback attempt one guarded reload after stale hashed chunks disappear during a deployment.

Layer Responsibilities

LayerDirectoryResponsibility
Routes/pagesfrontend/src/pages/Route composition and data orchestration
Componentsfrontend/src/components/Reusable UI and feature assemblies
Hooksfrontend/src/hooks/Auth, feature flags, voice and multiplayer lifecycles
Storesfrontend/src/stores/Ephemeral cross-component state
API servicesfrontend/src/services/api/Supabase and function contracts by domain
Typesfrontend/src/types/Domain models and canonical maturity catalogue
Utilitiesfrontend/src/utils/Permissions, formatting, extraction, colour generation
Aidafrontend/src/aida/Tool registry and page-interaction contract

See Frontend.

State Management Approach

Zustand

  • authStore owns the current user/company, loading state, session-history hint, and device-local sidebar preference.
  • conversationStore owns the active session, messages, current maturity and legacy readiness state, touched signals, voice mode, sending/error state.

authStore.reset() intentionally preserves sidebarHidden; it is a per-device preference and survives logout.

React Query

The application-wide defaults are a five-minute staleTime and one retry. Feature code also uses direct Supabase subscriptions and imperative service calls where real-time ordering or transactional control matters.

Durable server state

Zustand is never an authorization or durable storage layer. Reloadable state lives in Postgres/Auth/Storage/R2 and must be revalidated by the server.

Security Architecture

Layer 1 Browser security
CSP, HSTS, anti-framing, no-sniff, referrer and permissions policy

Layer 2 Identity
Supabase Auth token verified in RLS or by auth.getUser(token)

Layer 3 Tenant authorization
active membership + role + session/document binding

Layer 4 Data controls
RLS, grants, SECURITY DEFINER RPC checks, column guard triggers

Layer 5 Abuse and spend controls
fixed-window rate counters, feature flags, monthly budgets,
input limits, storage quotas, claim locks and idempotency ledgers

Layer 6 Audit and observability
token_usage, admin_audit_log, profile history, Sentry, function logs

Gateway verify_jwt = false does not mean unauthenticated access. It means the function owns authentication. Public surfaces must be explicitly designed and rate-limited. See Security and Privacy.

Secret Management

ValueBrowserEdge FunctionsAgent
Supabase URLYesAuto-providedYes
Supabase anon keyYesUsually unnecessaryNo
Supabase service roleNeverAuto-providedRequired
Gemini API keyNeverAI functionsRequired
LiveKit API secretNevertoken/control functionsRequired
R2 access secretNeverdocuments onlyNo
Resend API keyNevernotification functionsNo
Sentry browser DSNPublicNoNo
Sentry upload tokenNeverNoBuild environment only

Node-side E2E setup may use the service-role key. Because it has no VITE_ prefix it is not exposed to the browser bundle.

External Service Dependencies

ServiceUsed forDegraded behaviour
SupabaseAuth, database, Realtime, Storage, functionsCore application unavailable
GeminiText, Live, TTS and grounded researchAI actions fail; stored data remains
LiveKitMultiplayer media roomsText multiplayer remains; voice unavailable
LiveKit agent hostGroup-voice AI participantHumans may join rooms but no Navaid voice agent
Cloudflare R2Optional retained originalsBrowser extraction still works; retention/download disabled
ResendInvitation and notification mailDatabase action can succeed with sent:false
SentryBrowser telemetryApp continues; errors lack remote telemetry
Cloudflare PagesSPA and static assetsFrontend unavailable or cached until expiry

OTP delivery is controlled by Supabase Auth SMTP configuration, not by the application's RESEND_API_KEY alone.

Database Architecture Summary

The current 31-table inventory and RPC/trigger catalogue are maintained in Database Schema. Important patterns include:

  • active multi-company memberships
  • shared company maturity plus session snapshots
  • append-only profile history
  • audit rows with server-stamped actors
  • token audit rows plus daily/monthly aggregates
  • claim/lock RPCs for concurrent turns, summaries, code consumption, and R2 quota commits
  • pipeline-owned column guards

Extension Checklist

When adding a new capability:

  1. Decide which boundary owns the operation.
  2. Add the database migration before application code that depends on it.
  3. Define actor, company/session scope, and failure semantics.
  4. Use shared HTTP/authz/budget/locale helpers.
  5. Account for every Gemini call.
  6. Add rate limits and idempotency where retries or fan-out can amplify cost.
  7. Update CSP if the browser contacts a new origin.
  8. Add unit, E2E, and local security coverage.
  9. Update this guide and the function/data reference.