Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07, B-01..B-07, R-01..R-02, O-03. Security (28 deletions + 10 auth gates): - Delete 28 unauthenticated debug/cursor/firebase/test routes - Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth - Add HMAC-SHA256 signature verification to webhooks/coolify - Switch all admin secret comparisons to timingSafeStringEq Foundations (lib/server/*): - api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit - logger.ts: structured request-scoped logging with turnId - audit-log.ts: writeAuditLog helper + audit_log table - rate-limit.ts: Postgres sliding window rate limiter - coolify-webhook.ts: verifyCoolifySignature - timing-safe.ts: timingSafeStringEq Chat hardening (chat/route.ts): - MAX_TOOL_ROUNDS 15 → 8 (C-01) - Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02) - Add 6-consecutive-tool-call hard-break (C-02) - Mode: respond first, act second prompt block (C-03) - SSE heartbeat every 25s via setInterval (C-04) - Per-tool 45s timeout via Promise.race (C-05) - turnId per-turn UUID for log correlation (C-06) - Recovery fires when roundsSinceText >= 4 (C-07) - SSE plan event on plan_task_add/edit (B-05) Beta features: - invites table + GET/POST /api/invites (P4.8) - invites/[token] validate + redeem (P4.8) - fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1) - fs_project_secrets table + CRUD routes (P6.D2) - lib/integrations/brief-extract.ts (P3.7) Documentation: - app/api/ROUTES.md: full route map with auth + tenant
234 lines
8.1 KiB
TypeScript
234 lines
8.1 KiB
TypeScript
/**
|
|
* API route wrappers.
|
|
*
|
|
* Replaces the 8-line auth + ownership boilerplate that every route file
|
|
* was reimplementing (and getting subtly wrong in 18 of them — see the
|
|
* 2026-05-17 QA pass).
|
|
*
|
|
* Usage:
|
|
*
|
|
* // Plain session-required handler.
|
|
* export const GET = withAuth(async (req, ctx, { user }) => {
|
|
* return NextResponse.json({ email: user.email });
|
|
* });
|
|
*
|
|
* // Session + tenant-project ownership in one wrapper.
|
|
* export const POST = withTenantProject(async (req, ctx, { user, project }) => {
|
|
* // `project` is guaranteed to belong to `user`.
|
|
* }, { paramName: 'projectId' });
|
|
*
|
|
* // Workspace-scoped (session OR vibn_sk_ api key OK).
|
|
* export const POST = withWorkspace(async (req, ctx, { principal }) => {
|
|
* // `principal.workspace` is guaranteed to be tenant-checked.
|
|
* });
|
|
*
|
|
* // Admin secret (ops endpoint).
|
|
* export const POST = withAdminSecret(async (req, ctx) => { … }, {
|
|
* secretEnvVar: 'ADMIN_MIGRATE_SECRET',
|
|
* });
|
|
*/
|
|
|
|
import { NextResponse } from "next/server";
|
|
import type { Session } from "next-auth";
|
|
import { authSession } from "@/lib/auth/session-server";
|
|
import { queryOne } from "@/lib/db-postgres";
|
|
import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth";
|
|
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
|
import { rateLimit, type RateLimitOpts } from "@/lib/server/rate-limit";
|
|
|
|
type WithAuthCtx = { user: NonNullable<Session["user"]> };
|
|
type WithTenantProjectCtx = WithAuthCtx & { project: ProjectRow };
|
|
type WithAdminCtx = { secret: string };
|
|
|
|
export interface ProjectRow {
|
|
id: string;
|
|
data: Record<string, unknown>;
|
|
slug?: string;
|
|
}
|
|
|
|
type RouteHandler<TCtx = unknown, TParams = unknown> = (
|
|
req: Request,
|
|
ctx: { params: Promise<TParams> },
|
|
extra: TCtx,
|
|
) => Promise<Response> | Response;
|
|
|
|
// ─── withAuth ─────────────────────────────────────────────────────────────
|
|
|
|
export function withAuth<TParams = unknown>(
|
|
handler: RouteHandler<WithAuthCtx, TParams>,
|
|
) {
|
|
return async (req: Request, ctx: { params: Promise<TParams> }) => {
|
|
const session = await authSession();
|
|
if (!session?.user?.email) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
return handler(req, ctx, { user: session.user });
|
|
};
|
|
}
|
|
|
|
// ─── withTenantProject ────────────────────────────────────────────────────
|
|
|
|
export interface WithTenantProjectOpts {
|
|
/**
|
|
* Where to find the project id. Default `'projectId'` in `params`.
|
|
* - 'params:projectId' → ctx.params.projectId (default for `[projectId]` routes)
|
|
* - 'search:projectId' → searchParams.projectId
|
|
* - 'body:projectId' → body.projectId (consumes body via clone+json)
|
|
*/
|
|
source?: "params" | "search" | "body";
|
|
paramName?: string;
|
|
}
|
|
|
|
export function withTenantProject<TParams = Record<string, string>>(
|
|
handler: RouteHandler<WithTenantProjectCtx, TParams>,
|
|
opts: WithTenantProjectOpts = {},
|
|
) {
|
|
const source = opts.source ?? "params";
|
|
const name = opts.paramName ?? "projectId";
|
|
|
|
return async (req: Request, ctx: { params: Promise<TParams> }) => {
|
|
const session = await authSession();
|
|
if (!session?.user?.email) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
let projectId: string | undefined;
|
|
if (source === "params") {
|
|
const p = (await ctx.params) as Record<string, string>;
|
|
projectId = p?.[name];
|
|
} else if (source === "search") {
|
|
projectId = new URL(req.url).searchParams.get(name) ?? undefined;
|
|
} else if (source === "body") {
|
|
try {
|
|
const body = await req.clone().json();
|
|
projectId = body?.[name];
|
|
} catch {
|
|
// fallthrough; caller will get 400 below
|
|
}
|
|
}
|
|
|
|
if (!projectId) {
|
|
return NextResponse.json(
|
|
{ error: `${name} is required` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Ownership check: project must belong to the authenticated user.
|
|
const row = await queryOne<ProjectRow>(
|
|
`SELECT p.id::text AS id, p.data
|
|
FROM fs_projects p
|
|
JOIN fs_users u ON u.id = p.user_id
|
|
WHERE p.id = $1::text AND u.data->>'email' = $2::text
|
|
LIMIT 1`,
|
|
[projectId, session.user.email],
|
|
);
|
|
|
|
if (!row) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
|
|
return handler(req, ctx, { user: session.user, project: row });
|
|
};
|
|
}
|
|
|
|
// ─── withWorkspace (re-export of existing helper with consistent shape) ──
|
|
|
|
type WorkspacePrincipal = Exclude<
|
|
Awaited<ReturnType<typeof requireWorkspacePrincipal>>,
|
|
NextResponse
|
|
>;
|
|
|
|
export function withWorkspace<TParams = Record<string, string>>(
|
|
handler: RouteHandler<{ principal: WorkspacePrincipal }, TParams>,
|
|
opts: { paramName?: string } = {},
|
|
) {
|
|
const name = opts.paramName ?? "slug";
|
|
return async (req: Request, ctx: { params: Promise<TParams> }) => {
|
|
const params = (await ctx.params) as Record<string, string> | undefined;
|
|
const targetSlug = params?.[name];
|
|
const principal = await requireWorkspacePrincipal(req, { targetSlug });
|
|
if (principal instanceof NextResponse) return principal;
|
|
return handler(req, ctx, { principal });
|
|
};
|
|
}
|
|
|
|
// ─── withAdminSecret ──────────────────────────────────────────────────────
|
|
|
|
export interface WithAdminSecretOpts {
|
|
/** env var that holds the expected secret. */
|
|
secretEnvVar: string;
|
|
/** Header to read. Default `authorization` (expects `Bearer <secret>`). */
|
|
header?: string;
|
|
/** Alternate header that may also carry the secret (e.g. `x-admin-secret`). */
|
|
altHeader?: string;
|
|
}
|
|
|
|
export function withAdminSecret<TParams = unknown>(
|
|
handler: RouteHandler<WithAdminCtx, TParams>,
|
|
opts: WithAdminSecretOpts,
|
|
) {
|
|
return async (req: Request, ctx: { params: Promise<TParams> }) => {
|
|
const expected = process.env[opts.secretEnvVar]?.trim() ?? "";
|
|
if (!expected) {
|
|
return NextResponse.json(
|
|
{ error: `${opts.secretEnvVar} not configured — endpoint disabled` },
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
const header = (opts.header ?? "authorization").toLowerCase();
|
|
const raw = req.headers.get(header) ?? "";
|
|
const bearer = raw.toLowerCase().startsWith("bearer ")
|
|
? raw.slice(7).trim()
|
|
: "";
|
|
const alt = opts.altHeader
|
|
? (req.headers.get(opts.altHeader) ?? "").trim()
|
|
: "";
|
|
const incoming = bearer || alt;
|
|
if (!incoming || !timingSafeStringEq(expected, incoming)) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
return handler(req, ctx, { secret: expected });
|
|
};
|
|
}
|
|
|
|
// ─── withRateLimit ────────────────────────────────────────────────────────
|
|
|
|
export interface WithRateLimitOpts extends Omit<RateLimitOpts, "key"> {
|
|
/**
|
|
* How to derive the per-call key. Receives the bound auth context if any.
|
|
* Default: client IP.
|
|
*/
|
|
keyFn?: (req: Request, extra: unknown) => string | Promise<string>;
|
|
}
|
|
|
|
/** Wrap any other wrapper's handler. Composes neatly with withAuth/withWorkspace. */
|
|
export function withRateLimit<THandler extends (...args: any[]) => any>(
|
|
handler: THandler,
|
|
opts: WithRateLimitOpts,
|
|
): THandler {
|
|
return (async (req: Request, ...rest: unknown[]) => {
|
|
const extra = rest[1] ?? {};
|
|
const key =
|
|
(opts.keyFn ? await opts.keyFn(req, extra) : null) ??
|
|
`ip:${req.headers.get("x-forwarded-for") ?? "unknown"}`;
|
|
const rl = await rateLimit({
|
|
key,
|
|
limit: opts.limit,
|
|
windowMs: opts.windowMs,
|
|
});
|
|
if (!rl.ok) {
|
|
return NextResponse.json(
|
|
{ error: "Rate limit exceeded", retryAfterMs: rl.retryAfterMs },
|
|
{
|
|
status: 429,
|
|
headers: rl.retryAfterMs
|
|
? { "Retry-After": String(Math.ceil(rl.retryAfterMs / 1000)) }
|
|
: undefined,
|
|
},
|
|
);
|
|
}
|
|
return handler(req, ...rest);
|
|
}) as THandler;
|
|
}
|