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
| Need | Use | Never |
|---|---|---|
| Require a signed-in user | requireIdentity(ctx) → returns the user id or throws | hand-write getAuthUserId + if (!userId) throw |
| Optional / soft auth | getAuthUserId(ctx) then if (!userId) return … | throw on a read that should degrade |
| Membership / permission | requireMember, requirePermission, canReadResource, canManageChannel | re-query the member row inline |
| Build an in-app URL | routes.* e.g. routes.channel.view(wid, cid) | hand-write `/workspace/${…}` (ESLint blocks it) |
| A fixed set of string values | add to convex/shared/enums.ts (as const → validator + type) | scatter string literals |
| Rate-limit an endpoint | rateLimiter.limit(ctx, "<name>", { key, throws: true }) + a def in shared/rateLimits.ts | leave abuse-prone writes open |
| Return a user to clients | publicUserFields (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 ornullfor 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.
| Builder | Consumes | Injects | Gate |
|---|---|---|---|
workspacePermission(perm).{mutation,query} | workspaceId | member | requirePermission(…, perm) |
workspaceAdmin{Mutation,Query} | workspaceId | member | "workspace.edit" |
workspaceMember{Mutation,Query} | workspaceId | member | requireMember |
identity{Mutation,Query} | — | userId | requireIdentity |
appAdminMutation | appId | app, member | admin on the app's workspace |
webhookAdminMutation | webhookId | webhook, app, member | admin 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 reads —
getAuthUserId+ degrade to an empty result for anonymous callers. A throwing wrapper would break that contract. - Compound gates — e.g.
messages.createchecks 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.updateusesid, notchannelId), 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">fromconvex/_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.getin 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:
- Auth —
requireIdentity(throwing) orgetAuthUserId+ 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-testtest 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.