mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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 });
|
||||
});
|
||||
Reference in new issue
Block a user