From 65900cbac287efd2aa94c3b2b8300b506dd01846 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 2 Aug 2026 17:49:11 -0700 Subject: [PATCH] Add Hono webhook mount to ingress mountGranolaWebhook wires the existing verify/parse primitives into an actual Hono route. It lived in Scout's hub as hub/src/mounts/granola.ts; the host concretion (app) is a parameter, so it belongs with the rest of the ingress logic it wraps, not in a consumer's hub. --- bun.lock | 4 + package.json | 4 + src/ingress/index.ts | 7 ++ src/ingress/mount.test.ts | 227 ++++++++++++++++++++++++++++++++++++++ src/ingress/mount.ts | 169 ++++++++++++++++++++++++++++ src/ingress/webhook.ts | 2 +- 6 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 src/ingress/mount.test.ts create mode 100644 src/ingress/mount.ts diff --git a/bun.lock b/bun.lock index f741bcf..e55d12e 100644 --- a/bun.lock +++ b/bun.lock @@ -11,8 +11,12 @@ "devDependencies": { "@types/bun": "1.1.14", "@types/node": "22.10.5", + "hono": "^4.11.9", "typescript": "5.7.2", }, + "peerDependencies": { + "hono": "^4.0.0", + }, }, }, "packages": { diff --git a/package.json b/package.json index adf39b0..bd1c56f 100644 --- a/package.json +++ b/package.json @@ -61,9 +61,13 @@ "@intx/log": "0.2.2", "arktype": "^2.1.29" }, + "peerDependencies": { + "hono": "^4.0.0" + }, "devDependencies": { "@types/bun": "1.1.14", "@types/node": "22.10.5", + "hono": "^4.11.9", "typescript": "5.7.2" } } diff --git a/src/ingress/index.ts b/src/ingress/index.ts index 6c704cb..932276e 100644 --- a/src/ingress/index.ts +++ b/src/ingress/index.ts @@ -31,3 +31,10 @@ export { reconcileGranolaWebhookFolders, } from "./webhook-registration.js"; export type { EnsureGranolaWebhookOptions } from "./webhook-registration.js"; + +export { mountGranolaWebhook } from "./mount.js"; +export type { + OnGranolaEvent, + MountGranolaWebhookOptions, + MountedGranolaWebhook, +} from "./mount.js"; diff --git a/src/ingress/mount.test.ts b/src/ingress/mount.test.ts new file mode 100644 index 0000000..8b65ebc --- /dev/null +++ b/src/ingress/mount.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; + +import { signGranolaPayload } from "./webhook.js"; +import { mountGranolaWebhook } from "./mount.js"; + +const SECRET = "whsec_dGVzdHNlY3JldHZhbHVlMTIz"; + +function samplePayload(eventId: string): string { + return JSON.stringify({ + event_id: eventId, + event_type: "note.generated", + note_id: "note_1", + occurred_at: "2026-07-31T00:00:00Z", + }); +} + +function signedHeaders(eventId: string, rawBody: string): Record { + const webhookTimestamp = String(Math.floor(Date.now() / 1000)); + const signature = signGranolaPayload({ + secret: SECRET, + webhookId: eventId, + webhookTimestamp, + rawBody, + }); + return { + "content-type": "application/json", + "webhook-id": eventId, + "webhook-timestamp": webhookTimestamp, + "webhook-signature": signature, + }; +} + +describe("mountGranolaWebhook", () => { + test("does not mount without a secret", () => { + const app = new Hono(); + const result = mountGranolaWebhook(app, { secret: "", onEvent: async () => undefined }); + expect(result.mounted).toBe(false); + }); + + test("acks a validly signed request with 202 and invokes onEvent", async () => { + const app = new Hono(); + const received: string[] = []; + let resolveOnEvent: (() => void) | undefined; + const onEventStarted = new Promise((resolve) => { + resolveOnEvent = resolve; + }); + + mountGranolaWebhook(app, { + secret: SECRET, + onEvent: async (payload) => { + received.push(payload.event_id); + resolveOnEvent?.(); + }, + }); + + const rawBody = samplePayload("evt_ack"); + const res = await app.request("/api/granola/webhook", { + method: "POST", + headers: signedHeaders("evt_ack", rawBody), + body: rawBody, + }); + + expect(res.status).toBe(202); + await onEventStarted; + expect(received).toEqual(["evt_ack"]); + }); + + test("rejects an unsigned request with 401", async () => { + const app = new Hono(); + mountGranolaWebhook(app, { secret: SECRET, onEvent: async () => undefined }); + + const rawBody = samplePayload("evt_unsigned"); + const res = await app.request("/api/granola/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: rawBody, + }); + + expect(res.status).toBe(401); + }); + + test("rejects a tampered body with 401", async () => { + const app = new Hono(); + mountGranolaWebhook(app, { secret: SECRET, onEvent: async () => undefined }); + + const rawBody = samplePayload("evt_tamper"); + const headers = signedHeaders("evt_tamper", rawBody); + const res = await app.request("/api/granola/webhook", { + method: "POST", + headers, + body: samplePayload("evt_tamper").replace("note_1", "note_evil"), + }); + + expect(res.status).toBe(401); + }); + + test("duplicate event_id is a no-op the second time", async () => { + const app = new Hono(); + let callCount = 0; + mountGranolaWebhook(app, { + secret: SECRET, + onEvent: async () => { + callCount += 1; + }, + }); + + const rawBody = samplePayload("evt_dupe"); + const headers = signedHeaders("evt_dupe", rawBody); + + const first = await app.request("/api/granola/webhook", { + method: "POST", + headers, + body: rawBody, + }); + const second = await app.request("/api/granola/webhook", { + method: "POST", + headers, + body: rawBody, + }); + + expect(first.status).toBe(202); + expect(second.status).toBe(202); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(callCount).toBe(1); + }); + + test("responds before the onEvent handler resolves (fast ack)", async () => { + const app = new Hono(); + let handlerResolved = false; + mountGranolaWebhook(app, { + secret: SECRET, + onEvent: async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + handlerResolved = true; + }, + }); + + const rawBody = samplePayload("evt_fastack"); + const res = await app.request("/api/granola/webhook", { + method: "POST", + headers: signedHeaders("evt_fastack", rawBody), + body: rawBody, + }); + + expect(res.status).toBe(202); + expect(handlerResolved).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(handlerResolved).toBe(true); + }); + + test("does not mount and logs an error for a secret whose decoded key is unusably short", () => { + const app = new Hono(); + const result = mountGranolaWebhook(app, { + secret: "whsec_", + onEvent: async () => undefined, + }); + expect(result.mounted).toBe(false); + }); + + test("a redelivery after a failing onEvent is reprocessed (event_id is only remembered on success)", async () => { + const app = new Hono(); + let callCount = 0; + mountGranolaWebhook(app, { + secret: SECRET, + onEvent: async () => { + callCount += 1; + if (callCount === 1) throw new Error("transient failure"); + }, + }); + + const rawBody = samplePayload("evt_retry"); + const headers = signedHeaders("evt_retry", rawBody); + + const first = await app.request("/api/granola/webhook", { method: "POST", headers, body: rawBody }); + expect(first.status).toBe(202); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(callCount).toBe(1); + + const second = await app.request("/api/granola/webhook", { method: "POST", headers, body: rawBody }); + expect(second.status).toBe(202); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(callCount).toBe(2); + }); + + test("rejects a request whose Content-Length exceeds the 1 MiB cap with 413, without invoking onEvent", async () => { + const app = new Hono(); + let onEventCalled = false; + mountGranolaWebhook(app, { + secret: SECRET, + onEvent: async () => { + onEventCalled = true; + }, + }); + + const oversizedBody = "x".repeat(2 * 1024 * 1024); + const res = await app.request("/api/granola/webhook", { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": String(oversizedBody.length), + "webhook-id": "evt_big", + "webhook-timestamp": String(Math.floor(Date.now() / 1000)), + "webhook-signature": "v1,doesnotmatter", + }, + body: oversizedBody, + }); + + expect(res.status).toBe(413); + expect(onEventCalled).toBe(false); + }); + + test("rejects a request missing required headers before reading the body", async () => { + const app = new Hono(); + mountGranolaWebhook(app, { secret: SECRET, onEvent: async () => undefined }); + + const res = await app.request("/api/granola/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: samplePayload("evt_noheaders"), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("missing_headers"); + }); +}); diff --git a/src/ingress/mount.ts b/src/ingress/mount.ts new file mode 100644 index 0000000..720f47d --- /dev/null +++ b/src/ingress/mount.ts @@ -0,0 +1,169 @@ +/** + * Mount the Granola inbound webhook onto a host's Hono app. + * + * Signature verification (`verifyGranolaSignature`) is the only + * authentication — Granola is not a principal. Absent a secret is a valid + * configuration, not an error: the host runs fine without a Granola webhook + * and the route simply is not mounted. + * + * Granola gives the endpoint 15 seconds to respond and retries a delivery + * (same `event_id`) for up to 24 hours only when the response is NOT a 2xx. + * This route acks fast (202) before `onEvent` resolves — processing can take + * minutes — which means Granola will not redeliver an event we acked: a + * failure inside `onEvent` is logged, not retried by the vendor. The + * event-id dedupe is still remembered only on `onEvent` success, but that + * guards against Granola's at-least-once duplicate deliveries (which can + * race the ack), not against our own post-ack failures. This mount does not + * know what processing `onEvent` performs; callers inject it. + * + * The `app` is a plain constructor parameter — this file owns no host + * concretion. Headers, body-size limits, and HTTP status codes are the + * route's job; verification/parsing stays in `./webhook.ts`. + */ +import type { Hono } from "hono"; +import { getLogger } from "@intx/log"; + +import { + decodeSigningSecret, + parseGranolaPayload, + verifyGranolaSignature, + type GranolaWebhookPayload, +} from "./webhook.js"; + +const log = getLogger(["corbits", "granola", "mount"]); + +const GRANOLA_WEBHOOK_PATH = "/api/granola/webhook"; +const DEFAULT_DEDUPE_CAPACITY = 1000; +/** Bun's default request-body cap is 128MiB; an unauthenticated route needs a much tighter one. */ +const MAX_BODY_BYTES = 1 * 1024 * 1024; + +export type OnGranolaEvent = (payload: GranolaWebhookPayload) => Promise; + +export type MountGranolaWebhookOptions = { + secret: string; + onEvent: OnGranolaEvent; + /** Max remembered event ids before the oldest are evicted. Defaults to 1000. */ + dedupeCapacity?: number; +}; + +export type MountedGranolaWebhook = { mounted: boolean; path?: string }; + +/** + * Bounded FIFO set of seen event ids. A `Set` preserves insertion order, so + * eviction of the oldest entry on overflow is a plain iterator `.next()` — + * no separate ordering structure needed for a cap this small. + */ +function createEventIdDedupe(capacity: number) { + const seen = new Set(); + return { + hasSeen(eventId: string): boolean { + return seen.has(eventId); + }, + remember(eventId: string): void { + if (seen.has(eventId)) return; + if (seen.size >= capacity) { + const oldest = seen.values().next().value; + if (oldest !== undefined) seen.delete(oldest); + } + seen.add(eventId); + }, + }; +} + +export function mountGranolaWebhook( + app: Hono, + options: MountGranolaWebhookOptions, +): MountedGranolaWebhook { + const { secret, onEvent } = options; + if (!secret) { + log.info("Granola webhook not mounted — no secret provided"); + return { mounted: false }; + } + + const key = decodeSigningSecret(secret); + if (key instanceof Error) { + log.error( + "Granola webhook not mounted — secret is unusable: {message}", + { message: key.message }, + ); + return { mounted: false }; + } + + const dedupe = createEventIdDedupe(options.dedupeCapacity ?? DEFAULT_DEDUPE_CAPACITY); + + app.post(GRANOLA_WEBHOOK_PATH, async (c) => { + const headers = { + "webhook-id": c.req.header("webhook-id"), + "webhook-timestamp": c.req.header("webhook-timestamp"), + "webhook-signature": c.req.header("webhook-signature"), + }; + if (!headers["webhook-id"] || !headers["webhook-timestamp"] || !headers["webhook-signature"]) { + log.info("Granola webhook rejected: missing_headers"); + return c.json({ error: "missing_headers" }, 401); + } + + const contentLength = Number(c.req.header("content-length") ?? ""); + if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) { + log.info("Granola webhook rejected: body exceeds {maxBytes} bytes", { + maxBytes: MAX_BODY_BYTES, + }); + return c.json({ error: "payload_too_large" }, 413); + } + + // Best-effort cap: a chunked request with no content-length is only + // caught here, after the body has been buffered — Bun's own + // maxRequestBodySize (default 128 MiB) is the hard ceiling before that. + const rawBody = await c.req.text(); + if (Buffer.byteLength(rawBody, "utf8") > MAX_BODY_BYTES) { + log.info("Granola webhook rejected: body exceeds {maxBytes} bytes", { + maxBytes: MAX_BODY_BYTES, + }); + return c.json({ error: "payload_too_large" }, 413); + } + + const verification = verifyGranolaSignature({ secret, headers, rawBody }); + + if (!verification.ok) { + log.info("Granola webhook rejected: {reason}", { reason: verification.reason }); + return c.json({ error: verification.reason }, 401); + } + + const payload = parseGranolaPayload(rawBody); + if (payload instanceof Error) { + // 202 (not 400) here: both are terminal for Granola (neither triggers a + // retry), so the choice is purely about observability. A malformed body + // from a verified sender is unusual enough that we'd rather see it in + // logs than have Granola record it as a delivery failure. + log.error("Granola webhook payload rejected after valid signature: {message}", { + message: payload.message, + }); + return c.json({ ok: true }, 202); + } + + if (dedupe.hasSeen(payload.event_id)) { + log.info("Granola webhook duplicate event_id {eventId} — no-op", { + eventId: payload.event_id, + }); + return c.json({ ok: true }, 202); + } + + const response = c.json({ ok: true }, 202); + void onEvent(payload) + .then(() => { + dedupe.remember(payload.event_id); + }) + .catch((cause: unknown) => { + log.error( + "Granola onEvent handler failed for {eventId} after ack — the vendor will not redeliver an acked event; a later event for the same note (or a manual re-drop) is the recovery path: {error}", + { + eventId: payload.event_id, + error: cause instanceof Error ? cause.message : String(cause), + }, + ); + }); + return response; + }); + + log.info("Granola webhook mounted at {path}", { path: GRANOLA_WEBHOOK_PATH }); + return { mounted: true, path: GRANOLA_WEBHOOK_PATH }; +} diff --git a/src/ingress/webhook.ts b/src/ingress/webhook.ts index 8a1f888..e08d2c6 100644 --- a/src/ingress/webhook.ts +++ b/src/ingress/webhook.ts @@ -2,7 +2,7 @@ * Granola webhook verification and payload codec. * * Pure, unit-testable functions — no Hono, no network, no side effects. - * `hub/src/mounts/granola.ts` wires these into the actual route. + * `./mount.ts` wires these into the actual Hono route. * * Standard Webhooks (docs.granola.ai/webhooks): the signed content is * `{webhook-id}.{webhook-timestamp}.{raw body}`, HMAC-SHA256 keyed with the