• Skip to main content
  • Skip to navigation
  • Skip to search
    Petunia™
    FeaturesPricingIntegrationsAboutContact
    Log inStart free trialSign up
    Loading
    Petunia™

    Reimagining customer communication for the modern business.

    Product

    • Features
    • Pricing
    • Integrations
    • Roadmap
    • What's New

    Resources

    • Help Center
    • Documentation
    • Guides
    • API Reference
    • Community
    • Support

    Company

    • About Us
    • Careers
    • Blog
    • Press
    • Contact

    © 2026 Gray Group International LLC. All rights reserved.·
    Made by gardenpatch 🌱

    Privacy PolicyTerms of ServiceCookie Policy

    Petunia™ is a trademark of Gray Group International LLC. The Petunia name, brand, product design, and content are proprietary. Unauthorized use, imitation, or copying is prohibited.

    Documentation

    auth-patterns

    docs/auth-patterns.md
    Docs homeGuidesSupport
    Quick links
    Start here
    How the docs are organized.
    Environment setup
    Configure env + run locally.
    Unified Inbox
    Inbox concepts & behavior.
    Voice AI setup
    Providers, Twilio, testing.
    Pricing model
    Source-of-truth pricing.
    Operations runbook
    How to operate safely.

    API Route Authentication and Tenancy Patterns

    Overview

    This document defines the standard auth and tenancy patterns for Petunia API routes. Following these patterns ensures consistent security across all endpoints.

    Last Updated: 2025-11-28 Status: MANDATORY for all new routes, migration in progress for existing routes


    Recommended Pattern: withAuth HOC

    The withAuth higher-order function from @/lib/auth/route-auth is the recommended pattern for all authenticated API routes.

    Basic Usage

    import { NextResponse } from 'next/server';
    import { withAuth } from '@/lib/auth/route-auth';
    
    export const GET = withAuth(async (request, session) => {
      // session.user is guaranteed to exist here
      const userId = session.user.id;
    
      return NextResponse.json({ userId });
    });
    

    What withAuth Provides

    1. Session validation - Ensures user is authenticated
    2. User object access - session.user with id, email, role, isPlatformAdmin
    3. Onboarding check - Blocks incomplete onboarding users (except for onboarding routes)
    4. Consistent error responses - Returns 401/403 with proper codes
    5. Request logging - Logs unauthorized access attempts

    Session Object Structure

    interface AuthSession {
      user: {
        id: string;
        email: string;
        role?: string;
        isPlatformAdmin?: boolean;
        onboardingCompleted?: boolean;
        metadata?: Record<string, unknown>;
      };
    }
    

    Adding Tenancy Checks

    After authentication, you must verify the user has access to the requested tenant's data.

    Pattern 1: Using resolveUserPortalContext (Recommended)

    import { NextResponse } from 'next/server';
    import { withAuth } from '@/lib/auth/route-auth';
    import { resolveUserPortalContext, PortalAccessError } from '@/lib/server/portal-context';
    
    export const GET = withAuth(async (request, session) => {
      const { searchParams } = new URL(request.url);
      const petuniaID = searchParams.get('petuniaID');
    
      try {
        // Verify user has access to this portal
        const portalContext = await resolveUserPortalContext({
          userId: session.user.id,
          petuniaID,
        });
    
        // Now safe to query data for this portal
        const data = await getDataForPortal(portalContext.portalId);
    
        return NextResponse.json({ data, portal: portalContext });
      } catch (error) {
        if (error instanceof PortalAccessError) {
          return NextResponse.json({ error: error.message }, { status: error.status });
        }
        throw error;
      }
    });
    

    Pattern 2: Manual Portal Check (For Simple Cases)

    import { NextResponse } from 'next/server';
    import { withAuth } from '@/lib/auth/route-auth';
    import { prisma } from '@/lib/database';
    
    export const GET = withAuth(async (request, session) => {
      const { searchParams } = new URL(request.url);
      const portalId = searchParams.get('portalId');
    
      if (!portalId) {
        return NextResponse.json({ error: 'portalId is required' }, { status: 400 });
      }
    
      // Verify user has access to this portal
      const access = await prisma.userPortalAccess.findFirst({
        where: {
          userId: session.user.id,
          portalId,
        },
      });
    
      if (!access && !session.user.isPlatformAdmin) {
        return NextResponse.json({ error: 'Access denied to this portal' }, { status: 403 });
      }
    
      // Now safe to query data
      const data = await prisma.someTable.findMany({
        where: { portalId },
      });
    
      return NextResponse.json({ data });
    });
    

    Pattern 3: Platform Admin Override

    import { NextResponse } from 'next/server';
    import { withAuth } from '@/lib/auth/route-auth';
    
    export const GET = withAuth(async (request, session) => {
      // Platform admins can access all portals
      if (session.user.isPlatformAdmin) {
        const allData = await getAllData();
        return NextResponse.json({ data: allData });
      }
    
      // Regular users only see their portals
      const userPortals = await getUserPortals(session.user.id);
      const data = await getDataForPortals(userPortals);
    
      return NextResponse.json({ data });
    });
    

    Demo Mode Handling

    Always check for demo mode to return mock data instead of querying production:

    import { NextResponse } from 'next/server';
    import { withAuth } from '@/lib/auth/route-auth';
    import { isDemoPortal } from '@/lib/demo';
    import { resolveUserPortalContext } from '@/lib/server/portal-context';
    
    export const GET = withAuth(async (request, session) => {
      const { searchParams } = new URL(request.url);
      const petuniaID = searchParams.get('petuniaID');
    
      const portalContext = await resolveUserPortalContext({
        userId: session.user.id,
        petuniaID,
      });
    
      // Return demo data for demo portals
      if (isDemoPortal(portalContext.portalId)) {
        return NextResponse.json({
          data: getDemoData(),
          source: 'demo',
        });
      }
    
      // Query real data for production portals
      const data = await getRealData(portalContext.portalId);
      return NextResponse.json({
        data,
        source: 'production',
      });
    });
    

    Complete Example: Full Implementation

    /**
     * Example API Route with Full Auth + Tenancy
     *
     * @version 1.0.0
     * @lastModified 2025-11-28
     * @author Petunia Team
     * @stability stable
     */
    
    import type { NextRequest } from 'next/server';
    import { NextResponse } from 'next/server';
    
    import { withAuth } from '@/lib/auth/route-auth';
    import { prisma } from '@/lib/database';
    import { isDemoPortal } from '@/lib/demo';
    import { resolveUserPortalContext, PortalAccessError } from '@/lib/server/portal-context';
    import { createLogger } from '@/lib/utils/logging';
    
    const logger = createLogger('example-api');
    
    // Force dynamic rendering
    export const dynamic = 'force-dynamic';
    
    export const GET = withAuth(async (request: NextRequest, session) => {
      const { searchParams } = new URL(request.url);
      const petuniaID = searchParams.get('petuniaID');
    
      // 1. Validate required parameters
      if (!petuniaID) {
        return NextResponse.json(
          { error: 'petuniaID is required', code: 'MISSING_PORTAL_ID' },
          { status: 400 }
        );
      }
    
      try {
        // 2. Verify portal access
        const portalContext = await resolveUserPortalContext({
          userId: session.user.id,
          petuniaID,
        });
    
        logger.info('Accessing data', {
          userId: session.user.id,
          portalId: portalContext.portalId,
          isDemoPortal: portalContext.isDemoPortal,
        });
    
        // 3. Handle demo mode
        if (portalContext.isDemoPortal) {
          return NextResponse.json({
            data: { message: 'Demo data' },
            source: 'demo',
          });
        }
    
        // 4. Query real data with tenant filter
        const data = await prisma.someTable.findMany({
          where: {
            portalId: portalContext.portalId,
          },
        });
    
        return NextResponse.json({
          data,
          source: 'production',
        });
      } catch (error) {
        if (error instanceof PortalAccessError) {
          return NextResponse.json(
            { error: error.message, code: 'ACCESS_DENIED' },
            { status: error.status }
          );
        }
    
        logger.error('Error fetching data', { error });
        return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
      }
    });
    
    export const POST = withAuth(async (request: NextRequest, session) => {
      // Same pattern for mutations...
      const body = await request.json();
      const petuniaID = body.petuniaID;
    
      if (!petuniaID) {
        return NextResponse.json({ error: 'petuniaID is required' }, { status: 400 });
      }
    
      try {
        const portalContext = await resolveUserPortalContext({
          userId: session.user.id,
          petuniaID,
        });
    
        // Block mutations on demo portals
        if (portalContext.isDemoPortal) {
          return NextResponse.json({
            success: true,
            message: 'Changes simulated (demo mode)',
          });
        }
    
        // Perform actual mutation
        const result = await prisma.someTable.create({
          data: {
            ...body.data,
            portalId: portalContext.portalId,
          },
        });
    
        return NextResponse.json({ success: true, data: result });
      } catch (error) {
        if (error instanceof PortalAccessError) {
          return NextResponse.json({ error: error.message }, { status: error.status });
        }
        throw error;
      }
    });
    

    Migration Checklist

    When adding auth to an existing route:

    • Import withAuth from @/lib/auth/route-auth
    • Wrap handler function with withAuth
    • Add petuniaID or portalId parameter requirement
    • Use resolveUserPortalContext for portal access validation
    • Handle demo mode with isDemoPortal check
    • Add proper error responses for access denied
    • Update tests to include auth headers

    Routes That Don't Need Auth

    Some routes are intentionally public:

    CategoryExamplesReason
    Health checks/api/health, /api/readyKubernetes probes
    Auth endpoints/api/auth/signin, /api/auth/signupPre-authentication
    Webhooks/api/webhooks/twilio, /api/webhooks/stripeExternal service callbacks
    Cron jobs/api/cron/*Server-to-server with secrets
    Dev tools/api/_dev/*Development only

    Error Response Standards

    Always use consistent error response format:

    // 400 Bad Request - Missing parameters
    return NextResponse.json(
      { error: 'petuniaID is required', code: 'MISSING_PORTAL_ID' },
      { status: 400 }
    );
    
    // 401 Unauthorized - No auth
    return NextResponse.json(
      { error: 'Authentication required', code: 'AUTH_REQUIRED' },
      { status: 401 }
    );
    
    // 403 Forbidden - No access to resource
    return NextResponse.json(
      { error: 'Access denied to this portal', code: 'ACCESS_DENIED' },
      { status: 403 }
    );
    
    // 404 Not Found - Resource doesn't exist
    return NextResponse.json({ error: 'Portal not found', code: 'NOT_FOUND' }, { status: 404 });
    

    Testing Auth

    Example test for authenticated route:

    import { createMocks } from 'node-mocks-http';
    import { GET } from '@/app/api/example/route';
    
    describe('/api/example', () => {
      it('returns 401 without auth', async () => {
        const { req } = createMocks({ method: 'GET' });
        const response = await GET(req);
        expect(response.status).toBe(401);
      });
    
      it('returns 403 for unauthorized portal', async () => {
        // Mock authenticated user without portal access
        const { req } = createMocksWithAuth({
          method: 'GET',
          query: { petuniaID: 'other-portal' },
          user: { id: 'user-123' },
        });
        const response = await GET(req);
        expect(response.status).toBe(403);
      });
    
      it('returns data for authorized portal', async () => {
        // Mock authenticated user with portal access
        const { req } = createMocksWithAuth({
          method: 'GET',
          query: { petuniaID: 'my-portal' },
          user: { id: 'user-123' },
        });
        const response = await GET(req);
        expect(response.status).toBe(200);
      });
    });
    
    On this page
    OverviewRecommended Pattern: `withAuth` HOCBasic UsageWhat `withAuth` ProvidesSession Object StructureAdding Tenancy ChecksPattern 1: Using `resolveUserPortalContext` (Recommended)Pattern 2: Manual Portal Check (For Simple Cases)Pattern 3: Platform Admin OverrideDemo Mode HandlingComplete Example: Full ImplementationMigration ChecklistRoutes That Don't Need AuthError Response StandardsTesting Auth