nook: per-license device caps + grant CLI for free licenses
This commit is contained in:
28
scripts/grant-nook-license.ts
Normal file
28
scripts/grant-nook-license.ts
Normal 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.");
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -67,8 +72,8 @@ export async function POST(event: APIEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user