Sangriadocs

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

LayerWhat it is
FrontendNext.js 16 (App Router) + React 19. Local state in jotai; URL state in nuqs; Tailwind CSS.
BackendConvex (self-hosted locally): the schema, queries, mutations, actions, HTTP routes, scheduled functions, crons, and components.
Realtime mediaLiveKit rooms power huddles; membership is a projection of LiveKit's webhook events.
ShellsElectron (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 use fetch. An action marked "use node" runs in the Node runtime so it can use node modules (e.g. node:crypto for HMAC in apps/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:

RoutePurpose
POST /livekit/webhookLiveKit 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/messagesBot 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 functionsctx.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. pushDeliveryPool fans out web-push; eventDeliveryPool fans out signed outgoing app events. Both cap maxParallelism and 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 both requireMember (writes / admin / channel access) and assertCanRead (reads). It throws a ConvexError with a code (2FA_ENROLLMENT_REQUIRED / 2FA_CHALLENGE_REQUIRED) so the client can route.
  • Policyrequire2fa is a per-workspace flag in workspace_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_credentials row 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_state row 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.
  • ClientTwoFactorGate (src/features/security) wraps the workspace shell, reads the soft gateState query, and shows the challenge / forced enrollment before the shell mounts.
  • Admin resetresetMemberTwofa (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 as member.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 use residentKey: "required" (a discoverable credential, so login can be usernameless) and userVerification: "required" (biometric/PIN, so a passkey is device + user). One webauthn_credentials row 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 using retrieveAccount) or a current TOTP code (withTotp), which writes a short-lived per-session marker (account_reauth_state). assertMayRegister requires 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) — loginOptions is unauthenticated and usernameless (empty allowCredentials, so the browser offers any discoverable passkey); it stores a webauthn_login_challenges row (no user yet — looked up later by the signed challenge value). The client runs the ceremony and calls signIn("passkey", { response }). The passkey ConvexCredentials provider (convex/auth.ts) calls the internal verifyLogin, 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/auth mints 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 return null (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

  1. The client calls the sendMessage mutation.
  2. The mutation authorizes the resource, writes the messages row, and enqueues a single notification_events row plus (if an app is installed) an app-event dispatch — then returns. It never loops recipients.
  3. Scheduled workers resolve recipients, write in-app notifications, and hand push + outgoing events to their workpools.
  4. Every subscribed client's reactive queries re-run and the new message appears.

On this page