[grade=A] Harden server-managed license renewals
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

This commit is contained in:
Hermes
2026-08-22 18:15:37 -07:00
parent df55677bd1
commit 5e60c27f2b
5 changed files with 953 additions and 32 deletions
@@ -146,7 +146,7 @@ describe('LicenseManager: load()', () => {
} finally { await restore(); }
});
test('logs expired license on load but keeps it', async () => {
test('fails closed and removes expired license on load', async () => {
const { mgr, restore } = _makeManager();
try {
const activation = {
@@ -162,7 +162,7 @@ describe('LicenseManager: load()', () => {
await mgr.credentialManager.store('license.activation', JSON.stringify(activation));
await mgr.load();
expect(mgr.activation).toBeTruthy();
expect(mgr.activation).toBeNull();
expect(mgr.isExpired()).toBe(true);
expect(mgr._loaded).toBe(true);
} finally { await restore(); }
@@ -512,7 +512,7 @@ describe('LicenseManager: activate() — online validation', () => {
}
});
test('falls back to offline when server is unreachable (fetch throws)', async () => {
test('does not mint a new server-managed activation offline when server is unreachable', async () => {
const originalFetch = global.fetch;
const dir = _tmpDir();
const prevUrl = process.env.LICENSE_SERVER_URL;
@@ -540,8 +540,8 @@ describe('LicenseManager: activate() — online validation', () => {
});
const res = await result;
expect(res.success).toBe(true);
expect(res.activation.validationMethod).toBe('offline');
expect(res.success).toBe(false);
expect(res.message).toMatch(/temporarily unavailable/);
} finally {
if (prevUrl === undefined) delete process.env.LICENSE_SERVER_URL;
else process.env.LICENSE_SERVER_URL = prevUrl;
@@ -915,19 +915,19 @@ describe('LicenseManager: isExpired()', () => {
} finally { restore(); }
});
test('false for lifetime flag only (no durationDays)', () => {
test('fails closed for lifetime flag without signed zero duration', () => {
const { mgr, restore } = _makeManager();
try {
mgr.activation = { lifetime: true, expiresAt: '2020-01-01T00:00:00Z' };
expect(mgr.isExpired()).toBe(false);
expect(mgr.isExpired()).toBe(true);
} finally { restore(); }
});
test('false when expiresAt is null/missing (treated as lifetime)', () => {
test('fails closed when expiresAt is null or missing', () => {
const { mgr, restore } = _makeManager();
try {
mgr.activation = { durationDays: 30, lifetime: false, expiresAt: null };
expect(mgr.isExpired()).toBe(false);
expect(mgr.isExpired()).toBe(true);
} finally { restore(); }
});
@@ -1487,4 +1487,18 @@ describe('LicenseManager: full lifecycle integration', () => {
expect(result.activation.expired).toBe(false);
} finally { await restore(); }
});
test('expired offline code cannot mint a fresh entitlement term', async () => {
const actualNow = Date.now();
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(actualNow - 400 * 86400000);
const expiredCode = generateCode(TEST_SECRET, 30, 99123);
nowSpy.mockRestore();
const { mgr, restore } = _makeManager({ env: { LICENSE_SERVER_URL: undefined }, secret: TEST_SECRET });
try {
const result = await mgr.activate(expiredCode);
expect(result.success).toBe(false);
expect(result.message).toMatch(/expired/i);
expect(mgr.activation).toBeNull();
} finally { await restore(); }
});
});
@@ -0,0 +1,522 @@
const path = require('path');
const fs = require('fs');
function makeCreds() {
return {
values: {},
store: jest.fn(async function(key, value) { this.values[key] = value; }),
retrieve: jest.fn(async function(key) { return this.values[key] || null; }),
delete: jest.fn(async function(key) { delete this.values[key]; }),
};
}
describe('server-managed stable license contract', () => {
const previous = process.env.LICENSE_SERVER_URL;
beforeEach(() => {
jest.resetModules();
process.env.LICENSE_SERVER_URL = 'https://licenses.dashcaddy.net';
try { fs.unlinkSync('/tmp/dc-license-contract-config.json.license-revoked'); } catch (_) { /* absent */ }
});
afterAll(() => {
if (previous === undefined) delete process.env.LICENSE_SERVER_URL;
else process.env.LICENSE_SERVER_URL = previous;
});
test('refresh keeps the same key while accepting an extended server expiry', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
manager.activation = {
code,
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
features: ['sso'],
validationMethod: 'online',
};
const extendedExpiry = new Date(Date.now() + 90 * 86400000).toISOString();
manager._validateOnline = jest.fn().mockResolvedValue({
success: true,
activation: {
code,
durationDays: 90,
expiresAt: extendedExpiry,
features: ['sso', 'recipes', 'swarm'],
},
});
manager._updateConfig = jest.fn().mockResolvedValue();
expect(await manager.refreshOnline(true)).toBe(true);
expect(manager.activation.code).toBe(code);
expect(manager.activation.expiresAt).toBe(extendedExpiry);
expect(manager.activation.validationMethod).toBe('online');
expect(creds.store).toHaveBeenCalled();
});
test('server outage does not create a fresh offline activation', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
manager._validateOnline = jest.fn().mockResolvedValue(null);
const result = await manager.activate('DC-20F00-00059-JN7W8-0SW2N-MA200');
expect(result.success).toBe(false);
expect(result.message).toMatch(/temporarily unavailable/);
expect(manager.activation).toBeNull();
});
test('background timer forces refresh every 15 minutes', async () => {
jest.useFakeTimers();
try {
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
manager.activation = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
};
manager.refreshOnline = jest.fn().mockResolvedValue(true);
manager._startOnlineRefresh();
await jest.advanceTimersByTimeAsync(15 * 60 * 1000);
expect(manager.refreshOnline).toHaveBeenCalledWith(true);
clearInterval(manager._onlineRefreshTimer);
} finally {
jest.useRealTimers();
}
});
test('explicit server rejection revokes cached entitlement', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
manager.activation = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
validationMethod: 'online',
};
manager._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'License revoked' });
manager._updateConfig = jest.fn().mockResolvedValue();
expect(await manager.refreshOnline(true)).toBe(false);
expect(manager.activation).toBeNull();
expect(creds.delete).toHaveBeenCalledWith('license.activation');
});
test('server outage never trusts a legacy offline cache as server-managed', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
manager.activation = {
code,
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
validationMethod: 'offline',
};
manager._validateOnline = jest.fn().mockResolvedValue(null);
const result = await manager.activate(code);
expect(result.success).toBe(false);
expect(result.message).toMatch(/temporarily unavailable/);
});
test('startup quarantines a stored legacy offline entitlement during outage', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
creds.values['license.activation'] = JSON.stringify({
code,
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
validationMethod: 'offline',
});
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
manager._validateOnline = jest.fn().mockResolvedValue(null);
manager._updateConfig = jest.fn().mockResolvedValue();
await manager.load();
expect(manager.activation).toBeNull();
expect(creds.delete).toHaveBeenCalledWith('license.activation');
});
test('activation-time explicit rejection revokes matching cached entitlement', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
manager.activation = {
code,
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
validationMethod: 'online',
};
manager._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'Revoked' });
manager._updateConfig = jest.fn().mockResolvedValue();
const result = await manager.activate(code);
expect(result.success).toBe(false);
expect(manager.activation).toBeNull();
expect(creds.delete).toHaveBeenCalledWith('license.activation');
});
test('deactivate during refresh cannot resurrect entitlement', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
manager.activation = { code, durationDays: 30, expiresAt: new Date(Date.now() + 86400000).toISOString(), validationMethod: 'online' };
let release;
manager._validateOnline = jest.fn(() => new Promise(resolve => { release = resolve; }));
manager._updateConfig = jest.fn().mockResolvedValue();
manager._notifyDeactivation = jest.fn().mockResolvedValue();
const refresh = manager.refreshOnline(true);
const deactivate = manager.deactivate();
await new Promise(resolve => setImmediate(resolve));
release({ success: true, activation: { code, durationDays: 90, expiresAt: new Date(Date.now() + 90 * 86400000).toISOString() } });
await refresh;
expect((await deactivate).success).toBe(true);
expect(manager.activation).toBeNull();
});
test('different-key activation waits for refresh and remains current', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
const oldCode = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
const newCode = 'DC-20F00-0005A-JN7W8-0SW2N-MA200';
manager.activation = { code: oldCode, durationDays: 30, expiresAt: new Date(Date.now() + 86400000).toISOString(), validationMethod: 'online' };
let release;
manager._validateOnline = jest.fn()
.mockImplementationOnce(() => new Promise(resolve => { release = resolve; }))
.mockResolvedValueOnce({ success: true, activation: { code: newCode, durationDays: 30, expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), features: ['sso'] } });
manager._updateConfig = jest.fn().mockResolvedValue();
const refresh = manager.refreshOnline(true);
const activate = manager.activate(newCode);
await new Promise(resolve => setImmediate(resolve));
release({ success: true, activation: { code: oldCode, durationDays: 90, expiresAt: new Date(Date.now() + 90 * 86400000).toISOString() } });
await refresh;
expect((await activate).success).toBe(true);
expect(manager.activation.code).toBe(newCode);
});
test('concurrent activations commit in request order without stale overwrite', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
const firstCode = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
const secondCode = 'DC-20F00-0005A-JN7W8-0SW2N-MA200';
let releaseFirst;
manager._validateOnline = jest.fn()
.mockImplementationOnce(() => new Promise(resolve => { releaseFirst = resolve; }))
.mockResolvedValueOnce({ success: true, activation: { code: secondCode, durationDays: 90, expiresAt: new Date(Date.now() + 90 * 86400000).toISOString(), features: ['sso'] } });
manager._updateConfig = jest.fn().mockResolvedValue();
const first = manager.activate(firstCode);
const second = manager.activate(secondCode);
await new Promise(resolve => setImmediate(resolve));
releaseFirst({ success: true, activation: { code: firstCode, durationDays: 30, expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), features: ['sso'] } });
expect((await first).success).toBe(true);
expect((await second).success).toBe(true);
expect(manager.activation.code).toBe(secondCode);
});
test.each([429, 500, 502, 503])('retryable HTTP %i never revokes cached online entitlement', async (status) => {
const originalFetch = global.fetch;
try {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status,
json: async () => ({ error: 'temporary failure' }),
});
const { LicenseManager } = require('../src/managers/license-manager');
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
manager.activation = {
code,
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
validationMethod: 'online',
};
expect(await manager.refreshOnline(true)).toBe(false);
expect(manager.activation.code).toBe(code);
} finally {
global.fetch = originalFetch;
}
});
test.each([
{ durationDays: 30, features: ['sso'] },
{ expiresAt: 'not-a-date', durationDays: 30, features: ['sso'] },
{ expiresAt: new Date(Date.now() - 1000).toISOString(), durationDays: 30, features: ['sso'] },
{ expiresAt: new Date(Date.now() + 86400000).toISOString(), durationDays: 0, features: ['sso'] },
{ expiresAt: new Date(Date.now() + 86400000).toISOString(), durationDays: 30, features: 'sso' },
{ expiresAt: new Date(Date.now() + 20 * 365 * 86400000).toISOString(), durationDays: 30, features: ['sso'] },
])('malformed HTTP 200 success never creates an unbounded entitlement', async (payload) => {
const originalFetch = global.fetch;
try {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true, ...payload }),
});
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
const result = await manager.activate('DC-20F00-00059-JN7W8-0SW2N-MA200');
expect(result.success).toBe(false);
expect(manager.activation).toBeNull();
} finally {
global.fetch = originalFetch;
}
});
test.each([
{ durationDays: 30, features: ['sso'] },
{ expiresAt: 'bad-date', durationDays: 30, features: ['sso'] },
{ expiresAt: new Date(Date.now() - 1000).toISOString(), durationDays: 30, features: ['sso'] },
{ expiresAt: new Date(Date.now() + 20 * 365 * 86400000).toISOString(), durationDays: 30, features: ['sso'], activatedAt: new Date().toISOString() },
])('startup outage rejects malformed cached online entitlement', async (cached) => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
creds.values['license.activation'] = JSON.stringify({
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
validationMethod: 'online',
...cached,
});
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
manager._validateOnline = jest.fn().mockResolvedValue(null);
manager._updateConfig = jest.fn().mockResolvedValue();
await manager.load();
expect(manager.activation).toBeNull();
});
test('deactivate waiting on authoritative rejection does not dereference revoked state', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
manager.activation = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
features: ['sso'],
validationMethod: 'online',
};
let release;
manager._validateOnline = jest.fn(() => new Promise(resolve => { release = resolve; }));
manager._updateConfig = jest.fn().mockResolvedValue();
const refresh = manager.refreshOnline(true);
const deactivate = manager.deactivate();
await new Promise(resolve => setImmediate(resolve));
release({ success: false, message: 'Revoked' });
await refresh;
const result = await deactivate;
expect(result.success).toBe(false);
expect(manager.activation).toBeNull();
});
test('revocation tombstone prevents restart resurrection when credential deletion fails', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const configPath = path.join('/tmp', 'dc-license-contract-config.json');
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
const cached = {
code,
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
};
const creds = makeCreds();
creds.values['license.activation'] = JSON.stringify(cached);
creds.delete = jest.fn().mockRejectedValue(new Error('keychain unavailable'));
const first = new LicenseManager(creds, configPath, {});
first.activation = cached;
first._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'Revoked' });
first._updateConfig = jest.fn().mockResolvedValue();
expect(await first.refreshOnline(true)).toBe(false);
expect(fs.existsSync(`${configPath}.license-revoked`)).toBe(true);
const restarted = new LicenseManager(creds, configPath, {});
restarted._validateOnline = jest.fn().mockResolvedValue(null);
restarted._updateConfig = jest.fn().mockResolvedValue();
await restarted.load();
expect(restarted.activation).toBeNull();
expect(restarted._updateConfig).toHaveBeenCalled();
});
test('tombstone write failure still clears rejected entitlement in memory', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), { error: jest.fn(), warn: jest.fn() });
manager.activation = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
};
manager._writeRevocationTombstone = jest.fn().mockRejectedValue(new Error('disk full'));
manager._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'Revoked' });
manager._updateConfig = jest.fn().mockResolvedValue();
expect(await manager.refreshOnline(true)).toBe(false);
expect(manager.activation).toBeNull();
expect(creds.delete).toHaveBeenCalledWith('license.activation');
});
test('corrupt tombstone fails closed during restart', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const configPath = path.join('/tmp', 'dc-license-contract-config.json');
fs.writeFileSync(`${configPath}.license-revoked`, '{partial', { mode: 0o600 });
const creds = makeCreds();
creds.values['license.activation'] = JSON.stringify({
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
});
const manager = new LicenseManager(creds, configPath, {});
manager._validateOnline = jest.fn().mockResolvedValue(null);
await manager.load();
expect(manager.activation).toBeNull();
expect(manager._validateOnline).not.toHaveBeenCalled();
});
test('activation persistence failure rolls back in-memory premium access', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const creds = makeCreds();
creds.store = jest.fn().mockRejectedValue(new Error('keychain full'));
const manager = new LicenseManager(creds, path.join('/tmp', 'dc-license-contract-config.json'), {});
const code = 'DC-20F00-00059-JN7W8-0SW2N-MA200';
manager._validateOnline = jest.fn().mockResolvedValue({
success: true,
activation: {
code,
durationDays: 30,
activatedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
features: ['sso'],
}
});
const result = await manager.activate(code);
expect(result.success).toBe(false);
expect(manager.activation).toBeNull();
expect(manager.isPro()).toBe(false);
expect(manager.hasFeature('sso')).toBe(false);
});
test('combined revocation persistence failures cannot restore plaintext config backup', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const configPath = `/tmp/dc-combined-failure-${process.pid}.json`;
const cached = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
};
fs.writeFileSync(configPath, JSON.stringify({ licenseBackup: cached }));
const creds = makeCreds();
creds.values['license.activation'] = JSON.stringify(cached);
creds.delete = jest.fn().mockRejectedValue(new Error('keychain locked'));
const manager = new LicenseManager(creds, configPath, {});
manager.activation = cached;
manager._writeRevocationTombstone = jest.fn().mockRejectedValue(new Error('disk full'));
manager._validateOnline = jest.fn().mockResolvedValue({ success: false, message: 'Revoked' });
manager._updateConfig = jest.fn().mockRejectedValue(new Error('config locked'));
await manager.refreshOnline(true);
expect(manager.activation).toBeNull();
await expect(creds.retrieve('license.activation')).resolves.not.toBeNull();
const restarted = new LicenseManager(creds, configPath, {});
restarted._validateOnline = jest.fn().mockResolvedValue(null);
await restarted.load();
expect(restarted.activation).toBeNull();
fs.unlinkSync(configPath);
});
test('ambiguous empty HTTP 200 preserves bounded cached entitlement', async () => {
const originalFetch = global.fetch;
try {
global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
const { LicenseManager } = require('../src/managers/license-manager');
const manager = new LicenseManager(makeCreds(), path.join('/tmp', 'dc-license-contract-config.json'), {});
manager.activation = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
};
expect(await manager.refreshOnline(true)).toBe(false);
expect(manager.activation).not.toBeNull();
} finally {
global.fetch = originalFetch;
}
});
test('startup outage fails closed and automatically recovers in the same process', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const configPath = path.join('/tmp', 'dc-license-contract-config.json');
const cached = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
};
const creds = makeCreds();
creds.values['license.activation'] = JSON.stringify(cached);
const unavailable = new LicenseManager(creds, configPath, {});
unavailable._validateOnline = jest.fn().mockResolvedValue(null);
unavailable._updateConfig = jest.fn().mockResolvedValue();
await unavailable.load();
expect(unavailable.activation).toBeNull();
await expect(creds.retrieve('license.activation')).resolves.not.toBeNull();
expect(fs.existsSync(`${configPath}.license-revoked`)).toBe(false);
unavailable._validateOnline = jest.fn().mockResolvedValue({
success: true,
activation: { ...cached, expiresAt: new Date(Date.now() + 30 * 86400000).toISOString() }
});
const recovered = await unavailable._retryStartupValidation();
expect(recovered).toBe(true);
expect(unavailable.activation.code).toBe(cached.code);
expect(unavailable.activation.validationMethod).toBe('online');
});
test('startup recovery persistence failure stays fail-closed and remains retryable', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const configPath = path.join('/tmp', 'dc-license-contract-config.json');
const cached = {
code: 'DC-20F00-00059-JN7W8-0SW2N-MA200',
durationDays: 30,
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
activatedAt: new Date().toISOString(),
features: ['sso'],
validationMethod: 'online',
};
const creds = makeCreds();
creds.values['license.activation'] = JSON.stringify(cached);
const manager = new LicenseManager(creds, configPath, {});
manager._validateOnline = jest.fn().mockResolvedValue(null);
manager._updateConfig = jest.fn().mockResolvedValue();
await manager.load();
expect(manager.activation).toBeNull();
manager._validateOnline.mockResolvedValue({ success: true, activation: cached });
creds.store.mockRejectedValueOnce(new Error('credential disk full'));
expect(await manager._retryStartupValidation()).toBe(false);
expect(manager.activation).toBeNull();
expect(manager._pendingStartupCode).toBe(cached.code);
const preserved = await creds.retrieve('license.activation');
manager._updateConfig.mockRejectedValueOnce(new Error('config disk full'));
expect(await manager._retryStartupValidation()).toBe(false);
expect(manager.activation).toBeNull();
expect(await creds.retrieve('license.activation')).toBe(preserved);
expect(manager._pendingStartupCode).toBe(cached.code);
expect(await manager._retryStartupValidation()).toBe(true);
expect(manager.activation.code).toBe(cached.code);
});
});
@@ -0,0 +1,20 @@
const express = require('express');
const request = require('supertest');
const createLicenseRouter = require('../routes/license');
function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
test('GET license status forces online entitlement refresh before responding', async () => {
const licenseManager = {
refreshOnline: jest.fn().mockResolvedValue(true),
getStatus: jest.fn().mockReturnValue({ active: true, tier: 'premium' }),
};
const app = express();
app.use('/license', createLicenseRouter({ licenseManager, asyncHandler }));
const response = await request(app).get('/license/status');
expect(response.status).toBe(200);
expect(licenseManager.refreshOnline).toHaveBeenCalledWith();
expect(licenseManager.getStatus).toHaveBeenCalledTimes(1);
});
+1
View File
@@ -46,6 +46,7 @@ module.exports = function({ licenseManager, asyncHandler }) {
// Get current license status
router.get('/status', asyncHandler(async (req, res) => {
await licenseManager.refreshOnline?.();
const status = licenseManager.getStatus();
success(res, { license: status });
}, 'license-status'));
+386 -22
View File
@@ -5,9 +5,9 @@
* Uses credential-manager for secure storage of activation tokens.
*
* Hybrid model:
* - First activation: online validation against license server (if reachable)
* - Fallback: offline HMAC validation using embedded master secret hash
* - Ongoing: locally stored activation token checked on each premium request
* - Server-managed installs validate and renew online with a stable key.
* - Legacy installs without LICENSE_SERVER_URL retain offline HMAC validation.
* - A cached online entitlement survives a temporary outage only until its cached expiry.
*/
const crypto = require('crypto');
@@ -35,6 +35,8 @@ class LicenseManager {
this.activation = null; // Cached activation state
this.masterSecretHash = null; // Loaded from shipped secret hash (not the secret itself)
this._loaded = false;
this._activationGeneration = 0;
this._activationMutationQueue = Promise.resolve();
}
/**
@@ -48,6 +50,26 @@ class LicenseManager {
const stored = await this.credentialManager.retrieve(LICENSE_CRED_KEY);
if (stored) {
this.activation = JSON.parse(stored);
if (!this._isStructurallyValidLoadedActivation()) {
this.activation = null;
try { await this.credentialManager.delete(LICENSE_CRED_KEY); } catch (_) { /* fail closed in memory */ }
try { await this._updateConfig(true); } catch (_) { /* fail closed in memory */ }
this._loaded = true;
return;
}
if (await this._isRevocationTombstoned(this.activation.code)) {
this.activation = null;
try { await this.credentialManager.delete(LICENSE_CRED_KEY); } catch (_) { /* tombstone remains authoritative */ }
try { await this._updateConfig(true); } catch (error) {
this.log.warn?.('license', 'Could not persist inactive state after tombstone', { error: error.message });
}
this._loaded = true;
return;
}
if (!await this._validateLoadedActivationForServerMode()) {
this._loaded = true;
return;
}
if (this.isExpired()) {
this.log.info?.('license', 'License has expired', {
code: this._maskCode(this.activation.code),
@@ -61,6 +83,7 @@ class LicenseManager {
});
}
this._loaded = true;
this._startOnlineRefresh();
return;
}
} catch (error) {
@@ -73,7 +96,28 @@ class LicenseManager {
const data = await fsp.readFile(this.configFile, 'utf8');
const config = JSON.parse(data);
if (config.licenseBackup) {
// Server-managed entitlements are never restored from plaintext config backup.
// Only the credential store plus online validation/bounded cache is authoritative.
if (LICENSE_SERVER_URL) {
this.log.warn?.('license', 'Ignoring config license backup in server-managed mode');
this._loaded = true;
return;
}
this.activation = config.licenseBackup;
if (!this._isStructurallyValidLoadedActivation()) {
this.activation = null;
this._loaded = true;
return;
}
if (await this._isRevocationTombstoned(this.activation.code)) {
this.activation = null;
this._loaded = true;
return;
}
if (!await this._validateLoadedActivationForServerMode()) {
this._loaded = true;
return;
}
this.log.info?.('license', 'License restored from config backup', {
code: this._maskCode(this.activation.code),
lifetime: this.activation.lifetime
@@ -86,6 +130,7 @@ class LicenseManager {
this.log.warn?.('license', 'Could not re-store license in credential manager', { error: storeErr.message });
}
this._loaded = true;
this._startOnlineRefresh();
return;
}
} catch (_) {
@@ -147,6 +192,11 @@ class LicenseManager {
* @returns {Object} { success, message, activation? }
*/
async activate(code) {
const previousMutation = this._activationMutationQueue;
let releaseMutation;
this._activationMutationQueue = new Promise((resolve) => { releaseMutation = resolve; });
await previousMutation;
try {
if (!code || typeof code !== 'string') {
return { success: false, message: 'License code is required' };
}
@@ -156,9 +206,12 @@ class LicenseManager {
if (!code.startsWith('DC-')) {
return { success: false, message: 'Invalid code format. Codes start with DC-' };
}
if (this._refreshInFlight) await this._refreshInFlight;
this._activationGeneration++;
const previousActivation = this.activation;
// Check if already activated with this code
if (this.activation && this.activation.code === code && !this.isExpired()) {
if (this.activation && this.activation.code === code && !this.isExpired() && !LICENSE_SERVER_URL) {
return {
success: true,
message: 'This code is already activated',
@@ -171,17 +224,31 @@ class LicenseManager {
if (LICENSE_SERVER_URL) {
onlineResult = await this._validateOnline(code);
if (onlineResult && !onlineResult.success) {
// Server explicitly rejected — don't fallback to offline
// Server explicitly rejected — revoke any matching cached entitlement.
if (this.activation?.code === code) await this._revokeCachedEntitlement(onlineResult.message);
return onlineResult;
}
if (!onlineResult) {
if (this.activation && this.activation.code === code && this._isValidBoundedOnlineCache()) {
return {
success: true,
message: 'License server unavailable; using the last online entitlement until its cached expiry',
activation: this.getStatus()
};
}
return { success: false, message: 'License server is temporarily unavailable. Try again shortly.' };
}
}
// Offline validation (HMAC check)
if (!onlineResult) {
if (!onlineResult && !LICENSE_SERVER_URL) {
const offlineResult = this._validateOffline(code);
if (!offlineResult.valid) {
return { success: false, message: offlineResult.reason || 'Invalid license code' };
}
if (offlineResult.expired) {
return { success: false, message: 'License code has expired' };
}
// DC-052: LIFETIME keys are creator-only. Reject any lifetime code
// unless ALLOW_LIFETIME_LICENSE=true is set on this host (Sami's
@@ -203,7 +270,7 @@ class LicenseManager {
const now = new Date();
const expiresAt = isLifetime
? new Date('2099-12-31T23:59:59.999Z')
: new Date(now.getTime() + offlineResult.durationDays * 86400000);
: new Date(offlineResult.expiresAt);
this.activation = {
code,
@@ -220,6 +287,7 @@ class LicenseManager {
// Online validation succeeded — use server response
this.activation = onlineResult.activation;
this.activation.validationMethod = 'online';
this.activation.lastOnlineValidatedAt = new Date().toISOString();
}
// Store activation token
@@ -228,13 +296,16 @@ class LicenseManager {
activatedAt: this.activation.activatedAt,
expiresAt: this.activation.expiresAt
});
await this._clearRevocationTombstone();
} catch (error) {
this.activation = previousActivation || null;
this.log.error?.('license', 'Failed to store activation', { error: error.message });
return { success: false, message: 'License validated but failed to save activation' };
}
// Update config.json with license info (non-sensitive)
await this._updateConfig();
this._startOnlineRefresh();
this.log.info?.('license', 'License activated', {
code: this._maskCode(code),
@@ -249,6 +320,9 @@ class LicenseManager {
message: `License activated for ${durationLabel}`,
activation: this.getStatus()
};
} finally {
releaseMutation();
}
}
/**
@@ -256,9 +330,19 @@ class LicenseManager {
* @returns {Object} { success, message }
*/
async deactivate() {
const previousMutation = this._activationMutationQueue;
let releaseMutation;
this._activationMutationQueue = new Promise((resolve) => { releaseMutation = resolve; });
await previousMutation;
try {
if (!this.activation) {
return { success: false, message: 'No active license to deactivate' };
}
if (this._refreshInFlight) await this._refreshInFlight;
if (!this.activation) {
return { success: false, message: 'License was already revoked during online refresh' };
}
this._activationGeneration++;
const code = this._maskCode(this.activation.code);
@@ -274,11 +358,18 @@ class LicenseManager {
// Clear local activation
await this.credentialManager.delete(LICENSE_CRED_KEY);
this.activation = null;
if (this._onlineRefreshTimer) {
clearInterval(this._onlineRefreshTimer);
this._onlineRefreshTimer = null;
}
await this._updateConfig();
this.log.info?.('license', 'License deactivated', { code });
return { success: true, message: 'License deactivated. You can reuse this code on another machine.' };
} finally {
releaseMutation();
}
}
/**
@@ -315,6 +406,240 @@ class LicenseManager {
};
}
/** Refresh a server-managed entitlement without changing its stable key. */
async refreshOnline(force = false) {
await this._activationMutationQueue;
if (!LICENSE_SERVER_URL || !this.activation?.code) return false;
const last = new Date(this.activation.lastOnlineValidatedAt || 0).getTime();
if (!force && Date.now() - last < 60 * 60 * 1000) return true;
if (this._refreshInFlight) return this._refreshInFlight;
this._refreshInFlight = (async () => {
const generation = this._activationGeneration;
const refreshingCode = this.activation.code;
const originalActivatedAt = this.activation.activatedAt;
const result = await this._validateOnline(refreshingCode);
if (generation !== this._activationGeneration || this.activation?.code !== refreshingCode) return false;
if (result === null) return false;
if (!result.success) {
this.log.warn?.('license', 'License server explicitly rejected cached entitlement', { message: result.message });
await this._revokeCachedEntitlement(result.message);
return false;
}
this.activation = {
...this.activation,
...result.activation,
activatedAt: originalActivatedAt || result.activation.activatedAt,
validationMethod: 'online',
lastOnlineValidatedAt: new Date().toISOString()
};
await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation), {
activatedAt: this.activation.activatedAt,
expiresAt: this.activation.expiresAt
});
await this._clearRevocationTombstone();
await this._updateConfig();
return true;
})();
try {
return await this._refreshInFlight;
} finally {
this._refreshInFlight = null;
}
}
_startOnlineRefresh() {
if (!LICENSE_SERVER_URL || this._onlineRefreshTimer) return;
this._onlineRefreshTimer = setInterval(() => {
this.refreshOnline(true).catch((error) => {
this.log.warn?.('license', 'Periodic online entitlement refresh failed', { error: error.message });
});
}, 15 * 60 * 1000);
this._onlineRefreshTimer.unref?.();
}
_startStartupRecovery() {
if (this._startupRecoveryTimer || !this._pendingStartupCode) return;
this._startupRecoveryTimer = setInterval(() => {
this._retryStartupValidation().catch((error) => {
this.log.warn?.('license', 'Startup license recovery retry failed', { error: error.message });
});
}, 60 * 1000);
this._startupRecoveryTimer.unref?.();
}
async _retryStartupValidation() {
if (!this._pendingStartupCode || this.activation) return false;
const code = this._pendingStartupCode;
const result = await this._validateOnline(code);
if (result === null) return false;
if (!result.success) {
const stored = await this.credentialManager.retrieve(LICENSE_CRED_KEY);
try { this.activation = stored ? JSON.parse(stored) : { code }; } catch (_) { this.activation = { code }; }
await this._revokeCachedEntitlement(result.message || 'Server rejected entitlement');
this._pendingStartupCode = null;
clearInterval(this._startupRecoveryTimer);
this._startupRecoveryTimer = null;
return false;
}
const nextActivation = {
...result.activation,
validationMethod: 'online',
lastOnlineValidatedAt: new Date().toISOString()
};
const previousStored = await this.credentialManager.retrieve(LICENSE_CRED_KEY);
try {
await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(nextActivation));
this.activation = nextActivation;
await this._updateConfig(true);
} catch (error) {
this.activation = null;
try {
if (previousStored) await this.credentialManager.store(LICENSE_CRED_KEY, previousStored);
else await this.credentialManager.delete(LICENSE_CRED_KEY);
} catch (rollbackError) {
this.log.error?.('license', 'Recovery credential rollback failed; startup validation will still fail closed', { error: rollbackError.message });
}
this.log.warn?.('license', 'Recovered entitlement could not be committed; remaining fail-closed', { error: error.message });
return false;
}
this._pendingStartupCode = null;
clearInterval(this._startupRecoveryTimer);
this._startupRecoveryTimer = null;
this._startOnlineRefresh();
return true;
}
async _validateLoadedActivationForServerMode() {
if (!LICENSE_SERVER_URL || !this.activation) return true;
const cachedWasOnline = this.activation.validationMethod === 'online';
const result = await this._validateOnline(this.activation.code);
// Startup always requires a live server decision. Bounded cached access is
// only an in-process outage bridge; it is never trusted across restart.
if (result === null) {
// Fail closed now, preserve a validated-online credential, and retry in
// this process so connectivity recovery does not require a restart.
const pendingCode = this.activation.code;
this.activation = null;
if (!cachedWasOnline) {
try { await this.credentialManager.delete(LICENSE_CRED_KEY); } catch (_) { /* remains quarantined */ }
}
try { await this._updateConfig(true); } catch (error) {
this.log.warn?.('license', 'Could not persist startup outage state', { error: error.message });
}
if (cachedWasOnline) {
this._pendingStartupCode = pendingCode;
this._startStartupRecovery();
}
return false;
}
if (!result?.success) {
await this._revokeCachedEntitlement(result?.message || 'Server validation required');
return false;
}
this.activation = {
...this.activation,
...result.activation,
validationMethod: 'online',
lastOnlineValidatedAt: new Date().toISOString()
};
await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation));
await this._clearRevocationTombstone();
await this._updateConfig();
return true;
}
async _revokeCachedEntitlement(reason) {
const rejectedCode = this.activation?.code;
this.activation = null;
if (rejectedCode) {
try {
await this._writeRevocationTombstone(rejectedCode, reason);
} catch (error) {
this.log.error?.('license', 'CRITICAL: rejected entitlement could not be tombstoned; remaining fail-closed in memory', { error: error.message });
}
}
try {
await this.credentialManager.delete(LICENSE_CRED_KEY);
} catch (error) {
this.log.warn?.('license', 'Could not delete rejected entitlement from credential store', { error: error.message });
}
try {
await this._updateConfig(true);
} catch (error) {
this.log.warn?.('license', 'Could not update config after entitlement rejection', { error: error.message });
}
this.log.warn?.('license', 'Cached entitlement revoked', { reason });
}
_revocationTombstonePath() {
return `${this.configFile}.license-revoked`;
}
_codeDigest(code) {
return crypto.createHash('sha256').update(String(code || '')).digest('hex');
}
async _writeRevocationTombstone(code, reason) {
const tombstone = JSON.stringify({
codeHash: this._codeDigest(code),
revokedAt: new Date().toISOString(),
reason
});
const target = this._revocationTombstonePath();
const temp = `${target}.${process.pid}.${Date.now()}.tmp`;
let handle;
try {
handle = await fs.promises.open(temp, 'wx', 0o600);
await handle.writeFile(tombstone, 'utf8');
await handle.sync();
await handle.close();
handle = null;
await fs.promises.rename(temp, target);
const dirHandle = await fs.promises.open(path.dirname(target), 'r');
try { await dirHandle.sync(); } finally { await dirHandle.close(); }
} catch (error) {
if (handle) await handle.close().catch(() => {});
await fs.promises.unlink(temp).catch(() => {});
throw error;
}
}
async _isRevocationTombstoned(code) {
try {
const data = JSON.parse(await fs.promises.readFile(this._revocationTombstonePath(), 'utf8'));
return data.codeHash === this._codeDigest(code);
} catch (error) {
if (error.code === 'ENOENT') return false;
// Corrupt/unreadable marker is fail-closed: never resurrect cached premium access.
return true;
}
}
async _clearRevocationTombstone() {
try {
await fs.promises.unlink(this._revocationTombstonePath());
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
_isValidBoundedOnlineCache() {
if (!this.activation || this.activation.validationMethod !== 'online') return false;
const expiryMs = new Date(this.activation.expiresAt).getTime();
const validatedAtMs = new Date(this.activation.lastOnlineValidatedAt || this.activation.activatedAt).getTime();
const durationDays = Number(this.activation.durationDays);
const validFeatures = Array.isArray(this.activation.features)
&& this.activation.features.every((item) => typeof item === 'string');
return Number.isFinite(expiryMs)
&& Number.isFinite(validatedAtMs)
&& expiryMs > Date.now()
&& Number.isInteger(durationDays)
&& durationDays > 0
&& durationDays <= 3650
&& expiryMs <= validatedAtMs + (durationDays + 1) * 24 * 60 * 60 * 1000
&& validFeatures;
}
/**
* Check if a specific premium feature is available
* @param {string} feature - Feature key (e.g., 'sso', 'recipes', 'swarm')
@@ -358,10 +683,27 @@ class LicenseManager {
*/
isExpired() {
if (!this.activation) return true;
// Lifetime licenses never expire
if (this.activation.lifetime || this.activation.durationDays === 0) return false;
if (!this.activation.expiresAt) return false; // No expiry set = lifetime
return Date.now() > new Date(this.activation.expiresAt).getTime();
if (this.activation.validationMethod === 'online') {
const expiryMs = new Date(this.activation.expiresAt).getTime();
return !Number.isFinite(expiryMs) || expiryMs <= Date.now();
}
// Lifetime must be explicitly signed/recorded as lifetime, not inferred from a zero.
if (this.activation.lifetime === true && this.activation.durationDays === 0) return false;
const expiryMs = new Date(this.activation.expiresAt).getTime();
if (!Number.isFinite(expiryMs)) return true;
return Date.now() > expiryMs;
}
_isStructurallyValidLoadedActivation() {
const value = this.activation;
if (!value || typeof value.code !== 'string' || !value.code.startsWith('DC-')) return false;
if (!Array.isArray(value.features) || !value.features.every((feature) => typeof feature === 'string')) return false;
if (value.validationMethod === 'online') return this._isValidBoundedOnlineCache();
if (value.validationMethod !== 'offline') return false;
if (value.lifetime === true) return value.durationDays === 0;
if (!Number.isInteger(value.durationDays) || value.durationDays <= 0) return false;
const expiryMs = new Date(value.expiresAt).getTime();
return Number.isFinite(expiryMs) && expiryMs > Date.now();
}
/**
@@ -435,30 +777,51 @@ class LicenseManager {
if (!response.ok) {
const data = await response.json().catch(() => ({}));
const authoritativeStatuses = new Set([400, 401, 403, 404, 409, 410, 422]);
if (authoritativeStatuses.has(response.status)) {
return { success: false, message: data.error || `Server returned ${response.status}` };
}
this.log.warn?.('license', 'License server returned a retryable status', { status: response.status });
return null;
}
const data = await response.json();
if (data.success) {
const expiryMs = typeof data.expiresAt === 'string' ? new Date(data.expiresAt).getTime() : NaN;
const durationDays = Number(data.durationDays);
const validFeatures = Array.isArray(data.features) && data.features.every((item) => typeof item === 'string');
const maxExpiryMs = Date.now() + (durationDays + 1) * 24 * 60 * 60 * 1000;
if (!Number.isFinite(expiryMs) || expiryMs <= Date.now()
|| expiryMs > maxExpiryMs
|| !Number.isInteger(durationDays) || durationDays <= 0 || durationDays > 3650
|| !validFeatures) {
this.log.warn?.('license', 'License server returned a malformed success response');
return null;
}
return {
success: true,
activation: {
code,
codeId: data.codeId,
durationDays: data.durationDays,
durationDays,
activatedAt: new Date().toISOString(),
expiresAt: data.expiresAt,
machineId,
features: data.features || Object.keys(PREMIUM_FEATURES),
serverToken: data.token
features: data.features,
serverToken: data.token || null
}
};
}
return { success: false, message: data.message || 'License server rejected the code' };
if (data.success === false && (typeof data.error === 'string' || typeof data.message === 'string')) {
return { success: false, message: data.error || data.message };
}
this.log.warn?.('license', 'License server returned an ambiguous HTTP 200 response');
return null;
} catch (error) {
// Server unreachable — return null to fallback to offline
this.log.warn?.('license', 'License server unreachable, falling back to offline validation', {
// Server unreachable — keep only a previously online-validated cached
// entitlement, bounded by its last server-provided expiry.
this.log.warn?.('license', 'License server unreachable', {
error: error.message
});
return null;
@@ -483,11 +846,10 @@ class LicenseManager {
}
/**
* Update config.json with license info and full activation backup.
* The backup ensures the license survives encryption key changes
* (e.g. container rebuilds that generate new keys).
* Update config.json with license summary. Legacy offline mode also stores
* a backup; server-managed mode keeps activation tokens only in credentials.
*/
async _updateConfig() {
async _updateConfig(throwOnError = false) {
try {
const fsp = require('fs').promises;
let config = {};
@@ -507,7 +869,8 @@ class LicenseManager {
features: this.activation.features || Object.keys(PREMIUM_FEATURES)
};
// Full backup of activation data (config.json is volume-mounted and persists)
config.licenseBackup = this.activation;
if (LICENSE_SERVER_URL) delete config.licenseBackup;
else config.licenseBackup = this.activation;
} else {
config.license = { active: false, tier: 'free' };
delete config.licenseBackup;
@@ -517,6 +880,7 @@ class LicenseManager {
await fsp.writeFile(this.configFile, JSON.stringify(config, null, 2), 'utf8');
} catch (error) {
this.log.error?.('license', 'Failed to update config with license info', { error: error.message });
if (throwOnError) throw error;
}
}