Files
Andres Resiri 70daeb214a
CI / Quality gate (push) Has been cancelled
Initial project commit for Coolify staging
2026-08-03 10:45:41 -04:00

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:

  1. Error types (src/lib/api/errors.ts): typed exceptions with HTTP status codes attached
  2. withErrorHandler (src/lib/api/with-error-handler.ts): catches those exceptions and converts them to uniform JSON responses
  3. Auth guards (src/lib/auth/require.ts, src/lib/admin-auth.ts): throw the typed exceptions instead of returning responses, so they compose cleanly inside withErrorHandler
  4. Validation helpers (src/lib/validation/request.ts): parse and throw ValidationError on 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:

  1. Calls verifyBrandAccess(user.id, brandId), which checks BrandMembership rows and org-owner role. Throws ForbiddenError("Forbidden") if the user has no access.
  2. Fetches the brand with prisma.brand.findUnique. Throws NotFoundError("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.