diff --git a/scripts/grant-nook-license.ts b/scripts/grant-nook-license.ts new file mode 100644 index 0000000..ad9cfc9 --- /dev/null +++ b/scripts/grant-nook-license.ts @@ -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."); diff --git a/src/routes/api/the-nook/activate.ts b/src/routes/api/the-nook/activate.ts index 88e9ac8..36aa2f9 100644 --- a/src/routes/api/the-nook/activate.ts +++ b/src/routes/api/the-nook/activate.ts @@ -39,13 +39,18 @@ export async function POST(event: APIEvent) { const conn = NookConnectionFactory(); 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] }); if (licenseRes.rows.length === 0) { 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) { return error("License revoked", 403); } @@ -67,8 +72,8 @@ export async function POST(event: APIEvent) { } const count = await activeCount(conn, license.id); - if (count >= 3) { - return error("Activation limit reached (3 devices)", 409); + if (count >= license.maxDevices) { + return error(`Activation limit reached (${license.maxDevices} devices)`, 409); } await conn.execute({ diff --git a/src/server/nook.ts b/src/server/nook.ts index 54ee41c..59e5fb6 100644 --- a/src/server/nook.ts +++ b/src/server/nook.ts @@ -37,9 +37,19 @@ export const nookSchemaBootstrap: Promise = (async () => { email TEXT NOT NULL, stripe_session_id TEXT UNIQUE 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(` CREATE TABLE IF NOT EXISTS activations ( id TEXT PRIMARY KEY, @@ -87,15 +97,13 @@ export function verifyLicenseKey(key: string): boolean { } /** - * 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). + * Signs and stores a license row. Purchase licenses cap at 3 devices; gift + * licenses (via `grantLicense`) cap at 1 unless overridden. */ -export async function issueLicense( +async function insertLicense( email: string, - stripeSessionId: string + stripeSessionId: string, + maxDevices: number ): Promise { const id = crypto.randomUUID(); const payload = JSON.stringify({ @@ -106,9 +114,36 @@ export async function issueLicense( }); 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()] + sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked, max_devices) + VALUES (?, ?, ?, ?, ?, 0, ?)`, + args: [id, key, email, stripeSessionId, new Date().toISOString(), maxDevices] }); 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 { + return insertLicense(email, stripeSessionId, 3); +} + +/** + * Mints a free license outside the Stripe flow (gifting / comps). + * + * `stripe_session_id` holds a `gift:` sentinel so the UNIQUE NOT NULL + * constraint is satisfied. Defaults to a 1-device cap. + */ +export async function grantLicense( + email: string, + maxDevices = 1 +): Promise { + return insertLicense(email, `gift:${crypto.randomUUID()}`, maxDevices); +}