11 KiB
Phase 16: Validation and Auth Architecture
Overview
Before Phase 16, auth and validation were duplicated inline across API routes. Each handler called getCurrentUser() directly, repeated the null check, re-fetched the brand, re-verified membership, and manually returned NextResponse.json({ error: "..." }, { status: 401 }) in its own way. Zod parse errors had no standard shape. The result was inconsistent error responses and auth logic that could drift per route.
Phase 16 centralised this into four layers:
- Error types (
src/lib/api/errors.ts): typed exceptions with HTTP status codes attached withErrorHandler(src/lib/api/with-error-handler.ts): catches those exceptions and converts them to uniform JSON responses- Auth guards (
src/lib/auth/require.ts,src/lib/admin-auth.ts): throw the typed exceptions instead of returning responses, so they compose cleanly insidewithErrorHandler - Validation helpers (
src/lib/validation/request.ts): parse and throwValidationErroron bad input
Error Types
src/lib/api/errors.ts
All errors extend APIError, which carries a statusCode. Route handlers and helpers throw these; withErrorHandler catches them.
| Class | Status | When to use |
|---|---|---|
AuthError |
401 | User is not authenticated |
ForbiddenError |
403 | User is authenticated but lacks access |
NotFoundError |
404 | Resource does not exist |
ValidationError |
400 | Request body or query params failed schema validation |
RateLimitError |
429 | Rate limit exceeded |
ServerError |
500 | Explicit internal error (prefer letting unexpected errors bubble) |
withErrorHandler
src/lib/api/with-error-handler.ts
Wraps a route handler. Catches APIError subclasses and ZodError (as a backstop) and converts them to a consistent JSON shape. Logs unhandled errors to console.error.
export const GET = withErrorHandler(async (req, ...args) => {
// throw AuthError, ForbiddenError, ValidationError, etc. freely here
return NextResponse.json({ ... });
});
Response shapes on error:
// APIError
{ "error": "message" }
{ "error": "message", "details": { ... } } // when details are present
// ZodError (converted to ValidationError internally)
{ "error": "Invalid request body", "details": { "code": "VALIDATION_ERROR", "fieldErrors": { ... } } }
All routes that use requireUser or requireUserAndBrand must be wrapped with withErrorHandler. Without the wrapper, thrown AuthError / ForbiddenError instances are uncaught and produce a 500.
Auth Helpers
requireUser
src/lib/auth/require.ts
async function requireUser(): Promise<User>
Calls getCurrentUser() (Clerk session resolution + Prisma upsert). Throws AuthError("Unauthorized") if there is no active session.
Returns the Prisma User record including memberships (with nested organization).
requireUserAndBrand
async function requireUserAndBrand(
brandId: string
): Promise<{ user: User; brand: Brand }>
Calls requireUser, then:
- Calls
verifyBrandAccess(user.id, brandId), which checksBrandMembershiprows and org-owner role. ThrowsForbiddenError("Forbidden")if the user has no access. - Fetches the brand with
prisma.brand.findUnique. ThrowsNotFoundError("Brand not found")if the row does not exist.
Returns { user, brand }. The caller almost never needs to use the return value since the primary purpose is the guard, but the brand object is available when the handler needs it immediately without a second query.
// Guard only
export const GET = withErrorHandler(async (req, ...args) => {
const { brandId } = (args[0] as { params: { brandId: string } }).params;
await requireUserAndBrand(brandId);
// ... handler logic
});
// Using the return value
export const POST = withErrorHandler(async (req, ...args) => {
const { brandId } = (args[0] as { params: { brandId: string } }).params;
const { brand } = await requireUserAndBrand(brandId);
// brand.domain, brand.plan, etc. available without a second query
});
requireAdmin
src/lib/admin-auth.ts
async function requireAdmin(): Promise<
| { ok: true; user: AdminUser }
| { ok: false; status: number }
>
Legacy helper. Returns a discriminated union rather than throwing. Used by pre-existing /api/admin-legacy routes and a handful of older admin endpoints.
Checks the ADMIN_EMAILS environment variable (comma-separated list). If the env var is empty, denies all requests (fail-safe default; no misconfigured deployment accidentally grants access).
Callers must check ok and return early:
const auth = await requireAdmin();
if (!auth.ok) return NextResponse.json({ error: "Forbidden" }, { status: auth.status });
Do not use requireAdmin for new routes. Use requireSuperadmin instead.
requireSuperadmin
async function requireSuperadmin(): Promise<Response | null>
Current admin guard. Returns null when the caller is allowed to proceed, or a NextResponse (401/403) when denied. This is the opposite of the throwing pattern; callers return early on non-null.
export const GET = withErrorHandler(async (req) => {
const denied = await requireSuperadmin();
if (denied) return denied;
// ... handler logic
});
Checks (in order): Clerk session claims metadata.role === "superadmin", publicMetadata.role === "superadmin", metadata.isSuperadmin === true, publicMetadata.isSuperadmin === true, then falls back to ADMIN_EMAILS email match.
Does NOT throw (it returns a response), so withErrorHandler is not strictly required around it, but wrapping is still preferred for consistent unhandled-error behaviour.
getSuperadmin
async function getSuperadmin(): Promise<SuperadminIdentity | null>
Pure boolean check. Returns { userId, email } when the session is a superadmin, null otherwise. Does not return a response. Use this when you need to branch on admin status within a route that also serves non-admin users. requireSuperadmin is the right choice when the entire route is admin-only.
withAdminTiming
async function withAdminTiming<T>(name: string, handler: () => Promise<T>): Promise<T>
Wraps a block with console.info / console.error timing output tagged [admin:name]. Result array length is logged when the result is an array. Use in admin routes where query time is worth tracking in Vercel logs.
const result = await withAdminTiming("platform-metrics", async () => {
return await prisma.metricSnapshot.findMany({ ... });
});
Validation Helpers
src/lib/validation/request.ts
validateBody
async function validateBody<T>(req: Request, schema: ZodSchema<T>): Promise<T>
Parses req.json(). Throws ValidationError("Request body must be valid JSON") on JSON parse failure and ValidationError("Invalid request body") with fieldErrors on schema failure. Both are caught by withErrorHandler.
validateQuery
function validateQuery<T>(req: Request, schema: ZodSchema<T>): T
Parses new URL(req.url).searchParams as a flat string object. Throws ValidationError("Invalid query parameters") on schema failure. Note: all values are strings from the URL; Zod coercion (z.coerce.number()) is required to parse numerics.
Full Route Example
import { NextResponse } from "next/server";
import { z } from "zod";
import { withErrorHandler } from "@/lib/api/with-error-handler";
import { requireUserAndBrand } from "@/lib/auth/require";
import { validateBody, validateQuery } from "@/lib/validation/request";
const QuerySchema = z.object({
range: z.enum(["7d", "30d", "90d"]).default("30d"),
});
const BodySchema = z.object({
name: z.string().min(1),
value: z.number().int().positive(),
});
export const GET = withErrorHandler(async (req, ...args) => {
const { brandId } = (args[0] as { params: { brandId: string } }).params;
await requireUserAndBrand(brandId);
const { range } = validateQuery(req, QuerySchema);
return NextResponse.json({ range });
});
export const POST = withErrorHandler(async (req, ...args) => {
const { brandId } = (args[0] as { params: { brandId: string } }).params;
await requireUserAndBrand(brandId);
const body = await validateBody(req, BodySchema);
// ... use body.name, body.value
return NextResponse.json({ ok: true });
});
Migration Guide
Older routes (pre-Phase-16) do auth inline. The migration is mechanical:
Before:
export async function GET(req: Request) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const brandId = new URL(req.url).searchParams.get("brandId");
const brand = await prisma.brand.findUnique({
where: { id: brandId! },
include: { organization: { include: { memberships: true } } },
});
if (!brand || !brand.organization.memberships.some((m) => m.userId === user.id)) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
// ... handler
}
After:
export const GET = withErrorHandler(async (req, ...args) => {
const { brandId } = (args[0] as { params: { brandId: string } }).params;
await requireUserAndBrand(brandId);
// ... handler
});
Gotchas
requireUserAndBrand vs. manual membership check. The manual pre-Phase-16 pattern fetched the brand and checked memberships.some(m => m.userId === user.id). requireUserAndBrand calls verifyBrandAccess, which also grants access to org owners regardless of an explicit BrandMembership row. Converting a route to use requireUserAndBrand may grant access to org owners who previously could not reach that route. This is almost always the correct behaviour but worth noting.
withErrorHandler is required around throwing guards. requireUser and requireUserAndBrand throw exceptions. If you call them inside a handler that is not wrapped with withErrorHandler, the exception propagates and Next.js returns a 500. Always pair them.
requireSuperadmin returns a response, not an exception. It cannot be placed inside withErrorHandler and be handled automatically. The if (denied) return denied idiom is intentional; the return type Response | null makes the pattern explicit at the call site.
requireAdmin is legacy. New admin routes use requireSuperadmin. Do not add more callers of requireAdmin. The two helpers are not interchangeable; requireAdmin has no Clerk role awareness.
Query params are always strings. validateQuery uses new URL(req.url).searchParams, which returns strings for all values. Use z.coerce.number() or z.coerce.boolean() in the schema to parse non-string types from query strings.
validateBody is async, validateQuery is not. validateBody must be awaited; validateQuery is synchronous. Mixing them up is a TypeScript error but worth being aware of when reading unfamiliar routes.