Api Reference

Server Utilities

Server-side helpers and utilities for Nuxt Crouton applications

Server utilities in Nuxt Crouton provide powerful helpers for creating secure, multi-tenant API endpoints and managing team authorization. These utilities are designed to work seamlessly with Nuxt's server API routes and integrate with the client-side collection system.

Auto-Imported: All server utilities are automatically imported by Nuxt. No import statements are needed in your server routes.

createExternalCollectionHandler

Creates API endpoints that transform and serve external data in Crouton's collection format. This utility bridges external data sources with Crouton's client-side collection system.

Function Signature

export function createExternalCollectionHandler<T>(
  fetchFn: ExternalCollectionFetchFn<T>,
  transform: ExternalCollectionTransform<T>
): (event: H3Event<EventHandlerRequest>) => Promise<any[]>

Type Definitions

/**
 * Transform function that converts external data to Crouton format
 * Must return at minimum: { id: string, title: string }
 * The 'title' field is used by CroutonReferenceSelect for display
 */
export type ExternalCollectionTransform<T> = (item: T) => {
  id: string
  title: string
  [key: string]: any
}

/**
 * Fetch function that retrieves data from your external system
 * Receives the H3Event for access to params, auth, etc.
 */
export type ExternalCollectionFetchFn<T> = (
  event: H3Event<EventHandlerRequest>
) => Promise<T[]> | T[]

Purpose & Use Cases

  • External Data Integration: Connect external systems (SuperSaaS, NuxSaaS, custom APIs) to Crouton
  • Reference Dropdowns: Power CroutonReferenceSelect with dynamic data
  • Data Transformation: Convert external formats to Crouton's standardized structure
  • Multi-Tenancy: Combine with team auth to create tenant-specific endpoints
  • Query Filtering: Support ?ids= parameter for selective data fetching

Parameters

ParameterTypeDescription
fetchFnExternalCollectionFetchFn<T>Async function that retrieves items from your system. Receives H3Event for access to route params, auth, headers. Can throw errors to trigger proper error responses.
transformExternalCollectionTransform<T>Function that converts each item from your system to Crouton format. Must return object with at least { id, title }. Additional fields become available in the UI.

Return Value

Returns an H3 event handler function ready for use in Nuxt server routes:

(event: H3Event<EventHandlerRequest>) => Promise<any[]>

The handler returns an array of transformed items, each containing:

  • id (string, required) - Unique identifier for the item
  • title (string, required) - Display name in UI components
  • [key: string] (any) - Additional fields from your system

Example 1: Basic External Collection

// server/api/teams/[id]/users/index.get.ts

export default createExternalCollectionHandler(
  // Step 1: Fetch data from your system
  async (event) => {
    const teamId = getRouterParam(event, 'id')
    
    if (!teamId) {
      throw createError({
        statusCode: 400,
        statusMessage: 'Team ID is required'
      })
    }
    
    // Return raw data from your system
    return await getActiveTeamMembers(teamId)
  },

  // Step 2: Transform to Crouton format
  (member) => ({
    id: member.userId,
    title: member.name,              // Required for dropdown display
    email: member.email,
    avatarUrl: member.avatarUrl,
    role: member.role
  })
)

Usage in Components:

<script setup lang="ts">
// Reference select will fetch from /api/teams/[id]/users
const selectedUserId = ref('')
</script>

<template>
  <CroutonReferenceSelect
    v-model="selectedUserId"
    collection="teams"
    relationship="users"
    display-field="name"
  />
</template>

Example 2: Authorization with Team Context

// server/api/teams/[id]/projects/index.get.ts

export default createExternalCollectionHandler(
  async (event) => {
    const teamId = getRouterParam(event, 'id')
    const { user } = await requireUserSession(event)

    // Verify user is a team member
    const isMember = await isTeamMember(teamId, user.id)
    if (!isMember) {
      throw createError({
        statusCode: 403,
        statusMessage: 'You are not a member of this team'
      })
    }

    // Fetch team-specific projects
    return await db
      .select()
      .from(tables.projects)
      .where(eq(tables.projects.teamId, teamId))
      .all()
  },

  (project) => ({
    id: project.id,
    title: project.name,
    description: project.description,
    status: project.status,
    teamId: project.teamId
  })
)

Example 3: Query Parameter Filtering

// server/api/admin/users/index.get.ts

