diff --git a/apps/dashboard/lib/env.ts b/apps/dashboard/lib/env.ts index 47f2d66d7b..571fcb7da3 100644 --- a/apps/dashboard/lib/env.ts +++ b/apps/dashboard/lib/env.ts @@ -43,6 +43,7 @@ export const env = () => WORKOS_API_KEY: z.string().optional(), WORKOS_CLIENT_ID: z.string().optional(), + WORKOS_WEBHOOK_SECRET: z.string().optional(), NEXT_PUBLIC_WORKOS_REDIRECT_URI: z .string() .default("http://localhost:3000/auth/sso-callback"), diff --git a/apps/dashboard/middleware.ts b/apps/dashboard/middleware.ts index f8edf25a33..3b39923b24 100644 --- a/apps/dashboard/middleware.ts +++ b/apps/dashboard/middleware.ts @@ -23,6 +23,9 @@ export default async function (req: NextRequest, _evt: NextFetchEvent) { "/auth/oauth-sign-in", "/auth/join", "/favicon.ico", + "/api/webhooks/stripe", + "/api/v1/workos/webhooks", + "/api/v1/github/verify", "/_next", ], })(req); diff --git a/apps/dashboard/pages/api/v1/clerk/webhooks.ts b/apps/dashboard/pages/api/v1/clerk/webhooks.ts deleted file mode 100644 index 513c5a71c2..0000000000 --- a/apps/dashboard/pages/api/v1/clerk/webhooks.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { IncomingHttpHeaders } from "node:http"; -import { env } from "@/lib/env"; -import type { WebhookEvent } from "@clerk/nextjs/server"; -import { Resend } from "@unkey/resend"; -import freeDomains from "free-email-domains"; -import type { NextApiRequest, NextApiResponse } from "next"; -import type { WebhookRequiredHeaders } from "svix"; -import { Webhook } from "svix"; - -export const maxDuration = 60; -export const config = { - maxDuration: 60, - runtime: "nodejs", -}; - -const { CLERK_WEBHOOK_SECRET, RESEND_API_KEY, RESEND_AUDIENCE_ID } = env(); -export default async function handler( - req: NextApiRequestWithSvixRequiredHeaders, - res: NextApiResponse, -) { - const payload = JSON.stringify(req.body); - const headers = req.headers; - if (!CLERK_WEBHOOK_SECRET || !RESEND_API_KEY || !RESEND_AUDIENCE_ID) { - // just return a 400 here, it will never happen but it's good to be safe - return res.status(400).json({ Error: "Missing environment variables" }); - } - const wh = new Webhook(CLERK_WEBHOOK_SECRET); - - let evt: WebhookEvent; - try { - evt = wh.verify(payload, headers) as WebhookEvent; - } catch (_) { - // Don't log an error, just return a 400 because the webhook signature was invalid - return res.status(400).json({}); - } - - const resend = new Resend({ apiKey: RESEND_API_KEY }); - const eventType = evt.type; - if (eventType === "user.created") { - // we only care about the first email address, so we can just grab the first one - const email = evt.data.email_addresses[0].email_address; - if (!email) { - return res.status(400).json({ Error: "No email address found" }); - } - try { - await alertSlack(email); - await resend.client.contacts.create({ - audienceId: RESEND_AUDIENCE_ID, - email: email, - }); - await resend.sendWelcomeEmail({ - email, - }); - return res.status(200).json({}); - } catch (err) { - return res.status(400).json({ - error: (err as Error).message, - }); - } - } -} - -type NextApiRequestWithSvixRequiredHeaders = NextApiRequest & { - headers: IncomingHttpHeaders & WebhookRequiredHeaders; -}; - -/** - * temporary - * - * just a webhook to let us know about potential enterprise users by filtering out all gmail.com etc domains - */ -async function alertSlack(email: string): Promise { - const url = process.env.SLACK_WEBHOOK_URL_SIGNUP; - if (!url) { - return; - } - const domain = email.split("@").at(-1); - if (!domain) { - return; - } - if (freeDomains.includes(domain)) { - return; - } - - await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - text: `${email} signed up`, - blocks: [ - { - type: "section", - fields: [ - { - type: "mrkdwn", - text: `${email} signed up`, - }, - { - type: "mrkdwn", - text: ``, - }, - ], - }, - ], - }), - }).catch((err: Error) => { - console.error(err); - }); -} diff --git a/apps/dashboard/pages/api/v1/workos/webhooks.ts b/apps/dashboard/pages/api/v1/workos/webhooks.ts new file mode 100644 index 0000000000..9fb9b4e28d --- /dev/null +++ b/apps/dashboard/pages/api/v1/workos/webhooks.ts @@ -0,0 +1,98 @@ +import { WorkOS } from "@workos-inc/node"; +import { env } from "@/lib/env"; +import { Resend } from "@unkey/resend"; +import freeDomains from "free-email-domains"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === "POST") { + const payload = req.body; + const sigHeader = req.headers["workos-signature"] as string | undefined; + const { RESEND_API_KEY, RESEND_AUDIENCE_ID, WORKOS_API_KEY, WORKOS_WEBHOOK_SECRET } = env(); + if (!WORKOS_API_KEY || !WORKOS_WEBHOOK_SECRET || !RESEND_API_KEY || !RESEND_AUDIENCE_ID) { + return res.status(400).json({ Error: "Missing environment variables" }); + } + + if (!payload || !sigHeader) { + return res.status(400).json({ Error: "Nope" }); + } + const workos = new WorkOS(WORKOS_API_KEY); + + const webhook = await workos.webhooks.constructEvent({ + payload: payload, + sigHeader: sigHeader, + secret: WORKOS_WEBHOOK_SECRET, + }); + + if (!webhook) { + return res.status(400).json({ Error: "Invalid payload" }); + } + + if (webhook.event === "user.created") { + const webhookData = webhook.data; + + const resend = new Resend({ apiKey: RESEND_API_KEY }); + + if (!webhookData.email) { + return res.status(400).json({ Error: "No email address found" }); + } + try { + await alertSlack(webhookData.email); + await resend.client.contacts.create({ + audienceId: RESEND_AUDIENCE_ID, + email: webhookData.email, + }); + await resend.sendWelcomeEmail({ + email: webhookData.email, + }); + return res.status(200).json({}); + } catch (err) { + return res.status(400).json({ + error: (err as Error).message, + }); + } + } + return res.status(200).json({}); + } +}; + +async function alertSlack(email: string): Promise { + const url = process.env.SLACK_WEBHOOK_URL_SIGNUP; + if (!url) { + return; + } + const domain = email.split("@").at(-1); + if (!domain) { + return; + } + if (freeDomains.includes(domain)) { + return; + } + + await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text: `${email} signed up`, + blocks: [ + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `${email} signed up`, + }, + { + type: "mrkdwn", + text: ``, + }, + ], + }, + ], + }), + }).catch((err: Error) => { + console.error(err); + }); +} diff --git a/internal/db/src/schema/audit_logs.ts b/internal/db/src/schema/audit_logs.ts index fe01e58cc7..5fe5d80b0c 100644 --- a/internal/db/src/schema/audit_logs.ts +++ b/internal/db/src/schema/audit_logs.ts @@ -1,13 +1,20 @@ import { relations } from "drizzle-orm"; -import { bigint, index, int, json, mysqlTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/mysql-core"; +import { + bigint, + index, + int, + json, + mysqlTable, + primaryKey, + uniqueIndex, + varchar, +} from "drizzle-orm/mysql-core"; import { lifecycleDates } from "./util/lifecycle_dates"; import { workspaces } from "./workspaces"; import { newId } from "@unkey/id"; import { deleteProtection } from "./util/delete_protection"; - - export const auditLogBucket = mysqlTable( "audit_log_bucket", { @@ -34,7 +41,6 @@ export const auditLogBucket = mysqlTable( }), ); - export const auditLog = mysqlTable( "audit_log", { @@ -96,7 +102,7 @@ export const auditLogTarget = mysqlTable( // bucket is the name of the bucket that the target belongs to bucket: varchar("bucket", { length: 256 }).notNull().default("unkey_mutations"), - auditLogId: varchar("audit_log_id", { length: 256 }), + auditLogId: varchar("audit_log_id", { length: 256 }).notNull(), // A human readable name to display in the UI displayName: varchar("display_name", { length: 256 }).notNull(),