From e4a9be4899da9c50a9fd08b8d0f5be31fa00fa39 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 26 Aug 2026 17:41:57 -0400 Subject: [PATCH] prep for the nook --- .env.example | 8 ++ .gitignore | 1 + src/env/server.ts | 8 +- src/lib/nav-config.test.ts | 6 +- src/lib/nav-config.ts | 4 + src/lib/site-context.test.ts | 2 +- src/lib/site-context.ts | 17 ++- src/lib/sitemap-routes.test.ts | 2 +- src/lib/sitemap-routes.ts | 6 + src/routes/api/TheNook/appcast.xml.ts | 61 +++++++++ src/routes/api/downloads/[filename].ts | 14 +- src/routes/api/the-nook/_lib.ts | 21 +++ src/routes/api/the-nook/activate.ts | 97 ++++++++++++++ src/routes/api/the-nook/by-session.ts | 32 +++++ src/routes/api/the-nook/checkout.ts | 71 ++++++++++ src/routes/api/the-nook/deactivate.ts | 58 ++++++++ src/routes/api/the-nook/status.ts | 51 +++++++ src/routes/api/the-nook/trial.ts | 45 +++++++ src/routes/api/the-nook/webhooks/stripe.ts | 126 ++++++++++++++++++ src/routes/nook/checkout.tsx | 110 +++++++++++++++ src/routes/nook/index.tsx | 147 +++++++++++++++++++++ src/routes/nook/privacy.tsx | 107 +++++++++++++++ src/routes/nook/success.tsx | 112 ++++++++++++++++ src/server/database.ts | 10 +- src/server/db-connections.ts | 20 ++- src/server/nook.ts | 114 ++++++++++++++++ vercel.json | 4 +- 27 files changed, 1234 insertions(+), 20 deletions(-) create mode 100644 src/routes/api/TheNook/appcast.xml.ts create mode 100644 src/routes/api/the-nook/_lib.ts create mode 100644 src/routes/api/the-nook/activate.ts create mode 100644 src/routes/api/the-nook/by-session.ts create mode 100644 src/routes/api/the-nook/checkout.ts create mode 100644 src/routes/api/the-nook/deactivate.ts create mode 100644 src/routes/api/the-nook/status.ts create mode 100644 src/routes/api/the-nook/trial.ts create mode 100644 src/routes/api/the-nook/webhooks/stripe.ts create mode 100644 src/routes/nook/checkout.tsx create mode 100644 src/routes/nook/index.tsx create mode 100644 src/routes/nook/privacy.tsx create mode 100644 src/routes/nook/success.tsx create mode 100644 src/server/nook.ts diff --git a/.env.example b/.env.example index fe84e1f..4c19690 100644 --- a/.env.example +++ b/.env.example @@ -75,3 +75,11 @@ GITHUB_API_TOKEN="" # ghp_... / g # Source maps upload — create a token at: Settings > Projects > freno-dev > Client Keys (DSN) > Auth Token # or generate an internal auth token at: https://sentry.io/settings/account/api/keys/ SENTRY_AUTH_TOKEN="sntrys_" + +# ── The Nook licensing ── +NOOK_DB_URL="libsql://.turso.io" +NOOK_DB_TOKEN="" # eyJ... +NOOK_LICENSE_PRIVATE_KEY="" # base64 PKCS8 Ed25519 private key (scripts/generate-license-keys.ts) +NOOK_STRIPE_SK="sk_live_" +NOOK_STRIPE_WEBHOOK_SECRET="whsec_" +NOOK_STRIPE_PRICE_ID="price_" diff --git a/.gitignore b/.gitignore index e560895..6460862 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ perf-results-*.json Thumbs.db # pygienium run-state and check artifacts .pygienium/ +scripts/ diff --git a/src/env/server.ts b/src/env/server.ts index 5486834..e637020 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -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; diff --git a/src/lib/nav-config.test.ts b/src/lib/nav-config.test.ts index 5bf3e98..346df5d 100644 --- a/src/lib/nav-config.test.ts +++ b/src/lib/nav-config.test.ts @@ -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(); diff --git a/src/lib/nav-config.ts b/src/lib/nav-config.ts index 7dec861..f0a926e 100644 --- a/src/lib/nav-config.ts +++ b/src/lib/nav-config.ts @@ -116,6 +116,10 @@ export const NAV_CONFIG: Record = { { 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" } ] }; diff --git a/src/lib/site-context.test.ts b/src/lib/site-context.test.ts index 8df838e..4d2cc5b 100644 --- a/src/lib/site-context.test.ts +++ b/src/lib/site-context.test.ts @@ -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( diff --git a/src/lib/site-context.ts b/src/lib/site-context.ts index 1ee3823..47f90ec 100644 --- a/src/lib/site-context.ts +++ b/src/lib/site-context.ts @@ -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 `` and `window.__SITE__`. */ @@ -122,6 +122,18 @@ export const SITE_CONFIG: Record = { 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_CONFIG.nessa, SITE_CONFIG.lineage, SITE_CONFIG.gaze, - SITE_CONFIG.inputhalo + SITE_CONFIG.inputhalo, + SITE_CONFIG.nook ]; /** Matches `.localhost` and `.localhost:` (dev only). */ diff --git a/src/lib/sitemap-routes.test.ts b/src/lib/sitemap-routes.test.ts index 1b7ff3e..c3e3dfe 100644 --- a/src/lib/sitemap-routes.test.ts +++ b/src/lib/sitemap-routes.test.ts @@ -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); } diff --git a/src/lib/sitemap-routes.ts b/src/lib/sitemap-routes.ts index 1dc33e5..69a431e 100644 --- a/src/lib/sitemap-routes.ts +++ b/src/lib/sitemap-routes.ts @@ -77,5 +77,11 @@ export const SITEMAP_ROUTES: Record = { { 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 } ] }; diff --git a/src/routes/api/TheNook/appcast.xml.ts b/src/routes/api/TheNook/appcast.xml.ts new file mode 100644 index 0000000..9931b47 --- /dev/null +++ b/src/routes/api/TheNook/appcast.xml.ts @@ -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" + } + }); + } +} diff --git a/src/routes/api/downloads/[filename].ts b/src/routes/api/downloads/[filename].ts index 967ba39..0bd28d1 100644 --- a/src/routes/api/downloads/[filename].ts +++ b/src/routes/api/downloads/[filename].ts @@ -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(); diff --git a/src/routes/api/the-nook/_lib.ts b/src/routes/api/the-nook/_lib.ts new file mode 100644 index 0000000..2068936 --- /dev/null +++ b/src/routes/api/the-nook/_lib.ts @@ -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); +} diff --git a/src/routes/api/the-nook/activate.ts b/src/routes/api/the-nook/activate.ts new file mode 100644 index 0000000..a108494 --- /dev/null +++ b/src/routes/api/the-nook/activate.ts @@ -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; + 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 { + 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); +} diff --git a/src/routes/api/the-nook/by-session.ts b/src/routes/api/the-nook/by-session.ts new file mode 100644 index 0000000..e108d88 --- /dev/null +++ b/src/routes/api/the-nook/by-session.ts @@ -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 }); +} diff --git a/src/routes/api/the-nook/checkout.ts b/src/routes/api/the-nook/checkout.ts new file mode 100644 index 0000000..561dbb4 --- /dev/null +++ b/src/routes/api/the-nook/checkout.ts @@ -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; + 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 }); +} diff --git a/src/routes/api/the-nook/deactivate.ts b/src/routes/api/the-nook/deactivate.ts new file mode 100644 index 0000000..a18aa17 --- /dev/null +++ b/src/routes/api/the-nook/deactivate.ts @@ -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; + 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 }); +} diff --git a/src/routes/api/the-nook/status.ts b/src/routes/api/the-nook/status.ts new file mode 100644 index 0000000..eaf7f93 --- /dev/null +++ b/src/routes/api/the-nook/status.ts @@ -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; + 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 }); +} diff --git a/src/routes/api/the-nook/trial.ts b/src/routes/api/the-nook/trial.ts new file mode 100644 index 0000000..9a6668d --- /dev/null +++ b/src/routes/api/the-nook/trial.ts @@ -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; + 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 }); +} diff --git a/src/routes/api/the-nook/webhooks/stripe.ts b/src/routes/api/the-nook/webhooks/stripe.ts new file mode 100644 index 0000000..01b5d6e --- /dev/null +++ b/src/routes/api/the-nook/webhooks/stripe.ts @@ -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(); + 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 { + 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 }); +} diff --git a/src/routes/nook/checkout.tsx b/src/routes/nook/checkout.tsx new file mode 100644 index 0000000..753a5ca --- /dev/null +++ b/src/routes/nook/checkout.tsx @@ -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 ( + <> + + + +
+
+

