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 { 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<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");
|
||||
|
||||
@@ -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() {
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user