Initial project commit for Coolify staging
CI / Quality gate (push) Has been cancelled

This commit is contained in:
Andres Resiri
2026-08-03 10:45:41 -04:00
commit 70daeb214a
1199 changed files with 243503 additions and 0 deletions
+279
View File
@@ -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.