The Nook

+

Lifetime License — 3 devices

+ +
+ $10 one-time +
+
    +
  • • Activate on up to 3 of your own Macs
  • +
  • • No subscription, ever
  • +
  • • Key delivered by email + on this page
  • +
+ +
+ + + + {error() && ( +

{error()}

+ )} +

+ Billed once through Stripe. License delivered immediately after payment. +

+
+
+ + ); +} diff --git a/src/routes/nook/index.tsx b/src/routes/nook/index.tsx new file mode 100644 index 0000000..9059751 --- /dev/null +++ b/src/routes/nook/index.tsx @@ -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 ( + <> + + + + + {/* ── Hero ─────────────────────────────────────────────────────── */} +
+
+
+
+ The Nook +
+

+ Your Mac, under your control +

+

+ Fan control, thermal insight, and quiet system services — native macOS, one-time license. +

+

+ macOS 14+ · 14-day free trial · 3 devices +

+ +
+ + +
+

+ $10 one-time · 3 devices · 14-day free trial +

+
+
+ + {/* ── Feature highlights ───────────────────────────────────────── */} +
+
+

+ Quiet power, right where it belongs +

+
+ {FEATURES.map((feature) => ( +
+

+ {feature.title} +

+

{feature.body}

+
+ ))} +
+
+
+ + {/* ── CTA ──────────────────────────────────────────────────────── */} +
+
+

+ Try it free for 14 days +

+

+ 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. +

+
+ + + Buy a license → $10 + +
+

+ One license covers up to 3 of your own Macs. +

+
+
+ + ); +} diff --git a/src/routes/nook/privacy.tsx b/src/routes/nook/privacy.tsx new file mode 100644 index 0000000..d0e17b5 --- /dev/null +++ b/src/routes/nook/privacy.tsx @@ -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 ( + <> + + +
+
The Nook's Privacy Policy
+
Last Updated: August 26, 2026
+
+ Welcome to The Nook ('We', 'Us', 'Our'). + 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. +
+
    +
    +
    + 1. Data We Collect +
    +
    +
    +
    (a) Device hardware UUID:
    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. +
    +
    +
    (b) Device name:
    The Nook sends your + Mac's name so you can recognize the device in your license + activations. It is not used for any other purpose. +
    +
    +
    (c) Email:
    The email you provide at + checkout is used to deliver your license key and to look up your + order. +
    +
    +
    (d) Payment:
    Payments are processed by + Stripe. We never see or store your card details. +
    +
    +
    + +
    +
    + 2. How We Use It +
    +
    + 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. +
    +
    + +
    +
    + 3. Data Security +
    +
    + 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. +
    +
    + +
    +
    + 4. Changes to the Privacy Policy +
    +
    + We may update this policy periodically. Any changes are posted on + this page, so please review it from time to time. +
    +
    + +
    +
    + 5. Contact Us +
    +
    + If you have any questions about this policy, you can contact us{" "} + + here + + . +
    +
    +
+
+ + ); +} diff --git a/src/routes/nook/success.tsx b/src/routes/nook/success.tsx new file mode 100644 index 0000000..aafe31a --- /dev/null +++ b/src/routes/nook/success.tsx @@ -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(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 ( + <> + + + +
+
+ +

+ Confirming your order… +

+

+ Checking payment and preparing your license key. +

+
+ } + > +
+

Thank you

+

+ Check your email for your license key. +

+
+ + } + > +

