[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:
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user