Architecture
How the frontend, Convex backend, media, and shells fit together and how work flows through them.
Sangria is a Next.js frontend over a Convex backend, with LiveKit for realtime media. Desktop and mobile are the same deployed web app wrapped in native shells.
The layers
| Layer | What it is |
|---|---|
| Frontend | Next.js 16 (App Router) + React 19. Local state in jotai; URL state in nuqs; Tailwind CSS. |
| Backend | Convex (self-hosted locally): the schema, queries, mutations, actions, HTTP routes, scheduled functions, crons, and components. |
| Realtime media | LiveKit rooms power huddles; membership is a projection of LiveKit's webhook events. |
| Shells | Electron (desktop), Capacitor (iOS/Android), and a PWA — each wraps the deployed web app rather than reimplementing it. |
The shells share one codebase. Anything platform-specific (native notifications, deep links) is a thin adapter over the same Convex API the web app calls.
Convex primitives
The backend is built from a small set of function types, all defined under
convex/:
query— a read. Reactive: it re-runs on the client automatically whenever any document it read changes.mutation— a transactional write.action— non-transactional; the only place you may call external services or usefetch. An action marked"use node"runs in the Node runtime so it can use node modules (e.g.node:cryptofor HMAC inapps/eventDelivery.ts).internalQuery/internalMutation/internalAction— the same, but not client-callable. Fan-out workers, webhook handlers, and privileged helpers use these. See Conventions.
Functions are invoked through generated clients: api for public functions and
internal for internal ones, both in convex/_generated/. Server code
schedules or calls other functions by reference — e.g.
internal.notifications.notificationEvents.process.
Reactive queries re-run on every dependency change, so keep them cheap — index
and paginate, never .collect() an unbounded set. See
Conventions → Backend cost.
HTTP routes
Anything that isn't a Convex client call arrives through convex/http.ts. Each
httpAction verifies its own auth, then hands off to an internal mutation:
| Route | Purpose |
|---|---|
POST /livekit/webhook | LiveKit room/participant lifecycle events — the source of truth for huddle presence. Signature is verified in a "use node" action, then dispatched to idempotent mutations. |
POST /webhooks/t/<token> | Incoming integration webhook. The path token authenticates one app posting to one channel. |
POST /api/messages | Bot API. Authorization: Bearer <apiToken> posts as an app's bot to a channel. |
The webhook and bot routes are the Integrations API.
auth.addHttpRoutes(http) also mounts the auth provider's own endpoints.
Background work
Work that shouldn't block a request runs off the request path:
- Scheduled functions —
ctx.scheduler.runAfter(delay, fnRef, args)enqueues a function to run later. The notification producer uses this to keep the triggering mutation O(1); LiveKit webhooks use it to dispatch to mutations. - Crons (
convex/crons.ts) — recurring jobs: a presence sweep and inactive-user cleanup (every 5 min), a stuck-notification-event sweep, scheduled-message and "Later" reminder delivery (every minute), expired custom-status cleanup, a daily per-workspace analytics rollup, a 2FA session-state prune (every 6h), and a passkey-challenge sweep (hourly).
Convex components
Two Convex components handle work that needs concurrency limits and retries:
- Rate limiter — token-bucket and fixed-window limits defined in
convex/shared/rateLimits.ts. See Conventions → Rate limiting. - Workpool — bounded, retrying task pools.
pushDeliveryPoolfans out web-push;eventDeliveryPoolfans out signed outgoing app events. Both capmaxParallelismand retry with exponential backoff.
Two-factor authentication
Optional account-level TOTP 2FA, enforced in the authz layer so it can't be sidestepped by calling a query directly:
- Enforcement lives in
assertTwofaSatisfied(convex/shared/authz.ts), called from bothrequireMember(writes / admin / channel access) andassertCanRead(reads). It throws aConvexErrorwith a code (2FA_ENROLLMENT_REQUIRED/2FA_CHALLENGE_REQUIRED) so the client can route. - Policy —
require2fais a per-workspace flag inworkspace_permissions(default on for workspaces created after the feature; existing workspaces have no row and stay off, so nothing is retroactively locked out). - Credential — one
totp_credentialsrow per user: the secret, hashed one-time recovery codes, a failure/lockout counter, and a replay guard. TOTP is hand-rolled on the Web Crypto API (convex/shared/totp.ts) — no dependency. - Session model — clearing a challenge writes a
twofa_session_staterow keyed by the auth session id (getAuthSessionId); the gate checks it so a passed session isn't re-prompted. A cron prunes rows whose session no longer exists. Enrolled users are challenged on entry to any workspace; unenrolled users are force-enrolled only where a workspace requires it. - Client —
TwoFactorGate(src/features/security) wraps the workspace shell, reads the softgateStatequery, and shows the challenge / forced enrollment before the shell mounts. - Admin reset —
resetMemberTwofa(member-management gated; owner authority required for an admin/owner target) clears a member's TOTP credential so they re-enroll, for the lost-authenticator-and-codes case. Audited asmember.twofa_reset.
The enroll/verify mutations are account-scoped and never call the gate, so a user is never locked out of setting 2FA up.
Passkeys (passwordless sign-in)
Passkeys (WebAuthn) are a first-factor sign-in method, alongside password and
OAuth — not a 2FA second factor. @simplewebauthn handles the ceremonies;
@simplewebauthn/server verifies (ES256 / ECDSA-P256) in the Convex default
runtime, so no "use node" action is needed. RP ID + allowed origins are pinned
server-side in convex/shared/webauthn.ts (env WEBAUTHN_RP_ID /
WEBAUTHN_ORIGINS, defaulting to localhost + http://localhost:3000) — the
client's origin is never trusted.
- Registration (
convex/account/passkeys.ts) — a signed-in user registers a passkey from Account → Security. Options useresidentKey: "required"(a discoverable credential, so login can be usernameless) anduserVerification: "required"(biometric/PIN, so a passkey is device + user). Onewebauthn_credentialsrow per device (public key, signature counter, transports, label). - Step-up re-auth (
convex/account/reauth.ts) — a passkey is a permanent, password-free login credential, so adding one requires a recent step-up re-authentication (like GitHub/Google): the account re-confirms its password (withPassword, an action usingretrieveAccount) or a current TOTP code (withTotp), which writes a short-lived per-session marker (account_reauth_state).assertMayRegisterrequires that marker whenever the account has such a secret, and consumes it on success (one step-up ⇒ one add). This stops a walk-up on an unlocked session from planting a backdoor passkey. OAuth-only accounts with no 2FA have no local secret to confirm, so they fall back to the signed-in session (documented residual — enabling 2FA closes it). - Sign-in (
convex/account/passkeysLogin.ts) —loginOptionsis unauthenticated and usernameless (emptyallowCredentials, so the browser offers any discoverable passkey); it stores awebauthn_login_challengesrow (no user yet — looked up later by the signed challenge value). The client runs the ceremony and callssignIn("passkey", { response }). ThepasskeyConvexCredentialsprovider (convex/auth.ts) calls the internalverifyLogin, which verifies the assertion (UV required), advances the signature counter (with a clone check), creates the auth session itself and marks it 2FA-cleared, then returns{ userId, sessionId }— so@convex-dev/authmints tokens for a session that already satisfies the gate. A passkey login therefore signs in straight through, even in a workspace that requires 2FA, because unlocking the passkey already proved device + user. Failures returnnull(opaque — the client never learns whether a credential exists). - Cleanup — short-TTL challenge rows (both tables) are consumed on verify and
swept hourly by
pruneStaleChallenges.
Rate limits: passkeyRegister (per user) and passkeyLogin (a single coarse
global bucket, since sign-in is unauthenticated — see the DoS note in code).
How a message flows
- The client calls the
sendMessagemutation. - The mutation authorizes the resource, writes the
messagesrow, and enqueues a singlenotification_eventsrow plus (if an app is installed) an app-event dispatch — then returns. It never loops recipients. - Scheduled workers resolve recipients, write in-app
notifications, and hand push + outgoing events to their workpools. - Every subscribed client's reactive queries re-run and the new message appears.