[grade=A] Harden server-managed license renewals
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user