feat(deploy): admin notification + audit log on deploy success/failure

New endpoint `POST /api/internal/deploy-webhook` closes the loop on the
auto-deploy work started in the previous commit. Coolify (configured via
Settings → Notifications → Webhook with the `X-Deploy-Webhook-Secret`
custom header) calls this URL after every deploy attempt; we:

  * Verify the request via `DEPLOY_WEBHOOK_SECRET` env var with a
    timing-safe compare (same pattern as the Withings webhook).
  * Rate-limit by client IP (60/min — comfortably above Coolify's
    typical 1-2 callbacks per deploy, well below a brute-force probe).
  * Normalise Coolify's free-form payload into a small struct
    (`outcome`, `applicationName`, `error`, etc.) — preserving the
    full JSON in the audit row so future Coolify schema drift is
    forward-compatible without code change.
  * Write an audit-log row with one of `system.deploy.{success,failure,unknown}`
    — `unknown` covers in-progress states like "queued" so we observe
    them without paging.
  * On `failure`, fan a `SYSTEM_ALERT` through the existing
    `dispatchNotification()` to every admin user (silent no-op if no
    Telegram channel configured — matches `maybeAlertAdmins()` pattern
    in `src/lib/integrations/status.ts`).

Also exports a `GET` handler so Coolify's "Test" button on the
notification config page returns 200 with a valid secret.

Tests: 12 unit cases covering auth (header missing / wrong / env var
unset), happy path (success skips alert), failure path (audit + 2
admin alerts), unknown-status path (no alert), no-admin warning,
rate-limit short-circuit, malformed status type, and forward-compat
preservation of unknown payload fields. 952 / 952 unit pass total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 21:20:56 +02:00
1 parent e57fc0a8da
commit ad350fe68e
2 files changed
+531

No files matched your search

