This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
# 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`.
|
||||
|
||||
```typescript
|
||||
export const GET = withErrorHandler(async (req, ...args) => {
|
||||
// throw AuthError, ForbiddenError, ValidationError, etc. freely here
|
||||
return NextResponse.json({ ... });
|
||||
});
|
||||
```
|
||||
|
||||
**Response shapes on error:**
|
||||
|
||||
```json
|
||||
// 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`
|
||||
|
||||
```typescript
|
||||
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`
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```typescript
|
||||
// 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`
|
||||
|
||||
```typescript
|
||||
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:**
|
||||
|
||||
```typescript
|
||||
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`
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```typescript
|
||||
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`
|
||||
|
||||
```typescript
|
||||
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`
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```typescript
|
||||
const result = await withAdminTiming("platform-metrics", async () => {
|
||||
return await prisma.metricSnapshot.findMany({ ... });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Helpers
|
||||
|
||||
`src/lib/validation/request.ts`
|
||||
|
||||
### `validateBody`
|
||||
|
||||
```typescript
|
||||
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`
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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:**
|
||||
```typescript
|
||||
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:**
|
||||
```typescript
|
||||
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.
|
||||
Reference in New Issue
Block a user