mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
perf(proxy): cache LicenseService tier headers for the proxy hot path (#815)
The remote-node HTTP proxy and WebSocket forwarder read getTier() + getVariant() on every forwarded request to set the Distributed License Enforcement headers. Each call hits system_state 5+ times. Add a 30-second cached snapshot inside LicenseService and route every license_status write through a new private setLicenseStatus() helper so activate, deactivate, validate, and the auto-demote paths inside getTier() all invalidate the cache. Routing all license_status writes through one chokepoint also closes a latent drift window: the self-heal paths in getTier() (trial expired, offline grace exceeded, subscription expired) used to mutate state silently and now invalidate the cache the same way explicit license events do. The TTL becomes a safety net against any future write that bypasses the helper, not a load-bearing freshness bound. Existing 44 license and distributed-license tests pass unchanged.
This commit is contained in:
@@ -48,10 +48,12 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
||||
// tier to the remote node so tier-gated routes honor the main's
|
||||
// license instead of the node's local (likely Community) tier. The
|
||||
// remote's authMiddleware only trusts these headers when the request
|
||||
// carries a valid node_proxy JWT.
|
||||
const proxyLs = LicenseService.getInstance();
|
||||
proxyReq.setHeader(PROXY_TIER_HEADER, proxyLs.getTier());
|
||||
proxyReq.setHeader(PROXY_VARIANT_HEADER, proxyLs.getVariant() || '');
|
||||
// carries a valid node_proxy JWT. The cached snapshot here invalidates
|
||||
// on activate / deactivate / validate so the headers track license
|
||||
// state changes within one proxy call.
|
||||
const headers = LicenseService.getInstance().getProxyHeaders();
|
||||
proxyReq.setHeader(PROXY_TIER_HEADER, headers.tier);
|
||||
proxyReq.setHeader(PROXY_VARIANT_HEADER, headers.variant || '');
|
||||
// Strip the ?nodeId= query param so the remote's nodeContextMiddleware
|
||||
// doesn't reject the request with 404 ("Node X not found") - the remote
|
||||
// has no record of the gateway's node IDs and should treat the request
|
||||
|
||||
@@ -123,10 +123,17 @@ interface LemonSqueezyValidationResponse {
|
||||
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;
|
||||
// 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() { }
|
||||
|
||||
@@ -170,7 +177,7 @@ export class LicenseService {
|
||||
return 'paid';
|
||||
}
|
||||
// Trial expired - update status
|
||||
db.setSystemState('license_status', 'community');
|
||||
this.setLicenseStatus('community');
|
||||
return 'community';
|
||||
}
|
||||
|
||||
@@ -181,7 +188,7 @@ export class LicenseService {
|
||||
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');
|
||||
this.setLicenseStatus('community');
|
||||
return 'community';
|
||||
}
|
||||
}
|
||||
@@ -189,7 +196,7 @@ export class LicenseService {
|
||||
// Check expiry for subscription licenses
|
||||
const validUntil = db.getSystemState('license_valid_until');
|
||||
if (validUntil && new Date(validUntil) < new Date()) {
|
||||
db.setSystemState('license_status', 'expired');
|
||||
this.setLicenseStatus('expired');
|
||||
return 'community';
|
||||
}
|
||||
|
||||
@@ -254,6 +261,35 @@ export class LicenseService {
|
||||
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.
|
||||
*/
|
||||
@@ -322,7 +358,7 @@ export class LicenseService {
|
||||
// Store license data
|
||||
db.setSystemState('license_key', licenseKey);
|
||||
db.setSystemState('license_instance_id', data.instance?.id || '');
|
||||
db.setSystemState('license_status', 'active');
|
||||
this.setLicenseStatus('active');
|
||||
db.setSystemState('license_last_validated', Date.now().toString());
|
||||
|
||||
if (data.license_key?.expires_at) {
|
||||
@@ -410,7 +446,7 @@ export class LicenseService {
|
||||
for (const key of keysToRemove) {
|
||||
db.setSystemState(key, '');
|
||||
}
|
||||
db.setSystemState('license_status', 'community');
|
||||
this.setLicenseStatus('community');
|
||||
|
||||
console.log('[License] Deactivated. Reverted to Community tier.');
|
||||
return { success: true };
|
||||
@@ -443,7 +479,7 @@ export class LicenseService {
|
||||
|
||||
if (!data.valid) {
|
||||
// License revoked or invalid
|
||||
db.setSystemState('license_status', 'disabled');
|
||||
this.setLicenseStatus('disabled');
|
||||
console.warn('[License] Validation failed: license is no longer valid.');
|
||||
return { success: false, error: data.error || 'License is no longer valid' };
|
||||
}
|
||||
@@ -451,15 +487,15 @@ export class LicenseService {
|
||||
// Update status based on key status
|
||||
const keyStatus = data.license_key?.status;
|
||||
if (keyStatus === 'expired') {
|
||||
db.setSystemState('license_status', 'expired');
|
||||
this.setLicenseStatus('expired');
|
||||
return { success: false, error: 'License has expired' };
|
||||
}
|
||||
if (keyStatus === 'disabled') {
|
||||
db.setSystemState('license_status', 'disabled');
|
||||
this.setLicenseStatus('disabled');
|
||||
return { success: false, error: 'License has been disabled' };
|
||||
}
|
||||
|
||||
db.setSystemState('license_status', 'active');
|
||||
this.setLicenseStatus('active');
|
||||
|
||||
// Update expiry if changed
|
||||
if (data.license_key?.expires_at) {
|
||||
|
||||
@@ -40,13 +40,13 @@ export async function handleRemoteForwarder(
|
||||
let bearerTokenForProxy = node.api_token;
|
||||
if (isInteractiveConsolePath) {
|
||||
try {
|
||||
const ls = LicenseService.getInstance();
|
||||
const consoleHeaders = LicenseService.getInstance().getProxyHeaders();
|
||||
const tokenRes = await fetch(`${node.api_url.replace(/\/$/, '')}/api/system/console-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${node.api_token}`,
|
||||
[PROXY_TIER_HEADER]: ls.getTier(),
|
||||
[PROXY_VARIANT_HEADER]: ls.getVariant() || '',
|
||||
[PROXY_TIER_HEADER]: consoleHeaders.tier,
|
||||
[PROXY_VARIANT_HEADER]: consoleHeaders.variant || '',
|
||||
},
|
||||
});
|
||||
if (!tokenRes.ok) {
|
||||
@@ -67,9 +67,9 @@ export async function handleRemoteForwarder(
|
||||
// and would fail verification on the remote. Auth is handled exclusively
|
||||
// via the Bearer token.
|
||||
delete req.headers['cookie'];
|
||||
const wsLs = LicenseService.getInstance();
|
||||
req.headers[PROXY_TIER_HEADER] = wsLs.getTier();
|
||||
req.headers[PROXY_VARIANT_HEADER] = wsLs.getVariant() || '';
|
||||
const fwdHeaders = LicenseService.getInstance().getProxyHeaders();
|
||||
req.headers[PROXY_TIER_HEADER] = fwdHeaders.tier;
|
||||
req.headers[PROXY_VARIANT_HEADER] = fwdHeaders.variant || '';
|
||||
// Strip nodeId from the forwarded URL so the remote treats the request as
|
||||
// local. The remote has no record of the gateway's nodeId; leaving it would
|
||||
// trigger nodeContext's 404 branch.
|
||||
|
||||
Reference in New Issue
Block a user