export default createExternalCollectionHandler(
  async (event) => {
    const currentUser = await requireAuth(event)
    const query = getQuery(event)

    // Check for admin access
    if (currentUser.role !== 'admin') {
      throw createError({
        statusCode: 403,
        statusMessage: 'Admin access required'
      })
    }

    // Get optional filters from query string
    const roleFilter = query.role as string | undefined
    const includeBanned = query.includeBanned === 'true'

    // Build query with filters
    const conditions = []
    
    if (!includeBanned) {
      conditions.push(or(
        eq(tables.users.banned, false),
        eq(tables.users.banned, null)
      ))
    }

    if (roleFilter) {
      conditions.push(eq(tables.users.role, roleFilter))
    }

    return await db
      .select()
      .from(tables.users)
      .where(conditions.length > 0 ? and(...conditions) : undefined)
      .all()
  },

  (user) => ({
    id: user.id,
    title: user.name,
    email: user.email,
    role: user.role,
    banned: user.banned,
    image: user.image
  })
)

// Usage:
// GET /api/admin/users
// GET /api/admin/users?role=admin
// GET /api/admin/users?includeBanned=true
// GET /api/admin/users?ids=uuid1,uuid2,uuid3

Example 4: Subscription Data Integration

// server/api/users/[id]/subscriptions/index.get.ts

export default createExternalCollectionHandler(
  async (event) => {
    const userId = getRouterParam(event, 'id')
    const { user } = await requireUserSession(event)

    // Only users can access their own subscription data
    if (user.id !== userId) {
      throw createError({
        statusCode: 403,
        statusMessage: 'Unauthorized'
      })
    }

    // Fetch with relations
    return await db
      .select({
        subscription: tables.subscriptions,
        plan: tables.subscriptionPlans
      })
      .from(tables.subscriptions)
      .leftJoin(
        tables.subscriptionPlans,
        eq(tables.subscriptions.planId, tables.subscriptionPlans.id)
      )
      .where(eq(tables.subscriptions.userId, userId))
      .all()
  },

  (subscription) => ({
    id: subscription.subscription.id,
    title: subscription.plan?.name || 'Unknown Plan',
    status: subscription.subscription.status,
    periodEnd: subscription.subscription.periodEnd,
    cancelAtPeriodEnd: subscription.subscription.cancelAtPeriodEnd,
    planId: subscription.subscription.planId,
    features: subscription.plan?.features
  })
)

Example 5: Error Handling & Validation

// server/api/teams/[id]/documents/index.get.ts

export default createExternalCollectionHandler(
  async (event) => {
    const teamId = getRouterParam(event, 'id')
    
    // Validate input
    if (!teamId || typeof teamId !== 'string') {
      throw createError({
        statusCode: 400,
        statusMessage: 'Invalid or missing team ID'
      })
    }

    // Validate team exists
    const team = await db
      .select()
      .from(tables.teams)
      .where(eq(tables.teams.id, teamId))
      .get()

    if (!team) {
      throw createError({
        statusCode: 404,
        statusMessage: 'Team not found'
      })
    }

    // Validate user access
    const { user } = await requireUserSession(event)
    const access = await checkTeamAccess(user.id, teamId, 'read')

    if (!access) {
      throw createError({
        statusCode: 403,
        statusMessage: 'You do not have permission to view this team\'s documents'
      })
    }

    try {
      return await db
        .select()
        .from(tables.documents)
        .where(eq(tables.documents.teamId, teamId))
        .all()
    } catch (error) {
      console.error('[documents] Database error:', error)
      throw createError({
        statusCode: 500,
        statusMessage: 'Failed to fetch documents'
      })
    }
  },

  (doc) => {
    // Validate transform output
    if (!doc.id || !doc.title) {
      console.warn('[documents] Invalid document structure:', doc)
      return {
        id: doc.id || 'unknown',
        title: doc.title || 'Untitled'
      }
    }

    return {
      id: doc.id,
      title: doc.title,
      description: doc.description,
      documentType: doc.type,
      uploadedAt: doc.createdAt,
      uploadedBy: doc.uploadedBy
    }
  }
)

Features

  • Automatic Error Handling: Wraps your code in try-catch with proper HTTP error responses
  • Query Parameter Support: Built-in support for ?ids=id1,id2,id3 filtering
  • Type-Safe: Full TypeScript generics support for your data type
  • Auto-Import: No import needed - Nuxt auto-imports the utility
  • H3Event Access: Full access to route params, auth, headers, query params

Integration with useCollectionProxy

Client-side composables automatically integrate with external collection handlers:

// composables/useProjects.ts
import { useCollectionProxy } from '@friendlyinternet/nuxt-crouton'

