forked from Mike/freno-dev
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.
90 lines
3.0 KiB
TypeScript
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 });
|
|
}
|