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:
Anso
2026-04-28 00:13:07 -04:00
committed by GitHub
parent 836e384d17
commit 61a7e43d82
3 changed files with 57 additions and 19 deletions
+6 -4
View File
@@ -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
+45 -9
View File
@@ -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) {
+6 -6
View File
@@ -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.