export function useTeamProjects(teamId: string) {
  // Automatically calls /api/teams/[id]/projects
  const { items, loading, refresh } = useCollectionProxy('teams', teamId, 'projects')
  
  return { items, loading, refresh }
}

Team Authorization Utilities

resolveTeamAndCheckMembership

Resolves a team by slug or ID and verifies the current user is a team member.

Function Signature

export async function resolveTeamAndCheckMembership(
  event: any
): Promise<{
  team: Team
  user: User
  membership: TeamMember
}>

Purpose & Use Cases

  • Multi-Tenant Route Protection: Ensure user has access to team resources
  • Team Slug Resolution: Handle both slug and ID-based URLs
  • Membership Verification: Single function call replaces multiple database queries
  • Clean Error Handling: Returns 404 for missing teams, 403 for unauthorized access

Parameters

ParameterTypeDescription
eventH3EventThe Nuxt server event, automatically provides team slug/ID from route params

Return Value

{
  team: Team              // The resolved team object
  user: User              // The authenticated user
  membership: TeamMember  // The membership record proving user is in team
}

Example 1: Basic Team Route

// server/api/teams/[id]/settings.get.ts

export default defineEventHandler(async (event) => {
  // Resolves team by slug or ID, verifies user membership
  const { team, user } = await resolveTeamAndCheckMembership(event)

  return {
    teamId: team.id,
    teamName: team.name,
    userId: user.id,
    settings: team.settings
  }
})

Example 2: With Team Data

// server/api/teams/[id]/index.get.ts

export default defineEventHandler(async (event) => {
  const { team, user, membership } = await resolveTeamAndCheckMembership(event)

  // Fetch team statistics
  const memberCount = await db
    .select({ count: count() })
    .from(tables.teamMembers)
    .where(eq(tables.teamMembers.teamId, team.id))
    .get()

  const projectCount = await db
    .select({ count: count() })
    .from(tables.projects)
    .where(eq(tables.projects.teamId, team.id))
    .get()

  return {
    id: team.id,
    name: team.name,
    slug: team.slug,
    description: team.description,
    avatarUrl: team.avatarUrl,
    memberCount: memberCount.count,
    projectCount: projectCount.count,
    userRole: membership.role,
    currentUserId: user.id
  }
})

Example 3: Role-Based Access Control

// server/api/teams/[id]/members.delete.ts

export default defineEventHandler(async (event) => {
  const { team, membership } = await resolveTeamAndCheckMembership(event)

  // Check user has admin role
  if (membership.role !== 'admin') {
    throw createError({
      statusCode: 403,
      statusMessage: 'Only team admins can manage members'
    })
  }

  const { memberId } = await readBody(event)

  // Delete the member
  await db
    .delete(tables.teamMembers)
    .where(
      and(
        eq(tables.teamMembers.teamId, team.id),
        eq(tables.teamMembers.id, memberId)
      )
    )

  return { success: true }
})

Example 4: Update Team Settings

// server/api/teams/[id]/settings.patch.ts

export default defineEventHandler(async (event) => {
  const { team, membership } = await resolveTeamAndCheckMembership(event)

  // Verify owner permission
  if (membership.role !== 'owner') {
    throw createError({
      statusCode: 403,
      statusMessage: 'Only team owner can update settings'
    })
  }

  const body = await readBody(event)

  // Validate input
  const schema = z.object({
    name: z.string().min(1).max(100).optional(),
    description: z.string().max(500).optional(),
    avatarUrl: z.string().url().optional()
  })

  const validated = schema.parse(body)

  // Update team
  const updated = await db
    .update(tables.teams)
    .set(validated)
    .where(eq(tables.teams.id, team.id))
    .returning()
    .get()

  return updated
})

isTeamMember

Checks if a specific user is a member of a team. Useful for conditional logic in API routes.

Function Signature

export async function isTeamMember(
  teamId: string,
  userId: string
): Promise<boolean>

Purpose & Use Cases

  • Conditional Authorization: Skip full membership fetch if you only need boolean result
  • Batch Operations: Check access for multiple users efficiently
  • System Operations: Verify access in scheduled tasks or background jobs
  • Webhook Validation: Ensure webhook sender has team access

Parameters

ParameterTypeDescription
teamIdstringThe team's ID (not slug)
userIdstringThe user's ID to check

Return Value

Returns a boolean: true if user is a team member, false otherwise.

Example 1: Conditional Logic

// server/api/teams/[teamId]/invite.post.ts

