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.
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.
export function createExternalCollectionHandler<T>(
fetchFn: ExternalCollectionFetchFn<T>,
transform: ExternalCollectionTransform<T>
): (event: H3Event<EventHandlerRequest>) => Promise<any[]>
/**
* 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[]
CroutonReferenceSelect with dynamic data?ids= parameter for selective data fetching| Parameter | Type | Description |
|---|---|---|
fetchFn | ExternalCollectionFetchFn<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. |
transform | ExternalCollectionTransform<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. |
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 itemtitle (string, required) - Display name in UI components[key: string] (any) - Additional fields from your system// 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({
status: 400,
statusText: '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>
// 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({
status: 403,
statusText: '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
})
)
// 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({
status: 403,
statusText: '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
// 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({
status: 403,
statusText: '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
})
)
// 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({
status: 400,
statusText: '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({
status: 404,
statusText: 'Team not found'
})
}
// Validate user access
const { user } = await requireUserSession(event)
const access = await checkTeamAccess(user.id, teamId, 'read')
if (!access) {
throw createError({
status: 403,
statusText: '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({
status: 500,
statusText: '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
}
}
)
?ids=id1,id2,id3 filteringClient-side proxy utilities automatically integrate with external collection handlers:
// composables/useProjects.ts
import { applyProxyTransform, getProxiedEndpoint } from '@fyit/crouton'
export function useTeamProjects(teamId: string) {
const config = useCollections().getConfig('projects')
const endpoint = getProxiedEndpoint(config, 'projects')
// Fetches from the proxied endpoint and transforms data
return useFetch(`/api/teams/${teamId}/${endpoint}`)
}
Resolves a team by slug or ID and verifies the current user is a team member.
export async function resolveTeamAndCheckMembership(
event: any
): Promise<{
team: Team
user: User
membership: Member
}>
| Parameter | Type | Description |
|---|---|---|
event | H3Event | The Nuxt server event, automatically provides team slug/ID from route params |
{
team: Team // The resolved team object
user: User // The authenticated user
membership: Member // The member record proving user is in team
}
// server/api/teams/[id]/settings.get.ts
export default defineEventHandler(async (event) => {
// Resolves team by slug or ID, verifies user member
const { team, user } = await resolveTeamAndCheckMembership(event)
return {
teamId: team.id,
teamName: team.name,
userId: user.id,
settings: team.settings
}
})
// 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
}
})
// 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({
status: 403,
statusText: '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 }
})
// 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({
status: 403,
statusText: '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
})
Checks if a specific user is a member of a team. Requires H3 event context for Better Auth API access.
export async function isTeamMemberWithEvent(
event: H3Event,
teamId: string,
userId: string
): Promise<boolean>
| Parameter | Type | Description |
|---|---|---|
event | H3Event | The H3 event from the handler |
teamId | string | The team's ID (not slug) |
userId | string | The user's ID to check |
Returns a boolean: true if user is a team member, false otherwise.
// server/api/teams/[teamId]/invite.post.ts
import { isTeamMemberWithEvent } from '@fyit/crouton-auth/server'
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 isTeamMemberWithEvent(event, teamId, user.id)
if (!isInviter) {
throw createError({
status: 403,
statusText: 'You are not a member of this team'
})
}
// Send invite
return await sendTeamInvite(teamId, inviteEmail)
})
// ✅ Good - comprehensive validation
import { isTeamMemberWithEvent } from '@fyit/crouton-auth/server'
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({
status: 400,
statusText: 'Team ID is required'
})
}
// Step 3: Verify member
const isMember = await isTeamMemberWithEvent(event, teamId, user.id)
if (!isMember) {
throw createError({
status: 403,
statusText: '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!
})
// ✅ 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
})
// ✅ Good - specific error messages and codes
try {
const data = await externalAPI.fetch(params)
return data
} catch (error) {
if (error instanceof ValidationError) {
throw createError({
status: 400,
statusText: 'Invalid input: ' + error.message
})
}
if (error instanceof NotFoundError) {
throw createError({
status: 404,
statusText: 'Resource not found'
})
}
console.error('Unexpected error:', error)
throw createError({
status: 500,
statusText: 'Internal server error'
})
}
// ❌ Bad - generic errors
try {
return await something()
} catch (error) {
throw createError({
status: 500,
statusText: 'Something went wrong'
})
}
// ✅ 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)
}
})
// ✅ 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)
Always verify team ownership before returning team-specific data:
// ✅ Secure - check member 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))
Use member roles to gate sensitive operations:
export default defineEventHandler(async (event) => {
const { membership } = await resolveTeamAndCheckMembership(event)
if (membership.role !== 'admin') {
throw createError({
status: 403,
statusText: 'Admin access required'
})
}
// Safe to proceed with admin operations
})
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)
})
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({
status: 429,
statusText: 'Too many requests. Please try again later.'
})
}
// Proceed
})
If createExternalCollectionHandler is not found:
server/ or app/server/ directorypnpm devIf team endpoints return 404:
If resolveTeamAndCheckMembership throws 403:
teamMembers table in databaseIf CroutonReferenceSelect shows no options:
curl http://localhost:3000/api/pathid and title fields are present in responseIf TypeScript complains about H3Event:
// ✅ Correct import
import type { H3Event, EventHandlerRequest } from 'h3'
// Or just use 'any' for quick development
export function resolveTeamAndCheckMembership(event: any) {