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.
+
+
+
+
+ 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);
+ }
+}