Files
sencho/backend/src/services/LicenseService.ts
T
Anso d6b744e8e6 feat(license): replace local auto-trial with Lemon Squeezy hosted trial flow (#755)
Fresh installs land on the Community tier. The 14-day Admiral trial is now
issued by Lemon Squeezy via their hosted checkout: the user enters email +
card, receives a license key by email, and pastes it into the existing
Settings > License activation field.

Backend changes:
- LicenseService.initialize() no longer auto-creates a license_status='trial'
  row on first boot. It now only ensures an instance_id exists and starts
  periodic validation.
- Drop the TRIAL_DURATION_DAYS constant.
- Drop the status='trial' early-return in getVariant() so LS-issued trials
  resolve through the normal variant metadata path (variant_name / product_name).
- Trial branches in getTier() and getLicenseInfo() are retained for future
  work that may detect trial state from Lemon Squeezy metadata; they are
  currently unreachable via the Sencho code paths.

Frontend changes:
- Settings > License surfaces a new "Try Admiral free for 14 days" CTA block
  with Start monthly trial and Start annual trial buttons that open Lemon
  Squeezy hosted checkout. The CTA is visible only when the user has no paid
  access and is not already on a trial.
- Reserve the Admiral upgrade card for the Skipper-active upgrade path so
  unlicensed users see one Admiral path (the trial CTA) instead of two.
- Pull the inline Lemon Squeezy checkout URLs into named module constants so
  the Skipper, Admiral monthly, and Admiral annual endpoints are defined in
  one place.

Test changes:
- license-service.test.ts covers the no-auto-trial startup path and updates
  the trial-variant test to reflect the metadata-driven resolution.
- afterAll in the initialize() describe block calls destroy() so the 72-hour
  validation interval does not leak into sibling test files.

Docs:
- Rewrite the Free trial section in features/licensing.mdx to document the
  new LS checkout flow (email + card required, auto-converts on day 14 unless
  cancelled).
- Add an operations/troubleshooting entry for cases where the trial license
  key email does not arrive.
2026-04-24 16:26:36 -04:00

579 lines
22 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;
export class LicenseService {
private static instance: LicenseService;
private validationTimer: ReturnType<typeof setInterval> | null = null;
private constructor() { }
public static getInstance(): LicenseService {
if (!LicenseService.instance) {
LicenseService.instance = new LicenseService();
}
return LicenseService.instance;
}
/**
* 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
db.setSystemState('license_status', '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.');
db.setSystemState('license_status', 'community');
return 'community';
}
}
// Check expiry for subscription licenses
const validUntil = db.getSystemState('license_valid_until');
if (validUntil && new Date(validUntil) < new Date()) {
db.setSystemState('license_status', 'expired');
return 'community';
}
return 'paid';
}
return 'community';
}
/**
* Resolve Lemon Squeezy metadata to the internal variant type.
* Checks both variant_name and product_name since LS variant names may only
* describe the billing period (e.g. "Lifetime", "Monthly") while the product
* name contains the tier (e.g. "Sencho Admiral").
*/
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 Lemon Squeezy response to DB. */
private storeVariantMeta(db: DatabaseService, meta: { variant_name?: string; variant_id?: number; product_name?: string }): void {
if (meta.variant_name) {
db.setSystemState('license_variant_name', meta.variant_name);
db.setSystemState('license_variant_type', this.resolveVariantType(meta.variant_name, meta.product_name));
}
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 variantName = db.getSystemState('license_variant_name');
const productName = db.getSystemState('license_product_name') || undefined;
const storedType = db.getSystemState('license_variant_type');
// Self-heal: if we have source metadata, always cross-check the stored type
if (variantName) {
const resolved = this.resolveVariantType(variantName, productName);
if (resolved !== storedType) {
db.setSystemState('license_variant_type', resolved);
}
return resolved;
}
// No variant name available; trust stored type if valid
if (isLicenseVariant(storedType)) return normalizeVariant(storedType);
return 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' };
}
// Store license data
db.setSystemState('license_key', licenseKey);
db.setSystemState('license_instance_id', data.instance?.id || '');
db.setSystemState('license_status', '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);
}
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, '');
}
db.setSystemState('license_status', '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;
db.setSystemState('license_last_validated', Date.now().toString());
if (!data.valid) {
// License revoked or invalid
db.setSystemState('license_status', 'disabled');
console.warn('[License] Validation failed: license is no longer valid.');
return { success: false, error: data.error || 'License is no longer valid' };
}
// Update status based on key status
const keyStatus = data.license_key?.status;
if (keyStatus === 'expired') {
db.setSystemState('license_status', 'expired');
return { success: false, error: 'License has expired' };
}
if (keyStatus === 'disabled') {
db.setSystemState('license_status', 'disabled');
return { success: false, error: 'License has been disabled' };
}
db.setSystemState('license_status', '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);
}
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;
}
}
}