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
116 lines
3.6 KiB
TypeScript
116 lines
3.6 KiB
TypeScript
/**
|
|
* POST /api/webhooks/coolify?projectId={projectId}
|
|
*
|
|
* Receives deployment status events from Coolify and updates the project's
|
|
* contextSnapshot.lastDeployment in Postgres.
|
|
*
|
|
* Closes S-06: signature verification was missing. Coolify (≥4.0.0-beta.300)
|
|
* signs every webhook with HMAC-SHA256 of the raw body using the per-app
|
|
* `webhook_secret`. We now reject unsigned / mismatched requests.
|
|
*
|
|
* Configure: in Coolify, set the webhook target to
|
|
* https://vibnai.com/api/webhooks/coolify?projectId={projectId}
|
|
* AND set the per-app webhook secret to match `COOLIFY_WEBHOOK_SECRET`.
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { query } from "@/lib/db-postgres";
|
|
import { verifyCoolifySignature } from "@/lib/server/coolify-webhook";
|
|
import { log } from "@/lib/server/logger";
|
|
|
|
const COOLIFY_WEBHOOK_SECRET = process.env.COOLIFY_WEBHOOK_SECRET ?? "";
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const projectId = request.nextUrl.searchParams.get("projectId");
|
|
if (!projectId) {
|
|
return NextResponse.json({ error: "Missing projectId" }, { status: 400 });
|
|
}
|
|
|
|
if (!COOLIFY_WEBHOOK_SECRET) {
|
|
log.error("coolify webhook: COOLIFY_WEBHOOK_SECRET not configured", {
|
|
route: "api.webhooks.coolify",
|
|
projectId,
|
|
});
|
|
return NextResponse.json(
|
|
{ error: "Webhook receiver not configured" },
|
|
{ status: 503 },
|
|
);
|
|
}
|
|
|
|
// We need the RAW body for HMAC verification — `.json()` would
|
|
// re-serialize and could change byte order. Read text first, then parse.
|
|
const rawBody = await request.text();
|
|
const signature = request.headers.get("x-coolify-signature-256");
|
|
|
|
const ok = await verifyCoolifySignature(
|
|
rawBody,
|
|
signature,
|
|
COOLIFY_WEBHOOK_SECRET,
|
|
);
|
|
if (!ok) {
|
|
log.warn("coolify webhook: invalid signature", {
|
|
route: "api.webhooks.coolify",
|
|
projectId,
|
|
hasHeader: !!signature,
|
|
});
|
|
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
|
|
}
|
|
|
|
let payload: Record<string, unknown>;
|
|
try {
|
|
payload = JSON.parse(rawBody) as Record<string, unknown>;
|
|
} catch {
|
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
}
|
|
|
|
const rows = await query<{ id: string; data: Record<string, unknown> }>(
|
|
`SELECT id, data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
|
[projectId],
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
|
}
|
|
|
|
const project = rows[0];
|
|
const existingSnapshot =
|
|
(project.data?.contextSnapshot as Record<string, unknown>) ?? {};
|
|
|
|
// Coolify sends status events like: queued, in_progress, finished, failed, cancelled
|
|
const data = (payload.data ?? {}) as Record<string, unknown>;
|
|
const status = (payload.status ?? data.status ?? "unknown") as string;
|
|
const applicationUuid = (payload.application_uuid ??
|
|
data.application_uuid) as string | undefined;
|
|
const deploymentUuid = (payload.deployment_uuid ?? data.deployment_uuid) as
|
|
| string
|
|
| undefined;
|
|
const url = (payload.fqdn ?? data.fqdn ?? null) as string | null;
|
|
|
|
const newSnapshot = {
|
|
...existingSnapshot,
|
|
lastDeployment: {
|
|
status,
|
|
applicationUuid,
|
|
deploymentUuid,
|
|
url,
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await query(
|
|
`UPDATE fs_projects
|
|
SET data = jsonb_set(data, '{contextSnapshot}', $1::jsonb)
|
|
WHERE id = $2`,
|
|
[JSON.stringify(newSnapshot), projectId],
|
|
);
|
|
|
|
log.info("coolify webhook received", {
|
|
route: "api.webhooks.coolify",
|
|
projectId,
|
|
status,
|
|
deploymentUuid,
|
|
});
|
|
return NextResponse.json({ ok: true, status, projectId });
|
|
}
|