Compare commits

...

4 Commits

5 changed files with 137 additions and 23 deletions

View File

@@ -0,0 +1,45 @@
/**
* One-shot The Nook license Ed25519 keypair generator.
*
* Run with: bun scripts/generate-license-keys.ts
*
* Prints:
* - The Ed25519 PRIVATE key as base64 PKCS8 DER → NOOK_LICENSE_PRIVATE_KEY
* (freno-dev env).
* - The raw 32-byte Ed25519 public key (the X coordinate) as base64 SPKI DER
* suffix → compiled into the Swift `LicenseVerifier.PublicKeyConstant`.
*
* The private key lives ONLY in env — never in git.
* Paste the public key into Sources/NookCore/Licensing/LicenseVerifier.swift
* (step 4 of the distribution plan) after running this once.
*/
import { generateKeyPairSync, createPrivateKey } from "node:crypto";
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const privateKeyDer = privateKey.export({ format: "der", type: "pkcs8" });
const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" });
// Raw 32-byte X coordinate: tail of the SPKI DER public key.
const publicKeySpki = publicKey.export({ format: "der", type: "spki" });
const rawPublic = publicKeySpki.subarray(-32);
console.log("── The Nook license keypair ──────────────────────────────");
console.log("NOOK_LICENSE_PRIVATE_KEY (base64 PKCS8 DER):");
console.log(privateKeyDer.toString("base64"));
console.log("");
console.log("Private key PEM (reference, for license signing only):");
console.log(privateKeyPem);
console.log("");
console.log("LicenseVerifier public key base64 (raw 32-byte X, step 4):");
console.log(rawPublic.toString("base64"));
console.log("──────────────────────────────────────────────────────────");
// Sanity check: sign + verify round trip with the exported artifacts.
const publicKeyFromPem = createPrivateKey(privateKeyPem)
.export({ format: "der", type: "pkcs8" });
if (Buffer.compare(Buffer.from(privateKeyDer), Buffer.from(publicKeyFromPem)) !== 0) {
console.error("Keypair export sanity check failed.");
process.exit(1);
}
console.log("Sanity check passed.");

View File

@@ -0,0 +1,28 @@
import { nookSchemaBootstrap, grantLicense } from "~/server/nook";
// Mint a free The Nook license (gifting / comps). Defaults to 1 device.
//
// bun --env-file=.env scripts/grant-nook-license.ts \
// --email friend@example.com [--devices 1]
const arg = (name: string) => {
const i = process.argv.indexOf(`--${name}`);
return i === -1 ? undefined : process.argv[i + 1];
};
const email = arg("email");
const devices = Number(arg("devices") ?? "1");
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
console.error(
"Usage: bun --env-file=.env scripts/grant-nook-license.ts --email you@example.com [--devices 1]"
);
process.exit(1);
}
await nookSchemaBootstrap;
const { key } = await grantLicense(email, devices);
console.log(`Granted The Nook license for ${email} (${devices} device(s)):`);
console.log(key);
console.log("Send it to them; they enter it in Settings > License.");

View File

