Files
zeekayandHanzo Dev 42425d6bcd webhooks: deliver through Hanzo KV, and stop pretending a dropped event was sent
QStash was already gone — `lib/cron/index.ts` shipped a stub whose publishJSON
logged a warning, returned `{ messageId: "noop" }` and DISCARDED the event. Every
customer webhook has been silently dropped since. Its sibling stub, `receiver`,
returned `true` for any signature.

Deliveries now go to a KV list (lib/kv-queue/webhooks.ts): rpush to enqueue, lpop
to take, failures returned with a raised attempt count until MAX_ATTEMPTS so a
customer endpoint that is briefly down keeps its event and one that is gone does
not spin. The signature is computed at enqueue and carried with the item, so a
retry re-sends identical bytes rather than re-signing a payload whose shape may
have moved. Enqueue never throws: a KV outage must not fail the caller's write.
/api/cron/webhooks drains it.

The cron guard was worse than the stub. It read

    if (process.env.VERCEL === "1") { ...verify Upstash-Signature... }

and this app runs on Kubernetes, so the branch was ALWAYS false — every
/api/cron/* route was reachable by anyone with the path, and the verifier behind
it asserted nothing anyway. Replaced with a shared secret that FAILS CLOSED: no
CRON_SECRET, no cron. Nothing schedules these today, so a missing secret costs
nothing where the old behaviour cost everything. It is a secret, not an identity —
user-facing auth stays with Hanzo IAM.

Also drops the QStash env block, the dead Upstash locker vars, and the vendor
vocabulary in lib/kv.ts, where the shims describe a SHAPE (options object,
{ score, member }) rather than whoever shipped it.

Verified with npx tsc --noEmit: 100 errors before and after, 0 introduced,
compared set-wise by file:line:code.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 10:16:19 -07:00

60 lines
1.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import { verifyCron } from "@/lib/cron/verify";
import prisma from "@/lib/prisma";
import { recordWebhookEvent } from "@/lib/tinybird/publish";
import { getSearchParams } from "@/lib/utils/get-search-params";
import { WEBHOOK_TRIGGERS } from "@/lib/webhook/constants";
import {
webhookCallbackSchema,
webhookPayloadSchema,
} from "@/lib/zod/schemas/webhooks";
const searchParamsSchema = z.object({
webhookId: z.string(),
eventId: z.string(),
event: z.enum(WEBHOOK_TRIGGERS),
});
// POST /api/webhooks/callback – listen to webhooks status from QStash
export const POST = async (req: Request) => {
const rawBody = await req.text();
const auth = verifyCron(req);
if (!auth.ok) {
return new Response("Unauthorized", { status: auth.status });
}
const { url, status, body, sourceBody, sourceMessageId } =
webhookCallbackSchema.parse(JSON.parse(rawBody));
const { webhookId, eventId, event } = searchParamsSchema.parse(
getSearchParams(req.url),
);
const webhook = await prisma.webhook.findUnique({
where: { pId: webhookId },
});
if (!webhook) {
console.error("Webhook not found", { webhookId });
return new Response("Webhook not found");
}
const request = Buffer.from(sourceBody, "base64").toString("utf-8");
const response = Buffer.from(body, "base64").toString("utf-8");
const isFailed = status >= 400 || status === -1;
await recordWebhookEvent({
url,
event,
event_id: eventId,
http_status: status === -1 ? 503 : status,
webhook_id: webhookId || "",
request_body: request,
response_body: response,
message_id: sourceMessageId,
});
return new Response(`Webhook ${webhookId} processed`);
};