TinyKit Pro Docs

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...

TinyKit Pro implements a comprehensive three-tier access control system for Convex backend functions, providing enhanced security, clear separation of concerns, and improved developer experience.

Overview

The three-tier architecture separates backend functions into distinct access levels, each with specific security guarantees and use cases:

  • public/ - Frontend-callable without authentication
  • private/ - Frontend-callable requiring authentication
  • internal/ - Backend-only functions for inter-service communication

Architecture Benefits

Security Improvements

  1. Minimal Public Attack Surface: Only essential unauthenticated functions are exposed
  2. Clear Authentication Boundaries: No ambiguity about authentication requirements
  3. Enhanced Auditing: Easy identification of public vs authenticated endpoints
  4. Reduced Risk: Accidental exposure of authenticated functions in public namespace eliminated

Developer Experience

  1. Import Path Clarity: Function access level is immediately visible from import path
  2. Type Safety: TypeScript enforces correct usage patterns
  3. Consistent Patterns: All modules follow the same organizational structure
  4. Self-Documenting Code: Directory structure communicates security model

Directory Structure

Each Convex module follows this pattern:

convex/
├── moduleName/
│   ├── public/          # Frontend-callable without authentication
│   │   ├── queries.ts   # Read operations for public data
│   │   ├── mutations.ts # Write operations (signup, contact forms)
│   │   └── actions.ts   # External API calls, file uploads
│   ├── private/         # Frontend-callable requiring authentication
│   │   ├── queries.ts   # Authenticated read operations
│   │   ├── mutations.ts # Authenticated write operations
│   │   └── actions.ts   # Authenticated external operations
│   ├── internal/        # Backend-only functions
│   │   ├── queries.ts   # Internal read operations
│   │   ├── mutations.ts # Internal write operations
│   │   └── actions.ts   # Email sending, webhook processing
│   ├── helpers.ts       # Shared utility functions
│   ├── validators.ts    # Zod schemas and validation
│   └── schema.ts        # Database schema (if module-specific)

Access Tier Guidelines

Public Functions (public/)

Purpose: Unauthenticated access for landing pages, signup forms, and public data

Characteristics:

  • No authentication checks required
  • Minimal functions for security
  • Read-only or public write operations (signup, contact forms)
  • Always safe to expose to unauthenticated users

Examples:

// Waitlist signup - no auth required
export const joinWaitlist = mutation({
  args: { email: v.string() },
  handler: async (ctx, { email }) => {
    // No authentication check
    await ctx.db.insert("waitlist", { email });
    return { success: true };
  },
});

// Public settings for landing page
export const getWaitlistSettings = query({
  args: {},
  handler: async (ctx) => {
    const settings = await ctx.db.query("waitlistSettings").first();
    return {
      waitlistEnabled: settings?.waitlistEnabled ?? true,
      showWaitlistCount: settings?.showWaitlistCount ?? false,
    };
  },
});

Private Functions (private/)

Purpose: Authenticated user operations, admin functions, user-facing features

Characteristics:

  • Always include authentication checks
  • User-specific data and operations
  • Admin-only functions with role verification
  • All user-facing authenticated features

Examples:

// User profile access
export const me = query({
  handler: async (ctx) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });
    return await ctx.db.get(userId);
  },
});

// Admin-only function
export const deleteUser = mutation({
  args: { userId: v.string() },
  handler: async (ctx, args) => {
    const { userId: adminId } = await requireAccess(ctx, {
      userRole: ["admin"],
    });
    await ctx.db.delete(args.userId);
    return { success: true };
  },
});

Internal Functions (internal/)

Purpose: Backend-only operations, cross-service communication, email sending

Characteristics:

  • Not directly callable from frontend
  • Used via ctx.runQuery(), ctx.runMutation(), ctx.runAction()
  • Email sending, webhook processing
  • Cross-module function calls

Examples:

// Email notification - backend only
export const sendWelcomeEmail = action({
  args: { email: v.string(), name: v.string() },
  handler: async (ctx, { email, name }) => {
    // Internal function - no direct frontend access
    return await sendEmailViaResend({
      to: email,
      subject: "Welcome!",
      template: "welcome",
      data: { name },
    });
  },
});

