diff --git a/src/routes/api/the-nook/resend-license.test.ts b/src/routes/api/the-nook/resend-license.test.ts new file mode 100644 index 0000000..b65f47a --- /dev/null +++ b/src/routes/api/the-nook/resend-license.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, mock, beforeEach } from "bun:test"; + +// The route's two core contracts: +// 1. A license key is emailed only when the requested email owns one. +// 2. The HTTP response is identical whether or not a license exists — +// callers can't learn whether an email is registered. +// NookConnectionFactory and the nook helper module are mocked so the test +// needs no real DB, env, or outbound email. + +let licenseRows: { key: string }[] = []; +const sentEmails: { to: string; key: string }[] = []; +const queries: string[] = []; + +const fakeConn = { + execute: async (q: { sql: string; args?: unknown[] }) => { + queries.push(q.sql); + if (q.sql.includes("FROM licenses")) return { rows: licenseRows }; + return { rows: [] }; + } +}; + +mock.module("~/server/db-connections", () => ({ + NookConnectionFactory: () => fakeConn +})); + +mock.module("~/server/nook", () => ({ + nookSchemaBootstrap: Promise.resolve(), + emailLicenseKey: async (to: string, key: string) => { + sentEmails.push({ to, key }); + } +})); + +const { POST } = await import("./resend-license"); + +function request(body: unknown): any { + return { request: { json: async () => body } }; +} + +describe("POST /api/the-nook/resend-license", () => { + beforeEach(() => { + licenseRows = []; + sentEmails.length = 0; + queries.length = 0; + }); + + it("rejects an invalid email", async () => { + const res = await POST(request({ email: "not-an-email" })); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Invalid email" }); + expect(sentEmails).toHaveLength(0); + }); + + it("emails the stored key when a license exists for the email", async () => { + licenseRows = [{ key: "NOOK-ABC123" }]; + const res = await POST(request({ email: "USER@Example.com " })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true }); + expect(sentEmails).toEqual([{ to: "user@example.com", key: "NOOK-ABC123" }]); + expect(queries.some((q) => q.toLowerCase().includes("lower(email)"))).toBe(true); + }); + + it("sends nothing when no license exists, but still succeeds", async () => { + const res = await POST(request({ email: "nobody@example.com" })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true }); + expect(sentEmails).toHaveLength(0); + }); + + it("returns an identical response whether or not a license exists", async () => { + const withLicense = await POST(request({ email: "buyer@example.com" })); + licenseRows = [{ key: "NOOK-XYZ" }]; + const withoutLicense = await POST(request({ email: "buyer@example.com" })); + + expect(withLicense.status).toBe(withoutLicense.status); + expect(await withLicense.text()).toBe(await withoutLicense.text()); + }); +}); diff --git a/src/routes/api/the-nook/resend-license.ts b/src/routes/api/the-nook/resend-license.ts new file mode 100644 index 0000000..41bf8ee --- /dev/null +++ b/src/routes/api/the-nook/resend-license.ts @@ -0,0 +1,42 @@ +import type { APIEvent } from "@solidjs/start/server"; +import { NookConnectionFactory } from "~/server/db-connections"; +import { nookSchemaBootstrap, emailLicenseKey } from "~/server/nook"; +import { json, error } from "./_lib"; + +/** + * POST /api/the-nook/resend-license + * Body: { email } + * + * Re-sends the most recent license key to a buyer who lost it. If the email + * owns a license, the key is emailed; otherwise nothing is sent. The response + * is identical either way so callers can't learn whether an email has a + * license — only validation failures differ. + */ +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; + const email = typeof b.email === "string" ? b.email.trim().toLowerCase() : ""; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return error("Invalid email", 400); + } + + await nookSchemaBootstrap; + const conn = NookConnectionFactory(); + + const res = await conn.execute({ + sql: "SELECT key FROM licenses WHERE lower(email) = lower(?) ORDER BY created_at DESC LIMIT 1", + args: [email] + }); + + if (res.rows.length > 0) { + const { key } = res.rows[0] as { key: string }; + await emailLicenseKey(email, key); + } + + return json({ success: true }); +} diff --git a/src/routes/api/the-nook/webhooks/stripe.ts b/src/routes/api/the-nook/webhooks/stripe.ts index e09929b..01caeb8 100644 --- a/src/routes/api/the-nook/webhooks/stripe.ts +++ b/src/routes/api/the-nook/webhooks/stripe.ts @@ -1,7 +1,7 @@ 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 { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook"; import { json } from "../_lib"; import { createHmac, timingSafeEqual } from "node:crypto"; @@ -46,31 +46,6 @@ function verifyStripeSignature(rawBody: string, signatureHeader: string): boolea return timingSafeEqual(provided, expectedBuffer); } -async function emailLicenseKey(to: string, licenseKey: string): Promise { - 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"); diff --git a/src/routes/nook/index.tsx b/src/routes/nook/index.tsx index 58927e2..f883838 100644 --- a/src/routes/nook/index.tsx +++ b/src/routes/nook/index.tsx @@ -1,6 +1,8 @@ import { PageHead } from "~/components/PageHead"; import SubdomainHeader from "~/components/SubdomainHeader"; import Button from "~/components/ui/Button"; +import Input from "~/components/ui/Input"; +import { createSignal, Show } from "solid-js"; import { useDarkMode } from "~/context/darkMode"; import { useSite } from "~/context/SiteContext"; @@ -34,6 +36,32 @@ export default function NookLanding() { const { isDark } = useDarkMode(); const brandColor = () => isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor; + const [resendEmail, setResendEmail] = createSignal(""); + const [resendSending, setResendSending] = createSignal(false); + const [resendSent, setResendSent] = createSignal(false); + const [resendError, setResendError] = createSignal(false); + + const resendLicense = async () => { + if (resendSending()) return; + const email = resendEmail().trim(); + if (!email) return; + setResendSending(true); + setResendSent(false); + setResendError(false); + try { + const res = await fetch("/api/the-nook/resend-license", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email }) + }); + if (!res.ok) throw new Error("resend failed"); + setResendSent(true); + } catch { + setResendError(true); + } finally { + setResendSending(false); + } + }; return ( <> @@ -168,6 +196,54 @@ export default function NookLanding() {

