Files
freno-dev/src/routes/api/the-nook/checkout.ts
Paperclip Agent 39d874a7e2 analytics: make each weekly funnel stage separately measurable (FRE-366)
Gap 1 — click and served share one label: trackDownload now takes a
stage ("click" | "served") carried on a new VisitorAnalytics.stage
column; the Nook door and the tRPC proxy record clicks, the DMG file
route records served. summariseDownloads() and the /analytics downloads
block are untouched — the stage is a separate column, so the path
grouping cannot change.

Gap 2 — checkout start records nothing: the checkout route records a
created Stripe session through the shared trackRequest writer (same
referrer, campaign, country dimensions as every door) under its own
path, and STAGES.checkout reads it instead of the /checkout page proxy.

Gap 3 — loopback addresses, no host dimension: every writer now
resolves the client address through resolveClientIp, which trusts
x-forwarded-for only when the direct peer is the loopback production
proxy (trust boundary stated in request-client.ts), and records the
host on each row through the same site-context extractor routing uses.
stage and host reach the table through VISITOR_ANALYTICS_ADDED_COLUMNS;
the weekly job applies the same bootstrap before reading so it renders
seven stages against a database no deploy has migrated yet.

The weekly read splits downloadClick/dmgServed, scopes visits to
nook.freno.me, and computes its caveats from the window: pre-instrumentation
rows are stated and excluded, and the loopback warning now fires only
when rows recorded since the instrumentation still carry it.
2026-10-29 17:02:39 +00:00

90 lines
3.0 KiB
TypeScript

import type { APIEvent } from "@solidjs/start/server";
import { env } from "~/env/server";
import { TURNSTILE_CONFIG } from "~/config";
import { verifyTurnstileToken } from "~/server/fetch-utils";
import { json, error } from "./_lib";
import { trackRequest } from "~/server/track-download";
/**
* POST /api/the-nook/checkout
* Body: { turnstileToken }
*
* Verifies the Cloudflare Turnstile token, then creates a Stripe Checkout
* session for the one-time $10 The Nook license. Returns the hosted
* checkout URL for the client to redirect to.
*
* Analytics: a created session is the funnel's "checkout started" stage
* (FRE-366). Until then the only trace was the client-side /checkout page
* view — a page, not a started checkout. Recorded through the shared
* `trackRequest` writer (referrer, campaign tags, country, real client
* address, host) under this route's own path, best-effort: a failed
* analytics write never blocks a checkout, and an attempt Turnstile or
* Stripe rejects is correctly not a started checkout.
*
* Uses raw `fetch` (Node 24 / Vercel Node functions have global fetch) — no
* `stripe` npm dependency added.
*/
export async function POST(event: APIEvent) {
let body: unknown;
try {
body = await event.request.json();
} catch {
return error("Invalid JSON", 400);
}
const b = (body ?? {}) as Record<string, unknown>;
const turnstileToken = typeof b.turnstileToken === "string" ? b.turnstileToken : "";
const turnstileValid = await verifyTurnstileToken(
turnstileToken,
env.TURNSTILE_SECRET_KEY,
TURNSTILE_CONFIG.VERIFY_URL,
TURNSTILE_CONFIG.RESPONSE_TIMEOUT_MS
);
if (!turnstileValid) {
return error("Security verification failed", 403);
}
const params = new URLSearchParams({
mode: "payment",
"line_items[0][price]": env.NOOK_STRIPE_PRICE_ID,
"line_items[0][quantity]": "1",
success_url: "https://nook.freno.me/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://nook.freno.me/checkout"
});
let stripeRes: Response;
try {
stripeRes = await fetch("https://api.stripe.com/v1/checkout/sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${env.NOOK_STRIPE_SK}`,
"Content-Type": "application/x-www-form-urlencoded"
},
body: params
});
} catch {
return error("Stripe unreachable", 502);
}
if (!stripeRes.ok) {
const stripeError = await stripeRes.text();
console.error("Stripe checkout error:", stripeError);
return error(`Stripe error: ${stripeError}`, 502);
}
const data = (await stripeRes.json()) as { url?: string };
if (!data.url) {
return error("Stripe returned no checkout URL", 502);
}
// The session exists: that is "checkout started". Awaited so the row is
// flushed before the response — a buffered write is not guaranteed to
// survive the response on serverless.
await trackRequest(event, {
path: "/api/the-nook/checkout",
method: "POST"
});
return json({ checkoutUrl: data.url });
}