/** * DashCaddy License Manager * * Runtime license validation, activation, and feature gating. * Uses credential-manager for secure storage of activation tokens. * * Hybrid model: * - 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'); const os = require('os'); const fs = require('fs'); const path = require('path'); const { verifyCode, parseCode, VALID_DURATIONS } = require('../../license-keygen'); const { errorResponse } = require('../utils/responses'); const LICENSE_CRED_KEY = 'license.activation'; const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || null; // Set when license server exists // Features gated behind premium const PREMIUM_FEATURES = { sso: { name: 'Auto-Login SSO', description: 'Automatic single sign-on for deployed apps' }, recipes: { name: 'Recipes', description: 'Multi-container stack deployment' }, swarm: { name: 'Docker Swarm', description: 'Multi-node cluster orchestration' }, // DC-165: site has always sold Fleet as Premium (pricing page + comparison // table); the gate was missing. hasFeature() falls back to // Object.keys(PREMIUM_FEATURES) for activations that stored no explicit // features list, so existing premium licenses (incl. offline activations // like the creator lifetime key) gain fleet automatically on next load. // 'fleet' is now part of the shipped entitlement contract — a new // PREMIUM_FEATURES key must ALWAYS be accompanied by the license-server // grant list (plans.js) or online activations will 403 it. fleet: { name: 'Multi-Host Fleet Management', description: 'Register remote DashCaddy instances, health probes, and multi-host deploys' }, // DC-165: these two have ALWAYS been requirePremium-gated (routes/ // workflows.js line 32, routes/backups.js schedule routes) but were // never listed here — offline activations could therefore never gain // them, and the marketing site didn't advertise them either. The site // now advertises both (Sami's decision 2026-10-09: "advertise as // Premium"); listing them here closes the offline path and makes // PREMIUM_FEATURES the single source of truth for gated features. workflows: { name: 'Workflow Automation', description: 'Bundled one-click maintenance workflows — update checks, disk cleanup, cert expiry checks — enable, run, and review history' }, 'auto-backup': { name: 'Scheduled Backups', description: 'Automated per-app backup schedules (manual one-click backup stays free)' } }; class LicenseManager { constructor(credentialManager, configFile, log) { this.credentialManager = credentialManager; this.configFile = configFile; this.log = log || console; 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(); } /** * Load license state from storage on startup. * Primary: encrypted credential store. Fallback: config.json backup. * If the credential store fails (e.g. encryption key changed after rebuild), * restores from the config.json backup automatically. */ async load() { try { 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), expiredAt: this.activation.expiresAt }); } else { // DC-165 rev2 (judge P0): offline feature-snapshot repair, in a // helper to keep load() complexity/nesting inside house lint. await this._migrateOfflineFeatureSnapshot(); this.log.info?.('license', 'License loaded', { code: this._maskCode(this.activation.code), expiresAt: this.activation.expiresAt, daysRemaining: this.daysRemaining() }); } this._loaded = true; this._startOnlineRefresh(); return; } } catch (error) { this.log.warn?.('license', 'Failed to load from credential store, trying config backup', { error: error.message }); } // Fallback: restore from config.json backup try { const fsp = require('fs').promises; 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 }); // Re-store in credential manager so future loads succeed try { await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation)); this.log.info?.('license', 'License re-stored in credential manager'); } catch (storeErr) { this.log.warn?.('license', 'Could not re-store license in credential manager', { error: storeErr.message }); } this._loaded = true; this._startOnlineRefresh(); return; } } catch (_) { // Config doesn't exist or no backup — continue } this.log.info?.('license', 'No active license'); this.activation = null; this._loaded = true; } /** * Load the shipped master secret hash for offline validation. * The actual master secret is NEVER shipped — only a hash of it is embedded * in the product, and the keygen embeds HMAC signatures in codes using the real secret. * For offline validation, we verify the code's internal HMAC consistency. * * @param {string} secretFile - Path to .license-secret file (dev only) or .license-secret-hash (shipped) */ loadSecret(secretFile) { try { if (fs.existsSync(secretFile)) { const secret = fs.readFileSync(secretFile, 'utf8').trim(); this.masterSecretHash = secret; return true; } } catch (error) { this.log.warn?.('license', 'Could not load license secret', { error: error.message }); } return false; } /** * Generate a machine fingerprint for activation binding */ getMachineFingerprint() { const components = [ os.hostname(), os.platform(), os.arch(), os.cpus()[0]?.model || 'unknown' ]; // Get primary MAC address const interfaces = os.networkInterfaces(); for (const name of Object.keys(interfaces)) { for (const iface of interfaces[name]) { if (!iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00') { components.push(iface.mac); break; } } } return crypto.createHash('sha256').update(components.join('|')).digest('hex').substring(0, 16); } /** * Activate a license code * @param {string} code - License code (DC-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX) * @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' }; } // Normalize code format code = code.trim().toUpperCase(); 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() && !LICENSE_SERVER_URL) { return { success: true, message: 'This code is already activated', activation: this.getStatus() }; } // Try online validation first let onlineResult = null; if (LICENSE_SERVER_URL) { onlineResult = await this._validateOnline(code); if (onlineResult && !onlineResult.success) { // 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 && !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 // dev machine). Production / paid customers must NEVER be able to // activate a LIFETIME code — every other license is time-bound. const isLifetime = offlineResult.durationDays === 0; if (isLifetime && !this.allowsLifetimeLicense()) { this.log.warn?.('license', 'LIFETIME code rejected — not allowed on this host', { code: this._maskCode(code), }); return { success: false, message: 'Lifetime licenses are not available. Please use a time-bounded license key.', }; } // Code is cryptographically valid AND lifetime check passed const machineId = this.getMachineFingerprint(); const now = new Date(); const expiresAt = isLifetime ? new Date('2099-12-31T23:59:59.999Z') : new Date(offlineResult.expiresAt); this.activation = { code, codeId: offlineResult.codeId, durationDays: offlineResult.durationDays, lifetime: isLifetime, activatedAt: now.toISOString(), expiresAt: expiresAt.toISOString(), machineId, validationMethod: 'offline', features: Object.keys(PREMIUM_FEATURES) }; } else { // Online validation succeeded — use server response this.activation = onlineResult.activation; this.activation.validationMethod = 'online'; this.activation.lastOnlineValidatedAt = new Date().toISOString(); } // Store activation token try { await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation), { 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), durationDays: this.activation.durationDays, expiresAt: this.activation.expiresAt, method: this.activation.validationMethod }); const durationLabel = this.activation.lifetime ? 'lifetime' : `${this.activation.durationDays} days`; return { success: true, message: `License activated for ${durationLabel}`, activation: this.getStatus() }; } finally { releaseMutation(); } } /** * Deactivate the current license * @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); // If online server exists, notify it of deactivation if (LICENSE_SERVER_URL) { try { await this._notifyDeactivation(); } catch (error) { this.log.warn?.('license', 'Could not notify license server of deactivation', { error: error.message }); } } // 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(); } } /** * Get current license status * @returns {Object} Status object */ getStatus() { if (!this.activation) { return { active: false, tier: 'free', features: [], premiumFeatures: PREMIUM_FEATURES }; } const expired = this.isExpired(); const isLifetime = !!(this.activation.lifetime || this.activation.durationDays === 0); const daysRemaining = isLifetime ? null : this.daysRemaining(); return { active: !expired, tier: expired ? 'free' : 'premium', lifetime: isLifetime, code: this._maskCode(this.activation.code), durationDays: this.activation.durationDays, activatedAt: this.activation.activatedAt, expiresAt: isLifetime ? null : this.activation.expiresAt, daysRemaining: isLifetime ? null : Math.max(0, daysRemaining), expired, features: expired ? [] : (this.activation.features || Object.keys(PREMIUM_FEATURES)), premiumFeatures: PREMIUM_FEATURES, validationMethod: this.activation.validationMethod }; } /** 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; } /** * DC-165 rev2 (judge P0): repair an OFFLINE activation's feature snapshot * when it was stored by an older build whose PREMIUM_FEATURES lacked a * key this build gates (fleet, workflows, auto-backup). Without this the * offline snapshot would deny the feature forever despite an active * premium license. Refresh is an APPEND-ONLY union: current premium keys * first, then any unknown legacy entries preserved verbatim — a * missing-key repair never implies removal. Result is identical to what * re-activating the same code today would store (activate() writes * Object.keys(PREMIUM_FEATURES)). ONLINE activations are NOT touched: * their authoritative feature list comes from the license server on * validation; this only reconciles a local build's own derived snapshot * with its own constant. Store failure warns — in-memory state stays * correct for this process. */ async _migrateOfflineFeatureSnapshot() { if (!this.activation || this.activation.validationMethod !== 'offline') return; if (!Array.isArray(this.activation.features)) return; const current = Object.keys(PREMIUM_FEATURES); const stored = new Set(this.activation.features); const missing = current.filter((f) => !stored.has(f)); if (!missing.length) return; const extras = this.activation.features.filter((f) => !current.includes(f)); this.activation.features = current.concat(extras); try { await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation), { activatedAt: this.activation.activatedAt, expiresAt: this.activation.expiresAt }); } catch (error) { this.log.warn?.('license', 'Could not persist premium-feature migration (in-memory state still correct)', { error: error.message }); } this.log.info?.('license', 'Migrated offline activation to current premium feature set', { added: missing }); } 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') * @returns {boolean} */ hasFeature(feature) { if (!this.activation) return false; if (this.isExpired()) return false; const features = this.activation.features || Object.keys(PREMIUM_FEATURES); return features.includes(feature); } /** * DC-052: shorthand for "is this host on a Pro license right now?" * * Returns true only when there's an active, non-expired license. * Lifetime keys also count as Pro (they're just permanent Pro). * Free tier = false. Returns false when no activation exists. */ isPro() { if (!this.activation) return false; if (this.isExpired()) return false; // Lifetime keys are active forever; treat as Pro. return true; } /** * DC-052: are LIFETIME license codes permitted on this host? * * Default false. Set ALLOW_LIFETIME_LICENSE=true ONLY on the operator's * own dev machine — production hosts and paid customers must never be * able to activate a LIFETIME code. Per PRODUCT-SPEC-DECISIONS.md, * LIFETIME keys are creator-only; Stripe never issues them. */ allowsLifetimeLicense() { return process.env.ALLOW_LIFETIME_LICENSE === 'true'; } /** * Check if the license has expired */ isExpired() { if (!this.activation) return true; 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(); } /** * Get days remaining on the license */ daysRemaining() { if (!this.activation) return 0; const remaining = new Date(this.activation.expiresAt).getTime() - Date.now(); return Math.ceil(remaining / 86400000); } /** * Express middleware: gate a route behind a premium feature * @param {string} feature - Feature key * @returns {Function} Express middleware */ requirePremium(feature) { return (req, res, next) => { if (this.hasFeature(feature)) { return next(); } const featureInfo = PREMIUM_FEATURES[feature] || { name: feature }; return errorResponse(res, 403, `${featureInfo.name} requires a DashCaddy Premium subscription.`, { premiumRequired: true, feature, featureName: featureInfo.name, featureDescription: featureInfo.description, currentTier: this.isExpired() ? 'free' : 'expired', upgradeUrl: '/settings#license' }); }; } // Private methods /** * Validate code offline using HMAC */ _validateOffline(code) { if (!this.masterSecretHash) { // No secret available — try structural validation only try { const parsed = parseCode(code); // Without the secret we can't verify HMAC, but we can check structure if (parsed.version !== 1) return { valid: false, reason: 'Unsupported code version' }; if (parsed.durationDays !== 0 && !VALID_DURATIONS.includes(parsed.durationDays)) return { valid: false, reason: 'Invalid duration' }; // Can't verify signature without secret — reject return { valid: false, reason: 'License validation unavailable. Please try again when connected to the internet.' }; } catch (e) { return { valid: false, reason: e.message }; } } // Full verification with secret return verifyCode(this.masterSecretHash, code); } /** * Validate code against online license server */ async _validateOnline(code) { try { const machineId = this.getMachineFingerprint(); const response = await fetch(`${LICENSE_SERVER_URL}/api/license/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, machineId }), signal: AbortSignal.timeout(10000) // 10s timeout }); 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, activatedAt: new Date().toISOString(), expiresAt: data.expiresAt, machineId, features: data.features, serverToken: data.token || null } }; } 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 — 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; } } /** * Notify license server of deactivation */ async _notifyDeactivation() { if (!LICENSE_SERVER_URL || !this.activation) return; await fetch(`${LICENSE_SERVER_URL}/api/license/deactivate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: this.activation.code, machineId: this.activation.machineId, serverToken: this.activation.serverToken }), signal: AbortSignal.timeout(10000) }); } /** * Update config.json with license summary. Legacy offline mode also stores * a backup; server-managed mode keeps activation tokens only in credentials. */ async _updateConfig(throwOnError = false) { try { const fsp = require('fs').promises; let config = {}; try { const data = await fsp.readFile(this.configFile, 'utf8'); config = JSON.parse(data); } catch (e) { // Config doesn't exist yet } if (this.activation && !this.isExpired()) { config.license = { active: true, tier: 'premium', expiresAt: this.activation.expiresAt, daysRemaining: this.daysRemaining(), features: this.activation.features || Object.keys(PREMIUM_FEATURES) }; // Full backup of activation data (config.json is volume-mounted and persists) if (LICENSE_SERVER_URL) delete config.licenseBackup; else config.licenseBackup = this.activation; } else { config.license = { active: false, tier: 'free' }; delete config.licenseBackup; } config.updatedAt = new Date().toISOString(); 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; } } /** * Mask a license code for display (show first and last groups only) */ _maskCode(code) { if (!code) return 'none'; const parts = code.split('-'); if (parts.length < 4) return 'DC-*****'; return `${parts[0]}-${parts[1]}-*****-*****-${parts[parts.length - 1]}`; } } module.exports = { LicenseManager, PREMIUM_FEATURES };