forked from Mike/freno-dev
prep for the nook
This commit is contained in:
21
src/routes/api/the-nook/_lib.ts
Normal file
21
src/routes/api/the-nook/_lib.ts
Normal 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);
|
||||
}
|
||||
97
src/routes/api/the-nook/activate.ts
Normal file
97
src/routes/api/the-nook/activate.ts
Normal 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);
|
||||
}
|
||||
32
src/routes/api/the-nook/by-session.ts
Normal file
32
src/routes/api/the-nook/by-session.ts
Normal 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 });
|
||||
}
|
||||
71
src/routes/api/the-nook/checkout.ts
Normal file
71
src/routes/api/the-nook/checkout.ts
Normal 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 });
|
||||
}
|
||||
58
src/routes/api/the-nook/deactivate.ts
Normal file
58
src/routes/api/the-nook/deactivate.ts
Normal 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 });
|
||||
}
|
||||
51
src/routes/api/the-nook/status.ts
Normal file
51
src/routes/api/the-nook/status.ts
Normal 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 });
|
||||
}
|
||||
45
src/routes/api/the-nook/trial.ts
Normal file
45
src/routes/api/the-nook/trial.ts
Normal 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 });
|
||||
}
|
||||
126
src/routes/api/the-nook/webhooks/stripe.ts
Normal file
126
src/routes/api/the-nook/webhooks/stripe.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user