// Called from other Convex functions:
// await ctx.runAction(internal.emails.actions.sendWelcomeEmail, { email, name });

Migration Guide

From Mixed Public to Three-Tier

Before (Mixed authentication in public):

// All functions in public namespace
const userProfile = useQuery(api.users.public.queries.getMe); // Requires auth
const adminStats = useQuery(api.users.public.queries.getUserStats); // Admin only
const publicSettings = useQuery(api.waitlist.public.queries.getSettings); // No auth

After (Clear access separation):

// Clear separation by authentication requirements
const userProfile = useQuery(api.users.private.queries.getMe); // Requires auth
const adminStats = useQuery(api.users.private.queries.getUserStats); // Admin only
const publicSettings = useQuery(
  api.waitlist.public.queries.getWaitlistSettings,
); // No auth

Function Placement Decision Tree

Is the function callable from the frontend?
├─ No → internal/
└─ Yes
   └─ Does it require authentication?
      ├─ No → public/
      └─ Yes → private/

Implementation Status

Migrated Modules

All core modules have been migrated to the three-tier architecture:

  • users - User management and profiles
  • orgs - Organization operations and memberships
  • waitlist - Waitlist signup and management
  • testimonials - Testimonial display and admin management
  • notifications - Notification system (all private)
  • siteBanners - Site banner management (all private)
  • siteSettings - Site configuration (all private)
  • newsletter - Newsletter system (all private)
  • faqs - FAQ system with public access

Function Distribution

ModulePublic FunctionsPrivate FunctionsInternal Functions
users016 queries, 12 mutationsExisting internal
orgs2 queries11 queries, 14 mutationsExisting internal
waitlist2 functions10 functions1 action
testimonials2 queries2 queries, 7 mutations, 1 action-
notifications0All functionsExisting internal
siteBanners0All functions-
siteSettings0All functions-
newsletter0All functions-
faqs1 query4 functions-

Security Validation

Authentication Verification

  • Public functions: Zero authentication checks (verified)
  • Private functions: 100% include authentication checks
  • Internal functions: Used only within Convex backend

Access Control Testing

The architecture is validated through:

  1. TypeScript compilation: Ensures correct import patterns
  2. ESLint rules: Enforces authentication patterns
  3. Manual testing: Validates access control boundaries
  4. Code review: Manual verification of security patterns

Best Practices

Function Development

  1. Start with access tier decision: Determine public/private/internal first
  2. Authentication first: Always add auth checks before business logic in private functions
  3. Minimal public exposure: Keep public functions to absolute minimum
  4. Clear error messages: Distinguish between "unauthenticated" and "unauthorized"

Import Patterns

// Good: Clear access level indication
import { api } from "@/convex/_generated/api";
const userData = useQuery(api.users.private.queries.getMe);
const publicFAQs = useQuery(api.faqs.public.queries.list);

// Good: Internal function usage
await ctx.runAction(internal.emails.actions.sendNotification, { ... });

// Bad: Would cause TypeScript error now
const userData = useQuery(api.users.public.queries.getMe); // Function moved to private

Error Handling

// Private functions should always check authentication
export const privateFunction = query({
  handler: async (ctx) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });
    // requireAccess throws ConvexError if not authenticated

    // Business logic here
  },
});

Monitoring and Auditing

Security Metrics

  • Public API surface: Minimal (only essential unauthenticated functions)
  • Authentication coverage: 100% for private functions
  • Access control violations: Zero (enforced by architecture)

Development Metrics

  • Import clarity: 100% (access level visible in import path)
  • TypeScript errors: Zero authentication-related errors
  • Code review efficiency: Improved (security model self-evident)

Future Considerations

Planned Enhancements

  1. Automated migration tools: Scripts to assist with module migration
  2. ESLint rules: Custom rules to enforce three-tier patterns
  3. Documentation generation: Auto-generate API docs from access tiers
  4. Performance monitoring: Track public vs private function usage

Scalability

The three-tier architecture scales well with:

  • New modules: Follow established patterns
  • Team growth: Clear security model reduces onboarding complexity
  • Feature expansion: Proper access control from day one
  • Security audits: Easy identification of public attack surface

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro