-
Notifications
You must be signed in to change notification settings - Fork 5
feat(webhook): Vercel receiver + MongoDB queue (changeStream consumer) #205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
07eec2a
f905926
f502258
a0eab6c
79bc584
5b711e4
ea73861
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import { enqueueWebhook, ensureWebhookQueueIndexes } from "@/bot/webhook-queue"; | ||
| import { db } from "@/src/db"; | ||
| import { createHmac, timingSafeEqual } from "crypto"; | ||
| import { type NextRequest, NextResponse } from "next/server"; | ||
|
|
||
| /** | ||
| * Notion webhook receiver, deployed to Vercel. | ||
| * | ||
| * Notion's webhook setup goes through two phases: | ||
| * | ||
| * 1. Initial verification: Notion POSTs a JSON body | ||
| * `{ "verification_token": "..." }` to the configured URL once. The | ||
| * integration setup screen expects you to paste that token back. | ||
| * We log the token and also stash it in MongoDB | ||
| * (`webhook_notion_verification`) so the operator can retrieve it | ||
| * out-of-band without scrubbing logs. | ||
| * | ||
| * 2. Steady-state: signed events. The signature header is | ||
| * `X-Notion-Signature: sha256=<hex>` over the raw request body, keyed | ||
| * by the `verification_token` issued in phase 1. Set the same token | ||
| * as `NOTION_WEBHOOK_VERIFICATION_TOKEN` in this Vercel project so | ||
| * we can validate. | ||
| * | ||
| * @see https://developers.notion.com/reference/webhooks | ||
| */ | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| export const runtime = "nodejs"; | ||
|
|
||
| function verifyNotionSignature(rawBody: string, signature: string, token: string): boolean { | ||
| const hmac = createHmac("sha256", token).update(rawBody).digest("hex"); | ||
| const expected = Buffer.from(`sha256=${hmac}`); | ||
| const received = Buffer.from(signature); | ||
| if (expected.length !== received.length) return false; | ||
| return timingSafeEqual(expected, received); | ||
| } | ||
|
|
||
| export async function POST(request: NextRequest) { | ||
| const rawBody = await request.text(); | ||
|
|
||
| let payload: { | ||
| verification_token?: string; | ||
| type?: string; | ||
| id?: string; | ||
| workspace_id?: string; | ||
| [k: string]: unknown; | ||
| }; | ||
| try { | ||
| payload = JSON.parse(rawBody); | ||
| } catch { | ||
| return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); | ||
| } | ||
|
|
||
| // Phase 1: verification handshake. Capture the token so we can register | ||
| // it in env without copying from logs. | ||
| if (typeof payload.verification_token === "string" && !payload.type) { | ||
| try { | ||
| await db.collection("webhook_notion_verification").insertOne({ | ||
| verification_token: payload.verification_token, | ||
| receivedAt: new Date(), | ||
| userAgent: request.headers.get("user-agent"), | ||
| }); | ||
| } catch (err) { | ||
| console.warn("notion verification token persist failed", err); | ||
| } | ||
| console.log( | ||
| `[notion-webhook] verification handshake received; token starts with ${payload.verification_token.slice(0, 6)}…`, | ||
| ); | ||
| return new NextResponse("", { status: 200 }); | ||
| } | ||
|
|
||
| // Phase 2: signed events. | ||
| const token = process.env.NOTION_WEBHOOK_VERIFICATION_TOKEN; | ||
| if (!token) { | ||
| console.error( | ||
| "[notion-webhook] NOTION_WEBHOOK_VERIFICATION_TOKEN unset — accepting unsigned event for setup, but this is insecure", | ||
| ); | ||
| } else { | ||
| const sig = request.headers.get("x-notion-signature") ?? ""; | ||
| if (!sig || !verifyNotionSignature(rawBody, sig, token)) { | ||
| return new NextResponse("Invalid signature", { status: 401 }); | ||
| } | ||
| } | ||
|
|
||
| await ensureWebhookQueueIndexes(); | ||
|
|
||
| // Notion gives every event a stable id; fall back to a synthetic one so | ||
| // dedup still works on test deliveries that omit it. | ||
| const eventId = | ||
| typeof payload.id === "string" ? payload.id : `${payload.type ?? "unknown"}_${Date.now()}`; | ||
|
|
||
| try { | ||
| await db.collection("webhook_edge_dedup").insertOne({ | ||
| _id: `notion:${eventId}` as unknown as never, | ||
| createdAt: new Date(), | ||
| }); | ||
| } catch (err) { | ||
| const code = (err as { code?: number }).code; | ||
| if (code === 11000) { | ||
| // Duplicate delivery — Notion retries on non-2xx, we may have already | ||
| // enqueued. Treat as success. | ||
| return new NextResponse("", { status: 200 }); | ||
| } | ||
| console.warn("notion dedup insert failed, falling through", err); | ||
| } | ||
|
|
||
| await enqueueWebhook({ | ||
| source: "notion", | ||
| eventId, | ||
| payload, | ||
| meta: { | ||
| type: payload.type ?? null, | ||
| workspace_id: payload.workspace_id ?? null, | ||
| receivedAt: Date.now(), | ||
| }, | ||
| }); | ||
|
|
||
| return new NextResponse("", { status: 200 }); | ||
| } | ||
|
|
||
| export async function GET() { | ||
| try { | ||
| await db.admin().ping(); | ||
| const col = db.collection("webhook_queue"); | ||
| const total = await col.countDocuments({ source: "notion" }); | ||
| const pending = await col.countDocuments({ source: "notion", processed: false }); | ||
| const verifTokens = await db.collection("webhook_notion_verification").countDocuments(); | ||
| return NextResponse.json({ | ||
| status: "ok", | ||
| collection: "webhook_queue", | ||
| notion: { total, pending, verificationTokensReceived: verifTokens }, | ||
| }); | ||
| } catch (error) { | ||
| return NextResponse.json( | ||
| { | ||
| status: "error", | ||
| message: error instanceof Error ? error.message : String(error), | ||
| }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import { enqueueWebhook, ensureWebhookQueueIndexes } from "@/bot/webhook-queue"; | ||
| import { db } from "@/src/db"; | ||
| import { createHmac, timingSafeEqual } from "crypto"; | ||
| import { type NextRequest, NextResponse } from "next/server"; | ||
|
|
||
| /** | ||
| * Slack webhook receiver, deployed to Vercel. | ||
| * | ||
| * The local bot on the VM tails MongoDB's `webhook_queue` via changeStream, | ||
| * so this endpoint stays up even when the bot is restarting/down — no | ||
| * Slack retries get dropped. | ||
| * | ||
| * URL verification (initial setup): respond with the challenge directly. | ||
| * event_callback: verify HMAC, enqueue, 200. | ||
| * | ||
| * @see https://docs.slack.dev/apis/events-api/ | ||
| */ | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| export const runtime = "nodejs"; | ||
|
|
||
| function verifySlackSignature( | ||
| body: string, | ||
| timestamp: string, | ||
| signature: string, | ||
| secret: string, | ||
| ): boolean { | ||
| // Slack guards against >5min replay attacks; we mirror that. | ||
| if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; | ||
| const hmac = createHmac("sha256", secret).update(`v0:${timestamp}:${body}`).digest("hex"); | ||
| const expected = Buffer.from(`v0=${hmac}`); | ||
| const received = Buffer.from(signature); | ||
| if (expected.length !== received.length) return false; | ||
| return timingSafeEqual(expected, received); | ||
| } | ||
|
|
||
| export async function POST(request: NextRequest) { | ||
| const body = await request.text(); | ||
| const timestamp = request.headers.get("x-slack-request-timestamp") ?? ""; | ||
| const signature = request.headers.get("x-slack-signature") ?? ""; | ||
|
|
||
| const secret = process.env.SLACK_SIGNING_SECRET; | ||
| if (!secret) { | ||
| console.error("SLACK_SIGNING_SECRET is not set in this Vercel deployment"); | ||
| return NextResponse.json({ error: "server misconfigured" }, { status: 500 }); | ||
| } | ||
|
|
||
| if (!verifySlackSignature(body, timestamp, signature, secret)) { | ||
| return new NextResponse("Invalid signature", { status: 401 }); | ||
| } | ||
|
|
||
| let payload: { | ||
| type?: string; | ||
| challenge?: string; | ||
| event?: { team?: string; channel?: string; ts?: string; event_ts?: string }; | ||
| team_id?: string; | ||
| event_id?: string; | ||
| }; | ||
| try { | ||
| payload = JSON.parse(body); | ||
| } catch { | ||
| return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); | ||
| } | ||
|
|
||
| // URL verification challenge — Slack does this when you set the | ||
| // Request URL in the app config. | ||
| if (payload.type === "url_verification" && payload.challenge) { | ||
| return NextResponse.json({ challenge: payload.challenge }); | ||
| } | ||
|
|
||
| if (payload.type === "event_callback") { | ||
| const retryNum = request.headers.get("x-slack-retry-num"); | ||
| const retryReason = request.headers.get("x-slack-retry-reason"); | ||
| const eventId = | ||
| payload.event_id || | ||
| `${payload.event?.channel ?? "-"}_${payload.event?.event_ts ?? payload.event?.ts ?? "-"}`; | ||
|
|
||
| // Make sure indexes exist (no-op after first request per instance). | ||
| await ensureWebhookQueueIndexes(); | ||
|
|
||
| // Edge-level dedup so a Slack retry storm doesn't insert N copies. | ||
| // The actual queue is content-deduped by the bot consumer. | ||
| try { | ||
| const dedupCol = db.collection("webhook_edge_dedup"); | ||
| // _id is a string; the unique constraint is built-in, so a duplicate | ||
| // throws code 11000. | ||
| await dedupCol.insertOne({ | ||
| _id: `slack:${eventId}` as unknown as never, | ||
| createdAt: new Date(), | ||
| }); | ||
| } catch (err) { | ||
| const code = (err as { code?: number }).code; | ||
| if (code === 11000) { | ||
| console.log(`slack webhook dedup hit for ${eventId} (retry=${retryNum})`); | ||
| return new NextResponse("", { status: 200 }); | ||
| } | ||
| console.warn("dedup insert failed, falling through", err); | ||
| } | ||
|
|
||
| await enqueueWebhook({ | ||
| source: "slack", | ||
| eventId, | ||
| payload, | ||
| meta: { | ||
| retryNum: retryNum ?? null, | ||
| retryReason: retryReason ?? null, | ||
| receivedAt: Date.now(), | ||
| }, | ||
| }); | ||
| } | ||
|
Comment on lines
+81
to
+110
|
||
|
|
||
| // Always 200 the webhook within a few seconds — Slack times out at 3s | ||
| // and retries up to 3 times if it doesn't get one. | ||
| return new NextResponse("", { status: 200 }); | ||
| } | ||
|
|
||
| export async function GET() { | ||
| try { | ||
| await db.admin().ping(); | ||
| const col = db.collection("webhook_queue"); | ||
| const total = await col.countDocuments({ source: "slack" }); | ||
| const pending = await col.countDocuments({ source: "slack", processed: false }); | ||
| return NextResponse.json({ | ||
| status: "ok", | ||
| collection: "webhook_queue", | ||
| slack: { total, pending }, | ||
| }); | ||
| } catch (error) { | ||
| return NextResponse.json( | ||
| { | ||
| status: "error", | ||
| message: error instanceof Error ? error.message : String(error), | ||
| }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
| } | ||
|
Comment on lines
+117
to
+137
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
_id:slack:${eventId}as unknown as nevercast is unnecessary and obscures the schema forwebhook_edge_dedupdocs. Prefer giving the collection a simple type (e.g.,{ _id: string; createdAt: Date }) and inserting_idas a plain string so type checking stays meaningful.