Canonical product catalog at src/billing/catalog.js shared by Stripe Checkout client (src/billing/stripe-client.js), webhook bridge (scripts/stripe-license-bridge.js), and pricing page (status/pricing/index.html). One-time payment keyed by productId at $20/$50/$70/$99 — no more monthly/annual subscription drift. Bridge resolves duration via metadata.productId (single contract), requires payment_status === 'paid' before fulfillment (rejects unpaid/no_payment_required/missing with ack 200), handles async_payment_succeeded for ACH/SEPA delayed-payment flow. License persisted to fulfillment-store BEFORE email — SMTP failure path serves the persisted code via the new /api/v1/billing/lookup/:sessionId endpoint (the documented customer recovery path). Layer-1 (event-id) + layer-2 (session-id) idempotency prevent duplicate issuance. Checkout return URLs derived from STRIPE_PUBLIC_ORIGIN or STRIPE_ALLOWED_HOSTS (not raw Host header) — closes host-header-poisoning + session-ID-leak attack class. 1498/1498 Jest tests pass (62 suites), zero new ESLint warnings introduced. Test files: - stripe-license-bridge.test.js (24 tests) - billing-lookup.test.js (8 tests, HTTP-level) - bridge-lookup-http.test.js (5 tests, uses exported createServer) - pricing-page-catalog.test.js (9 tests, per-tier consistency) - checkout-origin.test.js (6 tests, host injection rejection) - stripe-client.test.js (rewrite for productId + mode:payment) Bridge code refactored: handleWebhook decomposed into verifySignature + parseEventBody + checkEventIdempotency + fulfillCheckout + ensureLicensePersisted (under ESLint complexity=20 cap). New createServer()/createRequestHandler() factories guarded by require.main === module. Removed 3 stale test files from the rolled-back DC-055 attempt.
535 lines
22 KiB
JavaScript
535 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* DashCaddy License Code Generator
|
|
*
|
|
* Admin-only CLI tool for generating license codes.
|
|
* NOT shipped with the product — runs only on the developer's machine.
|
|
*
|
|
* Usage:
|
|
* node license-keygen.js --duration 365 --count 10
|
|
* node license-keygen.js --duration 30 --count 1 --output codes.txt
|
|
* node license-keygen.js --verify DC-XXXXX-XXXXX-XXXXX-XXXXX
|
|
* node license-keygen.js --init-secret
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// Master secret file — lives only on admin machine, NEVER shipped.
|
|
// Default is `path.join(__dirname, '.license-secret')`. The path is
|
|
// overridable via the `LICENSE_SECRET_FILE` env var so the CLI can be
|
|
// driven from CI / isolated test environments without polluting the
|
|
// source directory (mirrors the `LICENSE_COUNTER_FILE` override pattern).
|
|
// The Stripe bridge uses the same env var to point at its own secret file
|
|
// on the bridge host.
|
|
function _defaultSecretFile() {
|
|
return process.env.LICENSE_SECRET_FILE || path.join(__dirname, '.license-secret');
|
|
}
|
|
|
|
// License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE
|
|
// Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(40bit)
|
|
// Total: 120 bits = 15 bytes, base32-encoded into 5 groups of 5 chars
|
|
// (25 base32 chars = 125 bits, comfortably fits 120 bits of data)
|
|
|
|
const VALID_DURATIONS = [30, 90, 180, 365];
|
|
const LIFETIME_DURATION = 0; // Admin-only, not publicly available
|
|
const VERSION = 1;
|
|
|
|
// Base32 alphabet (Crockford variant — no I/L/O/U to avoid confusion)
|
|
const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
|
|
function base32Encode(buffer) {
|
|
let bits = '';
|
|
for (const byte of buffer) {
|
|
bits += byte.toString(2).padStart(8, '0');
|
|
}
|
|
// Pad to multiple of 5
|
|
while (bits.length % 5 !== 0) bits += '0';
|
|
let result = '';
|
|
for (let i = 0; i < bits.length; i += 5) {
|
|
const index = parseInt(bits.substring(i, i + 5), 2);
|
|
result += BASE32[index];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function base32Decode(str) {
|
|
let bits = '';
|
|
for (const char of str.toUpperCase()) {
|
|
const index = BASE32.indexOf(char);
|
|
if (index === -1) throw new Error(`Invalid base32 character: ${char}`);
|
|
bits += index.toString(2).padStart(5, '0');
|
|
}
|
|
const bytes = [];
|
|
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
|
bytes.push(parseInt(bits.substring(i, i + 8), 2));
|
|
}
|
|
return Buffer.from(bytes);
|
|
}
|
|
|
|
function getSecret() {
|
|
const file = _defaultSecretFile();
|
|
if (!fs.existsSync(file)) {
|
|
console.error('No master secret found at', file);
|
|
console.error('Run with --init-secret first.');
|
|
process.exit(1);
|
|
}
|
|
return fs.readFileSync(file, 'utf8').trim();
|
|
}
|
|
|
|
// Counter location: the default is `path.join(__dirname, '.license-counter')`.
|
|
// That's adjacent to this source file on the admin machine (not the secret
|
|
// file — the secret and counter share a directory on the developer's
|
|
// workstation, but they are independent files). The CLI does not merge them.
|
|
// When this module is required from a packaged/installed location where
|
|
// __dirname might be read-only, override the counter location via the
|
|
// `LICENSE_COUNTER_FILE` env var. The Stripe bridge uses this same path.
|
|
function _defaultCounterFile() {
|
|
return process.env.LICENSE_COUNTER_FILE || path.join(__dirname, '.license-counter');
|
|
}
|
|
|
|
// Atomic counter write — write to a uniquely-named .tmp then rename. The
|
|
// .tmp suffix includes pid + Date.now() + Math.random so two concurrent
|
|
// calls in overlapping event-loop ticks (e.g. a Stripe webhook fan-out)
|
|
// can't collide on the temp name. POSIX rename is atomic on the same
|
|
// filesystem, so the live counter file is never observed in a half-written
|
|
// state. If writeFileSync throws, we re-throw without renaming — the
|
|
// original counter file is intact. If renameSync throws, we attempt to
|
|
// unlink the .tmp so it doesn't accumulate.
|
|
function _atomicWriteCounter(counterFile, value) {
|
|
const tmpFile = `${counterFile}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
try {
|
|
fs.writeFileSync(tmpFile, String(value));
|
|
} catch (err) {
|
|
throw new Error(`generateCodes: failed to write counter tmp file ${tmpFile}: ${err.message}`);
|
|
}
|
|
try {
|
|
fs.renameSync(tmpFile, counterFile);
|
|
} catch (err) {
|
|
try { fs.unlinkSync(tmpFile); } catch (_) { /* best effort cleanup */ }
|
|
throw new Error(`generateCodes: failed to rename counter tmp to ${counterFile}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// Concurrency note: this module is single-threaded JavaScript. Two
|
|
// synchronous calls to generateCodes() within the same event-loop tick
|
|
// cannot interleave — fs.*Sync blocks the thread and the second call runs
|
|
// only after the first returns. The "atomic" part of the counter write
|
|
// protects against a process crash between writeFileSync and renameSync
|
|
// (the original counter file is intact because rename never happened)
|
|
// and against OS-level write atomicity. It does NOT protect against a
|
|
// concurrent process — license-keygen.js is a single-instance admin tool
|
|
// and must not be invoked from multiple processes simultaneously.
|
|
// Callers needing cross-process safety (which is none currently) would
|
|
// need OS-level locking via fcntl or flock — out of scope.
|
|
|
|
/**
|
|
* Programmatic equivalent of the CLI's "generate codes" path.
|
|
*
|
|
* Differs from the CLI in two ways:
|
|
* 1. No console output — returns the resulting array.
|
|
* 2. Persists the counter file atomically (write to a uniquely-named
|
|
* .tmp, rename) so a crash mid-write doesn't leave the counter in a
|
|
* half-bumped state, and so concurrent calls don't collide on the
|
|
* same .tmp name.
|
|
*
|
|
* Concurrency: relies on Node's single-threaded event loop. Two
|
|
* synchronous calls in the same tick cannot interleave — the second call
|
|
* reads the post-write counter value. The atomic write helper protects
|
|
* against process crashes between writeFileSync and renameSync, and the
|
|
* unique .tmp suffix prevents filename collisions across ticks. Cross-process
|
|
* races are still possible — license-keygen.js is a single-instance admin
|
|
* tool, so callers must not invoke it from multiple processes simultaneously.
|
|
*
|
|
* Returns synchronously. The underlying counter allocator uses fs.*Sync,
|
|
* so the function never throws asynchronously. Wrap with Promise.resolve()
|
|
* if your caller needs a Promise.
|
|
*
|
|
* @param {Object} opts
|
|
* @param {string} opts.secret The master secret (hex string). Callers
|
|
* are responsible for loading it via
|
|
* loadSecret() or getSecret().
|
|
* @param {number} opts.durationDays 30, 90, 180, 365, or 0 for LIFETIME.
|
|
* Validated against VALID_DURATIONS / LIFETIME.
|
|
* @param {number} [opts.count=1] Number of codes to mint.
|
|
* @param {number} [opts.startId] Override the auto counter. If omitted,
|
|
* reads + increments the counter file.
|
|
* @param {string} [opts.counterFile] Override the counter file path.
|
|
* Defaults to env LICENSE_COUNTER_FILE or
|
|
* path.join(__dirname, '.license-counter').
|
|
* @returns {Array<{code: string, codeId: number, durationDays: number}>}
|
|
*/
|
|
// Throws on bad opts. Returns { secret, durationDays, count } with defaults applied.
|
|
function _validateGenerateOpts(opts) {
|
|
if (!opts || !opts.secret || typeof opts.secret !== 'string') {
|
|
throw new Error('generateCodes: secret is required');
|
|
}
|
|
const { secret, count = 1 } = opts;
|
|
const { durationDays } = opts;
|
|
// LIFETIME (0) is accepted; non-LIFETIME must be in the allowed list.
|
|
if (durationDays !== 0 && !VALID_DURATIONS.includes(durationDays)) {
|
|
throw new Error(`generateCodes: invalid duration ${durationDays}. Valid: ${VALID_DURATIONS.join(', ')}`);
|
|
}
|
|
if (!Number.isInteger(count) || count < 1 || count > 10000) {
|
|
throw new Error(`generateCodes: invalid count ${count} (must be 1..10000)`);
|
|
}
|
|
return { secret, durationDays, count };
|
|
}
|
|
|
|
// Resolves the next startId. startIdProvided=true means the caller passed
|
|
// opts.startId (even if the value is invalid — validation happens here).
|
|
// Reads the counter file on the auto path; throws on parse/IO error.
|
|
function _resolveStartId(startIdProvided, overrideStartId, counterFile) {
|
|
if (startIdProvided) {
|
|
if (!Number.isInteger(overrideStartId) || overrideStartId < 0 || overrideStartId > 0xFFFFFFFF) {
|
|
throw new Error(`generateCodes: startId out of range or non-integer (must be 0..0xFFFFFFFF, got ${overrideStartId})`);
|
|
}
|
|
return overrideStartId;
|
|
}
|
|
try {
|
|
if (fs.existsSync(counterFile)) {
|
|
const raw = fs.readFileSync(counterFile, 'utf8').trim();
|
|
if (!/^\d+$/.test(raw)) {
|
|
throw new Error(`counter file ${counterFile} contains non-numeric value '${raw}'`);
|
|
}
|
|
return parseInt(raw, 10) + 1;
|
|
}
|
|
return 1;
|
|
} catch (err) {
|
|
if (err.message && err.message.startsWith('counter file ')) throw err;
|
|
throw new Error(`generateCodes: failed to read counter file ${counterFile}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function generateCodes(opts) {
|
|
const { secret, durationDays, count } = _validateGenerateOpts(opts);
|
|
const overrideCounterFile = opts && opts.counterFile;
|
|
const counterFile = overrideCounterFile || _defaultCounterFile();
|
|
|
|
// Validate startId BEFORE selecting the allocation path. Any explicitly
|
|
// supplied startId (including floats, NaN, null, numeric strings) must
|
|
// either be a valid integer in range or throw — we use
|
|
// Object.prototype.hasOwnProperty to distinguish "caller passed startId"
|
|
// from "caller omitted startId" so the overrideStartId validation runs
|
|
// regardless of value.
|
|
const startIdProvided = opts && Object.prototype.hasOwnProperty.call(opts, 'startId');
|
|
const overrideStartId = startIdProvided ? opts.startId : undefined;
|
|
const startId = _resolveStartId(startIdProvided, overrideStartId, counterFile);
|
|
|
|
// Validate that the requested range fits in the code_id field (32 bits).
|
|
const lastCodeId = startId + count - 1;
|
|
if (lastCodeId > 0xFFFFFFFF) {
|
|
throw new Error(`generateCodes: codeId range exceeds 32-bit limit (startId=${startId}, count=${count}, lastCodeId=${lastCodeId})`);
|
|
}
|
|
|
|
const codes = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const codeId = startId + i;
|
|
const code = generateCode(secret, durationDays, codeId);
|
|
codes.push({ code, codeId, durationDays });
|
|
}
|
|
|
|
// Persist the new counter value (skipped when startId was overridden).
|
|
if (!startIdProvided) {
|
|
_atomicWriteCounter(counterFile, lastCodeId);
|
|
}
|
|
|
|
return codes;
|
|
}
|
|
|
|
/**
|
|
* Load the master secret from disk. Exported so the Stripe bridge can
|
|
* call it without going through getSecret() (which prints to stderr and
|
|
* exits on missing-secret — wrong semantics for a library call).
|
|
*
|
|
* @param {string} [overridePath] Defaults to the SECRET_FILE constant.
|
|
* @returns {string} The hex secret.
|
|
* @throws If the file is missing or unreadable.
|
|
*/
|
|
function loadSecret(overridePath) {
|
|
const file = overridePath || _defaultSecretFile();
|
|
if (!fs.existsSync(file)) {
|
|
throw new Error(`Master secret file not found at ${file}. Run --init-secret first.`);
|
|
}
|
|
return fs.readFileSync(file, 'utf8').trim();
|
|
}
|
|
|
|
function initSecret() {
|
|
const file = _defaultSecretFile();
|
|
if (fs.existsSync(file)) {
|
|
console.error('Master secret already exists at', file);
|
|
console.error('Delete it first if you want to regenerate (WARNING: invalidates all existing codes).');
|
|
process.exit(1);
|
|
}
|
|
const secret = crypto.randomBytes(32).toString('hex');
|
|
fs.writeFileSync(file, secret, { mode: 0o600 });
|
|
console.log('Master secret generated and saved to', file);
|
|
console.log('KEEP THIS FILE SAFE. It is needed to generate and validate all license codes.');
|
|
console.log('DO NOT ship this file with the product.');
|
|
}
|
|
|
|
function generateCode(secret, durationDays, codeId) {
|
|
// Pack payload: version(4b) + duration_days(12b) + code_id(32b) + created_ts(32b) = 80 bits = 10 bytes
|
|
const payload = Buffer.alloc(10);
|
|
|
|
// Byte 0-1: version (4 bits) + duration (12 bits) = 16 bits
|
|
const versionAndDuration = ((VERSION & 0x0F) << 12) | (durationDays & 0x0FFF);
|
|
payload.writeUInt16BE(versionAndDuration, 0);
|
|
|
|
// Byte 2-5: code_id (32 bits)
|
|
payload.writeUInt32BE(codeId, 2);
|
|
|
|
// Byte 6-9: created timestamp (32 bits, seconds since epoch)
|
|
const createdTs = Math.floor(Date.now() / 1000);
|
|
payload.writeUInt32BE(createdTs, 6);
|
|
|
|
// HMAC the payload to get signature
|
|
const hmac = crypto.createHmac('sha256', secret).update(payload).digest();
|
|
// Take first 5 bytes of HMAC (40 bits) — fits exactly in 25 base32 chars with 10-byte payload
|
|
const signature = hmac.subarray(0, 5);
|
|
|
|
// Combine: payload (10 bytes) + signature (5 bytes) = 15 bytes = 120 bits
|
|
// 25 base32 chars = 125 bits, comfortably fits 120 bits
|
|
const combined = Buffer.concat([payload, signature]);
|
|
|
|
let encoded = base32Encode(combined);
|
|
while (encoded.length < 25) encoded += '0';
|
|
encoded = encoded.substring(0, 25);
|
|
const groups = [];
|
|
for (let i = 0; i < 25; i += 5) {
|
|
groups.push(encoded.substring(i, i + 5));
|
|
}
|
|
|
|
return `DC-${groups.join('-')}`;
|
|
}
|
|
|
|
function parseCode(code) {
|
|
// Strip prefix and dashes
|
|
const cleaned = code.replace(/^DC-/, '').replace(/-/g, '');
|
|
if (cleaned.length !== 25) {
|
|
throw new Error(`Invalid code length: expected 25 base32 chars, got ${cleaned.length}`);
|
|
}
|
|
|
|
// Decode base32 — 25 chars = 125 bits = 15 full bytes
|
|
const decoded = base32Decode(cleaned);
|
|
if (decoded.length < 15) {
|
|
const padded = Buffer.alloc(15);
|
|
decoded.copy(padded);
|
|
return parsePayload(padded);
|
|
}
|
|
return parsePayload(decoded.subarray(0, 15));
|
|
}
|
|
|
|
function parsePayload(buffer) {
|
|
const payload = buffer.subarray(0, 10);
|
|
const signature = buffer.subarray(10, 15);
|
|
|
|
const versionAndDuration = payload.readUInt16BE(0);
|
|
const version = (versionAndDuration >> 12) & 0x0F;
|
|
const durationDays = versionAndDuration & 0x0FFF;
|
|
const codeId = payload.readUInt32BE(2);
|
|
const createdTs = payload.readUInt32BE(6);
|
|
|
|
return { version, durationDays, codeId, createdTs, payload, signature };
|
|
}
|
|
|
|
function verifyCode(secret, code) {
|
|
try {
|
|
const { version, durationDays, codeId, createdTs, payload, signature } = parseCode(code);
|
|
|
|
// Verify HMAC (5-byte signature)
|
|
const expectedHmac = crypto.createHmac('sha256', secret).update(payload).digest();
|
|
const expectedSig = expectedHmac.subarray(0, 5);
|
|
|
|
if (!crypto.timingSafeEqual(signature, expectedSig)) {
|
|
return { valid: false, reason: 'Invalid signature — code is forged or corrupted' };
|
|
}
|
|
|
|
if (version !== VERSION) {
|
|
return { valid: false, reason: `Unsupported version: ${version}` };
|
|
}
|
|
|
|
// Accept lifetime (0) and standard durations
|
|
if (durationDays !== LIFETIME_DURATION && !VALID_DURATIONS.includes(durationDays)) {
|
|
return { valid: false, reason: `Invalid duration: ${durationDays} days` };
|
|
}
|
|
|
|
const createdDate = new Date(createdTs * 1000);
|
|
const isLifetime = durationDays === LIFETIME_DURATION;
|
|
const expiresDate = isLifetime ? null : new Date(createdTs * 1000 + durationDays * 86400000);
|
|
|
|
return {
|
|
valid: true,
|
|
version,
|
|
durationDays,
|
|
codeId,
|
|
createdAt: createdDate.toISOString(),
|
|
expiresAt: isLifetime ? null : expiresDate.toISOString(),
|
|
expired: isLifetime ? false : Date.now() > expiresDate.getTime()
|
|
};
|
|
} catch (error) {
|
|
return { valid: false, reason: error.message };
|
|
}
|
|
}
|
|
|
|
// CLI
|
|
function main() {
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.includes('--help') || args.length === 0) {
|
|
console.log(`
|
|
DashCaddy License Code Generator
|
|
|
|
Usage:
|
|
node license-keygen.js --init-secret Initialize master secret (first time only)
|
|
node license-keygen.js --duration <days> [options] Generate Pro license codes
|
|
node license-keygen.js --lifetime [options] Generate a LIFETIME code (creator-only)
|
|
node license-keygen.js --verify <code> Verify a license code
|
|
node license-keygen.js --decode <code> Decode and display code details
|
|
|
|
Options:
|
|
--duration <days> Code validity: 30, 90, 180, or 365 days (required for generation, mutually exclusive with --lifetime)
|
|
--tier <tier> Tier label; only 'pro' is supported (optional label; valid in combination with --duration or --lifetime)
|
|
--lifetime Generate a LIFETIME code — REJECTED at activation on production hosts
|
|
--count <n> Number of codes to generate (default: 1)
|
|
--start-id <n> Starting code ID (default: auto from counter file)
|
|
--output <file> Write codes to file instead of stdout
|
|
--json Output as JSON
|
|
|
|
Valid durations: ${VALID_DURATIONS.join(', ')} days
|
|
Valid tiers: pro (cosmetic alias; does not change generation behavior)
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (args.includes('--init-secret')) {
|
|
initSecret();
|
|
return;
|
|
}
|
|
|
|
if (args.includes('--verify') || args.includes('--decode')) {
|
|
const codeIndex = args.indexOf('--verify') !== -1 ? args.indexOf('--verify') : args.indexOf('--decode');
|
|
const code = args[codeIndex + 1];
|
|
if (!code) {
|
|
console.error('Please provide a code to verify.');
|
|
process.exit(1);
|
|
}
|
|
const secret = getSecret();
|
|
const result = verifyCode(secret, code);
|
|
if (args.includes('--json')) {
|
|
console.log(JSON.stringify(result, null, 2));
|
|
} else if (result.valid) {
|
|
const isLifetime = result.durationDays === 0;
|
|
console.log('Code is VALID');
|
|
console.log(` Version: ${result.version}`);
|
|
console.log(` Duration: ${isLifetime ? 'LIFETIME' : result.durationDays + ' days'}`);
|
|
console.log(` Code ID: ${result.codeId}`);
|
|
console.log(` Created: ${result.createdAt}`);
|
|
console.log(` Expires: ${isLifetime ? 'NEVER' : result.expiresAt}`);
|
|
console.log(` Status: ${isLifetime ? 'LIFETIME' : (result.expired ? 'EXPIRED' : 'ACTIVE')}`);
|
|
} else {
|
|
console.log('Code is INVALID');
|
|
console.log(` Reason: ${result.reason}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Generate codes
|
|
const isLifetime = args.includes('--lifetime');
|
|
|
|
// --tier is a cosmetic label right now (only 'pro' is supported). It does
|
|
// NOT change generation behavior — every code minted with --duration is
|
|
// already a Pro code, and --lifetime is enforced separately at activation
|
|
// time. The flag exists to make operator intent obvious in shell history
|
|
// and to reserve a forward-compatible hook for a future tier that needs
|
|
// to alter code generation (e.g. a 'free' tier with a different prefix).
|
|
// It is only meaningful in combination with --duration or --lifetime —
|
|
// by itself, generation still requires one of those flags.
|
|
const tierIndex = args.indexOf('--tier');
|
|
if (tierIndex !== -1) {
|
|
const tier = (args[tierIndex + 1] || '').toLowerCase();
|
|
if (tier !== 'pro') {
|
|
console.error(`Invalid tier: '${tier}'. Supported: pro.`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const durationIndex = args.indexOf('--duration');
|
|
if (!isLifetime && durationIndex === -1) {
|
|
console.error('--duration is required (or use --lifetime). Use --help for usage.');
|
|
process.exit(1);
|
|
}
|
|
if (isLifetime && durationIndex !== -1) {
|
|
console.error('--lifetime and --duration are mutually exclusive.');
|
|
process.exit(1);
|
|
}
|
|
const duration = isLifetime ? LIFETIME_DURATION : parseInt(args[durationIndex + 1]);
|
|
if (!isLifetime && !VALID_DURATIONS.includes(duration)) {
|
|
console.error(`Invalid duration: ${duration}. Valid: ${VALID_DURATIONS.join(', ')}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const countIndex = args.indexOf('--count');
|
|
const count = countIndex !== -1 ? parseInt(args[countIndex + 1]) : 1;
|
|
|
|
const startIdIndex = args.indexOf('--start-id');
|
|
const overrideStartId = startIdIndex !== -1 ? parseInt(args[startIdIndex + 1]) : undefined;
|
|
|
|
const secret = getSecret();
|
|
// Only pass startId when --start-id was supplied on the CLI. generateCodes
|
|
// uses Object.prototype.hasOwnProperty.call(opts, 'startId') to distinguish
|
|
// "caller passed startId" from "caller omitted startId" and rejects
|
|
// non-integer values. Passing startId: undefined would mean "caller passed
|
|
// undefined", which the validation path then rejects.
|
|
const generateOpts = { secret, durationDays: duration, count };
|
|
if (overrideStartId !== undefined) {
|
|
generateOpts.startId = overrideStartId;
|
|
}
|
|
const codes = generateCodes(generateOpts);
|
|
|
|
// Output
|
|
const outputIndex = args.indexOf('--output');
|
|
if (args.includes('--json')) {
|
|
const output = JSON.stringify(codes, null, 2);
|
|
if (outputIndex !== -1) {
|
|
fs.writeFileSync(args[outputIndex + 1], output);
|
|
console.log(`${count} code(s) written to ${args[outputIndex + 1]}`);
|
|
} else {
|
|
console.log(output);
|
|
}
|
|
} else {
|
|
const lines = codes.map(c => `${c.code} (${c.durationDays === 0 ? 'LIFETIME' : c.durationDays + ' days'}, ID: ${c.codeId})`);
|
|
if (outputIndex !== -1) {
|
|
fs.writeFileSync(args[outputIndex + 1], codes.map(c => c.code).join('\n') + '\n');
|
|
console.log(`${count} code(s) written to ${args[outputIndex + 1]}`);
|
|
} else {
|
|
lines.forEach(l => console.log(l));
|
|
}
|
|
}
|
|
|
|
const lastCodeId = codes[codes.length - 1].codeId;
|
|
console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${lastCodeId + 1}`);
|
|
}
|
|
|
|
// Also export for use by license-manager.js and the Stripe webhook bridge.
|
|
// `generateCode` is exported so the bridge can mint codes in-process rather
|
|
// than spawning a child process (faster, atomic counter, easier to test).
|
|
// `generateCodes` (note the trailing 's') is the bulk-friendly wrapper that
|
|
// handles the counter-file write and returns a stable array of {code, codeId,
|
|
// durationDays} records — used by the bridge when one Stripe event must
|
|
// produce one code (typical case is just 1, but the API is uniform).
|
|
module.exports = {
|
|
verifyCode,
|
|
parseCode,
|
|
generateCode,
|
|
generateCodes,
|
|
loadSecret,
|
|
VALID_DURATIONS,
|
|
VERSION,
|
|
};
|
|
|
|
if (require.main === module) {
|
|
main();
|
|
}
|