license resend, license pretified
This commit is contained in:
79
src/routes/api/the-nook/resend-license.test.ts
Normal file
79
src/routes/api/the-nook/resend-license.test.ts
Normal file
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
42
src/routes/api/the-nook/resend-license.ts
Normal file
42
src/routes/api/the-nook/resend-license.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import { NookConnectionFactory } from "~/server/db-connections";
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
import { nookSchemaBootstrap, issueLicense } from "~/server/nook";
|
import { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook";
|
||||||
import { json } from "../_lib";
|
import { json } from "../_lib";
|
||||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
@@ -46,31 +46,6 @@ function verifyStripeSignature(rawBody: string, signatureHeader: string): boolea
|
|||||||
return timingSafeEqual(provided, expectedBuffer);
|
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) {
|
export async function POST(event: APIEvent) {
|
||||||
const rawBody = await event.request.text();
|
const rawBody = await event.request.text();
|
||||||
const signatureHeader = event.request.headers.get("Stripe-Signature");
|
const signatureHeader = event.request.headers.get("Stripe-Signature");
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
|
import Input from "~/components/ui/Input";
|
||||||
|
import { createSignal, Show } from "solid-js";
|
||||||
import { useDarkMode } from "~/context/darkMode";
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
import { useSite } from "~/context/SiteContext";
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
|
||||||
@@ -34,6 +36,32 @@ export default function NookLanding() {
|
|||||||
const { isDark } = useDarkMode();
|
const { isDark } = useDarkMode();
|
||||||
const brandColor = () =>
|
const brandColor = () =>
|
||||||
isDark() ? (site().brandColorDark ?? site().brandColor) : site().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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -168,6 +196,54 @@ export default function NookLanding() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── Resend license ─────────────────────────────────────────── */}
|
||||||
|
<section class="bg-base relative z-20 px-4 py-16 md:px-8">
|
||||||
|
<div class="border-overlay0 bg-surface0 mx-auto max-w-md rounded-xl border p-8">
|
||||||
|
<h2 class="text-text mb-2 text-center text-2xl font-bold">
|
||||||
|
Lost your license?
|
||||||
|
</h2>
|
||||||
|
<p class="text-subtext0 mb-6 text-center text-sm">
|
||||||
|
Enter the email you purchased with and we'll resend your key.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
resendLicense();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
label="Email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
disabled={resendSending()}
|
||||||
|
value={resendEmail()}
|
||||||
|
onInput={(e) => setResendEmail(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
color={brandColor()}
|
||||||
|
loading={resendSending()}
|
||||||
|
>
|
||||||
|
Resend license
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<Show when={resendSent()}>
|
||||||
|
<p class="text-subtext1 mt-4 text-center text-sm">
|
||||||
|
Check your inbox — if a license is registered to that email, your
|
||||||
|
key is on its way.
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
<Show when={resendError()}>
|
||||||
|
<p class="mt-4 text-center text-sm text-red-500">
|
||||||
|
Something went wrong. Please try again.
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,11 @@ import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
|
|||||||
* is bootstrapped idempotently (CREATE TABLE IF NOT EXISTS) so no migration
|
* is bootstrapped idempotently (CREATE TABLE IF NOT EXISTS) so no migration
|
||||||
* tool is needed.
|
* 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))
|
* Payload layout (v1) is defined below in `buildPayload`. The Swift client
|
||||||
*
|
* decodes the base32 and verifies the EXACT payload bytes against the
|
||||||
* Canonicalization is load-bearing. The Swift client verifies the EXACT
|
* Ed25519 signature, so the layout and signing must stay stable.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface IssueLicenseResult {
|
interface IssueLicenseResult {
|
||||||
@@ -76,24 +72,86 @@ function privateKeyObject() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function signPayload(payload: string): string {
|
const KEY_PREFIX = "NOOK-";
|
||||||
const signature = sign(null, Buffer.from(payload, "utf8"), privateKeyObject());
|
const SIGNATURE_LENGTH = 64;
|
||||||
return Buffer.from(signature).toString("base64url");
|
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). */
|
/** Re-verifies a license key's Ed25519 signature server-side (defense in depth). */
|
||||||
export function verifyLicenseKey(key: string): boolean {
|
export function verifyLicenseKey(key: string): boolean {
|
||||||
const token = key.match(/^([^]*?)\.([A-Za-z0-9_-]+)$/);
|
const token = base32Decode(key);
|
||||||
if (!token) return false;
|
if (!token || token.length <= SIGNATURE_LENGTH) return false;
|
||||||
const [, payload, sigB64] = token;
|
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());
|
const publicKey = createPublicKey(privateKeyObject());
|
||||||
let signature: Buffer;
|
return verify(null, payload as Buffer, publicKey, signature);
|
||||||
try {
|
|
||||||
signature = Buffer.from(sigB64!, "base64url");
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return verify(null, Buffer.from(payload!, "utf8"), publicKey, signature);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,13 +164,10 @@ async function insertLicense(
|
|||||||
maxDevices: number
|
maxDevices: number
|
||||||
): Promise<IssueLicenseResult> {
|
): Promise<IssueLicenseResult> {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const payload = JSON.stringify({
|
const iat = Math.floor(Date.now() / 1000);
|
||||||
v: PAYLOAD_VERSION,
|
const payload = buildPayload(id, email, iat);
|
||||||
lid: id,
|
const signature = sign(null, payload, privateKeyObject());
|
||||||
email: email,
|
const key = encodeKey(Buffer.concat([payload, signature]));
|
||||||
iat: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
const key = `${payload}.${signPayload(payload)}`;
|
|
||||||
await NookConnectionFactory().execute({
|
await NookConnectionFactory().execute({
|
||||||
sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked, max_devices)
|
sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked, max_devices)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, ?)`,
|
VALUES (?, ?, ?, ?, ?, 0, ?)`,
|
||||||
@@ -147,3 +202,31 @@ export async function grantLicense(
|
|||||||
): Promise<IssueLicenseResult> {
|
): Promise<IssueLicenseResult> {
|
||||||
return insertLicense(email, `gift:${crypto.randomUUID()}`, maxDevices);
|
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<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: { 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user