prep for the nook

This commit is contained in:
2026-08-26 17:41:57 -04:00
parent 898c891bd5
commit e4a9be4899
27 changed files with 1234 additions and 20 deletions

8
src/env/server.ts vendored
View File

@@ -67,7 +67,13 @@ const serverEnvSchema = z.object({
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
VITE_TURNSTILE_SITE_KEY: z.string().min(1),
TURNSTILE_SECRET_KEY: z.string().min(1)
TURNSTILE_SECRET_KEY: z.string().min(1),
NOOK_DB_URL: z.string().min(1),
NOOK_DB_TOKEN: z.string().min(1),
NOOK_LICENSE_PRIVATE_KEY: z.string().min(1),
NOOK_STRIPE_SK: z.string().min(1),
NOOK_STRIPE_WEBHOOK_SECRET: z.string().min(1),
NOOK_STRIPE_PRICE_ID: z.string().min(1)
});
export type ServerEnv = z.infer<typeof serverEnvSchema>;

View File

@@ -16,7 +16,7 @@ import {
} from "./nav-config";
import { SITE_CONFIG, type SiteId } from "./site-context";
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
describe("NAV_CONFIG — per-site link sets", () => {
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
@@ -80,7 +80,7 @@ describe("NAV_CONFIG — href correctness", () => {
});
it("subdomain nav hrefs are public browser paths, never the internal rewritten prefix", () => {
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) {
for (const item of NAV_CONFIG[id]) {
// No subdomain-prefixed paths leak into the public nav.
expect(item.href.startsWith(`/${id}/`)).toBe(false);
@@ -111,7 +111,7 @@ describe("NAV_CONFIG — href correctness", () => {
describe("NAV_CONFIG — auth-scoping by construction", () => {
it("no subdomain nav item sets showLoggedIn / showLoggedOut", () => {
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) {
for (const item of NAV_CONFIG[id]) {
expect(item.showLoggedIn).toBeUndefined();
expect(item.showLoggedOut).toBeUndefined();

View File

@@ -116,6 +116,10 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
{ label: "Contact", href: "/contact", icon: "contact" },
{ label: "Privacy", href: "/privacy", icon: "privacy" },
{ label: "Downloads", href: "/downloads", icon: "downloads" }
],
nook: [
{ label: "Home", href: "/", icon: "home" },
{ label: "Privacy", href: "/privacy", icon: "privacy" }
]
};

View File

@@ -81,7 +81,7 @@ describe("resolveSiteFromHost", () => {
});
it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => {
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
for (const id of ["nessa", "lineage", "gaze", "inputhalo", "nook"] as SiteId[]) {
expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`);
expect(SITE_CONFIG[id].subdomain).toBe(id);
expect(SITE_CONFIG[id].titleSuffix).toBe(

View File

@@ -12,7 +12,7 @@
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
*/
export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo";
export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo" | "nook";
export interface Site {
/** Canonical id, also serialized into `<html data-site>` and `window.__SITE__`. */
@@ -122,6 +122,18 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
brandColor: "#41a5ff",
ogDefaultImage: "/inputhalo/og-default.png",
faviconPath: "/inputhalo/favicon/favicon.ico"
},
nook: {
id: "nook",
subdomain: "nook",
domain: `nook.${BASE_DOMAIN}`,
baseRoutePrefix: "/nook",
displayName: "The Nook",
titleSuffix: " | The Nook",
brandColor: "#8b5cf6",
brandColorDark: "#a78bfa",
ogDefaultImage: "/nook/og-default.png",
faviconPath: "/nook/favicon/favicon.ico"
}
};
@@ -130,7 +142,8 @@ const SUBDOMAIN_SITES: ReadonlyArray<Site> = [
SITE_CONFIG.nessa,
SITE_CONFIG.lineage,
SITE_CONFIG.gaze,
SITE_CONFIG.inputhalo
SITE_CONFIG.inputhalo,
SITE_CONFIG.nook
];
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */

View File

@@ -146,7 +146,7 @@ describe("SITEMAP_ROUTES validation", () => {
});
it("each site has at least the home page entry", () => {
const siteIds: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
const siteIds: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
for (const id of siteIds) {
expect(SITEMAP_ROUTES[id].some((e) => e.path === "/")).toBe(true);
}

View File

@@ -77,5 +77,11 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
{ path: "/", changefreq: "weekly", priority: 1.0 },
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
],
nook: [
{ path: "/", changefreq: "weekly", priority: 1.0 },
{ path: "/checkout", changefreq: "monthly", priority: 0.5 },
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
]
};

View File

@@ -0,0 +1,61 @@
import type { APIEvent } from "@solidjs/start/server";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { env } from "~/env/server";
/**
* Serves the TheNook appcast.xml file from S3
* This endpoint is used by Sparkle updater to check for new versions
*
* URL: https://freno.me/api/TheNook/appcast.xml
*/
export async function GET(_event: APIEvent) {
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
const key = "api/TheNook/appcast.xml";
const credentials = {
accessKeyId: env.MY_AWS_ACCESS_KEY,
secretAccessKey: env.MY_AWS_SECRET_KEY
};
try {
const client = new S3Client({
region: env.AWS_REGION,
credentials: credentials
});
const command = new GetObjectCommand({
Bucket: bucket,
Key: key
});
const response = await client.send(command);
if (!response.Body) {
return new Response("Appcast not found", {
status: 404,
headers: {
"Content-Type": "text/plain"
}
});
}
const body = await response.Body.transformToString();
return new Response(body, {
status: 200,
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=300", // Cache for 5 minutes
"Access-Control-Allow-Origin": "*" // Allow CORS for Sparkle appcast
}
});
} catch (error) {
console.error("Failed to fetch appcast:", error);
return new Response("Internal Server Error", {
status: 500,
headers: {
"Content-Type": "text/plain"
}
});
}
}

View File

@@ -24,11 +24,13 @@ export async function GET(event: APIEvent) {
});
}
const validPrefixes = ["Gaze", "InputHalo"];
const validPrefixes = ["Gaze", "InputHalo", "TheNook"];
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
if (
!isValidPrefix ||
(!filename.endsWith(".dmg") && !filename.endsWith(".delta"))
(!filename.endsWith(".dmg") &&
!filename.endsWith(".delta") &&
!filename.endsWith(".zip"))
) {
return new Response("Invalid file format", {
status: 400,
@@ -69,9 +71,11 @@ export async function GET(event: APIEvent) {
});
}
const contentType = filename.endsWith(".dmg")
? "application/x-apple-diskimage"
: "application/octet-stream";
const contentType = filename.endsWith(".zip")
? "application/zip"
: filename.endsWith(".dmg")
? "application/x-apple-diskimage"
: "application/octet-stream";
const body = await response.Body.transformToByteArray();

View 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);
}

View 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);
}

View 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 });
}

View 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 });
}

View 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 });
}

View 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 });
}

View 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 });
}

View 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 });
}

View File

@@ -0,0 +1,110 @@
import { createSignal, onMount } from "solid-js";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import Button from "~/components/ui/Button";
import { env } from "~/env/client";
import { useSite } from "~/context/SiteContext";
import { useDarkMode } from "~/context/darkMode";
export default function NookCheckout() {
const site = useSite();
const { isDark } = useDarkMode();
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal("");
const [turnstileToken, setTurnstileToken] = createSignal("");
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
onMount(() => {
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.defer = true;
script.onload = () => {
const container = document.getElementById("turnstile-widget-nook");
if (container && (window as any).turnstile) {
(window as any).turnstile.render(container, {
sitekey: env.VITE_TURNSTILE_SITE_KEY,
theme: isDark() ? "dark" : "light",
callback: (token: string) => setTurnstileToken(token),
"expired-callback": () => setTurnstileToken("")
});
}
};
document.head.appendChild(script);
});
const buy = async () => {
if (loading()) return;
if (!turnstileToken()) {
setError("Complete the security check to continue.");
return;
}
setLoading(true);
setError("");
try {
const res = await fetch("/api/the-nook/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ turnstileToken: turnstileToken() })
});
const data = (await res.json()) as { checkoutUrl?: string; error?: string };
if (!res.ok || !data.checkoutUrl) {
setError(data.error ?? "Unable to start checkout. Please try again.");
return;
}
window.location.href = data.checkoutUrl;
} catch {
setError("Unable to start checkout. Please try again.");
} finally {
setLoading(false);
}
};
return (
<>
<PageHead
title="Checkout"
description="Buy a The Nook license — $10 one-time for up to 3 devices."
/>
<SubdomainHeader />
<div class="bg-base mx-auto max-w-lg px-4 py-16">
<div class="border-overlay0 bg-surface0 rounded-xl border p-8 shadow-2xl">
<h1 class="text-text mb-1 text-3xl font-bold">The Nook</h1>
<p class="text-subtext0 mb-6">Lifetime License 3 devices</p>
<div class="text-text mb-6 text-4xl font-extrabold">
$10<span class="text-subtext0 text-base font-normal"> one-time</span>
</div>
<ul class="text-subtext0 mb-6 space-y-2 text-sm">
<li> Activate on up to 3 of your own Macs</li>
<li> No subscription, ever</li>
<li> Key delivered by email + on this page</li>
</ul>
<div id="turnstile-widget-nook" class="mb-4" />
<Button
variant="download"
size="lg"
color={brandColor()}
fullWidth
loading={loading()}
onClick={buy}
>
Pay $10
</Button>
{error() && (
<p class="text-red mt-4 text-sm">{error()}</p>
)}
<p class="text-subtext1 mt-4 text-xs">
Billed once through Stripe. License delivered immediately after payment.
</p>
</div>
</div>
</>
);
}

147
src/routes/nook/index.tsx Normal file
View File

@@ -0,0 +1,147 @@
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import Button from "~/components/ui/Button";
import { useDarkMode } from "~/context/darkMode";
import { useSite } from "~/context/SiteContext";
const NOOK_DOWNLOAD_URL = "https://freno.me/api/downloads/TheNook-0.2.0.zip";
const FEATURES = [
{
title: "Unified device control",
body: "Monitor and control the hardware and services behind your Mac from one fast, native panel."
},
{
title: "Fan & thermal insight",
body: "Read and control system fans, watch temperatures, and keep performance predictable under load."
},
{
title: "Private by design",
body: "Runs fully on your machine with no mandatory accounts. Your hardware data never leaves the device."
},
{
title: "One-time license",
body: "Pay once, activate on up to three of your own Macs. No subscriptions, no forced renewals."
}
] as const;
export default function NookLanding() {
const site = useSite();
const { isDark } = useDarkMode();
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
return (
<>
<PageHead
title="Home"
description="The Nook — a native macOS utility for hardware control, fan and thermal insight, and quiet system services."
ogImage="/nook/og-default.png"
ogTitle="The Nook — native macOS hardware control"
ogDescription="A one-time-purchase macOS app for fan control, thermal insight, and quiet system services."
/>
<SubdomainHeader />
{/* ── Hero ─────────────────────────────────────────────────────── */}
<div class="relative flex min-h-screen flex-col overflow-hidden">
<div
class="fixed inset-0 z-0"
style={{
background: isDark()
? "radial-gradient(ellipse at top, #241a38 0%, #0b0b10 70%)"
: "radial-gradient(ellipse at top, #efe9ff 0%, #f5f5f5 70%)"
}}
/>
<div class="relative z-10 flex min-h-screen flex-col items-center justify-center px-4 py-24 text-center">
<div class="text-text/90 mb-6 rounded-2xl px-5 py-3 text-sm font-semibold tracking-wide backdrop-blur-sm"
style={{ border: "1px solid var(--color-overlay0)", background: "var(--color-surface0)" }}>
The Nook
</div>
<h1 class="text-text mb-4 text-5xl font-bold tracking-tight">
Your Mac, under your control
</h1>
<p class="text-subtext0 mb-2 max-w-xl text-xl">
Fan control, thermal insight, and quiet system services native macOS, one-time license.
</p>
<p class="text-subtext1 mb-8 text-sm">
macOS 14+ · 14-day free trial · 3 devices
</p>
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
<Button
variant="download"
size="lg"
color={brandColor()}
onClick={() => (window.location.href = NOOK_DOWNLOAD_URL)}
>
Download trial
</Button>
<Button
variant="secondary"
size="lg"
onClick={() => (window.location.href = "/checkout")}
>
Buy
</Button>
</div>
<p class="text-subtext1 mt-3 text-xs">
$10 one-time · 3 devices · 14-day free trial
</p>
</div>
</div>
{/* ── Feature highlights ───────────────────────────────────────── */}
<section class="bg-base relative z-20 px-4 py-20 md:px-8">
<div class="mx-auto max-w-4xl">
<h2 class="text-text mb-12 text-center text-3xl font-bold">
Quiet power, right where it belongs
</h2>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-2">
{FEATURES.map((feature) => (
<div class="border-overlay0 bg-surface0 rounded-lg border p-6">
<h3 class="text-text mb-2 text-xl font-semibold">
{feature.title}
</h3>
<p class="text-subtext0 leading-relaxed">{feature.body}</p>
</div>
))}
</div>
</div>
</section>
{/* ── CTA ──────────────────────────────────────────────────────── */}
<section class="bg-surface0 relative z-20 px-4 py-20 md:px-8">
<div class="mx-auto max-w-4xl text-center">
<h2 class="text-text mb-4 text-3xl font-bold">
Try it free for 14 days
</h2>
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
Watch temperatures, take control of your fans, and keep background
services quiet. When the trial ends, unlock everything with a
single one-time payment no subscription, ever.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4">
<Button
variant="download"
size="lg"
color={brandColor()}
onClick={() => (window.location.href = NOOK_DOWNLOAD_URL)}
>
Download trial
</Button>
<a
class="text-subtext1 my-auto text-sm underline decoration-dotted hover:opacity-80"
href="/checkout"
>
Buy a license $10
</a>
</div>
<p class="text-subtext1 mt-6 text-xs">
One license covers up to 3 of your own Macs.
</p>
</div>
</section>
</>
);
}

107
src/routes/nook/privacy.tsx Normal file
View File

@@ -0,0 +1,107 @@
/**
* The Nook privacy policy — `nook.freno.me/privacy`.
*
* Reflects the actual data The Nook sends when licensing: a masked hardware
* UUID + device name for trial/activation limits, the email used for purchase,
* and Stripe payment processing.
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function NookPrivacyPolicy() {
return (
<>
<PageHead
title="Privacy Policy"
description="Privacy policy for The Nook, a native macOS hardware and system control app."
/>
<SubdomainHeader />
<div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">The Nook&apos;s Privacy Policy</div>
<div class="py-2">Last Updated: August 26, 2026</div>
<div class="py-2">
Welcome to The Nook (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;).
Your privacy is important to us. This policy explains what we collect,
why, and how it is handled. The app itself runs locally on your device
and sends only the minimal data required for trial and licensing.
</div>
<ol>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">1.</span> Data We Collect
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Device hardware UUID:</div> When you start
a trial or activate a license, The Nook sends a hardware UUID
from your Mac. It is used solely to enforce the 14-day trial and
the 3-device activation limit, and to prevent resetting the
trial by reinstalling the app.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Device name:</div> The Nook sends your
Mac&apos;s name so you can recognize the device in your license
activations. It is not used for any other purpose.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Email:</div> The email you provide at
checkout is used to deliver your license key and to look up your
order.
</div>
<div class="pb-2">
<div class="-ml-6">(d) Payment:</div> Payments are processed by
Stripe. We never see or store your card details.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">2.</span> How We Use It
</div>
<div class="pb-2 pl-4">
Device identifiers, your email, and license records are used only
to operate the trial, deliver purchases, and enforce the 3-device
license. We do not use them for advertising or sell them to anyone.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">3.</span> Data Security
</div>
<div class="pb-2 pl-4">
License keys are signed and verified with Ed25519. The connection
to our servers uses TLS. Your license key is stored in the macOS
Keychain on your Mac.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">4.</span> Changes to the Privacy Policy
</div>
<div class="pb-2 pl-4">
We may update this policy periodically. Any changes are posted on
this page, so please review it from time to time.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">5.</span> Contact Us
</div>
<div class="pb-2 pl-4">
If you have any questions about this policy, you can contact us{" "}
<A href="/contact" class="text-blue hover-underline-animation">
here
</A>
.
</div>
</div>
</ol>
</div>
</>
);
}

112
src/routes/nook/success.tsx Normal file
View File

@@ -0,0 +1,112 @@
import { createSignal, Show, onMount } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import Button from "~/components/ui/Button";
import { useSite } from "~/context/SiteContext";
import { useDarkMode } from "~/context/darkMode";
const POLL_INTERVAL_MS = 1500;
const MAX_POLLS = 8;
export default function NookSuccess() {
const site = useSite();
const { isDark } = useDarkMode();
const [searchParams] = useSearchParams();
const sessionId = () => (searchParams.session_id as string | undefined) ?? "";
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
const [key, setKey] = createSignal<string | null>(null);
const [failed, setFailed] = createSignal(false);
const [copied, setCopied] = createSignal(false);
const copyKey = async () => {
const value = key();
if (!value) return;
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
onMount(async () => {
if (!sessionId()) {
setFailed(true);
return;
}
for (let i = 0; i < MAX_POLLS; i++) {
try {
const res = await fetch(
`/api/the-nook/by-session?session_id=${encodeURIComponent(sessionId())}`
);
if (res.ok) {
const data = (await res.json()) as { key: string };
setKey(data.key);
return;
}
} catch {
// transient — keep polling
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
setFailed(true);
});
return (
<>
<PageHead
title="Order complete"
description="Your The Nook license key."
/>
<SubdomainHeader />
<div class="bg-base mx-auto max-w-xl px-4 py-16">
<div class="border-overlay0 bg-surface0 rounded-xl border p-8 shadow-2xl">
<Show
when={key()}
fallback={
<Show
when={failed()}
fallback={
<div>
<h1 class="text-text mb-2 text-2xl font-bold">
Confirming your order
</h1>
<p class="text-subtext0 text-sm">
Checking payment and preparing your license key.
</p>
</div>
}
>
<div>
<h1 class="text-text mb-2 text-2xl font-bold">Thank you</h1>
<p class="text-subtext0 text-sm">
Check your email for your license key.
</p>
</div>
</Show>
}
>
<h1 class="text-text mb-2 text-2xl font-bold">Thanks for buying The Nook</h1>
<p class="text-subtext0 mb-6 text-sm">
Here is your license key. Open The Nook, go to Settings, and enter it to activate.
</p>
<div class="border-overlay0 bg-base mb-4 flex items-center justify-between gap-3 rounded-lg border p-4">
<code class="text-text break-all text-sm">{key()}</code>
</div>
<Button variant="download" size="md" color={brandColor()} onClick={copyKey}>
{copied() ? "Copied" : "Copy key"}
</Button>
<p class="text-subtext1 mt-4 text-xs">
We also emailed this key to you. You can activate up to 3 devices.
</p>
</Show>
</div>
</div>
</>
);
}

View File

@@ -14,10 +14,16 @@ import {
import {
ConnectionFactory,
LineageConnectionFactory,
NessaConnectionFactory
NessaConnectionFactory,
NookConnectionFactory
} from "~/server/db-connections";
// Re-export connection factories to avoid circular import with auth.ts
export { ConnectionFactory, LineageConnectionFactory, NessaConnectionFactory };
export {
ConnectionFactory,
LineageConnectionFactory,
NessaConnectionFactory,
NookConnectionFactory
};
export async function LineageDBInit() {
const turso = createAPIClient({

View File

@@ -1,9 +1,10 @@
import { createClient } from "@libsql/client/web";
import { createClient, type Client } from "@libsql/client/web";
import { env } from "~/env/server";
let mainDBConnection: ReturnType<typeof createClient> | null = null;
let lineageDBConnection: ReturnType<typeof createClient> | null = null;
let nessaDBConnection: ReturnType<typeof createClient> | null = null;
let mainDBConnection: Client | null = null;
let lineageDBConnection: Client | null = null;
let nessaDBConnection: Client | null = null;
let nookDBConnection: Client | null = null;
export function ConnectionFactory() {
if (!mainDBConnection) {
@@ -37,3 +38,14 @@ export function NessaConnectionFactory() {
}
return nessaDBConnection;
}
export function NookConnectionFactory() {
if (!nookDBConnection) {
const config = {
url: env.NOOK_DB_URL,
authToken: env.NOOK_DB_TOKEN
};
nookDBConnection = createClient(config);
}
return nookDBConnection;
}

114
src/server/nook.ts Normal file
View File

@@ -0,0 +1,114 @@
import { NookConnectionFactory } from "~/server/db-connections";
import { env } from "~/env/server";
import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
/**
* The Nook license schema + issueLicense helper.
*
* Kept out of `database.ts` to avoid circular imports: this module owns the
* dedicated The Nook Turso DB and the Ed25519 license-key signing. The schema
* is bootstrapped idempotently (CREATE TABLE IF NOT EXISTS) so no migration
* tool is needed.
*
* The license key is a compact printable string:
*
* 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.
*/
interface IssueLicenseResult {
key: string;
id: string;
}
const PAYLOAD_VERSION = 1;
export const nookSchemaBootstrap: Promise<unknown> = (async () => {
const conn = NookConnectionFactory();
await conn.execute(`
CREATE TABLE IF NOT EXISTS licenses (
id TEXT PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
stripe_session_id TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
)
`);
await conn.execute(`
CREATE TABLE IF NOT EXISTS activations (
id TEXT PRIMARY KEY,
license_id TEXT NOT NULL REFERENCES licenses(id),
device_fingerprint TEXT NOT NULL,
device_name TEXT NOT NULL,
activated_at TEXT NOT NULL,
deactivated_at TEXT
)
`);
await conn.execute(`
CREATE TABLE IF NOT EXISTS trials (
fingerprint TEXT PRIMARY KEY,
started_at TEXT NOT NULL
)
`);
})();
function privateKeyObject() {
return createPrivateKey({
key: Buffer.from(env.NOOK_LICENSE_PRIVATE_KEY, "base64"),
format: "der",
type: "pkcs8"
});
}
function signPayload(payload: string): string {
const signature = sign(null, Buffer.from(payload, "utf8"), privateKeyObject());
return Buffer.from(signature).toString("base64url");
}
/** 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 publicKey = createPublicKey(privateKeyObject());
let signature: Buffer;
try {
signature = Buffer.from(sigB64!, "base64url");
} catch {
return false;
}
return verify(null, Buffer.from(payload!, "utf8"), publicKey, signature);
}
/**
* Issues a license key for a completed Stripe checkout session.
*
* Caller is responsible for the uniqueness/idempotency of `stripeSessionId`
* (the `licenses.stripe_session_id` column is UNIQUE; the webhook catches the
* conflict and skips re-emailing).
*/
export async function issueLicense(
email: string,
stripeSessionId: string
): Promise<IssueLicenseResult> {
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)}`;
await NookConnectionFactory().execute({
sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked)
VALUES (?, ?, ?, ?, ?, 0)`,
args: [id, key, email, stripeSessionId, new Date().toISOString()]
});
return { key, id };
}