+ + {/* ── Resend license ─────────────────────────────────────────── */} +
+
+

+ Lost your license? +

+

+ Enter the email you purchased with and we'll resend your key. +

+
{ + e.preventDefault(); + resendLicense(); + }} + > + setResendEmail(e.currentTarget.value)} + /> + +
+ +

+ Check your inbox — if a license is registered to that email, your + key is on its way. +

+
+ +

+ Something went wrong. Please try again. +

+
+
+
); } diff --git a/src/server/nook.ts b/src/server/nook.ts index 59e5fb6..3422752 100644 --- a/src/server/nook.ts +++ b/src/server/nook.ts @@ -10,15 +10,11 @@ import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto"; * is bootstrapped idempotently (CREATE TABLE IF NOT EXISTS) so no migration * tool is needed. * - * The license key is a compact printable string: + * key = "NOOK-" + hyphen-grouped base32(payload || ed25519-signature) * - * key = payloadJson + "." + base64url(ed25519-signature(payloadJson)) - * - * Canonicalization is load-bearing. The Swift client verifies the EXACT - * payload bytes (the substring before the last "."), never a re-serialization - * of the decoded JSON — key order must stay stable. Without a compact, - * deterministic payload this breaks, so `issueLicense` builds the payload as - * a hand-ordered literal and `JSON.stringify`s it in place. + * Payload layout (v1) is defined below in `buildPayload`. The Swift client + * decodes the base32 and verifies the EXACT payload bytes against the + * Ed25519 signature, so the layout and signing must stay stable. */ interface IssueLicenseResult { @@ -76,24 +72,86 @@ function privateKeyObject() { }); } -function signPayload(payload: string): string { - const signature = sign(null, Buffer.from(payload, "utf8"), privateKeyObject()); - return Buffer.from(signature).toString("base64url"); +const KEY_PREFIX = "NOOK-"; +const SIGNATURE_LENGTH = 64; +const B32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +// Binary license payload (v1), signed with Ed25519: +// [0] version (1 byte) +// [1..17) lid (16 bytes, UUID decoded) +// [17..25) iat (8 bytes, big-endian uint64) +// [25] emailLen (1 byte) +// [26..) email (UTF-8) +// A key is a "NOOK-" prefixed, hyphen-grouped base32 of payload || 64-byte sig. + +function uuidToBytes(uuid: string): Buffer | null { + const hex = uuid.replace(/-/g, ""); + return /^[0-9a-fA-F]{32}$/.test(hex) ? Buffer.from(hex, "hex") : null; +} + +function buildPayload(lid: string, email: string, iat: number): Buffer { + const lidBytes = uuidToBytes(lid); + if (!lidBytes) throw new Error("Invalid license id"); + const emailBytes = Buffer.from(email, "utf8"); + const header = Buffer.allocUnsafe(26); + header[0] = PAYLOAD_VERSION; + lidBytes.copy(header, 1); + header.writeBigUInt64BE(BigInt(iat), 17); + header[25] = emailBytes.length; + return Buffer.concat([header, emailBytes]); +} + +function base32Encode(data: Buffer): string { + let bits = 0; + let value = 0; + let out = ""; + for (const byte of data) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + out += B32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += B32_ALPHABET[(value << (5 - bits)) & 31]; + return out; +} + +function base32Decode(input: string): Buffer | null { + const cleaned = input.trim().replace(/^NOOK-/i, "").replace(/[^A-Z2-7]/g, ""); + if (cleaned.length === 0) return null; + let bits = 0; + let value = 0; + const bytes: number[] = []; + for (const char of cleaned) { + const idx = B32_ALPHABET.indexOf(char); + if (idx < 0) return null; + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(bytes); +} + +function encodeKey(data: Buffer): string { + const encoded = base32Encode(data); + const groups: string[] = []; + for (let i = 0; i < encoded.length; i += 5) groups.push(encoded.slice(i, i + 5)); + return `${KEY_PREFIX}${groups.join("-")}`; } /** Re-verifies a license key's Ed25519 signature server-side (defense in depth). */ export function verifyLicenseKey(key: string): boolean { - const token = key.match(/^([^]*?)\.([A-Za-z0-9_-]+)$/); - if (!token) return false; - const [, payload, sigB64] = token; + const token = base32Decode(key); + if (!token || token.length <= SIGNATURE_LENGTH) return false; + const payload = token.subarray(0, token.length - SIGNATURE_LENGTH); + if (payload[0] !== PAYLOAD_VERSION || payload.length < 27) return false; + const signature = token.subarray(token.length - SIGNATURE_LENGTH); const publicKey = createPublicKey(privateKeyObject()); - let signature: Buffer; - try { - signature = Buffer.from(sigB64!, "base64url"); - } catch { - return false; - } - return verify(null, Buffer.from(payload!, "utf8"), publicKey, signature); + return verify(null, payload as Buffer, publicKey, signature); } /** @@ -106,13 +164,10 @@ async function insertLicense( maxDevices: number ): Promise { const id = crypto.randomUUID(); - const payload = JSON.stringify({ - v: PAYLOAD_VERSION, - lid: id, - email: email, - iat: Math.floor(Date.now() / 1000) - }); - const key = `${payload}.${signPayload(payload)}`; + const iat = Math.floor(Date.now() / 1000); + const payload = buildPayload(id, email, iat); + const signature = sign(null, payload, privateKeyObject()); + const key = encodeKey(Buffer.concat([payload, signature])); await NookConnectionFactory().execute({ sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked, max_devices) VALUES (?, ?, ?, ?, ?, 0, ?)`, @@ -147,3 +202,31 @@ export async function grantLicense( ): Promise { return insertLicense(email, `gift:${crypto.randomUUID()}`, maxDevices); } + +/** + * Sends a license key to the buyer. Best-effort — failures are logged, never + * thrown, so the caller (checkout webhook / resend) isn't interrupted. + */ +export async function emailLicenseKey(to: string, licenseKey: string): Promise { + 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: { name: "The Nook", email: "support@freno.me" }, + 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— Michael` + }) + }); + } catch (error) { + console.error("Failed to email The Nook license key:", error); + } +}