export default defineEventHandler(async (event) => {
  const { user } = await requireUserSession(event)
  const { teamId } = getRouterParams(event)
  const { inviteEmail } = await readBody(event)

  // Only team members can invite others
  const isInviter = await isTeamMember(teamId, user.id)
  
  if (!isInviter) {
    throw createError({
      statusCode: 403,
      statusMessage: 'You are not a member of this team'
    })
  }

  // Send invite
  return await sendTeamInvite(teamId, inviteEmail)
})

Example 2: Batch Access Check

// server/utils/teamAccess.ts

export async function filterAccessibleTeams(
  userId: string,
  teamIds: string[]
): Promise<string[]> {
  const accessible = []

  for (const teamId of teamIds) {
    const isMember = await isTeamMember(teamId, userId)
    if (isMember) {
      accessible.push(teamId)
    }
  }

  return accessible
}

// Usage in API:
export default defineEventHandler(async (event) => {
  const { user } = await requireUserSession(event)
  const { teamIds } = await readBody(event)

  // Verify user can access each team
  const allowed = await filterAccessibleTeams(user.id, teamIds)

  return { allowedTeams: allowed }
})

Example 3: Webhook Validation

// server/api/webhooks/stripe.post.ts

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const signature = getHeader(event, 'stripe-signature')

  // Verify Stripe signature
  const webhookSecret = useRuntimeConfig().stripeWebhookSecret
  const verified = stripe.webhooks.constructEvent(
    body,
    signature,
    webhookSecret
  )

  const { type, data } = verified

  if (type === 'customer.subscription.updated') {
    const customerId = data.object.customer
    
    // Find user with this Stripe ID
    const user = await db
      .select()
      .from(tables.users)
      .where(eq(tables.users.stripeCustomerId, customerId))
      .get()

    if (!user) return { received: true }

    // Verify user still has access (in case they were removed from all teams)
    const isActiveMember = await isTeamMember(user.primaryTeamId, user.id)
    
    if (!isActiveMember) {
      // Downgrade service
      await suspendUserServices(user.id)
    }

    return { received: true }
  }

  return { received: true }
})

Example 4: Background Job Access Control

// server/tasks/cleanupOldFiles.ts

export default defineEventHandler(async (event) => {
  // System task - fetch all teams and files
  const teams = await db
    .select()
    .from(tables.teams)
    .all()

  for (const team of teams) {
    // Get all files in team
    const files = await db
      .select()
      .from(tables.files)
      .where(
        and(
          eq(tables.files.teamId, team.id),
          lt(tables.files.createdAt, oneMonthAgo)
        )
      )
      .all()

    for (const file of files) {
      // Verify uploader still has team access
      const uploaderStillMember = await isTeamMember(team.id, file.uploadedBy)
      
      if (!uploaderStillMember) {
        // Safe to delete - uploader is no longer in team
        await deleteFile(file.id)
      }
    }
  }
})

Best Practices for Server-Side Code

1. Always Validate and Authorize

// ✅ Good - comprehensive validation
export default defineEventHandler(async (event) => {
  // Step 1: Authenticate
  const { user } = await requireUserSession(event)

  // Step 2: Get and validate team
  const teamId = getRouterParam(event, 'id')
  if (!teamId) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Team ID is required'
    })
  }

  // Step 3: Verify membership
  const isMember = await isTeamMember(teamId, user.id)
  if (!isMember) {
    throw createError({
      statusCode: 403,
      statusMessage: 'Unauthorized'
    })
  }

  // Now safe to proceed
})

// ❌ Bad - assumes access
export default defineEventHandler(async (event) => {
  const { user } = await requireUserSession(event)
  const teamId = getRouterParam(event, 'id')
  // Missing validation!
})

2. Use External Collection Handler for Lists

// ✅ Good - use createExternalCollectionHandler
export default createExternalCollectionHandler(
  async (event) => {
    const { team } = await resolveTeamAndCheckMembership(event)
    return await db.select().from(tables.items).where(...)
  },
  (item) => ({ id: item.id, title: item.name, ... })
)

// ❌ Bad - manual transformation
export default defineEventHandler(async (event) => {
  const { team } = await resolveTeamAndCheckMembership(event)
  const items = await db.select().from(tables.items).where(...)
  return items.map(item => ({...}))  // Error handling is your problem
})

3. Proper Error Handling

// ✅ Good - specific error messages and codes
try {
  const data = await externalAPI.fetch(params)
  return data
} catch (error) {
  if (error instanceof ValidationError) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Invalid input: ' + error.message
    })
  }
  
  if (error instanceof NotFoundError) {
    throw createError({
      statusCode: 404,
      statusMessage: 'Resource not found'
    })
  }

  console.error('Unexpected error:', error)
  throw createError({
    statusCode: 500,
    statusMessage: 'Internal server error'
  })
}

