Sangriadocs

Conventions

The shared helpers you're expected to reuse, and the per-feature checklist.

Most "new" boilerplate already has a home. Reach for these before writing your own — the linter and reviewers assume you did.

Reuse these — don't reinvent them

NeedUseNever
Require a signed-in userrequireIdentity(ctx) → returns the user id or throwshand-write getAuthUserId + if (!userId) throw
Optional / soft authgetAuthUserId(ctx) then if (!userId) return …throw on a read that should degrade
Membership / permissionrequireMember, requirePermission, canReadResource, canManageChannelre-query the member row inline
Build an in-app URLroutes.* e.g. routes.channel.view(wid, cid)hand-write `/workspace/${…}` (ESLint blocks it)
A fixed set of string valuesadd to convex/shared/enums.ts (as const → validator + type)scatter string literals
Rate-limit an endpointrateLimiter.limit(ctx, "<name>", { key, throws: true }) + a def in shared/rateLimits.tsleave abuse-prone writes open
Return a user to clientspublicUserFields (shared/publicUser)spread the whole user doc (leaks auth fields)

All of these live under convex/shared/.

Authorization

Authentication (who are you) is not authorization (are you allowed). Gate the resource, not just the session — a logged-in user is not automatically an allowed one.

  • requireIdentity(ctx) — throws if unauthenticated; returns the user id.
  • getAuthUserId(ctx) — soft: returns the id or null for reads that should degrade.
  • requireMember(ctx, workspaceId) / getMember(...) — workspace membership. A deactivated member (deactivatedAt != null) is treated as a non-member.
  • requirePermission(ctx, workspaceId, "workspace.edit") — role/permission gate; returns { member }.
  • canReadResource / assertCanRead(ctx, userId, channelId, conversationId) — can this user read this channel or DM.
  • isPrimaryOwner(member, workspace) — the workspace creator; only they can transfer ownership or delete the workspace.

Auth-injecting function wrappers (preferred over manual gates)

The helpers above are the primitives. On top of them, convex/shared/functions.ts provides function builders that fold the gate into the function definition — so a handler can't run ungated and you can't forget the check. They're built on convex-helpers customQuery/customMutation: each consumes an id from the client args, resolves + authorizes, and injects the resolved rows onto ctx.

BuilderConsumesInjectsGate
workspacePermission(perm).{mutation,query}workspaceIdmemberrequirePermission(…, perm)
workspaceAdmin{Mutation,Query}workspaceIdmember"workspace.edit"
workspaceMember{Mutation,Query}workspaceIdmemberrequireMember
identity{Mutation,Query}userIdrequireIdentity
appAdminMutationappIdapp, memberadmin on the app's workspace
webhookAdminMutationwebhookIdwebhook, app, memberadmin on the app's workspace

Resource-keyed wrappers specific to one domain live in that domain's file (e.g. groupManageMutation in userGroups.ts, emojiMemberMutation in customEmoji.ts).

// Before — the gate is opt-in and repeated in every handler:
export const setEventSubscription = mutation({
  args: { appId: v.id("apps"), url: v.string() },
  handler: async (ctx, args) => {
    const { app, member } = await loadOwnedApp(ctx, args.appId); // easy to forget
  },
});

// After — the gate is part of the function; ctx.app / ctx.member are guaranteed:
export const setEventSubscription = appAdminMutation({
  args: { url: v.string() }, // appId consumed by the wrapper
  handler: async (ctx, args) => {
    const { app, member } = ctx; // already resolved + authorized
  },
});

When to stay manual (don't force a wrapper)

  • Soft readsgetAuthUserId + degrade to an empty result for anonymous callers. A throwing wrapper would break that contract.
  • Compound gates — e.g. messages.create checks read access to the target channel/DM AND to a forwarded source message; a single-resource wrapper can't express "read A and read B."
  • Conditional gates — a wrapper enforces only the necessary base gate; keep any tightening in the handler (e.g. a base "can manage this channel" check, with structural privacy/default changes additionally requiring channel.manage).
  • Arg-name mismatch — if a function's id arg isn't the name the wrapper consumes (channels.update uses id, not channelId), leave it manual rather than rename a public arg to fit.

Adopted so far: apps, userGroups, customEmoji, workspaces, conversations, members, and the identity-writes in notifications / huddles / presence.

Module boundaries

convex/shared/ is the foundation layer — authz, enums, routes, the rate limits, publicUser, and the function builders above. Every feature domain (apps, workspace, messaging, notifications, huddles, presence, …) builds on it. Feature domains legitimately import each other — a message write fires a notification event, a huddle posts into a conversation — so that graph is intentionally dense and is not restricted.

The one rule that is enforced: shared/ must never import from a feature domain. A foundation that reaches back "up" into a feature inverts the dependency direction and lets the base layer rot. An ESLint rule (no-restricted-imports, scoped to convex/shared/**) fails lint if it does. When shared code needs a feature primitive, move the primitive down into shared — e.g. the conversation-membership read (isConversationMember) lives in shared/authz beside getMember, and is re-exported from workspace/conversationsHelpers so the feature callers keep one import.

Client types come from the backend

The frontend never hand-writes a type that mirrors a query's return shape — those copies drift silently (the integrations-list type had already lost a field the query returns). Derive them instead:

  • A row from a query → (typeof api.x.y._returnType)[number], or ._returnType["page"][number] for a paginated query.
  • A stored document → Doc<"table"> from convex/_generated/dataModel.

Never as-cast a useQuery result to a hand-written type: the cast suppresses exactly the drift the type exists to catch.

Public vs internal

query / mutation / action are client-callable. Anything that fans out, runs privileged logic, or is only called by other server code must be internalQuery / internalMutation / internalAction.

Rate limiting

If an endpoint writes durable state, fans out notifications, calls an external service, or mints URLs/tokens, it needs a limit. Skip chatty reads, presence, and autosave. Two shapes (see shared/rateLimits.ts):

  • Token bucket — smooth with a burst allowance; for chatty actions (sendMessage, toggleReaction, incomingWebhook).
  • Fixed window — a hard cap per window; for expensive/abusable actions (failedSignIn, createWorkspace, startHuddle).

Backend cost

Reactive queries re-run on every dependency change, so keep them cheap.

  • No unbounded .collect() — use an index + .paginate().
  • No N+1 ctx.db.get in loops.
  • Fan-out is chunked through the durable outbox — see Notifications pipeline.
  • Return only the fields the client needs.

The bot-identity model

An integration ("app") is modeled as a synthetic users row (isBot: true) plus a members row. Because a bot is a real member, author resolution (name, avatar) works unchanged everywhere it posts. Bots are excluded from rosters, counts, mentions, and never accrue notifications (a single guard in shouldDeliver). See Integrations API.

Definition of Done

Every new backend function:

  • AuthrequireIdentity (throwing) or getAuthUserId + soft return.
  • Authorization — gate the resource, and verify every incoming id belongs to the caller's workspace (tenant boundary).
  • Public vs internal — the correct callable boundary.
  • Rate limit — where it writes durable state / fans out / calls out / mints tokens.
  • Tests — a convex-test test covering anonymous rejected → non-member/cross-tenant rejected → happy path → edge cases. See Testing.

New Convex functions 404 at runtime until you push them with npx convex dev --once — even when tsc and tests pass. Push after adding functions.

On this page