@@ -39,13 +39,18 @@ export async function POST(event: APIEvent) {
const conn = NookConnectionFactory(); const conn = NookConnectionFactory();
const licenseRes = await conn.execute({ const licenseRes = await conn.execute({
sql: "SELECT id, email, revoked FROM licenses WHERE key = ?", sql: "SELECT id, email, revoked, max_devices FROM licenses WHERE key = ?",
args: [key] args: [key]
}); });
if (licenseRes.rows.length === 0) { if (licenseRes.rows.length === 0) {
return error("License not found", 404); return error("License not found", 404);
} }
const license = licenseRes.rows[0] as { id: string; email: string; revoked: number }; const license = licenseRes.rows[0] as {
id: string;
email: string;
revoked: number;
maxDevices: number;
};
if (license.revoked === 1) { if (license.revoked === 1) {
return error("License revoked", 403); return error("License revoked", 403);
} }
@@ -57,17 +62,18 @@ export async function POST(event: APIEvent) {
args: [license.id, fingerprint] args: [license.id, fingerprint]
}); });
if (existingRes.rows.length > 0) { if (existingRes.rows.length > 0) {
const existing = existingRes.rows[0] as { id: string }; // row we inserted as an activation
await conn.execute({ await conn.execute({
sql: "UPDATE activations SET activated_at = ? WHERE id = ?", sql: "UPDATE activations SET activated_at = ? WHERE id = ?",
args: [new Date().toISOString(), existingRes.rows[0] as { id: string }] args: [new Date().toISOString(), existing.id]
}); });
const count = await activeCount(conn, license.id); const count = await activeCount(conn, license.id);
return json({ ok: true, email: license.email, activatedCount: count }); return json({ ok: true, email: license.email, activatedCount: count, maxDevices: license.maxDevices });
} }
const count = await activeCount(conn, license.id); const count = await activeCount(conn, license.id);
if (count >= 3) { if (count >= license.maxDevices) {
return error("Activation limit reached (3 devices)", 409); return error(`Activation limit reached (${license.maxDevices} devices)`, 409);
} }
await conn.execute({ await conn.execute({
@@ -82,7 +88,7 @@ export async function POST(event: APIEvent) {
new Date().toISOString() new Date().toISOString()
] ]
}); });
return json({ ok: true, email: license.email, activatedCount: count + 1 }); return json({ ok: true, email: license.email, activatedCount: count + 1, maxDevices: license.maxDevices });
} }
async function activeCount( async function activeCount(

View File

@@ -29,16 +29,16 @@ export async function POST(event: APIEvent) {
const conn = NookConnectionFactory(); const conn = NookConnectionFactory();
const licenseRes = await conn.execute({ const licenseRes = await conn.execute({
sql: "SELECT id, revoked FROM licenses WHERE key = ?", sql: "SELECT id, revoked, max_devices FROM licenses WHERE key = ?",
args: [key] args: [key]
}); });
if (licenseRes.rows.length === 0) { if (licenseRes.rows.length === 0) {
return json({ state: "unknown_key", activatedCount: 0 }); return json({ state: "unknown_key", activatedCount: 0, maxDevices: 0 });
} }
const license = licenseRes.rows[0] as { id: string; revoked: number }; const license = licenseRes.rows[0] as { id: string; revoked: number; maxDevices: number };
if (license.revoked === 1) { if (license.revoked === 1) {
return json({ state: "revoked", activatedCount: 0 }); return json({ state: "revoked", activatedCount: 0, maxDevices: 0 });
} }
const countRes = await conn.execute({ const countRes = await conn.execute({
@@ -47,5 +47,5 @@ export async function POST(event: APIEvent) {
}); });
const activatedCount = Number((countRes.rows[0] as { n: number | bigint }).n); const activatedCount = Number((countRes.rows[0] as { n: number | bigint }).n);
return json({ state: "valid", activatedCount }); return json({ state: "valid", activatedCount, maxDevices: license.maxDevices });
} }

View File

@@ -37,9 +37,19 @@ export const nookSchemaBootstrap: Promise<unknown> = (async () => {
email TEXT NOT NULL, email TEXT NOT NULL,
stripe_session_id TEXT UNIQUE NOT NULL, stripe_session_id TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0 revoked INTEGER NOT NULL DEFAULT 0,
max_devices INTEGER NOT NULL DEFAULT 3
) )
`); `);
const licenseCols = await conn.execute(`PRAGMA table_info(licenses)`);
const hasMaxDevices = licenseCols.rows.some(
(r) => (r as { name?: string }).name === "max_devices"
);
if (!hasMaxDevices) {
await conn.execute(
`ALTER TABLE licenses ADD COLUMN max_devices INTEGER NOT NULL DEFAULT 3`
);
}
await conn.execute(` await conn.execute(`
CREATE TABLE IF NOT EXISTS activations ( CREATE TABLE IF NOT EXISTS activations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -87,15 +97,13 @@ export function verifyLicenseKey(key: string): boolean {
} }
/** /**
* Issues a license key for a completed Stripe checkout session. * Signs and stores a license row. Purchase licenses cap at 3 devices; gift
* * licenses (via `grantLicense`) cap at 1 unless overridden.
* 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( async function insertLicense(
email: string, email: string,
stripeSessionId: string stripeSessionId: string,
maxDevices: number
): Promise<IssueLicenseResult> { ): Promise<IssueLicenseResult> {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const payload = JSON.stringify({ const payload = JSON.stringify({
@@ -106,9 +114,36 @@ export async function issueLicense(
}); });
const key = `${payload}.${signPayload(payload)}`; const key = `${payload}.${signPayload(payload)}`;
await NookConnectionFactory().execute({ await NookConnectionFactory().execute({
sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked) sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked, max_devices)
VALUES (?, ?, ?, ?, ?, 0)`, VALUES (?, ?, ?, ?, ?, 0, ?)`,
args: [id, key, email, stripeSessionId, new Date().toISOString()] args: [id, key, email, stripeSessionId, new Date().toISOString(), maxDevices]
}); });
return { key, id }; return { key, id };
} }
/**
* Issues a license key for a completed Stripe checkout session (3 devices).
*
* 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> {
return insertLicense(email, stripeSessionId, 3);
}
/**
* Mints a free license outside the Stripe flow (gifting / comps).
*
* `stripe_session_id` holds a `gift:<uuid>` sentinel so the UNIQUE NOT NULL
* constraint is satisfied. Defaults to a 1-device cap.
*/
export async function grantLicense(
email: string,
maxDevices = 1
): Promise<IssueLicenseResult> {
return insertLicense(email, `gift:${crypto.randomUUID()}`, maxDevices);
}