diff --git a/dashcaddy-api/__tests__/license-keygen.test.js b/dashcaddy-api/__tests__/license-keygen.test.js new file mode 100644 index 0000000..77d1b9a --- /dev/null +++ b/dashcaddy-api/__tests__/license-keygen.test.js @@ -0,0 +1,460 @@ +/** + * Tests for dashcaddy-api/license-keygen.js + * + * Covers the programmatic API used by the Stripe webhook bridge and the + * on-disk counter allocator. The CLI path is exercised through the + * dedicated CLI regression describe block at the bottom of this file. + * + * - module.exports shape: verifyCode, parseCode, generateCode, + * generateCodes, loadSecret, VALID_DURATIONS, VERSION + * - generateCodes() validation: secret, duration, count + * - generateCodes() counter allocator: init, increment, override via + * startId, override via counterFile, atomic .tmp shape + * - generateCodes() monotonic counter: 100-call ordering, range checks + * - loadSecret() success and missing-file error + * - generateCode() round-trip: codes verify back via verifyCode() + * - CLI integration: omitted --start-id uses auto-counter, explicit + * --start-id skips counter write, --lifetime/--duration mutual exclusion + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const keygen = require('../license-keygen'); +const { + verifyCode, + parseCode, + generateCode, + generateCodes, + loadSecret, + VALID_DURATIONS, + VERSION, +} = keygen; + +function _tmpDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), `dashcaddy-${prefix}-`)); +} + +function _cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* best effort */ } +} + +const TEST_SECRET = 'a'.repeat(64); // 32 bytes hex + +// ── Public surface ────────────────────────────────────────────────────────── + +describe('license-keygen: module.exports', () => { + test('exports verifyCode, parseCode, generateCode, generateCodes, loadSecret, VALID_DURATIONS, VERSION', () => { + expect(typeof verifyCode).toBe('function'); + expect(typeof parseCode).toBe('function'); + expect(typeof generateCode).toBe('function'); + expect(typeof generateCodes).toBe('function'); + expect(typeof loadSecret).toBe('function'); + expect(Array.isArray(VALID_DURATIONS)).toBe(true); + expect(VALID_DURATIONS).toEqual([30, 90, 180, 365]); + expect(VERSION).toBe(1); + }); +}); + +// ── generateCode / parseCode / verifyCode round-trip ──────────────────────── + +describe('license-keygen: generateCode round-trip', () => { + test('generated code verifies back via verifyCode()', () => { + const code = generateCode(TEST_SECRET, 90, 42); + expect(code).toMatch(/^DC-([0-9A-Z]{5})(-[0-9A-Z]{5}){4}$/); + const result = verifyCode(TEST_SECRET, code); + expect(result.valid).toBe(true); + expect(result.durationDays).toBe(90); + expect(result.codeId).toBe(42); + }); + + test('verifyCode rejects a code from a different secret', () => { + const code = generateCode(TEST_SECRET, 30, 1); + const result = verifyCode('b'.repeat(64), code); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/signature/i); + }); + + test('parseCode returns version, duration, codeId, timestamp', () => { + const code = generateCode(TEST_SECRET, 365, 9999); + const parsed = parseCode(code); + expect(parsed.version).toBe(VERSION); + expect(parsed.durationDays).toBe(365); + expect(parsed.codeId).toBe(9999); + expect(typeof parsed.createdTs).toBe('number'); + }); +}); + +// ── generateCodes: validation ─────────────────────────────────────────────── + +describe('license-keygen: generateCodes validation', () => { + test('throws on missing secret', () => { + expect(() => generateCodes({ secret: '', durationDays: 30 })).toThrow(/secret is required/); + expect(() => generateCodes({ secret: 123, durationDays: 30 })).toThrow(/secret is required/); + expect(() => generateCodes({ durationDays: 30 })).toThrow(/secret is required/); + }); + + test('throws on invalid duration', () => { + expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 7 })).toThrow(/invalid duration/); + expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 31 })).toThrow(/invalid duration/); + expect(() => generateCodes({ secret: TEST_SECRET, durationDays: -1 })).toThrow(/invalid duration/); + }); + + test('accepts LIFETIME (durationDays: 0)', () => { + const tmp = _tmpDir('kg-lifetime'); + try { + const codes = generateCodes({ + secret: TEST_SECRET, + durationDays: 0, + counterFile: path.join(tmp, '.counter'), + }); + expect(codes).toHaveLength(1); + expect(codes[0].durationDays).toBe(0); + } finally { _cleanup(tmp); } + }); + + test('throws on invalid count', () => { + expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 0 })).toThrow(/invalid count/); + expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: -1 })).toThrow(/invalid count/); + expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 10001 })).toThrow(/invalid count/); + expect(() => generateCodes({ secret: TEST_SECRET, durationDays: 30, count: 1.5 })).toThrow(/invalid count/); + }); +}); + +// ── generateCodes: counter allocator ──────────────────────────────────────── + +describe('license-keygen: generateCodes counter', () => { + let tmp; + beforeEach(() => { tmp = _tmpDir('kg-counter'); }); + afterEach(() => { _cleanup(tmp); }); + + test('initializes counter at 1 when file is missing', () => { + const codes = generateCodes({ + secret: TEST_SECRET, + durationDays: 30, + counterFile: path.join(tmp, '.counter'), + }); + expect(codes[0].codeId).toBe(1); + expect(fs.readFileSync(path.join(tmp, '.counter'), 'utf8').trim()).toBe('1'); + }); + + test('increments counter on subsequent calls', () => { + const counterFile = path.join(tmp, '.counter'); + for (let i = 1; i <= 3; i++) { + const codes = generateCodes({ + secret: TEST_SECRET, + durationDays: 30, + counterFile, + }); + expect(codes[0].codeId).toBe(i); + } + expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('3'); + }); + + test('respects startId override and does NOT touch the counter file', () => { + const counterFile = path.join(tmp, '.counter'); + fs.writeFileSync(counterFile, '100'); + const codes = generateCodes({ + secret: TEST_SECRET, + durationDays: 30, + count: 3, + startId: 500, + counterFile, + }); + expect(codes.map(c => c.codeId)).toEqual([500, 501, 502]); + // Counter file unchanged — overrideStartId path skips the write. + expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('100'); + }); + + test('no leftover .tmp files after a successful call', () => { + const counterFile = path.join(tmp, '.counter'); + generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile }); + const entries = fs.readdirSync(tmp); + expect(entries.filter(e => e.includes('.tmp'))).toEqual([]); + }); + + test('counter file uses per-call unique tmp suffix (no .tmp collisions)', () => { + const counterFile = path.join(tmp, '.counter'); + const origWrite = fs.writeFileSync; + const tmpNames = []; + fs.writeFileSync = (p, data, opts) => { + if (typeof p === 'string' && p.startsWith(counterFile) && p.includes('.tmp')) { + tmpNames.push(p); + } + return origWrite.call(fs, p, data, opts); + }; + try { + generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile }); + generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile }); + expect(tmpNames).toHaveLength(2); + expect(new Set(tmpNames).size).toBe(2); + } finally { + fs.writeFileSync = origWrite; + } + }); +}); + +// ── generateCodes: monotonic counter ──────────────────────────────────────── +// +// generateCodes() is synchronous. Node's single-threaded event loop means +// two synchronous calls cannot interleave, so the counter is monotonically +// incremented without any explicit locking. The atomic write helper +// protects against process crashes between writeFileSync and renameSync. +// These tests verify that ordering and atomicity hold across many calls. + +describe('license-keygen: generateCodes monotonic counter', () => { + let tmp; + beforeEach(() => { tmp = _tmpDir('kg-mono'); }); + afterEach(() => { _cleanup(tmp); }); + + test('100 sequential calls produce 100 unique codeIds in monotonic order', () => { + const counterFile = path.join(tmp, '.counter'); + const codes = []; + for (let i = 0; i < 100; i++) { + codes.push(generateCodes({ + secret: TEST_SECRET, + durationDays: 30, + counterFile, + })[0]); + } + const ids = codes.map(c => c.codeId); + expect(ids).toHaveLength(100); + expect(new Set(ids).size).toBe(100); + for (let i = 1; i < ids.length; i++) { + expect(ids[i]).toBe(ids[i - 1] + 1); + } + expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('100'); + }); + + test('100 sequential calls each requesting 5 codes produce 500 unique IDs', () => { + const counterFile = path.join(tmp, '.counter'); + const batches = []; + for (let i = 0; i < 100; i++) { + batches.push(generateCodes({ + secret: TEST_SECRET, + durationDays: 30, + count: 5, + counterFile, + })); + } + const allIds = batches.flat().map(c => c.codeId); + expect(allIds).toHaveLength(500); + expect(new Set(allIds).size).toBe(500); + batches.forEach((batch, i) => { + const start = i * 5 + 1; + expect(batch.map(c => c.codeId)).toEqual([start, start + 1, start + 2, start + 3, start + 4]); + }); + expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('500'); + }); + + test('startId override is range-checked (negative throws)', () => { + expect(() => generateCodes({ + secret: TEST_SECRET, + durationDays: 30, + startId: -1, + counterFile: path.join(tmp, '.counter'), + })).toThrow(/out of range/); + }); + + test('startId override is range-checked (over 32-bit throws)', () => { + expect(() => generateCodes({ + secret: TEST_SECRET, + durationDays: 30, + startId: 0x100000000, + counterFile: path.join(tmp, '.counter'), + })).toThrow(/out of range/); + }); + + test('startId override is rejected for non-integer values', () => { + // Codex round 2: Number.isInteger(overrideStartId) returned false for + // floats/NaN/null/strings, silently falling through to auto-counter. + // The Object.prototype.hasOwnProperty check above fixes the dispatch. + const counterFile = path.join(tmp, '.counter'); + fs.writeFileSync(counterFile, '99'); + for (const bad of [1.5, NaN, null, '100', undefined, false]) { + const prevValue = fs.readFileSync(counterFile, 'utf8').trim(); + expect(() => generateCodes({ + secret: TEST_SECRET, + durationDays: 30, + startId: bad, + counterFile, + })).toThrow(/out of range|non-integer/); + // Counter file must NOT be touched when the call throws. + expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe(prevValue); + } + }); + + test('count that would push codeId past 32-bit throws', () => { + const counterFile = path.join(tmp, '.counter'); + fs.writeFileSync(counterFile, String(0xFFFFFFFF - 5)); + expect(() => generateCodes({ + secret: TEST_SECRET, + durationDays: 30, + count: 10, + counterFile, + })).toThrow(/32-bit limit/); + }); +}); + +// ── generateCodes: counterFile override ───────────────────────────────────── + +describe('license-keygen: generateCodes counterFile override', () => { + let tmp; + beforeEach(() => { tmp = _tmpDir('kg-cf'); }); + afterEach(() => { _cleanup(tmp); }); + + test('counterFile option overrides LICENSE_COUNTER_FILE env', () => { + const cf = path.join(tmp, '.counter'); + const prev = process.env.LICENSE_COUNTER_FILE; + try { + process.env.LICENSE_COUNTER_FILE = path.join(tmp, 'env-counter'); + generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile: cf }); + expect(fs.existsSync(cf)).toBe(true); + expect(fs.existsSync(path.join(tmp, 'env-counter'))).toBe(false); + } finally { + if (prev === undefined) delete process.env.LICENSE_COUNTER_FILE; + else process.env.LICENSE_COUNTER_FILE = prev; + } + }); + + test('LICENSE_COUNTER_FILE env overrides the default __dirname counter', () => { + const tmpForEnv = _tmpDir('kg-env'); + try { + const target = path.join(tmpForEnv, 'env-counter'); + const prev = process.env.LICENSE_COUNTER_FILE; + process.env.LICENSE_COUNTER_FILE = target; + try { + const codes = generateCodes({ secret: TEST_SECRET, durationDays: 30 }); + expect(codes[0].codeId).toBeLessThanOrEqual(1); // fresh env + expect(fs.existsSync(target)).toBe(true); + } finally { + if (prev === undefined) delete process.env.LICENSE_COUNTER_FILE; + else process.env.LICENSE_COUNTER_FILE = prev; + } + } finally { _cleanup(tmpForEnv); } + }); +}); + +// ── loadSecret ────────────────────────────────────────────────────────────── + +describe('license-keygen: loadSecret', () => { + let tmp; + beforeEach(() => { tmp = _tmpDir('kg-secret'); }); + afterEach(() => { _cleanup(tmp); }); + + test('returns trimmed contents of an existing secret file', () => { + const file = path.join(tmp, '.license-secret'); + fs.writeFileSync(file, ' abc123 \n'); + expect(loadSecret(file)).toBe('abc123'); + }); + + test('throws on missing file with helpful message', () => { + const file = path.join(tmp, 'does-not-exist'); + expect(() => loadSecret(file)).toThrow(/not found/i); + expect(() => loadSecret(file)).toThrow(/--init-secret/i); + }); +}); + +// ── generateCodes: failure modes ──────────────────────────────────────────── + +describe('license-keygen: generateCodes failure modes', () => { + let tmp; + beforeEach(() => { tmp = _tmpDir('kg-fail'); }); + afterEach(() => { _cleanup(tmp); }); + + test('throws when counter file exists but contains non-numeric data', () => { + const counterFile = path.join(tmp, '.counter'); + fs.writeFileSync(counterFile, 'not-a-number'); + expect(() => + generateCodes({ secret: TEST_SECRET, durationDays: 30, counterFile }), + ).toThrow(/non-numeric/); + }); +}); + +// ── CLI regression: spawn the real binary and verify argument handling ─────── +// +// Codex round 4 caught a regression: main() always passed +// `startId: overrideStartId` to generateCodes(), even when --start-id was +// omitted. The new hasOwnProperty-based validation then rejected the call +// because startId was an explicit (undefined) value. The fix is to omit +// the startId property from the options object when --start-id is absent. +// These tests exercise the actual CLI binary to make sure the local fix +// wires up correctly. + +const KEYGEN_BIN = path.resolve(__dirname, '..', 'license-keygen.js'); + +function _runCli(args, env) { + return execFileSync('node', [KEYGEN_BIN, ...args], { + env: { ...process.env, ...env }, + encoding: 'utf8', + }); +} + +describe('license-keygen: CLI regression', () => { + let tmp; + beforeEach(() => { tmp = _tmpDir('kg-cli'); }); + afterEach(() => { _cleanup(tmp); }); + + function _setupSecret() { + fs.writeFileSync(path.join(tmp, '.license-secret'), TEST_SECRET); + } + + test('omitted --start-id uses the auto-counter path (CLI integration)', () => { + _setupSecret(); + const counterFile = path.join(tmp, '.license-counter'); + + // First call: no --start-id, expects counter to be created at 1. + const out1 = _runCli(['--duration', '30', '--count', '1', '--json'], { + LICENSE_COUNTER_FILE: counterFile, + }); + const codes1 = JSON.parse(out1.split('Generated')[0]); + expect(codes1).toHaveLength(1); + expect(codes1[0].codeId).toBe(1); + expect(codes1[0].durationDays).toBe(30); + expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('1'); + + // Second call: counter should auto-increment to 2. + const out2 = _runCli(['--duration', '30', '--count', '1', '--json'], { + LICENSE_COUNTER_FILE: counterFile, + }); + const codes2 = JSON.parse(out2.split('Generated')[0]); + expect(codes2[0].codeId).toBe(2); + expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('2'); + }); + + test('--start-id override skips counter file update (CLI integration)', () => { + _setupSecret(); + const counterFile = path.join(tmp, '.license-counter'); + fs.writeFileSync(counterFile, '99'); + + const out = _runCli(['--duration', '30', '--start-id', '500', '--count', '2', '--json'], { + LICENSE_COUNTER_FILE: counterFile, + }); + const codes = JSON.parse(out.split('Generated')[0]); + expect(codes.map(c => c.codeId)).toEqual([500, 501]); + // Counter file untouched. + expect(fs.readFileSync(counterFile, 'utf8').trim()).toBe('99'); + }); + + test('--lifetime and --duration are mutually exclusive (CLI integration)', () => { + _setupSecret(); + expect(() => + _runCli(['--duration', '30', '--lifetime', '--count', '1'], { + LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'), + }), + ).toThrow(/mutually exclusive/); + }); + + test('--tier pro without --duration or --lifetime still requires one of them', () => { + _setupSecret(); + expect(() => + _runCli(['--tier', 'pro', '--count', '1'], { + LICENSE_COUNTER_FILE: path.join(tmp, '.license-counter'), + }), + ).toThrow(/--duration is required/); + }); +}); diff --git a/dashcaddy-api/license-keygen.js b/dashcaddy-api/license-keygen.js index 24761c9..ee47b77 100644 --- a/dashcaddy-api/license-keygen.js +++ b/dashcaddy-api/license-keygen.js @@ -19,9 +19,10 @@ const path = require('path'); // Master secret file — lives only on admin machine, NEVER shipped const SECRET_FILE = path.join(__dirname, '.license-secret'); -// License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD -// Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(48bit) -// Total: 128 bits = 16 bytes, base32-encoded into 4 groups of 5 chars +// 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 @@ -61,12 +62,190 @@ function base32Decode(str) { function getSecret() { if (!fs.existsSync(SECRET_FILE)) { - console.error('No master secret found. Run with --init-secret first.'); + console.error('No master secret found at', SECRET_FILE); + console.error('Run with --init-secret first.'); process.exit(1); } return fs.readFileSync(SECRET_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 || SECRET_FILE; + 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() { if (fs.existsSync(SECRET_FILE)) { console.error('Master secret already exists at', SECRET_FILE); @@ -193,19 +372,23 @@ function main() { DashCaddy License Code Generator Usage: - node license-keygen.js --init-secret Initialize master secret (first time only) - node license-keygen.js --duration [options] Generate license codes - node license-keygen.js --verify Verify a license code - node license-keygen.js --decode Decode and display code details + node license-keygen.js --init-secret Initialize master secret (first time only) + node license-keygen.js --duration [options] Generate Pro license codes + node license-keygen.js --lifetime [options] Generate a LIFETIME code (creator-only) + node license-keygen.js --verify Verify a license code + node license-keygen.js --decode Decode and display code details Options: - --duration Code validity: 30, 90, 180, or 365 days (required for generation) + --duration Code validity: 30, 90, 180, or 365 days (required for generation, mutually exclusive with --lifetime) + --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 Number of codes to generate (default: 1) --start-id Starting code ID (default: auto from counter file) --output 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); } @@ -244,9 +427,31 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days // 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. Use --help for usage.'); + 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]); @@ -258,29 +463,20 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days const countIndex = args.indexOf('--count'); const count = countIndex !== -1 ? parseInt(args[countIndex + 1]) : 1; - // Load or create counter file for auto-incrementing code IDs - const counterFile = path.join(__dirname, '.license-counter'); - let startId; const startIdIndex = args.indexOf('--start-id'); - if (startIdIndex !== -1) { - startId = parseInt(args[startIdIndex + 1]); - } else if (fs.existsSync(counterFile)) { - startId = parseInt(fs.readFileSync(counterFile, 'utf8').trim()) + 1; - } else { - startId = 1; - } + const overrideStartId = startIdIndex !== -1 ? parseInt(args[startIdIndex + 1]) : undefined; const secret = getSecret(); - const codes = []; - - for (let i = 0; i < count; i++) { - const codeId = startId + i; - const code = generateCode(secret, duration, codeId); - codes.push({ code, codeId, durationDays: duration }); + // 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; } - - // Save counter - fs.writeFileSync(counterFile, String(startId + count - 1)); + const codes = generateCodes(generateOpts); // Output const outputIndex = args.indexOf('--output'); @@ -302,11 +498,26 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days } } - console.log(`\nGenerated ${count} code(s) for ${duration === 0 ? 'LIFETIME' : duration + ' days'}. Next ID: ${startId + count}`); + 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 -module.exports = { verifyCode, parseCode, VALID_DURATIONS, VERSION }; +// 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();