[grade=B] refactor(license-keygen): extract programmatic API + atomic counter

Round-trip cleanup of dashcaddy-api/license-keygen.js:

- Export generateCodes({secret, durationDays, count, startId, counterFile})
  alongside generateCode and loadSecret for the Stripe webhook bridge.
- Replace the duplicate counter-write logic in main() with a single call
  through generateCodes(), so the CLI and the programmatic API share the
  same atomic allocator.
- _atomicWriteCounter() writes a uniquely-named .tmp file (pid+ts+rand
  suffix) and renames over the destination. POSIX rename is atomic on the
  same filesystem; the .tmp suffix prevents collisions across the event
  loop. Stale .tmp files are unlinked if rename fails.
- Numeric counter validation: reject non-numeric content in the counter
  file at startId read time (e.g. operator mucked up the file by hand).
- startId range-check: 0..0xFFFFFFFF, non-integer values rejected with a
  clear error. Uses Object.prototype.hasOwnProperty.call(opts, 'startId')
  to distinguish 'caller passed startId' from 'caller omitted startId',
  so the CLI's omitted --start-id path hits the auto-counter branch.
- 32-bit codeId overflow check: startId + count - 1 must fit.
- CLI: --tier pro added as a cosmetic label (only valid with --duration
  or --lifetime); --lifetime added as a synonym for --duration 0.
  --lifetime and --duration are mutually exclusive. --start-id override
  skips the counter write.
- fix comment at top of file: code format is 5 groups of 5 base32 chars
  encoding 120 bits (40-bit HMAC) — not 4 groups / 128 bits (48-bit HMAC).
- Add __tests__/license-keygen.test.js — 28 tests covering the public
  API, the counter allocator, validation, monotonic counter (100-call
  stress test), counterFile override, env var override, loadSecret
  error path, and CLI integration via execFileSync against the actual
  binary.
This commit is contained in:
Hermes
2026-07-25 14:07:47 -07:00
parent 6d5b1992b5
commit 592a9fd939
2 changed files with 703 additions and 32 deletions
@@ -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/);
});
});
+243 -32
View File
@@ -19,9 +19,10 @@ const path = require('path');
// Master secret file — lives only on admin machine, NEVER shipped // Master secret file — lives only on admin machine, NEVER shipped
const SECRET_FILE = path.join(__dirname, '.license-secret'); const SECRET_FILE = path.join(__dirname, '.license-secret');
// License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD // License code format: DC-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE
// Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(48bit) // Encodes: version(4bit) + duration_days(12bit) + code_id(32bit) + created_ts(32bit) + hmac(40bit)
// Total: 128 bits = 16 bytes, base32-encoded into 4 groups of 5 chars // 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 VALID_DURATIONS = [30, 90, 180, 365];
const LIFETIME_DURATION = 0; // Admin-only, not publicly available const LIFETIME_DURATION = 0; // Admin-only, not publicly available
@@ -61,12 +62,190 @@ function base32Decode(str) {
function getSecret() { function getSecret() {
if (!fs.existsSync(SECRET_FILE)) { 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); process.exit(1);
} }
return fs.readFileSync(SECRET_FILE, 'utf8').trim(); 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() { function initSecret() {
if (fs.existsSync(SECRET_FILE)) { if (fs.existsSync(SECRET_FILE)) {
console.error('Master secret already exists at', SECRET_FILE); console.error('Master secret already exists at', SECRET_FILE);
@@ -193,19 +372,23 @@ function main() {
DashCaddy License Code Generator DashCaddy License Code Generator
Usage: Usage:
node license-keygen.js --init-secret Initialize master secret (first time only) node license-keygen.js --init-secret Initialize master secret (first time only)
node license-keygen.js --duration <days> [options] Generate license codes node license-keygen.js --duration <days> [options] Generate Pro license codes
node license-keygen.js --verify <code> Verify a license code node license-keygen.js --lifetime [options] Generate a LIFETIME code (creator-only)
node license-keygen.js --decode <code> Decode and display code details node license-keygen.js --verify <code> Verify a license code
node license-keygen.js --decode <code> Decode and display code details
Options: Options:
--duration <days> Code validity: 30, 90, 180, or 365 days (required for generation) --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) --count <n> Number of codes to generate (default: 1)
--start-id <n> Starting code ID (default: auto from counter file) --start-id <n> Starting code ID (default: auto from counter file)
--output <file> Write codes to file instead of stdout --output <file> Write codes to file instead of stdout
--json Output as JSON --json Output as JSON
Valid durations: ${VALID_DURATIONS.join(', ')} days Valid durations: ${VALID_DURATIONS.join(', ')} days
Valid tiers: pro (cosmetic alias; does not change generation behavior)
`); `);
process.exit(0); process.exit(0);
} }
@@ -244,9 +427,31 @@ Valid durations: ${VALID_DURATIONS.join(', ')} days
// Generate codes // Generate codes
const isLifetime = args.includes('--lifetime'); 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'); const durationIndex = args.indexOf('--duration');
if (!isLifetime && durationIndex === -1) { 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); process.exit(1);
} }
const duration = isLifetime ? LIFETIME_DURATION : parseInt(args[durationIndex + 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 countIndex = args.indexOf('--count');
const count = countIndex !== -1 ? parseInt(args[countIndex + 1]) : 1; 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'); const startIdIndex = args.indexOf('--start-id');
if (startIdIndex !== -1) { const overrideStartId = startIdIndex !== -1 ? parseInt(args[startIdIndex + 1]) : undefined;
startId = parseInt(args[startIdIndex + 1]);
} else if (fs.existsSync(counterFile)) {
startId = parseInt(fs.readFileSync(counterFile, 'utf8').trim()) + 1;
} else {
startId = 1;
}
const secret = getSecret(); const secret = getSecret();
const codes = []; // Only pass startId when --start-id was supplied on the CLI. generateCodes
// uses Object.prototype.hasOwnProperty.call(opts, 'startId') to distinguish
for (let i = 0; i < count; i++) { // "caller passed startId" from "caller omitted startId" and rejects
const codeId = startId + i; // non-integer values. Passing startId: undefined would mean "caller passed
const code = generateCode(secret, duration, codeId); // undefined", which the validation path then rejects.
codes.push({ code, codeId, durationDays: duration }); const generateOpts = { secret, durationDays: duration, count };
if (overrideStartId !== undefined) {
generateOpts.startId = overrideStartId;
} }
const codes = generateCodes(generateOpts);
// Save counter
fs.writeFileSync(counterFile, String(startId + count - 1));
// Output // Output
const outputIndex = args.indexOf('--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 // Also export for use by license-manager.js and the Stripe webhook bridge.
module.exports = { verifyCode, parseCode, VALID_DURATIONS, VERSION }; // `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) { if (require.main === module) {
main(); main();