// ❌ Bad - generic errors
try {
  return await something()
} catch (error) {
  throw createError({
    statusCode: 500,
    statusMessage: 'Something went wrong'
  })
}

4. Type Your Server Code

// ✅ Good - fully typed
interface TeamStats {
  membersCount: number
  projectsCount: number
  filesSize: number
}

export default defineEventHandler(async (event): Promise<TeamStats> => {
  const { team } = await resolveTeamAndCheckMembership(event)

  const stats: TeamStats = {
    membersCount: await countTeamMembers(team.id),
    projectsCount: await countTeamProjects(team.id),
    filesSize: await calculateTeamFilesSize(team.id)
  }

  return stats
})

// ❌ Bad - untyped
export default defineEventHandler(async (event) => {
  const { team } = await resolveTeamAndCheckMembership(event)
  return {
    membersCount: await countTeamMembers(team.id),
    projectsCount: await countTeamProjects(team.id),
    filesSize: await calculateTeamFilesSize(team.id)
  }
})

5. Logging and Debugging

// ✅ Good - contextual logging
const teamId = getRouterParam(event, 'id')
const { user } = await requireUserSession(event)

console.log('[teams.get]', {
  teamId,
  userId: user.id,
  timestamp: new Date().toISOString()
})

try {
  const data = await fetchTeamData(teamId)
  return data
} catch (error) {
  console.error('[teams.get] Error:', {
    teamId,
    userId: user.id,
    error: error instanceof Error ? error.message : String(error)
  })
  throw error
}

// ❌ Bad - no context
console.log('Error:', error)

Security Considerations

1. Team Isolation

Always verify team ownership before returning team-specific data:

// ✅ Secure - check membership on every request
const { team } = await resolveTeamAndCheckMembership(event)

// ❌ Insecure - trusting client-provided teamId
const teamId = getQuery(event).teamId
const data = await db.select().from(tables.items)
  .where(eq(tables.items.teamId, teamId))

2. Role-Based Access Control

Use membership roles to gate sensitive operations:

export default defineEventHandler(async (event) => {
  const { membership } = await resolveTeamAndCheckMembership(event)

  if (membership.role !== 'admin') {
    throw createError({
      statusCode: 403,
      statusMessage: 'Admin access required'
    })
  }

  // Safe to proceed with admin operations
})

3. Input Validation

Always validate request body and query parameters:

import { z } from 'zod'

const updateSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().max(500).optional(),
  isPublic: z.boolean().optional()
})

export default defineEventHandler(async (event) => {
  const { team } = await resolveTeamAndCheckMembership(event)
  const body = await readBody(event)

  // Validate and throw 400 on invalid data
  const validated = updateSchema.parse(body)

  return await updateTeam(team.id, validated)
})

4. Rate Limiting

Consider rate limiting for expensive operations:

export default defineEventHandler(async (event) => {
  const { user } = await requireUserSession(event)

  // Check rate limit
  const requests = await rateLimit.check(user.id, 'list-items', {
    maxRequests: 100,
    windowMs: 60000  // 1 minute
  })

  if (!requests.success) {
    throw createError({
      statusCode: 429,
      statusMessage: 'Too many requests. Please try again later.'
    })
  }

  // Proceed
})

Troubleshooting

Handler Not Auto-Imported

If createExternalCollectionHandler is not found:

  1. Ensure Nuxt app is running (server needs to build imports)
  2. Check file is in server/ or app/server/ directory
  3. Restart dev server: pnpm dev

404 on Team Routes

If team endpoints return 404:

  1. Verify team slug or ID matches URL param
  2. Check user is actually a team member
  3. Confirm database has team record

Membership Check Fails

If resolveTeamAndCheckMembership throws 403:

  1. Log in with correct user account
  2. Verify user was added to team
  3. Check teamMembers table in database

External Collection Returns Empty

If CroutonReferenceSelect shows no options:

  1. Verify endpoint returns data: curl http://localhost:3000/api/path
  2. Check id and title fields are present in response
  3. Verify fetch function isn't throwing error (check server console)

TypeScript Errors with H3Event

If TypeScript complains about H3Event:

// ✅ Correct import
import type { H3Event, EventHandlerRequest } from 'h3'

// Or just use 'any' for quick development
export function resolveTeamAndCheckMembership(event: any) {