import { type SlackEvent } from "@slack/web-api"; import { timingSafeEqual } from "node:crypto"; import type { JsonSchemaToTsProvider } from "@fastify/type-provider-json-schema-to-ts"; import Fastify, { type FastifyRequest, type FastifyServerOptions } from "fastify"; import type { EventsClient } from "./events/events.ts"; import type { SlackClient } from "./slack/slack.ts"; import { newCommandHandler } from "./commands/commands.ts"; export interface ServerDeps { eventsClient: EventsClient; slackClient: SlackClient; inviteWebhookToken: string; inviteChannel: string; } declare module "fastify" { interface FastifyRequest { rawBody?: string; } } export function buildServer(deps: ServerDeps, opts?: { logger?: FastifyServerOptions["logger"] }) { const app = Fastify({ logger: opts?.logger ?? { level: "info" }, }).withTypeProvider(); const commandHandler = newCommandHandler({ eventsClient: deps.eventsClient, slackClient: deps.slackClient, inviteChannel: deps.inviteChannel, }); // Capture the raw request body so the Slack signature can be verified over // the exact bytes Slack signed. Fastify's default JSON parser would discard them. app.addContentTypeParser( "application/json", { parseAs: "string" }, (req: FastifyRequest, body: string, done: (err: Error | null, body?: unknown) => void) => { req.rawBody = body; try { done(null, JSON.parse(body)); } catch (err) { done(err as Error); } }, ); app.get("/health", async () => ({ status: "ok" })); const inviteRequestSchema = { type: "object", properties: { name: { type: "string" }, email: { type: "string" }, reason: { type: "string" }, livesHere: { type: "string" }, }, required: ["name", "email", "reason", "livesHere"], additionalProperties: false, } as const; app.post( "/invites/slack/request", { schema: { body: inviteRequestSchema } }, async (request, reply) => { const auth = request.headers.authorization; if (!safeEqualBearer(auth, deps.inviteWebhookToken)) { request.log.warn("invites/request: missing or invalid token"); return reply.code(401).send({ error: "unauthorized" }); } const { name, email, reason, livesHere } = request.body; request.log.info({ name, channel: deps.inviteChannel }, "invites/request: posting request"); const msg = formatInviteRequest({ name, email, reason, livesHere }); try { await deps.slackClient.sendMessage({ msg, channel: deps.inviteChannel }); } catch (err) { request.log.error({ err }, "invites/request: sendMessage failed"); return reply.code(500).send({ error: "failed to post request" }); } reply.code(202).send({ status: "posted" }); }, ); app.post("/slack/webhook", async (request, reply) => { const timestamp = request.headers["x-slack-request-timestamp"]; const signature = request.headers["x-slack-signature"]; if (typeof timestamp !== "string" || typeof signature !== "string") { request.log.warn( { hasTimestamp: typeof timestamp === "string", hasSignature: typeof signature === "string", }, "slack webhook: missing signature headers", ); return reply.code(401).send({ error: "missing signature headers" }); } const rawBody = request.rawBody ?? ""; const valid = deps.slackClient.verifySignature({ timestamp, rawBody, signature, onReject: (reason) => request.log.warn({ reason }, "slack webhook: signature rejected"), }); if (!valid) return reply.code(401).send({ error: "invalid signature" }); const payload = JSON.parse(rawBody) as SlackEventEnvelope; if (payload.type === "url_verification") { request.log.info("slack webhook: url_verification challenge"); return reply.send({ challenge: payload.challenge }); } if (payload.type !== "event_callback") { request.log.info({ type: payload.type }, "slack webhook: unhandled envelope type"); return reply.code(200).send({}); } const event = payload.event; if (!event) { request.log.warn("slack webhook: event_callback with no event"); return reply.code(200).send({}); } request.log.info({ eventType: event.type }, "slack webhook: event_callback received"); try { switch (event.type) { case "app_mention": await commandHandler.handleBotMention(event, request.log); } } catch (err) { request.log.error({ err }, "slack webhook: command handling failed"); return reply.code(200).send({}); } reply.code(200).send({}); }); return app; } function safeEqualBearer(header: string | undefined, expected: string): boolean { if (typeof header !== "string") return false; const received = header.startsWith("Bearer ") ? header.slice(7) : ""; const a = Buffer.from(received, "utf8"); const b = Buffer.from(expected, "utf8"); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } function formatInviteRequest({ name, email, reason, livesHere, }: { name: string; email: string; reason: string; livesHere: string; }): string { return [ "New Slack invite request", `Name: ${name}`, `Email: ${email}`, `What brings you to devICT: ${reason}`, `Do you live in the Wichita, KS region?: ${livesHere}`, "", "Approve by replying `@bot !invite` in this thread.", ].join("\n"); } type SlackEventEnvelope = { type: string; challenge?: string; event?: SlackEvent; };