Thanks for buying The Nook

+

+ Here is your license key. Open The Nook, go to Settings, and enter it to activate. +

+ +
+ {key()} +
+ + + +

+ We also emailed this key to you. You can activate up to 3 devices. +

+ +
+
+ + ); +} diff --git a/src/server/database.ts b/src/server/database.ts index 464afc1..67a0925 100644 --- a/src/server/database.ts +++ b/src/server/database.ts @@ -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({ diff --git a/src/server/db-connections.ts b/src/server/db-connections.ts index 3adbc5c..5caefc4 100644 --- a/src/server/db-connections.ts +++ b/src/server/db-connections.ts @@ -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 | null = null; -let lineageDBConnection: ReturnType | null = null; -let nessaDBConnection: ReturnType | 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; +} diff --git a/src/server/nook.ts b/src/server/nook.ts new file mode 100644 index 0000000..54ee41c --- /dev/null +++ b/src/server/nook.ts @@ -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 = (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 { + 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 }; +} diff --git a/vercel.json b/vercel.json index c7b0dec..983ee05 100644 --- a/vercel.json +++ b/vercel.json @@ -23,7 +23,9 @@ { "source": "/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/nessa/$1" }, { "source": "/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/lineage/$1" }, { "source": "/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/gaze/$1" }, - { "source": "/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/inputhalo/$1" } + { "source": "/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/inputhalo/$1" }, + { "source": "/api/(.*)", "has": [{ "type": "host", "value": "nook.freno.me" }], "destination": "/api/$1" }, + { "source": "/(.*)", "has": [{ "type": "host", "value": "nook.freno.me" }], "destination": "/nook/$1" } ], "headers": [ {