- Forge webhook POST /api/webhooks/forge HMAC sha256 verify, marker echodo-convoy → pr_review→merged as automation - Completion artifacts table 0017, deterministic summary template, gallery /artifacts - Ops copilot tRPC copilot + context tools (fleet, convoy, backlog) + panel + Draft Task → backlog.createTask - Doc-writer SOPs: webhook rotation, copilot prompt, published flow examples - Allow pr_review→merged as automation for webhook Convoy: .convoys/owner-cockpit-v02 Co-authored-by: orchestrator
143 lines
4.1 KiB
TypeScript
143 lines
4.1 KiB
TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
|
|
import { db } from "@tasks/database";
|
|
import { objects } from "@tasks/database/schema";
|
|
import { transitionConvoy } from "@/server/lib/convoy/convoy-service";
|
|
import { rateLimit } from "@/server/lib/rate-limit";
|
|
|
|
export const runtime = "nodejs";
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const CONVOY_MARKER_RE = /echodo-convoy:\s*([0-9a-f-]{36})/i;
|
|
const RATE_LIMIT = { limit: 60, windowMs: 60_000 } as const;
|
|
|
|
function getClientIp(req: Request): string {
|
|
const fwd = req.headers.get("x-forwarded-for");
|
|
if (fwd) {
|
|
const first = fwd.split(",")[0]?.trim();
|
|
if (first) return first;
|
|
}
|
|
const realIp = req.headers.get("x-real-ip");
|
|
if (realIp?.trim()) return realIp.trim();
|
|
return "unknown";
|
|
}
|
|
|
|
function verifyHmacSignature(rawBody: string, signatureHeader: string | null, secret: string): boolean {
|
|
if (!signatureHeader) return false;
|
|
let sig = signatureHeader.trim();
|
|
if (sig.startsWith("sha256=")) sig = sig.slice(7);
|
|
const computed = createHmac("sha256", secret).update(Buffer.from(rawBody, "utf8")).digest("hex");
|
|
const a = Buffer.from(computed, "utf8");
|
|
const b = Buffer.from(sig, "utf8");
|
|
if (a.length !== b.length) {
|
|
try {
|
|
timingSafeEqual(a, a);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return false;
|
|
}
|
|
return timingSafeEqual(a, b);
|
|
}
|
|
|
|
function json(status: number, body: unknown): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
export async function POST(req: Request): Promise<Response> {
|
|
const ip = getClientIp(req);
|
|
const rl = rateLimit({
|
|
key: `forge-webhook:${ip}`,
|
|
limit: RATE_LIMIT.limit,
|
|
windowMs: RATE_LIMIT.windowMs,
|
|
});
|
|
if (!rl.allowed) {
|
|
return json(429, { ok: false, error: "rate_limited", retryAfterMs: rl.retryAfterMs });
|
|
}
|
|
|
|
const secret = process.env.FORGE_WEBHOOK_SECRET?.trim();
|
|
if (!secret) {
|
|
return json(500, { ok: false, error: "webhook_not_configured" });
|
|
}
|
|
|
|
let rawBody: string;
|
|
try {
|
|
rawBody = await req.text();
|
|
} catch {
|
|
return json(400, { ok: false, error: "invalid_body" });
|
|
}
|
|
|
|
const hubSig = req.headers.get("x-hub-signature-256") ?? req.headers.get("x-hub-signature");
|
|
const forgejoSig = req.headers.get("x-forgejo-signature");
|
|
const providedSig = hubSig ?? forgejoSig;
|
|
|
|
if (!providedSig) {
|
|
return json(401, { ok: false, error: "missing_signature" });
|
|
}
|
|
|
|
if (!verifyHmacSignature(rawBody, providedSig, secret)) {
|
|
return json(401, { ok: false, error: "invalid_signature" });
|
|
}
|
|
|
|
let payload: Record<string, unknown>;
|
|
try {
|
|
payload = rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {};
|
|
} catch {
|
|
return json(200, { ok: true });
|
|
}
|
|
|
|
const action = typeof payload.action === "string" ? payload.action : null;
|
|
const prRaw = payload.pull_request as Record<string, unknown> | null | undefined;
|
|
|
|
if (action !== "closed" || !prRaw || typeof prRaw !== "object") {
|
|
return json(200, { ok: true });
|
|
}
|
|
|
|
const merged = prRaw.merged === true;
|
|
if (!merged) {
|
|
return json(200, { ok: true });
|
|
}
|
|
|
|
const bodyText = typeof prRaw.body === "string" ? prRaw.body : "";
|
|
const match = CONVOY_MARKER_RE.exec(bodyText);
|
|
if (!match?.[1]) {
|
|
return json(200, { ok: true });
|
|
}
|
|
|
|
const convoyId = match[1].toLowerCase();
|
|
const prNumber = typeof prRaw.number === "number" ? prRaw.number : null;
|
|
|
|
const [convoyRow] = await db
|
|
.select({ id: objects.id, workspaceId: objects.workspaceId, status: objects.status, type: objects.type })
|
|
.from(objects)
|
|
.where(eq(objects.id, convoyId))
|
|
.limit(1);
|
|
|
|
if (!convoyRow || convoyRow.type !== "project") {
|
|
return json(200, { ok: true });
|
|
}
|
|
|
|
if (convoyRow.status !== "pr_review") {
|
|
return json(200, { ok: true });
|
|
}
|
|
|
|
const reason = prNumber !== null ? `pr#${prNumber} merged` : "pr merged";
|
|
|
|
try {
|
|
await transitionConvoy(db, {
|
|
workspaceId: convoyRow.workspaceId,
|
|
convoyId: convoyRow.id,
|
|
toStatus: "merged",
|
|
actor: "automation",
|
|
reason,
|
|
});
|
|
} catch {
|
|
return json(200, { ok: true });
|
|
}
|
|
|
|
return json(200, { ok: true });
|
|
}
|