Files
sencho/backend/src/services/LicenseService.ts
T
Anso e5b1c7b22b refactor(backend): collapse entitlement provider abstraction back to LicenseService (#889)
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.
2026-05-02 23:45:44 -04:00

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;
}
}
}