From 5e60c27f2bc20f1a453655a6e48cebe64f4dc52a Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 22 Aug 2026 17:35:34 -0700 Subject: [PATCH] [grade=A] Harden server-managed license renewals --- .../__tests__/license-manager.test.js | 32 +- .../__tests__/license-server-contract.test.js | 522 ++++++++++++++++++ .../__tests__/license-status-refresh.test.js | 20 + dashcaddy-api/routes/license.js | 1 + dashcaddy-api/src/managers/license-manager.js | 410 +++++++++++++- 5 files changed, 953 insertions(+), 32 deletions(-) create mode 100644 dashcaddy-api/__tests__/license-server-contract.test.js create mode 100644 dashcaddy-api/__tests__/license-status-refresh.test.js diff --git a/dashcaddy-api/__tests__/license-manager.test.js b/dashcaddy-api/__tests__/license-manager.test.js index 7d5ad82..458460f 100644 --- a/dashcaddy-api/__tests__/license-manager.test.js +++ b/dashcaddy-api/__tests__/license-manager.test.js @@ -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(); } + }); }); diff --git a/dashcaddy-api/__tests__/license-server-contract.test.js b/dashcaddy-api/__tests__/license-server-contract.test.js new file mode 100644 index 0000000..6eb0072 --- /dev/null +++ b/dashcaddy-api/__tests__/license-server-contract.test.js @@ -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); + }); +}); diff --git a/dashcaddy-api/__tests__/license-status-refresh.test.js b/dashcaddy-api/__tests__/license-status-refresh.test.js new file mode 100644 index 0000000..eb03df6 --- /dev/null +++ b/dashcaddy-api/__tests__/license-status-refresh.test.js @@ -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); +}); diff --git a/dashcaddy-api/routes/license.js b/dashcaddy-api/routes/license.js index f1d05e6..8aa20b7 100644 --- a/dashcaddy-api/routes/license.js +++ b/dashcaddy-api/routes/license.js @@ -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')); diff --git a/dashcaddy-api/src/managers/license-manager.js b/dashcaddy-api/src/managers/license-manager.js index 1383f4c..139670c 100644 --- a/dashcaddy-api/src/managers/license-manager.js +++ b/dashcaddy-api/src/managers/license-manager.js @@ -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(() => ({})); - return { success: false, message: data.error || `Server returned ${response.status}` }; + 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; } }