Loading…
The system exposes two API surfaces:
app/api/** on the repo root, deployed at matters.ai/api/*. Mostly
lead-capture, webhook, and content-revalidation endpoints. No Clerk session required.apps/workspace/app/api/**, deployed at workspace.matters.ai/api/*.
Every route gated by the workspace proxy.ts (Clerk + email allowlist), plus per-handler
RBAC.POST / PUT / PATCH / DELETE) validate the body via a Zod schema
before any side-effect. Validation failures return 400 with
{ error, issues?: ZodIssue[] }.{ success: true, data?, ... } or a typed payload.{ error: string, code?: string }. Standardised shape; never leaks internal
error.message from a 500.| Code | Meaning |
|---|---|
| 200 | OK |
| 400 | Invalid request body / query |
| 401 | Missing or invalid session |
| 403 | Authenticated but not authorised (RBAC) |
| 404 | Resource not found |
| 429 | Rate-limit exceeded — Retry-After header set |
| 500 | Internal error (already logged + Sentry'd) |
High-risk routes use enforceRateLimit from @/lib/security/api-guards. On breach:
HTTP/1.1 429 Too Many Requests
Retry-After: 1800
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1748169600000
{ "error": "Rate limit exceeded", "retryAfterSec": 1800 }
A rate_limit_exceeded audit log entry is written on every breach.
Every workspace mutation calls auditMutation after the side-effect succeeds. The audit log
record (MongoDB audit_logs) has:
{
eventType: string; // e.g. "webinars.bulk-invite"
userId?: string; // Clerk userId
ip: string; // from x-forwarded-for
userAgent: string;
resource?: string; // e.g. "webinar:matters-2026-spring"
action?: string; // mirrors eventType for query convenience
result: "success" | "failure";
metadata?: Record<string, unknown>;
timestamp: Date;
}
Read via lib/security/audit.ts → getAuditLogs(options).
Pattern for a new workspace mutation route:
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { z } from "zod";
import { checkRole } from "@/lib/auth/rbac";
import { logger } from "@/lib/errors/logger";
import { auditMutation, enforceRateLimit } from "@/lib/security/api-guards";
const bodySchema = z.object({
// ...
});
export async function POST(req: NextRequest) {
// 1. RBAC
if (!(await checkRole("org:content:edit"))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// 2. Identity for rate limit + audit
const { userId } = await auth();
const identity = userId ?? "anonymous";
// 3. Rate limit
const limited = await enforceRateLimit(
{ identity, action: "content.do-thing", limit: 30, window: "hour" },
req
);
if (limited) return limited;
// 4. Validate input
const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", issues: parsed.error.issues },
{ status: 400 }
);
}
try {
// 5. Do the work
const result = await doWork(parsed.data);
// 6. Audit log
await auditMutation(
{
identity,
action: "content.do-thing",
resource: `content:${result.id}`,
result: "success",
metadata: { /* shape-relevant detail */ },
},
req
);
return NextResponse.json({ success: true, data: result });
} catch (error) {
logger.error("content.do-thing failed", error instanceof Error ? error : undefined);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
Five steps. Every new mutation handler should look like this.