mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
e5b1c7b22b
Removes backend/src/entitlements/ (registry, loadProvider, CommunityEntitlementProvider, types, headers, normalize) and the two abstraction-only tests. Relocates headers/normalize/types to services/license-*.ts. Swaps 22 consumer call sites from getEntitlementProvider() to LicenseService.getInstance(). Drops the Dockerfile install step plus PRO_PACKAGE_VERSION build-arg and github_token BuildKit secret in docker-publish.yml. Removes the now stale no-restricted-imports rule in backend/eslint.config.mjs. Net: 37 files changed, ~700 lines removed, no behavior change. Local dev no longer requires GitHub Packages auth to start the backend. Rationale and revisit conditions in docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md.
719 lines
29 KiB
TypeScript
719 lines
29 KiB
TypeScript
import crypto from 'crypto';
|
|
import axios from 'axios';
|
|
import { DatabaseService } from './DatabaseService';
|
|
import type {
|
|
LicenseInfo,
|
|
LicenseStatus,
|
|
LicenseTier,
|
|
LicenseVariant,
|
|
SeatLimits,
|
|
} from './license-types';
|
|
import { isLicenseVariant, normalizeVariant } from './license-normalize';
|
|
|
|
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;
|
|
|
|
/**
|
|
* Single in-tree license service. Owns Lemon Squeezy validation and
|
|
* exposes the tier / variant / seat-limit API consumed across the
|
|
* backend. See `docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md`
|
|
* for the conditions that would justify reintroducing an interface seam.
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
}
|