Files
sencho/backend/src/services/LicenseService.ts
T
Anso d17562ac60 fix(license): reject activation when LS response is missing instance.id (#867)
LicenseService.activate() previously stored data.instance?.id || '' on
success. If LS ever returned activated:true without an instance.id
(malformed response, API change, transient bug), the user saw
"Activated successfully" but every subsequent validate() and
deactivate() short-circuited on the empty license_instance_id with a
generic "no active license" error. The activation appeared to succeed
while leaving the install in a broken state.

After the existing catalog-id guard, also require a non-empty
data.instance.id and reject up front with a retry-friendly message if
missing. The check costs nothing on the happy path (LS has historically
always returned instance.id on success) and turns a silent state
divergence into a clear, actionable error.

Adds two tests covering instance object absent and instance.id empty
string. Both assert mockSetSystemState was never called, which catches
any future code that accidentally writes state above the guard.

Adds a block comment above initialize() explaining the dual-name
relationship that confused the original audit: instance_id is the local
UUID we pass to LS as instance_name, license_instance_id is the
LS-issued activation id we pass back as instance_id on validate and
deactivate. Same area, swapped names, no overlap.
2026-05-02 00:39:41 -04:00

761 lines
31 KiB
TypeScript

import crypto from 'crypto';
import axios from 'axios';
import { DatabaseService } from './DatabaseService';
export type LicenseTier = 'community' | 'paid';
export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled';
export type LicenseVariant = 'skipper' | 'admiral' | null;
const VALID_TIERS: readonly string[] = ['community', 'paid'] satisfies readonly LicenseTier[];
const VALID_VARIANTS: readonly string[] = ['skipper', 'admiral'] satisfies readonly LicenseVariant[];
// Legacy names from pre-0.38.1 versions. Accepted on input and normalized to current names.
const LEGACY_TIER_MAP: Record<string, LicenseTier> = { pro: 'paid' };
const LEGACY_VARIANT_MAP: Record<string, Exclude<LicenseVariant, null>> = { personal: 'skipper', team: 'admiral' };
/** Check if value is a recognized tier (current or legacy name). */
export function isLicenseTier(value: unknown): value is string {
return typeof value === 'string' && ((VALID_TIERS as readonly string[]).includes(value) || value in LEGACY_TIER_MAP);
}
/** Check if value is a recognized variant (current or legacy name). */
export function isLicenseVariant(value: unknown): value is string {
return typeof value === 'string' && ((VALID_VARIANTS as readonly string[]).includes(value) || value in LEGACY_VARIANT_MAP);
}
/** Normalize a tier value, mapping legacy names to current equivalents. Must be called after isLicenseTier validation. */
export function normalizeTier(value: string): LicenseTier {
return LEGACY_TIER_MAP[value] ?? (value as LicenseTier);
}
/** Normalize a variant value, mapping legacy names to current equivalents. Must be called after isLicenseVariant validation. */
export function normalizeVariant(value: string): Exclude<LicenseVariant, null> {
return LEGACY_VARIANT_MAP[value] ?? (value as Exclude<LicenseVariant, null>);
}
/** Header names used for Distributed License Enforcement between nodes. */
export const PROXY_TIER_HEADER = 'x-sencho-tier';
export const PROXY_VARIANT_HEADER = 'x-sencho-variant';
export interface LicenseInfo {
tier: LicenseTier;
status: LicenseStatus;
variant: LicenseVariant;
customerName: string | null;
productName: string | null;
maskedKey: string | null;
validUntil: string | null;
trialDaysRemaining: number | null;
instanceId: string;
portalUrl: string | null;
isLifetime: boolean;
}
/** Seat limits per variant. null = unlimited. */
export interface SeatLimits {
maxAdmins: number | null;
maxViewers: number | null;
}
const SEAT_LIMITS: Record<string, SeatLimits> = {
skipper: { maxAdmins: 1, maxViewers: 3 },
admiral: { maxAdmins: null, maxViewers: null },
};
interface LemonSqueezyActivationResponse {
activated: boolean;
error?: string;
license_key?: {
id: number;
status: string;
key: string;
activation_limit: number;
activation_usage: number;
created_at: string;
expires_at: string | null;
};
instance?: {
id: string;
name: string;
created_at: string;
};
meta?: {
store_id: number;
order_id: number;
order_item_id: number;
product_id: number;
product_name: string;
variant_id: number;
variant_name: string;
customer_id: number;
customer_name: string;
customer_email: string;
};
}
interface LemonSqueezyValidationResponse {
valid: boolean;
error?: string;
license_key?: {
id: number;
status: string;
key: string;
activation_limit: number;
activation_usage: number;
created_at: string;
expires_at: string | null;
};
meta?: {
store_id: number;
order_id: number;
order_item_id: number;
product_id: number;
product_name: string;
variant_id: number;
variant_name: string;
customer_id: number;
customer_name: string;
customer_email: string;
};
}
const LEMON_SQUEEZY_API = 'https://api.lemonsqueezy.com/v1/licenses';
const VALIDATION_INTERVAL_MS = 72 * 60 * 60 * 1000; // 72 hours
const OFFLINE_GRACE_DAYS = 30;
/**
* Lemon Squeezy catalog identifiers Sencho is willing to honor. Without these
* checks, a license issued for any other LS store or product could activate
* Sencho, because LS's /licenses/validate endpoint returns valid: true for
* any well-formed license key regardless of which product it belongs to.
*
* The validate response contains store_id / product_id / variant_id under
* meta; resolveSenchoVariantFromMeta() rejects any combination not listed
* here. variant_id also serves as the canonical source for tier resolution,
* replacing the older substring match against variant_name / product_name.
*
* If a new tier or billing cadence is added in the LS dashboard, this map
* must be updated in the same release.
*/
export const SENCHO_LS_STORE_ID = 321715;
export const SENCHO_LS_PRODUCT_ID_SKIPPER = 924135;
export const SENCHO_LS_PRODUCT_ID_ADMIRAL = 924153;
const SENCHO_LS_PRODUCT_IDS: ReadonlySet<number> = new Set([
SENCHO_LS_PRODUCT_ID_SKIPPER,
SENCHO_LS_PRODUCT_ID_ADMIRAL,
]);
const SENCHO_LS_VARIANT_TO_TYPE: ReadonlyMap<number, Exclude<LicenseVariant, null>> = new Map([
[1453178, 'skipper'], // Skipper Monthly
[1453197, 'skipper'], // Skipper Annual
[1453198, 'skipper'], // Skipper Lifetime
[1453209, 'admiral'], // Admiral Monthly
[1453212, 'admiral'], // Admiral Annual
[1453217, 'admiral'], // Admiral Lifetime
]);
/**
* Resolve a Lemon Squeezy validate / activate response's meta block to a
* Sencho variant. Returns null when the meta is missing, the store does not
* match, the product is not a Sencho product, or the variant is unknown.
*
* Callers must reject the activation/validation when this returns null.
* Persisting any state from a non-matching response would let foreign LS
* licenses unlock paid features.
*/
export function resolveSenchoVariantFromMeta(
meta: { store_id?: number; product_id?: number; variant_id?: number } | undefined,
): Exclude<LicenseVariant, null> | null {
if (!meta) return null;
if (meta.store_id !== SENCHO_LS_STORE_ID) return null;
if (meta.product_id === undefined || !SENCHO_LS_PRODUCT_IDS.has(meta.product_id)) return null;
if (meta.variant_id === undefined) return null;
return SENCHO_LS_VARIANT_TO_TYPE.get(meta.variant_id) ?? null;
}
// Short TTL for the proxy-headers cache. The remote-node proxy reads tier
// and variant on every forwarded request; without caching, each call hits
// system_state 5+ times. Every license_status write goes through
// setLicenseStatus() which invalidates the cache, so the TTL is a safety
// net against any future bypass rather than a load-bearing freshness bound.
const PROXY_HEADERS_CACHE_TTL_MS = 30_000;
export class LicenseService {
private static instance: LicenseService;
private validationTimer: ReturnType<typeof setInterval> | null = null;
private cachedProxyHeaders: { value: { tier: LicenseTier; variant: LicenseVariant }; expiresAt: number } | null = null;
private constructor() { }
public static getInstance(): LicenseService {
if (!LicenseService.instance) {
LicenseService.instance = new LicenseService();
}
return LicenseService.instance;
}
/**
* Two distinct identifiers live in `system_state` and the names are
* confusingly close, so for future maintainers (and audit agents):
*
* `instance_id` local UUID generated once on first boot. We
* send it to LS as the activation's
* `instance_name` (LS treats this as a
* free-form label, e.g. a hostname).
*
* `license_instance_id` the activation ID LS returns on /activate.
* We send it back to LS as the `instance_id`
* parameter on /validate and /deactivate.
*
* They are NOT redundant and NEITHER overwrites the other. Renaming
* `instance_id` to e.g. `local_install_id` would be clearer but would
* churn frontend consumers and migrations for no functional change.
*/
/**
* Initialize the license service on startup.
* Ensures an instance ID exists and starts periodic validation for active licenses.
* Fresh installs land on Community; trials are issued by Lemon Squeezy via the
* hosted checkout (email + card required) and activated locally by pasting the key.
*/
public initialize(): void {
const db = DatabaseService.getInstance();
// Generate persistent instance ID on first boot
if (!db.getSystemState('instance_id')) {
db.setSystemState('instance_id', crypto.randomUUID());
}
this.startPeriodicValidation();
}
/**
* Returns the current license tier. Synchronous - reads from cached DB state only.
*/
public getTier(): LicenseTier {
const db = DatabaseService.getInstance();
const status = db.getSystemState('license_status') as LicenseStatus | null;
if (!status || status === 'community') return 'community';
if (status === 'disabled' || status === 'expired') return 'community';
if (status === 'trial') {
const validUntil = db.getSystemState('license_valid_until');
if (validUntil && new Date(validUntil) > new Date()) {
return 'paid';
}
// Trial expired - update status
this.setLicenseStatus('community');
return 'community';
}
if (status === 'active') {
// Check offline grace period
const lastValidated = db.getSystemState('license_last_validated');
if (lastValidated) {
const daysSinceValidation = (Date.now() - parseInt(lastValidated, 10)) / (1000 * 60 * 60 * 24);
if (daysSinceValidation > OFFLINE_GRACE_DAYS) {
console.warn('[License] Offline grace period exceeded. Degrading to community.');
this.setLicenseStatus('community');
return 'community';
}
}
// Check expiry for subscription licenses
const validUntil = db.getSystemState('license_valid_until');
if (validUntil && new Date(validUntil) < new Date()) {
this.setLicenseStatus('expired');
return 'community';
}
return 'paid';
}
return 'community';
}
/**
* Resolve Lemon Squeezy metadata to the internal variant type from string
* metadata. Used as a fallback when license_variant_id is unavailable.
*
* With the catalog guard in resolveSenchoVariantFromMeta(), every new
* activation stores license_variant_id, so production callers always hit
* the variant_id path in getVariant(). This substring fallback is retained
* for test fixtures that drive getVariant() without a variant_id present.
* Once the per-Directive-20 cleanup branch lands, this method and its two
* call sites can be deleted.
*/
private resolveVariantType(variantName: string, productName?: string): 'skipper' | 'admiral' {
const combined = `${variantName} ${productName || ''}`.toLowerCase();
if (combined.includes('team') || combined.includes('admiral')) return 'admiral';
if (combined.includes('personal') || combined.includes('skipper')) return 'skipper';
return 'skipper';
}
/**
* Persist variant metadata from a Lemon Squeezy response to the DB.
*
* When `resolvedType` is supplied (always, in production paths via
* resolveSenchoVariantFromMeta), it wins over the legacy substring match
* against variant_name / product_name. The substring fallback is kept for
* tests that exercise the legacy path and as a defensive default; new
* activations always carry a resolved type.
*/
private storeVariantMeta(
db: DatabaseService,
meta: { variant_name?: string; variant_id?: number; product_name?: string },
resolvedType?: Exclude<LicenseVariant, null>,
): void {
if (meta.variant_name) {
db.setSystemState('license_variant_name', meta.variant_name);
const type = resolvedType ?? this.resolveVariantType(meta.variant_name, meta.product_name);
db.setSystemState('license_variant_type', type);
}
if (meta.variant_id) {
db.setSystemState('license_variant_id', String(meta.variant_id));
}
}
/**
* Get the license variant (skipper or admiral) from stored metadata.
* Trial and active licenses both resolve via Lemon Squeezy metadata stored by activate();
* trial-granted variant is whatever Lemon Squeezy returned for the trial variant.
*
* Self-healing: on every call, cross-checks the stored variant_type against what
* resolveVariantType() produces from the current product/variant names. If they
* disagree (e.g. stale cache from a previous buggy version), re-resolves and
* persists the corrected value.
*/
public getVariant(): LicenseVariant {
const db = DatabaseService.getInstance();
const variantIdStr = db.getSystemState('license_variant_id');
const storedType = db.getSystemState('license_variant_type');
// Prefer variant_id-based resolution. variant_id is a stable LS catalog
// identifier, while variant_name / product_name are display strings that
// can be edited in the LS dashboard. Activation already rejected any
// unrecognized variant_id, so a hit here is always trustworthy.
if (variantIdStr) {
const variantId = parseInt(variantIdStr, 10);
if (Number.isFinite(variantId)) {
const fromId = SENCHO_LS_VARIANT_TO_TYPE.get(variantId);
if (fromId) {
if (fromId !== storedType) {
db.setSystemState('license_variant_type', fromId);
}
return fromId;
}
}
}
// Fall back to name-based resolution for any state without a
// variant_id (test fixtures, partial DB writes from older code paths).
const variantName = db.getSystemState('license_variant_name');
const productName = db.getSystemState('license_product_name') || undefined;
if (variantName) {
const resolved = this.resolveVariantType(variantName, productName);
if (resolved !== storedType) {
db.setSystemState('license_variant_type', resolved);
}
return resolved;
}
// No source metadata available; trust the stored type if it parses.
if (isLicenseVariant(storedType)) return normalizeVariant(storedType);
return null;
}
/**
* Tier + variant snapshot for the remote-node proxy headers, cached for
* a short window to spare the proxy hot path from re-running getTier()
* and getVariant() on every forwarded request. All license-status writes
* route through setLicenseStatus(), which invalidates this cache, so
* tier changes take effect within one proxy call.
*/
public getProxyHeaders(): { tier: LicenseTier; variant: LicenseVariant } {
const now = Date.now();
if (this.cachedProxyHeaders && this.cachedProxyHeaders.expiresAt > now) {
return this.cachedProxyHeaders.value;
}
const value = { tier: this.getTier(), variant: this.getVariant() };
this.cachedProxyHeaders = { value, expiresAt: now + PROXY_HEADERS_CACHE_TTL_MS };
return value;
}
/**
* Single chokepoint for license_status writes. Persists the new status
* and invalidates the proxy-headers cache so tier-gated routes on
* remote nodes observe the change on the next forwarded request.
* Every license_status write must go through this method; bypassing
* it leaves the cache stale until the TTL expires.
*/
private setLicenseStatus(status: LicenseStatus): void {
DatabaseService.getInstance().setSystemState('license_status', status);
this.cachedProxyHeaders = null;
}
/**
* Get seat limits for the current license variant.
*/
public getSeatLimits(): SeatLimits {
const variant = this.getVariant();
if (!variant) return { maxAdmins: 1, maxViewers: 0 }; // community
return SEAT_LIMITS[variant] || SEAT_LIMITS.skipper;
}
/**
* Get full license information for the API response.
*/
public getLicenseInfo(): LicenseInfo {
const db = DatabaseService.getInstance();
const status = (db.getSystemState('license_status') || 'community') as LicenseStatus;
const key = db.getSystemState('license_key');
const validUntil = db.getSystemState('license_valid_until');
const instanceId = db.getSystemState('instance_id') || '';
let trialDaysRemaining: number | null = null;
if (status === 'trial' && validUntil) {
const remaining = (new Date(validUntil).getTime() - Date.now()) / (1000 * 60 * 60 * 24);
trialDaysRemaining = Math.max(0, Math.ceil(remaining));
}
// Lifetime license: active with a stored key but no expiry date
const isLifetime = status === 'active' && !!key && !validUntil;
return {
tier: this.getTier(),
status,
variant: this.getVariant(),
customerName: db.getSystemState('license_customer_name'),
productName: db.getSystemState('license_product_name'),
maskedKey: key ? `****-****-****-${key.slice(-4)}` : null,
validUntil,
trialDaysRemaining,
instanceId,
portalUrl: db.getSystemState('billing_portal_url') || db.getSystemState('customer_portal_url') || null,
isLifetime,
};
}
/**
* Activate a license key with Lemon Squeezy.
*/
public async activate(licenseKey: string): Promise<{ success: boolean; error?: string }> {
const db = DatabaseService.getInstance();
const instanceId = db.getSystemState('instance_id') || crypto.randomUUID();
try {
const response = await axios.post<LemonSqueezyActivationResponse>(
`${LEMON_SQUEEZY_API}/activate`,
{
license_key: licenseKey,
instance_name: instanceId,
},
{ timeout: 15000 }
);
const data = response.data;
if (!data.activated) {
return { success: false, error: data.error || 'Activation failed' };
}
// Reject licenses that don't belong to the Sencho LS catalog.
// LS's /activate succeeds for any product in any store, so without
// this check a license bought elsewhere could unlock Sencho.
const variantType = resolveSenchoVariantFromMeta(data.meta);
if (variantType === null) {
console.warn('[License] Activation rejected: license does not match the Sencho catalog.');
return { success: false, error: 'This license key is not valid for Sencho.' };
}
// Reject if LS did not return a usable instance id. Storing an
// empty license_instance_id would silently break later validate()
// and deactivate() calls, both of which short-circuit on a falsy
// instance id with a generic "no active license" error. Better to
// surface the broken activation immediately than ship a state where
// the user thinks they are activated but every subsequent call
// fails. LS has historically always returned data.instance.id on
// a successful activation; this is a defense against an API change
// or transient malformed response, not a routinely-hit branch.
const lsInstanceId = data.instance?.id;
if (!lsInstanceId) {
console.warn('[License] Activation rejected: LS response missing instance.id.');
return { success: false, error: 'License server returned an incomplete activation. Please try again.' };
}
// Store license data
db.setSystemState('license_key', licenseKey);
db.setSystemState('license_instance_id', lsInstanceId);
this.setLicenseStatus('active');
db.setSystemState('license_last_validated', Date.now().toString());
if (data.license_key?.expires_at) {
db.setSystemState('license_valid_until', data.license_key.expires_at);
} else {
// Lifetime license - no expiry
db.setSystemState('license_valid_until', '');
}
if (data.meta?.customer_name) {
db.setSystemState('license_customer_name', data.meta.customer_name);
}
if (data.meta?.product_name) {
db.setSystemState('license_product_name', data.meta.product_name);
}
if (data.meta) {
this.storeVariantMeta(db, data.meta, variantType);
}
if (data.meta?.customer_id) {
db.setSystemState('customer_id', String(data.meta.customer_id));
}
// Clear any cached portal URL so it's refreshed on next request
db.setSystemState('billing_portal_url', '');
db.setSystemState('billing_portal_expires', '');
console.log('[License] Activated successfully.');
return { success: true };
} catch (err) {
// Handle Lemon Squeezy error responses (4xx)
if (axios.isAxiosError(err) && err.response?.data) {
const errorMsg = err.response.data.error || 'Activation failed';
console.error('[License] Activation error:', errorMsg);
return { success: false, error: errorMsg };
}
console.error('[License] Activation network error:', (err as Error).message);
return { success: false, error: 'Unable to reach license server. Check your internet connection.' };
}
}
/**
* Deactivate the current license, reverting to community.
*/
public async deactivate(): Promise<{ success: boolean; error?: string }> {
const db = DatabaseService.getInstance();
const licenseKey = db.getSystemState('license_key');
const instanceId = db.getSystemState('license_instance_id');
if (licenseKey && instanceId) {
try {
await axios.post(
`${LEMON_SQUEEZY_API}/deactivate`,
{
license_key: licenseKey,
instance_id: instanceId,
},
{ timeout: 15000 }
);
} catch (err) {
console.warn('[License] Deactivation API call failed (proceeding with local cleanup):', (err as Error).message);
}
}
// Clear all license state
const keysToRemove = [
'license_key',
'license_instance_id',
'license_status',
'license_valid_until',
'license_last_validated',
'license_customer_name',
'license_product_name',
'license_variant_name',
'license_variant_type',
'license_variant_id',
'subscription_id',
'customer_id',
'customer_portal_url',
'update_payment_url',
'order_id',
'receipt_url',
'billing_portal_url',
'billing_portal_expires',
];
for (const key of keysToRemove) {
db.setSystemState(key, '');
}
this.setLicenseStatus('community');
console.log('[License] Deactivated. Reverted to Community tier.');
return { success: true };
}
/**
* Validate the current license against Lemon Squeezy.
*/
public async validate(): Promise<{ success: boolean; error?: string }> {
const db = DatabaseService.getInstance();
const licenseKey = db.getSystemState('license_key');
const instanceId = db.getSystemState('license_instance_id');
if (!licenseKey || !instanceId) {
return { success: false, error: 'No active license to validate' };
}
try {
const response = await axios.post<LemonSqueezyValidationResponse>(
`${LEMON_SQUEEZY_API}/validate`,
{
license_key: licenseKey,
instance_id: instanceId,
},
{ timeout: 15000 }
);
const data = response.data;
if (!data.valid) {
// License revoked or invalid
this.setLicenseStatus('disabled');
console.warn('[License] Validation failed: license is no longer valid.');
return { success: false, error: data.error || 'License is no longer valid' };
}
// Reject if the license is no longer recognized as a Sencho catalog
// entry (e.g. variant_id removed, product moved). Same defense as
// activate(): without this, any LS license can pass periodic
// validation and keep paid features unlocked.
const variantType = resolveSenchoVariantFromMeta(data.meta);
if (variantType === null) {
this.setLicenseStatus('disabled');
console.warn('[License] Validation rejected: license does not match the Sencho catalog.');
return { success: false, error: 'License is not valid for Sencho.' };
}
// license_last_validated means "we got an authoritative answer from
// LS for a recognized Sencho license," not just "we reached the LS
// API." Writing it before the catalog guard would let a foreign LS
// license refresh the offline grace window during the brief window
// the rejected status is being persisted.
db.setSystemState('license_last_validated', Date.now().toString());
// Update status based on key status
const keyStatus = data.license_key?.status;
if (keyStatus === 'expired') {
this.setLicenseStatus('expired');
return { success: false, error: 'License has expired' };
}
if (keyStatus === 'disabled') {
this.setLicenseStatus('disabled');
return { success: false, error: 'License has been disabled' };
}
this.setLicenseStatus('active');
// Update expiry if changed
if (data.license_key?.expires_at) {
db.setSystemState('license_valid_until', data.license_key.expires_at);
}
// Update customer/product info if available
if (data.meta?.customer_name) {
db.setSystemState('license_customer_name', data.meta.customer_name);
}
if (data.meta?.product_name) {
db.setSystemState('license_product_name', data.meta.product_name);
}
if (data.meta) {
this.storeVariantMeta(db, data.meta, variantType);
}
if (data.meta?.customer_id && !db.getSystemState('customer_id')) {
db.setSystemState('customer_id', String(data.meta.customer_id));
}
console.log('[License] Validation successful.');
return { success: true };
} catch (err) {
// Network failure - don't change status, just log
console.warn('[License] Validation network error (keeping current status):', (err as Error).message);
return { success: false, error: 'Unable to reach license server' };
}
}
/**
* Fetch a signed billing portal URL via the sencho.io proxy.
* Returns a pre-signed Lemon Squeezy Customer Portal URL (valid 24hrs).
* Caches the URL for 12 hours to reduce external API calls.
*/
public async getBillingPortalUrl(): Promise<{ url: string } | { error: string }> {
const db = DatabaseService.getInstance();
const status = db.getSystemState('license_status');
const licenseKey = db.getSystemState('license_key');
if (status !== 'active' || !licenseKey) {
return { error: 'No billing portal available. Ensure you have an active license.' };
}
// Lifetime licenses have no recurring subscription to manage
const validUntil = db.getSystemState('license_valid_until');
if (!validUntil) {
return { error: 'Billing portal is not available for lifetime licenses.' };
}
// Check cache (12hr TTL)
const cachedUrl = db.getSystemState('billing_portal_url');
const cachedExpires = db.getSystemState('billing_portal_expires');
if (cachedUrl && cachedExpires && Date.now() < parseInt(cachedExpires, 10)) {
return { url: cachedUrl };
}
try {
const response = await axios.post<{ url: string }>(
'https://sencho.io/api/billing-portal',
{ license_key: licenseKey },
{ timeout: 15000 }
);
const url = response.data?.url;
if (!url) {
return { error: 'No billing portal available. Ensure you have an active license.' };
}
// Cache for 12 hours
const ttl = 12 * 60 * 60 * 1000;
db.setSystemState('billing_portal_url', url);
db.setSystemState('billing_portal_expires', String(Date.now() + ttl));
return { url };
} catch (err) {
console.warn('[License] Failed to fetch billing portal URL:', (err as Error).message);
// Return stale cache if available
if (cachedUrl) return { url: cachedUrl };
return { error: 'Failed to retrieve billing portal URL.' };
}
}
/**
* Start periodic background validation every 72 hours.
*/
public startPeriodicValidation(): void {
if (this.validationTimer) {
clearInterval(this.validationTimer);
}
this.validationTimer = setInterval(async () => {
const db = DatabaseService.getInstance();
const status = db.getSystemState('license_status');
// Only validate active licenses (not trial, community, etc.)
if (status === 'active') {
await this.validate();
}
}, VALIDATION_INTERVAL_MS);
// Run an initial validation on startup for active licenses (after a short delay)
const db = DatabaseService.getInstance();
if (db.getSystemState('license_status') === 'active') {
setTimeout(() => this.validate(), 5000);
}
}
/**
* Cleanup on shutdown.
*/
public destroy(): void {
if (this.validationTimer) {
clearInterval(this.validationTimer);
this.validationTimer = null;
}
}
}