Files
freno-dev/src/routes/api/the-nook/webhooks/stripe.ts
Michael Freno f66fd43f62 nook: capture trial email and run the two lifecycle emails
Trials were anonymous — a 14-day trial you cannot talk to. trials gains
an email column (idempotent ALTER), POST /api/the-nook/trial-email
attaches the optional first-launch prompt's answer, and the address
lands in a Brevo list when NOOK_TRIAL_LIST_ID is set. The lifecycle
emails follow: a D+11 trial expiry reminder with a buy link, a purchase
thank-you from the Stripe webhook, and scheduled testimonial (D+3) and
review (D+14) asks via the daily /api/the-nook/lifecycle cron. Rows are
marked before sending so retries never double-send; trial addresses
that already bought get no buy reminder; the endpoint requires a
Bearer secret (CRON_SECRET / NOOK_LIFECYCLE_SECRET) and fails closed
when unconfigured — an open trigger would email real users. All sends
are best-effort and logged, never thrown.

Tests: trial-email (3) and lifecycle (6) suites; full run 539 pass.

Issues: FRE-10, FRE-11
2026-10-04 23:22:24 -04:00

106 lines
3.6 KiB
TypeScript

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<string, string>();
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 });
}