Convex Best Practices & AI Development Ruleset
This document serves as a comprehensive ruleset for AI assistants and developers working with Convex in TinyKit Pro. It combines the philosophy of Convex dev...
This document serves as a comprehensive ruleset for AI assistants and developers working with Convex in TinyKit Pro. It combines the philosophy of Convex development with practical best practices for building scalable, type-safe applications.
Table of Contents
- Core Philosophy
- Function Design Rules
- Database Optimization
- TypeScript Integration
- Security & Validation
- Performance Guidelines
- Code Organization
- Anti-Patterns to Avoid
Core Philosophy
The Zen of Convex
🎯 Primary Rule: Center your application around the deterministic, reactive database.
Essential Principles
DO:
- ✅ Use queries for almost all app reads
- ✅ Keep sync engine functions lightweight (under 100ms, few hundred records)
- ✅ Leverage built-in Convex caching and consistency controls
- ✅ Build custom solutions using Convex's primitive functions
- ✅ Process work in smaller, incremental batches
DON'T:
- ❌ Create unnecessary local cache layers
- ❌ Use mutation return values for complex state changes
- ❌ Invoke actions directly from browsers
- ❌ Treat actions as background jobs
Development Workflow Philosophy
- Utilize the Dashboard: Actively use the Convex dashboard for development and debugging
- Community-First: Seek help from Convex community and Discord before creating custom solutions
- "Pit of Success": Follow Convex's opinionated design to naturally write performant code
Function Design Rules
🔧 Query Functions
// ✅ GOOD: Lightweight, indexed query
export const getTeamMessages = query({
args: { teamId: v.id("teams") },
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_team", (q) => q.eq("teamId", args.teamId))
.order("desc")
.take(50);
},
});
// ❌ BAD: Heavy computation in query
export const getComplexAnalytics = query({
handler: async (ctx) => {
const allData = await ctx.db.query("data").collect(); // Too much data
// Heavy computation here...
},
});Query Rules:
- ✅ Use argument validators for all public queries
- ✅ Implement access control checks
- ✅ Keep under 100ms execution time
- ✅ Limit result sets (use
.take()or pagination) - ✅ Use indexes with
.withIndex()instead of.filter()
🔄 Mutation Functions
// ✅ GOOD: Granular, validated mutation
export const updateTeamSettings = mutation({
args: {
teamId: v.id("teams"),
settings: v.object({
name: v.optional(v.string()),
description: v.optional(v.string()),
}),
},
handler: async (ctx, args) => {
// Access control first
const hasPermission = await hasTeamRole(ctx, args.teamId, [
"owner",
"admin",
]);
if (!hasPermission) {
throw new ConvexError("Insufficient permissions");
}
// Granular update
await ctx.db.patch(args.teamId, args.settings);
},
});
// ❌ BAD: Broad, unvalidated mutation
export const updateEverything = mutation({
args: { data: v.any() }, // Never use v.any()
handler: async (ctx, args) => {
// No access control
// Broad, dangerous updates
},
});Mutation Rules:
- ✅ Use granular functions over broad update functions
- ✅ Implement argument validation with
vvalidators - ✅ Check permissions before any data modification
- ✅ Use helper functions for shared logic
- ✅ Await all Promises to prevent unexpected behavior
🚀 Action Functions
// ✅ GOOD: Action for external API calls
export const sendEmailNotification = action({
args: { userId: v.string(), message: v.string() },
handler: async (ctx, args) => {
// Get user data via mutation/query
const user = await ctx.runQuery(api.users.queries.getById, {
id: args.userId,
});
// External API call
await sendEmail(user.email, args.message);
// Update database via mutation
await ctx.runMutation(api.notifications.mutations.markSent, {
userId: args.userId,
type: "email",
});
},
});Action Rules:
- ✅ Use for external API calls and non-deterministic operations
- ✅ Chain actions with mutations for trackable progress
- ✅ Use
ctx.runQueryandctx.runMutationsparingly - ✅ Avoid sequential database calls when possible
- ❌ Don't invoke directly from browser clients
Database Optimization
🗄️ Query Optimization Rules
Critical Performance Rules:
// ✅ GOOD: Use indexes for efficient querying
export const getTeamMembers = query({
args: { teamId: v.id("teams") },
handler: async (ctx, args) => {
return await ctx.db
.query("teamMembers")
.withIndex("by_team", (q) => q.eq("teamId", args.teamId))
.collect();
},
});
// ❌ BAD: Using filter instead of index
export const getTeamMembersBad = query({
args: { teamId: v.id("teams") },
handler: async (ctx, args) => {
return await ctx.db
.query("teamMembers")
.filter((q) => q.eq(q.field("teamId"), args.teamId)) // Slow!
.collect();
},
});Index Strategy
DO:
- ✅ Create indexes for all common query patterns
- ✅ Use compound indexes for multi-field queries
- ✅ Remove redundant indexes after analysis
DON'T:
- ❌ Use
.filter()on database queries for primary access patterns - ❌ Use
.collect()with large result sets - ❌ Create unnecessary indexes that slow down writes
Data Modeling Rules
// ✅ GOOD: Denormalized for read performance
teams: defineTable({
name: v.string(),
slug: v.string(),
memberCount: v.number(), // Denormalized for quick access
ownerName: v.string(), // Denormalized for listings
// ... other fields
}).index("by_slug", ["slug"]);
// ✅ GOOD: Separate table for frequently queried relations
teamMembers: defineTable({
teamId: v.id("teams"),
userId: v.string(),
role: v.union(UserRoleValidator),
joinedAt: v.number(),
})
.index("by_team", ["teamId"])
.index("by_user", ["userId"])
.index("by_team_and_user", ["teamId", "userId"]);TypeScript Integration
🏗️ Type Safety Rules
Essential TypeScript Patterns:
// ✅ GOOD: Proper typing with generated types
import type { Doc, Id } from "@/convex/_generated/dataModel";
import type { QueryCtx, MutationCtx } from "@/convex/_generated/server";
// Helper function with proper context typing
export async function getTeamWithPermissions(
ctx: QueryCtx,
teamId: Id<"teams">,
userId: Id<"users">,
): Promise<Doc<"teams"> & { userRole: string | null }> {
const team = await ctx.db.get(teamId);
if (!team) throw new ConvexError("Team not found");
const membership = await ctx.db
.query("teamMembers")
.withIndex("by_team_and_user", (q) =>
q.eq("teamId", teamId).eq("userId", userId),
)
.first();
return { ...team, userRole: membership?.role ?? null };
}
// ✅ GOOD: Using Infer for type conversion
const createTeamArgs = {
name: v.string(),
description: v.optional(v.string()),
};
type CreateTeamArgs = Infer<typeof createTeamArgs>;Schema and Validation Rules
DO:
- ✅ Define comprehensive schemas for type inference
- ✅ Use generated types (
Doc,Id) consistently - ✅ Implement argument validators for all public functions
- ✅ Use
WithoutSystemFieldsfor create/update operations - ✅ Leverage context types (
QueryCtx,MutationCtx,ActionCtx)
DON'T:
- ❌ Use
anytype (forbidden in TinyKit Pro) - ❌ Skip type annotations on complex internal functions
- ❌ Ignore TypeScript errors or warnings
Client-Side Integration
// ✅ GOOD: Type-safe React component
import type { Id } from "@/convex/_generated/dataModel";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
interface TeamDashboardProps {
teamId: Id<"teams">;
}
export function TeamDashboard({ teamId }: TeamDashboardProps) {
const team = useQuery(api.teams.queries.getById, { id: teamId });
const messages = useQuery(api.messages.queries.getTeamMessages, { teamId });
if (!team) return <LoadingSpinner />;
return (
<div>
<h1>{team.name}</h1>
{/* Type-safe access to team properties */}
</div>
);
}Security & Validation
🔒 Security Rules (Critical Priority)
Modern Access Control with requireAccess:
// ✅ GOOD: Comprehensive permission checking with requireAccess
// In notifications/private/mutations.ts
import { mutation } from "../../_generated/server";
import { requireAccess, requireRateLimit } from "../../lib/access";
import { v } from "convex/values";
export const updateNotificationSettings = mutation({
args: {
emailEnabled: v.boolean(),
pushEnabled: v.boolean(),
},
handler: async (ctx, args) => {
// 1. Enforce authentication and get user context
const { userId } = await requireAccess(ctx, {
userRole: ["user"],
});
// 2. Rate limit to prevent abuse (5 updates/min with burst capacity of 10)
await requireRateLimit(ctx, "profileUpdates", { key: userId });
// 3. Validate input
if (args.emailEnabled === undefined || args.pushEnabled === undefined) {
throw new Error("Invalid settings");
}
// 4. Perform update
await ctx.db.patch(userId, {
emailNotifications: args.emailEnabled,
pushNotifications: args.pushEnabled,
});
return { success: true };
},
});
// ✅ GOOD: Organization-scoped permission checking
// In orgs/private/mutations.ts
export const updateOrganizationSettings = mutation({
args: {
orgId: v.string(),
name: v.optional(v.string()),
description: v.optional(v.string()),
},
handler: async (ctx, args) => {
// Check user has org admin/owner role
const { userId, orgRole } = await requireAccess(ctx, {
orgId: args.orgId,
orgRole: ["owner", "admin"], // Array allows either role
});
// Validate org exists
const org = await ctx.db.get(args.orgId);
if (!org) {
throw new Error("Organization not found");
}
// Build update object
const updates: Partial<{ name: string; description: string }> = {};
if (args.name !== undefined) updates.name = args.name;
if (args.description !== undefined) updates.description = args.description;
if (Object.keys(updates).length > 0) {
await ctx.db.patch(args.orgId, updates);
}
return { success: true };
},
});
// ✅ GOOD: Custom condition for complex requirements
// In billing/private/mutations.ts
export const cancelSubscription = mutation({
args: { subscriptionId: v.string() },
handler: async (ctx, args) => {
const { userId } = await requireAccess(ctx, {
userRole: ["user"],
condition: async (accessCtx) => {
// Custom validation: user owns this subscription
const sub = await ctx.db.get(args.subscriptionId);
return sub?.userId === accessCtx.userId;
},
});
// Proceed with cancellation
await ctx.db.patch(args.subscriptionId, {
cancelAtPeriodEnd: true,
});
return { success: true };
},
});Validation Rules
Required Validations:
- ✅ Authentication check using
requireAccess()for all protected functions - ✅ Authorization check based on roles/ownership/permissions
- ✅ Input validation using
vvalidators in args - ✅ Business logic validation (length limits, format checks)
- ✅ Cross-reference validation (entity relationships)
- ✅ Rate limiting using
requireRateLimit()for mutations
Rate Limiting Pattern
Protect mutations from abuse with rate limiting:
import { requireAccess, requireRateLimit } from "../../lib/access";
// ✅ GOOD: Rate-limited mutation
export const createPost = mutation({
args: {
title: v.string(),
content: v.string(),
},
handler: async (ctx, args) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// Rate limit: 20 posts per minute with burst capacity of 30
await requireRateLimit(ctx, "contentCreation", { key: userId });
// Proceed with post creation
return await ctx.db.insert("posts", {
title: args.title,
content: args.content,
userId,
});
},
});
// ✅ GOOD: Custom token consumption for variable-cost operations
export const generateAIContent = mutation({
args: {
prompt: v.string(),
complexity: v.number(), // 1-10 scale
},
handler: async (ctx, args) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// Consume tokens based on complexity (50/hour with burst of 100)
await requireRateLimit(ctx, "externalApiCalls", {
key: userId,
count: args.complexity, // Variable token consumption
});
// Call external AI API
return await generateContent(args.prompt);
},
});
// ✅ GOOD: Check rate limit without throwing
export const checkUploadAvailability = query({
handler: async (ctx) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// Check if user can upload without consuming tokens
const { ok, retryAfter } = await requireRateLimit(ctx, "fileUploads", {
key: userId,
throws: false, // Don't throw on limit exceeded
});
return {
canUpload: ok,
retryAfter: retryAfter || null,
};
},
});Pre-configured Rate Limits:
authAttempts- 10/hour (fixed window) - Authentication operationsprofileUpdates- 5/min, burst 10 (token bucket) - Profile changescontentCreation- 20/min, burst 30 (token bucket) - Posts, comments, messagesadminOperations- 100/hour, burst 150 (token bucket) - Admin actionsexternalApiCalls- 50/hour, burst 100 (token bucket) - Third-party APIsfileUploads- 10/hour, burst 20 (token bucket) - File storagesearchQueries- 30/min, burst 50 (token bucket) - Search operations
Security Checklist
For Every Private Function:
- Use
requireAccess()for authentication verification - Specify appropriate role requirements (userRole or orgRole)
- Add rate limiting for mutations with
requireRateLimit() - Input validation with proper
vvalidators - Business logic validation (length, format, existence checks)
- Audit logging for sensitive operations (optional)
Modular Access Control System
🎯 Core Access Control Functions
TinyKit Pro uses a modular, policy-based RBAC system:
import { requireAccess, hasAccess } from "../../lib/access";
// Primary functions:
// - requireAccess() - Enforces access requirements, throws on failure
// - hasAccess() - Checks access requirements, returns booleanAccess Control Patterns
1. User Role-Based Access:
// Single role requirement
const { userId } = await requireAccess(ctx, {
userRole: "admin",
});
// Multiple allowed roles
const { userId } = await requireAccess(ctx, {
userRole: ["admin"], // Admin role required
});
// Role hierarchy: admin >= user2. Organization Role-Based Access:
// Organization-scoped access
const { userId, orgRole } = await requireAccess(ctx, {
orgId: args.orgId,
orgRole: ["owner", "admin"],
});
// Org role hierarchy: owner >= admin >= member3. Permission-Based Access:
// Check specific permission
const { userId } = await requireAccess(ctx, {
permission: "users:delete", // Format: "resource:action"
});
// Permissions are defined in convex/lib/permissions.ts4. Access Level-Based Access (Subscription Tiers):
// Personal subscription access level
const { userId } = await requireAccess(ctx, {
minPersonalAccessLevel: 2, // 0=free, 1=basic, 2=pro, 3=enterprise
});
// Organization subscription access level
const { userId } = await requireAccess(ctx, {
orgId: args.orgId,
minOrgAccessLevel: 2, // Pro tier or higher
});5. Custom Condition-Based Access:
// Complex business logic validation
const { userId } = await requireAccess(ctx, {
userRole: ["user"],
condition: async (accessCtx) => {
// Custom validation logic
const resource = await ctx.db.get(args.resourceId);
return resource?.userId === accessCtx.userId;
},
});6. Combined Requirements:
// Multiple requirements (all must pass)
const { userId, orgRole } = await requireAccess(ctx, {
userRole: ["user"], // Must be authenticated
orgId: args.orgId, // Must be org member
orgRole: ["admin", "owner"], // Must have admin/owner role
minOrgAccessLevel: 1, // Org must have paid subscription
permission: "orgs:settings:update", // Must have permission
});Access Context Return Value
requireAccess returns comprehensive context:
interface AccessContext {
userId: Id<"users">; // Guaranteed non-null
user: Doc<"users">; // Full user document
userRole: string; // User's site-wide role
userPermissions: string[]; // User's permission array
personalAccessLevel: number; // User's subscription level
// Organization context (if orgId provided)
orgRole?: string | null; // User's role in this org
orgPermissions?: string[]; // Org role permissions
orgAccessLevel?: number | null; // Org subscription level
}
// Usage example:
const { userId, user, userRole, orgRole, personalAccessLevel } =
await requireAccess(ctx, {
userRole: ["user"],
orgId: args.orgId,
});Boolean Access Checking
Use hasAccess for conditional logic:
// Check without throwing
const isAdmin = await hasAccess(ctx, {
userRole: ["admin"],
});
if (isAdmin) {
// Show admin features
} else {
// Show regular user features
}
// Useful for optional features
const canAccessPremium = await hasAccess(ctx, {
minPersonalAccessLevel: 2,
});Import Patterns
// In Convex backend functions
import { requireAccess, hasAccess } from "../../lib/access";
import { requireRateLimit } from "../../lib/access"; // Rate limiting
// In frontend (utility functions only)
import {
getUserRolePermissions,
getOrgRolePermissions,
checkPermission,
} from "@/convex/lib/access";Performance Guidelines
⚡ Performance Optimization Rules
Database Performance:
// ✅ GOOD: Efficient pagination
export const getTeamMessagesPaginated = query({
args: {
teamId: v.id("teams"),
cursor: v.optional(v.string()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = Math.min(args.limit ?? 20, 100); // Cap at 100
let query = ctx.db
.query("messages")
.withIndex("by_team_and_time", (q) => q.eq("teamId", args.teamId));
if (args.cursor) {
query = query.filter((q) => q.lt(q.field("_creationTime"), args.cursor));
}
const messages = await query.order("desc").take(limit + 1);
const hasMore = messages.length > limit;
return {
messages: messages.slice(0, limit),
nextCursor: hasMore ? messages[limit]._creationTime : null,
};
},
});
// ❌ BAD: Loading everything at once
export const getAllMessages = query({
handler: async (ctx) => {
return await ctx.db.query("messages").collect(); // Potentially huge!
},
});Runtime Optimization:
- ✅ Use
runActiononly when requiring different runtime - ✅ Batch database operations when possible
- ✅ Minimize sequential
ctx.runMutation/ctx.runQuerycalls - ✅ Cache expensive computations in helper functions
- ✅ Use appropriate data structures (Maps, Sets) for lookups
Code Organization
📁 File Structure Patterns
Follow TinyKit Pro Three-Tier Domain Organization:
convex/
├── users/ # User management module
│ ├── public/ # Public endpoints (no auth required)
│ │ └── queries.ts # Public user queries
│ ├── private/ # Private endpoints (auth required)
│ │ ├── queries.ts # Authenticated user queries
│ │ └── mutations.ts # Authenticated user mutations
│ ├── internal/ # Internal-only functions
│ │ ├── queries.ts # Backend-only queries
│ │ ├── mutations.ts # Backend-only mutations
│ │ └── actions.ts # Backend-only actions
│ ├── schema.ts # Module-specific schema
│ ├── validators.ts # Validation schemas
│ └── helpers.ts # Shared helper functions
├── orgs/ # Organization management
│ ├── public/
│ ├── private/
│ ├── internal/
│ ├── schema.ts
│ └── helpers.ts
├── billing/ # Stripe integration
│ ├── public/
│ ├── private/
│ └── internal/
├── notifications/ # Notification system
│ ├── private/
│ └── internal/
├── lib/ # Shared utilities
│ ├── access/ # Modular access control system
│ │ ├── index.ts # Main exports
│ │ ├── requireAccess.ts # Core access functions
│ │ ├── types.ts # Type definitions
│ │ └── utils.ts # Helper utilities
│ ├── triggers/ # Database triggers
│ ├── rateLimiter.ts # Rate limiting system
│ ├── permissions.ts # Permission definitions
│ ├── logger.ts # Logging utilities
│ ├── resend.ts # Email utilities
│ └── timezone.ts # Timezone helpers
└── schema.ts # Main database schemaThree-Tier Access Pattern Benefits:
- Clear Authentication Boundaries: Folder structure immediately shows auth requirements
- Enhanced Security: Reduced attack surface with minimal public API exposure
- Improved Auditing: Easy to identify and track all public vs authenticated endpoints
- Better Organization: Functions grouped by access level rather than type
- Developer Clarity: Import path indicates authentication requirements
Function Organization Rules
Three-Tier Pattern with Modular Access Control:
// ✅ GOOD: Private mutation with requireAccess
// In orgs/private/mutations.ts
import { mutation } from "../../_generated/server";
import { requireAccess } from "../../lib/access";
import { v } from "convex/values";
export const createOrganization = mutation({
args: {
name: v.string(),
slug: v.optional(v.string()),
},
handler: async (ctx, args) => {
// Enforce authentication and authorization
const { userId } = await requireAccess(ctx, {
userRole: ["user"], // Any authenticated user can create org
});
return await createOrganizationHelper(ctx, userId, args);
},
});
// ✅ GOOD: Public query (no auth required)
// In orgs/public/queries.ts
import { query } from "../../_generated/server";
import { v } from "convex/values";
export const getOrganizationBySlug = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
// No requireAccess - this is public data
return await ctx.db
.query("orgs")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
},
});
// ✅ GOOD: Internal action (backend only)
// In orgs/internal/actions.ts
import { internalAction } from "../../_generated/server";
import { v } from "convex/values";
export const sendOrganizationWelcomeEmail = internalAction({
args: {
orgId: v.string(),
userId: v.string(),
},
handler: async (ctx, args) => {
// Only callable from other Convex functions
// No requireAccess needed - already internal
const org = await ctx.runQuery(internal.orgs.getOrgById, {
orgId: args.orgId,
});
// Send email via external API
await sendEmail(/* ... */);
},
});
// In orgs/helpers.ts
import { MutationCtx } from "../_generated/server";
import { Id } from "../_generated/dataModel";
export async function createOrganizationHelper(
ctx: MutationCtx,
userId: Id<"users">,
args: { name: string; slug?: string },
): Promise<Id<"orgs">> {
// Business logic extracted to helper
const slug = args.slug || generateSlug(args.name);
// Check slug availability
const existingOrg = await ctx.db
.query("orgs")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.first();
if (existingOrg) {
throw new Error("Organization name already taken");
}
// Create organization
const orgId = await ctx.db.insert("orgs", {
name: args.name,
slug,
createdBy: userId,
});
// Add creator as owner
await ctx.db.insert("orgMembers", {
orgId,
userId,
orgRole: "owner",
});
return orgId;
}Admin Function Pattern
Admin functions use the modular access control system:
// ✅ GOOD: Admin query with role hierarchy
// In users/private/queries.ts
import { query } from "../../_generated/server";
import { requireAccess } from "../../lib/access";
import { v } from "convex/values";
export const listUsers = query({
args: {
paginationOpts: v.optional(
v.object({
numItems: v.number(),
cursor: v.union(v.string(), v.null()),
}),
),
},
handler: async (ctx, args) => {
// Role hierarchy: admin >= user
await requireAccess(ctx, {
userRole: ["admin"],
});
return await ctx.db
.query("users")
.order("desc")
.paginate(args.paginationOpts ?? { numItems: 10, cursor: null });
},
});
// ✅ GOOD: Admin only mutation
// In users/private/mutations.ts
export const updateUserRole = mutation({
args: {
userId: v.string(),
newRole: v.union(v.literal("admin"), v.literal("user")),
},
handler: async (ctx, args) => {
// Only admin can modify roles
await requireAccess(ctx, {
userRole: ["admin"],
});
await ctx.db.patch(args.userId, {
userRole: args.newRole,
});
return { success: true };
},
});
// ✅ GOOD: Permission-based access
// In users/private/mutations.ts
export const deleteUser = mutation({
args: { userId: v.string() },
handler: async (ctx, args) => {
// Check specific permission
await requireAccess(ctx, {
permission: "users:delete",
});
// Soft delete pattern
await ctx.db.patch(args.userId, {
deletedAt: Date.now(),
});
return { success: true };
},
});Anti-Patterns to Avoid
🚫 Critical Anti-Patterns
Database Anti-Patterns:
// ❌ NEVER: Sequential database calls in loops
for (const userId of userIds) {
const user = await ctx.db.get(userId); // Bad!
// Process user...
}
// ✅ GOOD: Batch operations
const users = await Promise.all(userIds.map((id) => ctx.db.get(id)));Type Safety Anti-Patterns:
// ❌ NEVER: Using any type
args: {
data: v.any();
}
// ❌ NEVER: Ignoring null checks
const user = await ctx.db.get(userId);
user.name; // Potential runtime error!
// ✅ GOOD: Proper null handling
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError("User not found");Security Anti-Patterns:
// ❌ NEVER: Missing access control checks
export const deleteUser = mutation({
args: { userId: v.string() },
handler: async (ctx, args) => {
await ctx.db.delete(args.userId); // Anyone can delete!
},
});
// ✅ GOOD: Always use requireAccess
export const deleteUser = mutation({
args: { userId: v.string() },
handler: async (ctx, args) => {
await requireAccess(ctx, {
userRole: ["admin"],
permission: "users:delete",
});
await ctx.db.delete(args.userId);
},
});
// ❌ NEVER: Manual authentication checks
export const badAuth = mutation({
args: {},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
// Manual, error-prone approach
},
});
// ✅ GOOD: Use requireAccess for all auth
export const goodAuth = mutation({
args: {},
handler: async (ctx, args) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// Clean, consistent, and comprehensive
},
});
// ❌ NEVER: Client-side security
// Relying on UI to hide admin features without server-side checks
// ❌ NEVER: Missing rate limiting on mutations
export const createPost = mutation({
args: { content: v.string() },
handler: async (ctx, args) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// Missing rate limit - vulnerable to spam!
await ctx.db.insert("posts", { userId, content: args.content });
},
});
// ✅ GOOD: Always rate limit mutations
export const createPost = mutation({
args: { content: v.string() },
handler: async (ctx, args) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
await requireRateLimit(ctx, "contentCreation", { key: userId });
await ctx.db.insert("posts", { userId, content: args.content });
},
});Performance Anti-Patterns:
// ❌ NEVER: Heavy computation in queries
export const expensiveCalculation = query({
handler: async (ctx) => {
const data = await ctx.db.query("largeTable").collect();
return data.reduce((acc, item) => {
// Heavy computation...
}, {});
},
});
// ❌ NEVER: Unnecessary external API calls in queries
export const getWeatherData = query({
handler: async (ctx) => {
const response = await fetch("https://api.weather.com"); // Wrong!
return response.json();
},
});Quick Reference Checklist
Before Writing Any Function
- Determine correct access tier (public/private/internal)
- Choose correct function type (query/mutation/action)
- Add proper
vargument validators - Use
requireAccess()for all private functions - Add
requireRateLimit()for mutations that modify data - Consider database index requirements
- Plan for type safety and error handling
Three-Tier Function Placement
Decision Tree
- ✅ NO AUTH REQUIRED →
module/public/- Landing pages, public data, sign-up flows - ✅ AUTH REQUIRED →
module/private/- User operations, dashboard, settings - ✅ BACKEND ONLY →
module/internal/- Email sending, webhooks, cross-module calls
Security Checklist (Every Private Function)
- Use
requireAccess()at the start of handler - Specify appropriate role requirements (userRole or orgRole)
- Add
requireRateLimit()for mutations - Validate all inputs with
vvalidators - Check ownership/permissions for resource access
- Never trust client-provided data
Before Deploying Changes
- Run
bun lint(must pass with zero errors) - Run
bun typecheck(must pass with zero errors) - Test all permission scenarios (user, admin, org roles)
- Verify rate limits work correctly
- Verify database query performance (check indexes)
- Check function execution times in Convex dashboard
- Test with production-like data volumes
Code Review Checklist
- No usage of
anytype - All functions have
vargument validators - All private functions use
requireAccess() - Mutations use
requireRateLimit()where appropriate - Functions organized in correct access tier (public/private/internal)
- Efficient database queries with
.withIndex() - Proper error handling with descriptive messages
- Follows three-tier architecture pattern
- Uses modular access control system
- Imports from correct paths (../../lib/access)
Three-Tier Security Architecture
TinyKit Pro implements a comprehensive three-tier access control system for Convex backend functions, providing enhanced security, clear separation of concer...
HTTP Routes Architecture
TinyKit Pro uses Hono with OpenAPI for HTTP endpoints, providing automatic API documentation, type-safe routing, and a modular domain-based organization patt...