import type { APIEvent } from "@solidjs/start/server"; import { env } from "~/env/server"; import { NookConnectionFactory } from "~/server/db-connections"; import { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook"; import { emailThankYou } from "~/server/nook-lifecycle"; import { json } from "../_lib"; import { createHmac, timingSafeEqual } from "node:crypto"; /** * POST /api/the-nook/webhooks/stripe * * Verifies the Stripe webhook signature manually (no `stripe` npm package), * handles `checkout.session.completed`: issues a license row (idempotent via * the UNIQUE `stripe_session_id`) and emails the key to the buyer. * * Email failure is a logged warning, never a purchase failure — the license * row is written before emailing, and the success page can still fetch the * key via by-session. */ interface StripeSessionCompleted { id: string; type: string; data?: { object?: { id?: string; customer_details?: { email?: string } } }; } function verifyStripeSignature(rawBody: string, signatureHeader: string): boolean { const params = new Map(); for (const pair of signatureHeader.split(",")) { const eq = pair.indexOf("="); if (eq > 0) params.set(pair.slice(0, eq), pair.slice(eq + 1)); } const t = params.get("t"); const v1 = params.get("v1"); if (!t || !v1) return false; const now = Math.floor(Date.now() / 1000); const ts = Number(t); if (!Number.isFinite(ts) || Math.abs(now - ts) > 300) return false; const expected = createHmac("sha256", env.NOOK_STRIPE_WEBHOOK_SECRET) .update(`${t}.${rawBody}`) .digest("hex"); const provided = Buffer.from(v1, "hex"); const expectedBuffer = Buffer.from(expected, "hex"); if (provided.length !== expectedBuffer.length) return false; return timingSafeEqual(provided, expectedBuffer); } export async function POST(event: APIEvent) { const rawBody = await event.request.text(); const signatureHeader = event.request.headers.get("Stripe-Signature"); if (!signatureHeader) { return json({ error: "Missing signature" }, 400); } if (!verifyStripeSignature(rawBody, signatureHeader)) { return json({ error: "Invalid signature" }, 400); } let payload: unknown; try { payload = JSON.parse(rawBody); } catch { return json({ error: "Invalid JSON" }, 400); } const body = payload as StripeSessionCompleted; if (body.type !== "checkout.session.completed") { // Unknown event types are acknowledged, not retried. return json({ received: true }); } const sessionId = body.data?.object?.id; const email = body.data?.object?.customer_details?.email; if (!sessionId || !email) { console.error("checkout.session.completed missing id or email:", rawBody); return json({ received: true }); } await nookSchemaBootstrap; const conn = NookConnectionFactory(); // Idempotency: a session that already produced a license is acknowledged // without re-issuing or re-emailing. const existing = await conn.execute({ sql: "SELECT id FROM licenses WHERE stripe_session_id = ?", args: [sessionId] }); if (existing.rows.length > 0) { return json({ received: true }); } try { const { key } = await issueLicense(email, sessionId); await emailLicenseKey(email, key); // Purchase thank-you (the D+3 testimonial and D+14 review asks are // scheduled from the daily lifecycle job, not here). await emailThankYou(email); } catch (error) { // UNIQUE stripe_session_id conflict from a racing duplicate delivery. console.error("Failed to issue The Nook license (webhook):", error); } return json({ received: true }); }