prep for the nook

This commit is contained in:
2026-08-26 17:41:57 -04:00
parent 898c891bd5
commit e4a9be4899
27 changed files with 1234 additions and 20 deletions

View File

@@ -0,0 +1,21 @@
/**
* Shared JSON response helpers for the The Nook license API routes.
* Underscore-prefixed file — not a route.
*/
export function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" }
});
}
export function error(message: string, status: number): Response {
return json({ error: message }, status);
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isUuid(value: unknown): value is string {
return typeof value === "string" && UUID_RE.test(value);
}

View File

@@ -0,0 +1,97 @@
import type { Client } from "@libsql/client/web";
import type { APIEvent } from "@solidjs/start/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap, verifyLicenseKey } from "~/server/nook";
import { json, error, isUuid } from "./_lib";
/**
* POST /api/the-nook/activate
* Body: { key, deviceFingerprint, deviceName }
*
* Re-verifies the Ed25519 license signature server-side (defense in depth —
* the app already verified it offline), enforces the 3-device activation cap,
* and records/refreshes an activation row. Re-activating an already-activated
* fingerprint is idempotent and does NOT consume an extra seat.
*/
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 key = typeof b.key === "string" ? b.key : "";
const fingerprint = b.deviceFingerprint;
const deviceName =
typeof b.deviceName === "string" && b.deviceName.length > 0
? b.deviceName
: "Mac";
if (!key || !isUuid(fingerprint)) {
return error("Invalid request", 400);
}
if (!verifyLicenseKey(key)) {
return error("Invalid license key", 400);
}
await nookSchemaBootstrap;
const conn = NookConnectionFactory();
const licenseRes = await conn.execute({
sql: "SELECT id, email, revoked FROM licenses WHERE key = ?",
args: [key]
});
if (licenseRes.rows.length === 0) {
return error("License not found", 404);
}
const license = licenseRes.rows[0] as { id: string; email: string; revoked: number };
if (license.revoked === 1) {
return error("License revoked", 403);
}
// Idempotent re-activation: already-active fingerprint does not consume a seat.
const existingRes = await conn.execute({
sql: `SELECT id FROM activations
WHERE license_id = ? AND device_fingerprint = ? AND deactivated_at IS NULL`,
args: [license.id, fingerprint]
});
if (existingRes.rows.length > 0) {
await conn.execute({
sql: "UPDATE activations SET activated_at = ? WHERE id = ?",
args: [new Date().toISOString(), existingRes.rows[0] as { id: string }]
});
const count = await activeCount(conn, license.id);
return json({ ok: true, email: license.email, activatedCount: count });
}
const count = await activeCount(conn, license.id);
if (count >= 3) {
return error("Activation limit reached (3 devices)", 409);
}
await conn.execute({
sql: `INSERT INTO activations
(id, license_id, device_fingerprint, device_name, activated_at, deactivated_at)
VALUES (?, ?, ?, ?, ?, NULL)`,
args: [
crypto.randomUUID(),
license.id,
fingerprint,
deviceName,
new Date().toISOString()
]
});
return json({ ok: true, email: license.email, activatedCount: count + 1 });
}
async function activeCount(
conn: Client,
licenseId: string
): Promise<number> {
const res = await conn.execute({
sql: "SELECT COUNT(*) AS n FROM activations WHERE license_id = ? AND deactivated_at IS NULL",
args: [licenseId]
});
return Number((res.rows[0] as { n: number | bigint }).n);
}

View File

@@ -0,0 +1,32 @@
import type { APIEvent } from "@solidjs/start/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap } from "~/server/nook";
import { json, error } from "./_lib";
/**
* GET /api/the-nook/by-session?session_id=cs_...
*
* Used by the success page (nook.freno.me/success) to retrieve the license
* key once the `checkout.session.completed` webhook has landed. The page
* polls this endpoint until the license row exists.
*/
export async function GET(event: APIEvent) {
const url = new URL(event.request.url);
const sessionId = url.searchParams.get("session_id");
if (!sessionId || !sessionId.startsWith("cs_")) {
return error("Invalid session id", 400);
}
await nookSchemaBootstrap;
const conn = NookConnectionFactory();
const res = await conn.execute({
sql: "SELECT key, email FROM licenses WHERE stripe_session_id = ?",
args: [sessionId]
});
if (res.rows.length === 0) {
return error("Not found", 404);
}
const license = res.rows[0] as { key: string; email: string };
return json({ key: license.key, email: license.email });
}

View File