@@ -0,0 +1,309 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
// --- Module-boundary mocks must come before importing the route. ---
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/notifications/dispatcher", () => ({
dispatchNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => ({
setAuth: vi.fn(),
addWarning: vi.fn(),
})),
}));
import { POST, GET } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { checkRateLimit } from "@/lib/rate-limit";
import { getEvent } from "@/lib/logging/context";
const ORIGINAL_SECRET = process.env.DEPLOY_WEBHOOK_SECRET;
beforeEach(() => {
vi.resetAllMocks();
process.env.DEPLOY_WEBHOOK_SECRET = "test-secret";
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(getEvent).mockReturnValue({
setAuth: vi.fn(),
addWarning: vi.fn(),
} as never);
vi.mocked(prisma.user.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
if (ORIGINAL_SECRET === undefined) delete process.env.DEPLOY_WEBHOOK_SECRET;
else process.env.DEPLOY_WEBHOOK_SECRET = ORIGINAL_SECRET;
});
function jsonRequest(
body: unknown,
headers: Record<string, string> = {},
): NextRequest {
return new NextRequest("http://localhost/api/internal/deploy-webhook", {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify(body),
});
}
describe("POST /api/internal/deploy-webhook", () => {
it("returns 401 with no secret header", async () => {
const res = await POST(jsonRequest({ status: "success" }));
expect(res.status).toBe(401);
expect(auditLog).not.toHaveBeenCalled();
expect(dispatchNotification).not.toHaveBeenCalled();
});
it("returns 401 with a wrong secret", async () => {
const res = await POST(
jsonRequest(
{ status: "success" },
{ "x-deploy-webhook-secret": "WRONG" },
),
);
expect(res.status).toBe(401);
expect(auditLog).not.toHaveBeenCalled();
});
it("returns 401 when DEPLOY_WEBHOOK_SECRET env var is unset, regardless of header", async () => {
delete process.env.DEPLOY_WEBHOOK_SECRET;
const addWarning = vi.fn();
vi.mocked(getEvent).mockReturnValue({
setAuth: vi.fn(),
addWarning,
} as never);
const res = await POST(
jsonRequest(
{ status: "success" },
{ "x-deploy-webhook-secret": "anything" },
),
);
expect(res.status).toBe(401);
expect(addWarning).toHaveBeenCalledWith(
"DEPLOY_WEBHOOK_SECRET not configured",
);
});
it("happy path: success payload writes system.deploy.success audit row, no admin alert", async () => {
const res = await POST(
jsonRequest(
{
status: "success",
application_name: "HealthLog",
application_uuid: "pg8wggwogo8c4gc4ks0kk4ss",
deployment_uuid: "dep-123",
},
{ "x-deploy-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string; outcome: string };
expect(body).toEqual({ status: "ok", outcome: "success" });
expect(auditLog).toHaveBeenCalledTimes(1);
expect(auditLog).toHaveBeenCalledWith(
"system.deploy.success",
expect.objectContaining({
details: expect.objectContaining({
applicationName: "HealthLog",
applicationUuid: "pg8wggwogo8c4gc4ks0kk4ss",
deploymentUuid: "dep-123",
error: null,
raw: expect.objectContaining({ status: "success" }),
}),
}),
);
expect(dispatchNotification).not.toHaveBeenCalled();
});
it("failure payload writes system.deploy.failure audit row AND fans a SYSTEM_ALERT to every admin", async () => {
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([
{ id: "admin-1" },
{ id: "admin-2" },
] as never);
const res = await POST(
jsonRequest(
{
status: "failed",
application_name: "HealthLog",
application_uuid: "pg8wggwogo8c4gc4ks0kk4ss",
deployment_uuid: "dep-456",
error: "container exited 137",
},
{ "x-deploy-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { outcome: string };
expect(body.outcome).toBe("failure");
expect(auditLog).toHaveBeenCalledWith(
"system.deploy.failure",
expect.objectContaining({
details: expect.objectContaining({
error: "container exited 137",
}),
}),
);
expect(dispatchNotification).toHaveBeenCalledTimes(2);
expect(dispatchNotification).toHaveBeenCalledWith(
expect.objectContaining({
eventType: "SYSTEM_ALERT",
userId: "admin-1",
title: expect.stringContaining("Deploy failed"),
message: expect.stringContaining("container exited 137"),
metadata: expect.objectContaining({
source: "deploy-webhook",
deploymentUuid: "dep-456",
}),
}),
);
expect(dispatchNotification).toHaveBeenCalledWith(
expect.objectContaining({ userId: "admin-2" }),
);
});
it("treats unknown statuses (e.g. 'queued') as system.deploy.unknown without alerting", async () => {
const res = await POST(
jsonRequest(
{ status: "queued", application_name: "HealthLog" },
{ "x-deploy-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { outcome: string };
expect(body.outcome).toBe("unknown");
expect(auditLog).toHaveBeenCalledWith(
"system.deploy.unknown",
expect.any(Object),
);
expect(dispatchNotification).not.toHaveBeenCalled();
});
it("emits a Wide Event warning when a deploy fails but no admin user exists", async () => {
const addWarning = vi.fn();
vi.mocked(getEvent).mockReturnValue({
setAuth: vi.fn(),
addWarning,
} as never);
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([] as never);
await POST(
jsonRequest(
{ status: "failed", application_name: "HealthLog" },
{ "x-deploy-webhook-secret": "test-secret" },
),
);
expect(addWarning).toHaveBeenCalledWith(
expect.stringMatching(/no admin user/i),
);
expect(dispatchNotification).not.toHaveBeenCalled();
});
it("returns 429 and skips audit + alert when rate-limited", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const res = await POST(
jsonRequest(
{ status: "success" },
{ "x-deploy-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(429);
expect(auditLog).not.toHaveBeenCalled();
expect(dispatchNotification).not.toHaveBeenCalled();
});
it("tolerates malformed (non-string) status fields by treating them as unknown", async () => {
const res = await POST(
jsonRequest(
{ status: 42 },
{ "x-deploy-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { outcome: string };
expect(body.outcome).toBe("unknown");
expect(dispatchNotification).not.toHaveBeenCalled();
});
it("preserves the full Coolify payload in the audit-log raw field for forward compat", async () => {
const fullPayload = {
status: "success",
application_name: "HealthLog",
application_uuid: "pg8wggwogo8c4gc4ks0kk4ss",
deployment_uuid: "dep-789",
future_field: { commit_sha: "abc123", environment: "production" },
};
await POST(
jsonRequest(fullPayload, { "x-deploy-webhook-secret": "test-secret" }),
);
expect(auditLog).toHaveBeenCalledWith(
"system.deploy.success",
expect.objectContaining({
details: expect.objectContaining({
raw: expect.objectContaining({
future_field: { commit_sha: "abc123", environment: "production" },
}),
}),
}),
);
});
});
describe("GET /api/internal/deploy-webhook (reachability check)", () => {
it("returns 200 with valid secret header", async () => {
const req = new NextRequest("http://localhost/api/internal/deploy-webhook", {
method: "GET",
headers: { "x-deploy-webhook-secret": "test-secret" },
});
const res = await GET(req);
expect(res.status).toBe(200);
});
it("returns 401 without a secret", async () => {
const req = new NextRequest("http://localhost/api/internal/deploy-webhook", {
method: "GET",
});
const res = await GET(req);
expect(res.status).toBe(401);
});
});
@@ -0,0 +1,222 @@
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getClientIp, safeJson } from "@/lib/api-response";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { prisma } from "@/lib/db";
/**
* Deploy-status webhook (phase C2 / v1.4.15).
*
* Coolify (Settings → Notifications → Webhook) calls this endpoint after
* every deploy attempt. We verify the request via a shared secret in the
* `X-Deploy-Webhook-Secret` header (timing-safe compare against
* `process.env.DEPLOY_WEBHOOK_SECRET`), audit-log the event, and on
* failure page the admin via Telegram if the channel is configured.
*
* Why a *separate* webhook for status when Coolify already has a deploy
* trigger URL: the trigger is one-shot ("queue a deploy"); the status
* comes back asynchronously when the container is healthy / fails to
* start. Without this surface we lose visibility once the deploy is
* fired — exactly the gap that produced v1.4.6..v1.4.14's manual
* verification recipe (Marc had to SSH and check digests).
*
* Payload shape: Coolify sends a free-form JSON object whose exact
* fields drift between beta releases. We treat it as `Record<string,
* unknown>` and only extract a small set of well-known keys
* (`status`, `application_name`, `application_uuid`, `deployment_uuid`,
* `error`) defensively, falling back to "unknown" when missing. The
* payload itself is stored in the audit-log `details` field so a
* future Coolify upgrade that adds richer metadata becomes
* automatically available without code change.
*/
interface CoolifyDeployPayload {
status?: unknown;
application_name?: unknown;
application_uuid?: unknown;
deployment_uuid?: unknown;
error?: unknown;
[key: string]: unknown;
}
interface NormalizedEvent {
outcome: "success" | "failure" | "unknown";
applicationName: string;
applicationUuid: string | null;
deploymentUuid: string | null;
error: string | null;
raw: CoolifyDeployPayload;
}
function asStringOr<T extends string | null>(value: unknown, fallback: T): string | T {
return typeof value === "string" && value.length > 0 ? value : fallback;
}
function classifyOutcome(status: unknown): NormalizedEvent["outcome"] {
if (typeof status !== "string") return "unknown";
const s = status.toLowerCase();
// Coolify uses "success" / "finished" for happy path and
// "failed" / "error" / "stopped" for unhappy. Anything else is
// logged but doesn't trigger a Telegram page (avoids alert fatigue
// on intermediate states like "queued" or "in_progress").
if (s === "success" || s === "finished" || s === "succeeded") return "success";
if (s === "failed" || s === "error" || s === "stopped" || s === "failure") {
return "failure";
}
return "unknown";
}
function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent {
return {
outcome: classifyOutcome(payload.status),
applicationName: asStringOr(payload.application_name, "unknown"),
applicationUuid: asStringOr(payload.application_uuid, null),
deploymentUuid: asStringOr(payload.deployment_uuid, null),
error: asStringOr(payload.error, null),
raw: payload,
};
}
function hasValidSecret(request: NextRequest): boolean {
const expected = process.env.DEPLOY_WEBHOOK_SECRET;
if (!expected) {
getEvent()?.addWarning("DEPLOY_WEBHOOK_SECRET not configured");
return false;
}
const received = request.headers.get("x-deploy-webhook-secret");
if (!received) return false;
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Reuse the `maybeAlertAdmins()` pattern from
* `src/lib/integrations/status.ts` — fan out a SYSTEM_ALERT through the
* existing dispatcher to every user whose role is ADMIN. The dispatcher
* silently no-ops when no Telegram channel is configured, so this is
* safe even on a first-deploy where Marc hasn't wired the bot yet.
*/
async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
const admins = await prisma.user.findMany({
where: { role: "ADMIN" },
select: { id: true },
});
if (admins.length === 0) {
getEvent()?.addWarning(
"No admin user configured to alert about deploy failure",
);
return;
}
const title = `Deploy failed: ${event.applicationName}`;
const message = [
`The Coolify deploy for ${event.applicationName} reported a failure.`,
event.error ? `Error: ${event.error}` : null,
event.deploymentUuid ? `Deployment: ${event.deploymentUuid}` : null,
"Logs: https://apps-01.bombeck.io",
]
.filter((line): line is string => line !== null)
.join("\n");
for (const admin of admins) {
await dispatchNotification({
eventType: "SYSTEM_ALERT",
userId: admin.id,
title,
message,
metadata: {
source: "deploy-webhook",
applicationName: event.applicationName,
applicationUuid: event.applicationUuid,
deploymentUuid: event.deploymentUuid,
},
});
}
}
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "deploy.webhook" } });
// Rate-limit by client IP. 60 requests / minute is comfortably above
// Coolify's per-event burst (typically 1-2 per deploy) but well below
// a hostile actor flooding to fish for a working secret.
const ip = getClientIp(request);
const rl = await checkRateLimit(`deploy-webhook:${ip ?? "unknown"}`, 60, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ status: "rate_limited" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
if (!hasValidSecret(request)) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
const { data: payload, error: jsonError } =
await safeJson<CoolifyDeployPayload>(request);
if (jsonError) return jsonError;
const event = normalizePayload(payload);
annotate({
meta: {
deploy_outcome: event.outcome,
application_name: event.applicationName,
application_uuid: event.applicationUuid,
deployment_uuid: event.deploymentUuid,
},
});
// One audit-log row per webhook call. Action namespace mirrors the
// existing `system.*` lane (`system.error`, `system.cleanup.*`) for
// ops-style events that aren't tied to a single user.
const action =
event.outcome === "success"
? "system.deploy.success"
: event.outcome === "failure"
? "system.deploy.failure"
: "system.deploy.unknown";
await auditLog(action, {
ipAddress: ip ?? null,
details: {
applicationName: event.applicationName,
applicationUuid: event.applicationUuid,
deploymentUuid: event.deploymentUuid,
error: event.error,
// Preserve the full Coolify payload so we can adapt to schema
// drift without code change.
raw: event.raw,
},
});
if (event.outcome === "failure") {
await notifyAdminsOfFailure(event);
}
return NextResponse.json({ status: "ok", outcome: event.outcome }, { status: 200 });
});
/**
* Coolify (and most webhook UIs) ping `GET` for a reachability check
* before saving the configuration. We respond 200 if the secret is
* valid, 401 otherwise — same shape as the Withings webhook.
*/
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "deploy.webhook.verify" } });
if (!hasValidSecret(request)) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
return NextResponse.json({ status: "ok" }, { status: 200 });
});