From 8863d43793955591a91070a833ad1ed951bb3743 Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 12 May 2026 22:36:28 +0100 Subject: [PATCH] feat: set up partner webhook handler --- apps/web/src/pages/api/partner/_utils.ts | 19 ++ apps/web/src/pages/api/partner/callback.ts | 168 ++++++++++++++++++ apps/web/src/pages/api/partner/link.ts | 64 +++++++ apps/web/src/pages/api/partner/webhook.ts | 192 +++++++++++++++++++++ apps/web/src/pages/partner/activate.tsx | 98 +++++++++++ 5 files changed, 541 insertions(+) create mode 100644 apps/web/src/pages/api/partner/_utils.ts create mode 100644 apps/web/src/pages/api/partner/callback.ts create mode 100644 apps/web/src/pages/api/partner/link.ts create mode 100644 apps/web/src/pages/api/partner/webhook.ts create mode 100644 apps/web/src/pages/partner/activate.tsx diff --git a/apps/web/src/pages/api/partner/_utils.ts b/apps/web/src/pages/api/partner/_utils.ts new file mode 100644 index 00000000..164f35a3 --- /dev/null +++ b/apps/web/src/pages/api/partner/_utils.ts @@ -0,0 +1,19 @@ +export type WorkspacePlan = "free" | "team" | "pro" | "enterprise"; + +export interface TierConfig { + plan: WorkspacePlan; + seats: number | null; + unlimitedSeats: boolean; +} + +const TIER_MAP: Record = { + 1: { plan: "team", seats: 5, unlimitedSeats: false }, + 2: { plan: "pro", seats: 15, unlimitedSeats: false }, + 3: { plan: "pro", seats: null, unlimitedSeats: true }, +}; + +export function tierConfig(tier: number): TierConfig { + const config = TIER_MAP[tier]; + if (!config) throw new Error(`Unknown partner tier: ${tier}`); + return config; +} diff --git a/apps/web/src/pages/api/partner/callback.ts b/apps/web/src/pages/api/partner/callback.ts new file mode 100644 index 00000000..45b8ac2a --- /dev/null +++ b/apps/web/src/pages/api/partner/callback.ts @@ -0,0 +1,168 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { createNextApiContext } from "@kan/api/trpc"; +import { withApiLogging } from "@kan/api/utils/apiLogging"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; +import * as subscriptionRepo from "@kan/db/repository/subscription.repo"; +import * as workspaceRepo from "@kan/db/repository/workspace.repo"; +import { createLogger } from "@kan/logger"; + +import { tierConfig } from "./_utils"; + +const log = createLogger("api"); + +interface TokenResponse { + access_token: string; + token_type: string; +} + +interface OAuthLicenseResponse { + license_key: string; + status: string; +} + +interface LicenseDetailResponse { + license_key: string; + status: string; + tier: number; +} + +async function exchangeCodeForToken(code: string): Promise { + const tokenUrl = process.env.PARTNER_TOKEN_URL; + const clientId = process.env.PARTNER_CLIENT_ID; + const clientSecret = process.env.PARTNER_CLIENT_SECRET; + const redirectUrl = process.env.PARTNER_REDIRECT_URL; + if (!tokenUrl) throw new Error("PARTNER_TOKEN_URL not configured"); + if (!clientId) throw new Error("PARTNER_CLIENT_ID not configured"); + if (!clientSecret) throw new Error("PARTNER_CLIENT_SECRET not configured"); + if (!redirectUrl) throw new Error("PARTNER_REDIRECT_URL not configured"); + + const res = await fetch(tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUrl, + }), + }); + + if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`); + return res.json() as Promise; +} + +async function fetchOAuthLicense( + accessToken: string, +): Promise { + const oauthLicenseUrl = process.env.PARTNER_OAUTH_LICENSE_URL; + if (!oauthLicenseUrl) + throw new Error("PARTNER_OAUTH_LICENSE_URL not configured"); + + const res = await fetch(`${oauthLicenseUrl}?access_token=${accessToken}`); + if (!res.ok) throw new Error(`OAuth license fetch failed: ${res.status}`); + return res.json() as Promise; +} + +async function fetchLicenseDetail( + licenseKey: string, +): Promise { + const licenseApiUrl = process.env.PARTNER_LICENSE_API_URL; + const apiKey = process.env.PARTNER_API_KEY; + if (!licenseApiUrl) throw new Error("PARTNER_LICENSE_API_URL not configured"); + if (!apiKey) throw new Error("PARTNER_API_KEY not configured"); + + const apiKeyHeader = process.env.PARTNER_API_KEY_HEADER; + if (!apiKeyHeader) throw new Error("PARTNER_API_KEY_HEADER not configured"); + + const res = await fetch(`${licenseApiUrl}/${licenseKey}`, { + headers: { [apiKeyHeader]: apiKey }, + }); + if (!res.ok) throw new Error(`License detail fetch failed: ${res.status}`); + return res.json() as Promise; +} + +export default withRateLimit( + { points: 20, duration: 60 }, + withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== "GET") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const { code } = req.query; + + if (!code || typeof code !== "string") { + return res.status(400).json({ message: "Missing code parameter" }); + } + + let license: LicenseDetailResponse; + + try { + const tokenData = await exchangeCodeForToken(code); + const oauthLicense = await fetchOAuthLicense(tokenData.access_token); + license = await fetchLicenseDetail(oauthLicense.license_key); + } catch (err) { + log.error({ err }, "Partner OAuth flow failed"); + return res.redirect(`/partner/activate?error=oauth_failed`); + } + + const { db, user } = await createNextApiContext(req); + + const cfg = tierConfig(license.tier); + const isActive = license.status === "active"; + const status = isActive ? "active" : "inactive"; + + if (!user) { + await subscriptionRepo.upsertByPartnerLicenseKey( + db, + license.license_key, + { + plan: cfg.plan, + status, + partnerTier: license.tier, + seats: cfg.seats, + unlimitedSeats: cfg.unlimitedSeats, + }, + ); + return res.redirect( + `/partner/activate?license_key=${encodeURIComponent(license.license_key)}`, + ); + } + + const memberships = await workspaceRepo.getAllByUserId(db, user.id); + const workspace = memberships?.[0]?.workspace; + + if (!workspace) { + await subscriptionRepo.upsertByPartnerLicenseKey( + db, + license.license_key, + { + plan: cfg.plan, + status, + partnerTier: license.tier, + seats: cfg.seats, + unlimitedSeats: cfg.unlimitedSeats, + }, + ); + return res.redirect( + `/onboarding?license_key=${encodeURIComponent(license.license_key)}`, + ); + } + + await subscriptionRepo.upsertByPartnerLicenseKey(db, license.license_key, { + plan: cfg.plan, + status, + partnerTier: license.tier, + seats: cfg.seats, + unlimitedSeats: cfg.unlimitedSeats, + referenceId: workspace.publicId, + }); + + if (isActive) { + await workspaceRepo.update(db, workspace.publicId, { plan: cfg.plan }); + } + + return res.redirect(`/?partner_activated=1`); + }), +); diff --git a/apps/web/src/pages/api/partner/link.ts b/apps/web/src/pages/api/partner/link.ts new file mode 100644 index 00000000..70d23084 --- /dev/null +++ b/apps/web/src/pages/api/partner/link.ts @@ -0,0 +1,64 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { createNextApiContext } from "@kan/api/trpc"; +import { withApiLogging } from "@kan/api/utils/apiLogging"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; +import * as subscriptionRepo from "@kan/db/repository/subscription.repo"; +import * as workspaceRepo from "@kan/db/repository/workspace.repo"; + +export default withRateLimit( + { points: 20, duration: 60 }, + withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== "GET") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const { license_key } = req.query; + + if (!license_key || typeof license_key !== "string") { + return res.redirect("/?partner_error=missing_license"); + } + + const { db, user } = await createNextApiContext(req); + + if (!user) { + return res.redirect( + `/login?next=${encodeURIComponent(`/api/partner/link?license_key=${license_key}`)}`, + ); + } + + const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key); + + if (!sub) { + return res.redirect("/?partner_error=invalid_license"); + } + + if (sub.status !== "active") { + return res.redirect("/?partner_error=license_inactive"); + } + + const memberships = await workspaceRepo.getAllByUserId(db, user.id); + const workspace = memberships?.[0]?.workspace; + + if (!workspace) { + return res.redirect( + `/onboarding?license_key=${encodeURIComponent(license_key)}`, + ); + } + + await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, { + plan: sub.plan, + status: sub.status, + partnerTier: sub.partnerTier ?? 1, + seats: sub.seats ?? null, + unlimitedSeats: sub.unlimitedSeats, + referenceId: workspace.publicId, + }); + + await workspaceRepo.update(db, workspace.publicId, { + plan: sub.plan as "free" | "team" | "pro" | "enterprise", + }); + + return res.redirect("/?partner_activated=1"); + }), +); diff --git a/apps/web/src/pages/api/partner/webhook.ts b/apps/web/src/pages/api/partner/webhook.ts new file mode 100644 index 00000000..507dcc5a --- /dev/null +++ b/apps/web/src/pages/api/partner/webhook.ts @@ -0,0 +1,192 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import type { NextApiRequest, NextApiResponse } from "next"; +import type { Readable } from "node:stream"; + +import { createNextApiContext } from "@kan/api/trpc"; +import { withApiLogging } from "@kan/api/utils/apiLogging"; +import * as subscriptionRepo from "@kan/db/repository/subscription.repo"; +import * as workspaceRepo from "@kan/db/repository/workspace.repo"; +import { createLogger } from "@kan/logger"; + +import { tierConfig } from "./_utils"; + +const log = createLogger("api"); + +async function buffer(readable: Readable) { + const chunks: Buffer[] = []; + for await (const chunk of readable) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks); +} + +function verifySignature( + rawBody: string, + signature: string, + timestamp: string, +): boolean { + const secret = process.env.PARTNER_API_KEY; + if (!secret) return false; + + const ts = Number(timestamp); + if (isNaN(ts) || Date.now() / 1000 - ts > 300) return false; + + const payload = `${timestamp}.${rawBody}`; + const expected = createHmac("sha256", secret).update(payload).digest("hex"); + + try { + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + return false; + } +} + +interface WebhookPayload { + event: + | "purchase" + | "activate" + | "deactivate" + | "upgrade" + | "downgrade" + | "migrate"; + license_key: string; + license_status: string; + tier: number; + prev_license_key?: string; +} + +export default withApiLogging( + async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== "POST") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const signatureHeader = process.env.PARTNER_SIGNATURE_HEADER; + const timestampHeader = process.env.PARTNER_TIMESTAMP_HEADER; + + if (!signatureHeader || !timestampHeader) { + log.error("Webhook signature headers not configured"); + return res.status(500).json({ message: "Server misconfiguration" }); + } + + const signature = req.headers[signatureHeader] as string | undefined; + const timestamp = req.headers[timestampHeader] as string | undefined; + + if (!signature || !timestamp) { + return res.status(400).json({ message: "Missing signature headers" }); + } + + const buf = await buffer(req as unknown as Readable); + const rawBody = buf.toString("utf8"); + + if (!verifySignature(rawBody, signature, timestamp)) { + return res.status(401).json({ message: "Invalid signature" }); + } + + const payload = JSON.parse(rawBody) as WebhookPayload; + const { event, license_key, license_status, tier, prev_license_key } = + payload; + + const { db } = await createNextApiContext(req); + + switch (event) { + case "purchase": { + const cfg = tierConfig(tier); + await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, { + plan: cfg.plan, + status: license_status, + partnerTier: tier, + seats: cfg.seats, + unlimitedSeats: cfg.unlimitedSeats, + }); + break; + } + + case "activate": { + const cfg = tierConfig(tier); + await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, { + plan: cfg.plan, + status: "active", + partnerTier: tier, + seats: cfg.seats, + unlimitedSeats: cfg.unlimitedSeats, + }); + break; + } + + case "deactivate": { + const sub = await subscriptionRepo.getByPartnerLicenseKey( + db, + license_key, + ); + if (sub) { + await subscriptionRepo.updateById(db, sub.id, { + plan: "free", + status: "inactive", + }); + if (sub.referenceId) { + await workspaceRepo.update(db, sub.referenceId, { plan: "free" }); + } + } + break; + } + + case "upgrade": + case "downgrade": { + const sub = await subscriptionRepo.getByPartnerLicenseKey( + db, + license_key, + ); + if (sub) { + const cfg = tierConfig(tier); + await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, { + plan: cfg.plan, + status: license_status, + partnerTier: tier, + seats: cfg.seats, + unlimitedSeats: cfg.unlimitedSeats, + }); + if (sub.referenceId) { + await workspaceRepo.update(db, sub.referenceId, { plan: cfg.plan }); + } + } + break; + } + + case "migrate": { + if (prev_license_key) { + const sub = await subscriptionRepo.getByPartnerLicenseKey( + db, + prev_license_key, + ); + if (sub) { + const cfg = tierConfig(tier); + await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, { + plan: cfg.plan, + status: sub.status, + partnerTier: tier, + seats: cfg.seats, + unlimitedSeats: cfg.unlimitedSeats, + referenceId: sub.referenceId ?? undefined, + }); + await subscriptionRepo.updateById(db, sub.id, { + status: "inactive", + }); + } + } + break; + } + + default: + log.warn({ event }, "Unhandled partner webhook event"); + } + + return res.status(200).json({ success: true, event }); + }, +); + +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/apps/web/src/pages/partner/activate.tsx b/apps/web/src/pages/partner/activate.tsx new file mode 100644 index 00000000..e0f1bed0 --- /dev/null +++ b/apps/web/src/pages/partner/activate.tsx @@ -0,0 +1,98 @@ +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { useEffect, useState } from "react"; + +import { authClient } from "@kan/auth/client"; + +import { Auth } from "~/components/AuthForm"; +import { PageHead } from "~/components/PageHead"; +import PatternedBackground from "~/components/PatternedBackground"; + +export default function PartnerActivatePage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const licenseKey = searchParams.get("license_key"); + const error = searchParams.get("error"); + + const { data: session, isPending } = authClient.useSession(); + const [isMagicLinkSent, setIsMagicLinkSent] = useState(false); + const [magicLinkRecipient, setMagicLinkRecipient] = useState(""); + + useEffect(() => { + if (!isPending && session?.user && licenseKey) { + router.push( + `/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`, + ); + } else if (!isPending && session?.user && !licenseKey) { + router.push("/boards"); + } + }, [session, isPending, licenseKey, router]); + + if (isPending || (session?.user && licenseKey)) return null; + + return ( + <> + +
+
+
+ +

+ kan.bn +

+ +

+ {isMagicLinkSent ? t`Check your inbox` : t`Activate your account`} +

+

+ {isMagicLinkSent ? ( + We sent a link to {magicLinkRecipient} + ) : ( + t`Sign in or create an account to activate your license` + )} +

+ + {error && ( +
+ {t`Something went wrong during activation. Please try again.`} +
+ )} + + {!isMagicLinkSent && ( +
+
+ { + setIsMagicLinkSent(val); + setMagicLinkRecipient(recipient); + }} + /> +
+
+ )} + +

+ + Already have an account?{" "} + + + Sign in + + + +

+
+ +
+
+ + ); +}