@@ -0,0 +1,71 @@
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";
/**
* 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.
*
* 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);
}
return json({ checkoutUrl: data.url });
}

View File

@@ -0,0 +1,58 @@
import type { APIEvent } from "@solidjs/start/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap, verifyLicenseKey } from "~/server/nook";
import { json, error, isUuid } from "./_lib";
/**
* POST /api/the-nook/deactivate
* Body: { key, deviceFingerprint }
*
* Marks the matching activation as deactivated, freeing a device seat.
* - No activation row for this (license, fingerprint): 404.
* - Already deactivated: idempotent success (does not error).
*/
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 key = typeof b.key === "string" ? b.key : "";
const fingerprint = b.deviceFingerprint;
if (!key || !isUuid(fingerprint)) {
return error("Invalid request", 400);
}
if (!verifyLicenseKey(key)) {
return error("Invalid license key", 400);
}
await nookSchemaBootstrap;
const conn = NookConnectionFactory();
const licenseRes = await conn.execute({
sql: "SELECT id FROM licenses WHERE key = ?",
args: [key]
});
if (licenseRes.rows.length === 0) {
return error("License not found", 404);
}
const license = licenseRes.rows[0] as { id: string };
const activationRes = await conn.execute({
sql: "SELECT id FROM activations WHERE license_id = ? AND device_fingerprint = ?",
args: [license.id, fingerprint]
});
if (activationRes.rows.length === 0) {
return error("No activation found for this device", 404);
}
await conn.execute({
sql: "UPDATE activations SET deactivated_at = ? WHERE id = ?",
args: [new Date().toISOString(), (activationRes.rows[0] as { id: string }).id]
});
return json({ ok: true });
}

View File

@@ -0,0 +1,51 @@
import type { APIEvent } from "@solidjs/start/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap, verifyLicenseKey } from "~/server/nook";
import { json, error, isUuid } from "./_lib";
/**
* POST /api/the-nook/status
* Body: { key, deviceFingerprint }
*
* POST (not GET) because the license key is sensitive in URLs/logs.
* Returns the license state: "valid" | "revoked" | "unknown_key" with the
* current active activation count (for the 3-seat cap display).
*/
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 key = typeof b.key === "string" ? b.key : "";
if (!key) {
return error("Invalid request", 400);
}
await nookSchemaBootstrap;
const conn = NookConnectionFactory();
const licenseRes = await conn.execute({
sql: "SELECT id, revoked FROM licenses WHERE key = ?",
args: [key]
});
if (licenseRes.rows.length === 0) {
return json({ state: "unknown_key", activatedCount: 0 });
}
const license = licenseRes.rows[0] as { id: string; revoked: number };
if (license.revoked === 1) {
return json({ state: "revoked", activatedCount: 0 });
}
const countRes = await conn.execute({
sql: "SELECT COUNT(*) AS n FROM activations WHERE license_id = ? AND deactivated_at IS NULL",
args: [license.id]
});
const activatedCount = Number((countRes.rows[0] as { n: number | bigint }).n);
return json({ state: "valid", activatedCount });
}

View File

@@ -0,0 +1,45 @@
import type { APIEvent } from "@solidjs/start/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap } from "~/server/nook";
import { json, error, isUuid } from "./_lib";
/**
* POST /api/the-nook/trial
* Body: { fingerprint, deviceName }
*
* Returns the canonical trial start date. If a `trials` row already exists
* for the fingerprint it returns the STORED value (the anti-reset point) —
* a wiped-Keychain reinstall cannot push the trial start forward.
*/
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 fingerprint = b.fingerprint;
if (!isUuid(fingerprint)) {
return error("Invalid fingerprint", 400);
}
await nookSchemaBootstrap;
const conn = NookConnectionFactory();
const existing = await conn.execute({
sql: "SELECT started_at FROM trials WHERE fingerprint = ?",
args: [fingerprint]
});
if (existing.rows.length > 0) {
const trialStart = existing.rows[0] as { started_at: string };
return json({ fingerprint, trialStart: trialStart.started_at, trialDays: 14 });
}
const trialStart = new Date().toISOString();
await conn.execute({
sql: "INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)",
args: [fingerprint, trialStart]
});
return json({ fingerprint, trialStart, trialDays: 14 });
}

View File

@@ -0,0 +1,126 @@
import type { APIEvent } from "@solidjs/start/server";
import { env } from "~/env/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap, issueLicense } from "~/server/nook";
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;
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);
}
async function emailLicenseKey(to: string, licenseKey: string): Promise<void> {
try {
await fetch("https://api.brevo.com/v3/smtp/email", {
method: "POST",
headers: {
"api-key": env.SENDINBLUE_KEY,
"content-type": "application/json",
accept: "application/json"
},
body: JSON.stringify({
sender: { email: env.EMAIL_FROM },
to: [{ email: to }],
subject: "Your The Nook license key",
textContent:
`Your The Nook license key is:\n\n${licenseKey}\n\n` +
`Open The Nook, go to Settings, and enter this key to activate.\n` +
`You can activate up to 3 devices.\n\n— The Nook`
})
});
} catch (error) {
// Purchase must not fail because email delivery did.
console.error("Failed to email The Nook license key:", error);
}
}
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.id;
const email = body.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);
} 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 });
}