prep for the nook
This commit is contained in:
@@ -75,3 +75,11 @@ GITHUB_API_TOKEN="<rotate-in-github-settings>" # ghp_... / g
|
|||||||
# Source maps upload — create a token at: Settings > Projects > freno-dev > Client Keys (DSN) > Auth Token
|
# 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/
|
# or generate an internal auth token at: https://sentry.io/settings/account/api/keys/
|
||||||
SENTRY_AUTH_TOKEN="sntrys_<generate-in-sentry-dashboard>"
|
SENTRY_AUTH_TOKEN="sntrys_<generate-in-sentry-dashboard>"
|
||||||
|
|
||||||
|
# ── The Nook licensing ──
|
||||||
|
NOOK_DB_URL="libsql://<nook-db>.turso.io"
|
||||||
|
NOOK_DB_TOKEN="<rotate-in-turso-dashboard>" # eyJ...
|
||||||
|
NOOK_LICENSE_PRIVATE_KEY="<generate-license-keypair>" # base64 PKCS8 Ed25519 private key (scripts/generate-license-keys.ts)
|
||||||
|
NOOK_STRIPE_SK="sk_live_<rotate-in-stripe-dashboard>"
|
||||||
|
NOOK_STRIPE_WEBHOOK_SECRET="whsec_<rotate-in-stripe-dashboard>"
|
||||||
|
NOOK_STRIPE_PRICE_ID="price_<from-stripe-dashboard>"
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ perf-results-*.json
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
# pygienium run-state and check artifacts
|
# pygienium run-state and check artifacts
|
||||||
.pygienium/
|
.pygienium/
|
||||||
|
scripts/
|
||||||
|
|||||||
8
src/env/server.ts
vendored
8
src/env/server.ts
vendored
@@ -67,7 +67,13 @@ const serverEnvSchema = z.object({
|
|||||||
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
|
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
|
||||||
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
|
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
|
||||||
VITE_TURNSTILE_SITE_KEY: z.string().min(1),
|
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>;
|
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from "./nav-config";
|
} from "./nav-config";
|
||||||
import { SITE_CONFIG, type SiteId } from "./site-context";
|
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", () => {
|
describe("NAV_CONFIG — per-site link sets", () => {
|
||||||
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
|
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", () => {
|
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]) {
|
for (const item of NAV_CONFIG[id]) {
|
||||||
// No subdomain-prefixed paths leak into the public nav.
|
// No subdomain-prefixed paths leak into the public nav.
|
||||||
expect(item.href.startsWith(`/${id}/`)).toBe(false);
|
expect(item.href.startsWith(`/${id}/`)).toBe(false);
|
||||||
@@ -111,7 +111,7 @@ describe("NAV_CONFIG — href correctness", () => {
|
|||||||
|
|
||||||
describe("NAV_CONFIG — auth-scoping by construction", () => {
|
describe("NAV_CONFIG — auth-scoping by construction", () => {
|
||||||
it("no subdomain nav item sets showLoggedIn / showLoggedOut", () => {
|
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]) {
|
for (const item of NAV_CONFIG[id]) {
|
||||||
expect(item.showLoggedIn).toBeUndefined();
|
expect(item.showLoggedIn).toBeUndefined();
|
||||||
expect(item.showLoggedOut).toBeUndefined();
|
expect(item.showLoggedOut).toBeUndefined();
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
|
|||||||
{ label: "Contact", href: "/contact", icon: "contact" },
|
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||||
{ label: "Privacy", href: "/privacy", icon: "privacy" },
|
{ label: "Privacy", href: "/privacy", icon: "privacy" },
|
||||||
{ label: "Downloads", href: "/downloads", icon: "downloads" }
|
{ label: "Downloads", href: "/downloads", icon: "downloads" }
|
||||||
|
],
|
||||||
|
nook: [
|
||||||
|
{ label: "Home", href: "/", icon: "home" },
|
||||||
|
{ label: "Privacy", href: "/privacy", icon: "privacy" }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ describe("resolveSiteFromHost", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => {
|
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].baseRoutePrefix).toBe(`/${id}`);
|
||||||
expect(SITE_CONFIG[id].subdomain).toBe(id);
|
expect(SITE_CONFIG[id].subdomain).toBe(id);
|
||||||
expect(SITE_CONFIG[id].titleSuffix).toBe(
|
expect(SITE_CONFIG[id].titleSuffix).toBe(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
|
* `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 {
|
export interface Site {
|
||||||
/** Canonical id, also serialized into `<html data-site>` and `window.__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",
|
brandColor: "#41a5ff",
|
||||||
ogDefaultImage: "/inputhalo/og-default.png",
|
ogDefaultImage: "/inputhalo/og-default.png",
|
||||||
faviconPath: "/inputhalo/favicon/favicon.ico"
|
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.nessa,
|
||||||
SITE_CONFIG.lineage,
|
SITE_CONFIG.lineage,
|
||||||
SITE_CONFIG.gaze,
|
SITE_CONFIG.gaze,
|
||||||
SITE_CONFIG.inputhalo
|
SITE_CONFIG.inputhalo,
|
||||||
|
SITE_CONFIG.nook
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */
|
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ describe("SITEMAP_ROUTES validation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("each site has at least the home page entry", () => {
|
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) {
|
for (const id of siteIds) {
|
||||||
expect(SITEMAP_ROUTES[id].some((e) => e.path === "/")).toBe(true);
|
expect(SITEMAP_ROUTES[id].some((e) => e.path === "/")).toBe(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,5 +77,11 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
|||||||
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||||
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
|
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
|
||||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
|
{ 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 }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
61
src/routes/api/TheNook/appcast.xml.ts
Normal file
61
src/routes/api/TheNook/appcast.xml.ts
Normal 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"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
|
||||||
if (
|
if (
|
||||||
!isValidPrefix ||
|
!isValidPrefix ||
|
||||||
(!filename.endsWith(".dmg") && !filename.endsWith(".delta"))
|
(!filename.endsWith(".dmg") &&
|
||||||
|
!filename.endsWith(".delta") &&
|
||||||
|
!filename.endsWith(".zip"))
|
||||||
) {
|
) {
|
||||||
return new Response("Invalid file format", {
|
return new Response("Invalid file format", {
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -69,7 +71,9 @@ export async function GET(event: APIEvent) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = filename.endsWith(".dmg")
|
const contentType = filename.endsWith(".zip")
|
||||||
|
? "application/zip"
|
||||||
|
: filename.endsWith(".dmg")
|
||||||
? "application/x-apple-diskimage"
|
? "application/x-apple-diskimage"
|
||||||
: "application/octet-stream";
|
: "application/octet-stream";
|
||||||
|
|
||||||
|
|||||||
21
src/routes/api/the-nook/_lib.ts
Normal file
21
src/routes/api/the-nook/_lib.ts
Normal 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);
|
||||||
|
}
|
||||||
97
src/routes/api/the-nook/activate.ts
Normal file
97
src/routes/api/the-nook/activate.ts
Normal 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);
|
||||||
|
}
|
||||||
32
src/routes/api/the-nook/by-session.ts
Normal file
32
src/routes/api/the-nook/by-session.ts
Normal 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 });
|
||||||
|
}
|
||||||
71
src/routes/api/the-nook/checkout.ts
Normal file
71
src/routes/api/the-nook/checkout.ts
Normal 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 });
|
||||||
|
}
|
||||||
58
src/routes/api/the-nook/deactivate.ts
Normal file
58
src/routes/api/the-nook/deactivate.ts
Normal 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 });
|
||||||
|
}
|
||||||
51
src/routes/api/the-nook/status.ts
Normal file
51
src/routes/api/the-nook/status.ts
Normal 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 });
|
||||||
|
}
|
||||||
45
src/routes/api/the-nook/trial.ts
Normal file
45
src/routes/api/the-nook/trial.ts
Normal 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 });
|
||||||
|
}
|
||||||
126
src/routes/api/the-nook/webhooks/stripe.ts
Normal file
126
src/routes/api/the-nook/webhooks/stripe.ts
Normal 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 });
|
||||||
|
}
|
||||||
110
src/routes/nook/checkout.tsx
Normal file
110
src/routes/nook/checkout.tsx
Normal 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
147
src/routes/nook/index.tsx
Normal 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
107
src/routes/nook/privacy.tsx
Normal 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's Privacy Policy</div>
|
||||||
|
<div class="py-2">Last Updated: August 26, 2026</div>
|
||||||
|
<div class="py-2">
|
||||||
|
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.
|
||||||
|
</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'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
112
src/routes/nook/success.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,10 +14,16 @@ import {
|
|||||||
import {
|
import {
|
||||||
ConnectionFactory,
|
ConnectionFactory,
|
||||||
LineageConnectionFactory,
|
LineageConnectionFactory,
|
||||||
NessaConnectionFactory
|
NessaConnectionFactory,
|
||||||
|
NookConnectionFactory
|
||||||
} from "~/server/db-connections";
|
} from "~/server/db-connections";
|
||||||
// Re-export connection factories to avoid circular import with auth.ts
|
// Re-export connection factories to avoid circular import with auth.ts
|
||||||
export { ConnectionFactory, LineageConnectionFactory, NessaConnectionFactory };
|
export {
|
||||||
|
ConnectionFactory,
|
||||||
|
LineageConnectionFactory,
|
||||||
|
NessaConnectionFactory,
|
||||||
|
NookConnectionFactory
|
||||||
|
};
|
||||||
|
|
||||||
export async function LineageDBInit() {
|
export async function LineageDBInit() {
|
||||||
const turso = createAPIClient({
|
const turso = createAPIClient({
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createClient } from "@libsql/client/web";
|
import { createClient, type Client } from "@libsql/client/web";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
|
|
||||||
let mainDBConnection: ReturnType<typeof createClient> | null = null;
|
let mainDBConnection: Client | null = null;
|
||||||
let lineageDBConnection: ReturnType<typeof createClient> | null = null;
|
let lineageDBConnection: Client | null = null;
|
||||||
let nessaDBConnection: ReturnType<typeof createClient> | null = null;
|
let nessaDBConnection: Client | null = null;
|
||||||
|
let nookDBConnection: Client | null = null;
|
||||||
|
|
||||||
export function ConnectionFactory() {
|
export function ConnectionFactory() {
|
||||||
if (!mainDBConnection) {
|
if (!mainDBConnection) {
|
||||||
@@ -37,3 +38,14 @@ export function NessaConnectionFactory() {
|
|||||||
}
|
}
|
||||||
return nessaDBConnection;
|
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
114
src/server/nook.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -23,7 +23,9 @@
|
|||||||
{ "source": "/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/nessa/$1" },
|
{ "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": "lineage.freno.me" }], "destination": "/lineage/$1" },
|
||||||
{ "source": "/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/gaze/$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": [
|
"headers": [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user