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

119 lines
3.5 KiB
TypeScript

import { kv } from "@/lib/kv";
/**
* Outbound webhook delivery, queued in Hanzo KV.
*
* This replaces QStash, which this fork had already stubbed to a no-op — every
* `publishJSON` logged a warning, returned `{ messageId: "noop" }` and DROPPED
* the event. Customers' webhooks have not fired since.
*
* A KV list is the queue: `rpush` to enqueue, `lpop` to take. Delivery is a
* plain POST — the signature the receiver checks is computed at enqueue time and
* carried with the item, so a retry re-sends the same bytes with the same
* signature rather than re-signing a payload that may have changed shape.
*
* Nothing here fires on a timer. `drain()` is called by /api/cron/webhooks, so
* an unscheduled queue accumulates durably (with a TTL) instead of vanishing —
* which is already the difference between this and the no-op it replaces.
*/
const QUEUE_KEY = "webhooks:pending";
const QUEUE_TTL_SECONDS = 7 * 24 * 60 * 60; // a week to notice and drain
const MAX_ATTEMPTS = 5;
export type WebhookDelivery = {
webhookId: string;
url: string;
/** The exact JSON string that was signed. Re-signing on retry would break it. */
body: string;
signature: string;
eventId: string;
event: string;
attempts: number;
queuedAt: number;
};
/** Enqueue one delivery. Never throws — a KV outage must not fail the caller's write. */
export async function enqueue(
delivery: Omit<WebhookDelivery, "attempts" | "queuedAt">,
): Promise<boolean> {
try {
const item: WebhookDelivery = { ...delivery, attempts: 0, queuedAt: Date.now() };
const pipeline = kv.pipeline();
pipeline.rpush(QUEUE_KEY, JSON.stringify(item));
pipeline.expire(QUEUE_KEY, QUEUE_TTL_SECONDS);
await pipeline.exec();
return true;
} catch (error) {
console.error("[webhooks] could not queue delivery", error);
return false;
}
}
/** How many deliveries are waiting. */
export async function pending(): Promise<number> {
try {
return await kv.llen(QUEUE_KEY);
} catch {
return 0;
}
}
/**
* Take up to `limit` deliveries and POST them.
*
* A failure goes BACK on the queue with its attempt count raised, until
* MAX_ATTEMPTS — so a customer endpoint that is briefly down does not lose the
* event, and one that is permanently gone does not spin forever.
*/
export async function drain(limit = 50): Promise<{
sent: number;
failed: number;
dropped: number;
}> {
let sent = 0;
let failed = 0;
let dropped = 0;
for (let i = 0; i < limit; i++) {
const raw = await kv.lpop(QUEUE_KEY);
if (!raw) break;
let item: WebhookDelivery;
try {
item = typeof raw === "string" ? JSON.parse(raw) : (raw as WebhookDelivery);
} catch {
dropped++; // unparseable: dropping it is the only way to make progress
continue;
}
try {
const res = await fetch(item.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hanzo-Dataroom-Signature": item.signature,
},
body: item.body,
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
sent++;
} catch (error) {
const attempts = item.attempts + 1;
if (attempts >= MAX_ATTEMPTS) {
dropped++;
console.error(
`[webhooks] giving up on ${item.webhookId} after ${attempts} attempts`,
error,
);
} else {
failed++;
await kv.rpush(QUEUE_KEY, JSON.stringify({ ...item, attempts }));
}
}
}
return { sent, failed, dropped };
}