mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(licensing): backward-compatible tier/variant enforcement and self-healing variant detection (#385)
Accept legacy tier ('pro') and variant ('personal', 'team') names from older
remote nodes, normalizing them to current values ('paid', 'skipper', 'admiral')
in authMiddleware. This fixes distributed license enforcement failing between
v0.38.3 and v0.38.0 nodes due to the tier rename in v0.38.1.
Also fixes:
- Self-healing getVariant() that cross-checks stored variant_type against
product/variant name metadata on every call, correcting stale cached values
from previous buggy resolution logic
- Unguarded API responses in ResourcesView causing potential t.map crashes
- Fleet update status now polls on a 120s interval (was only fetched on mount)
- fetchRemoteMeta failures now logged for diagnosability
This commit is contained in:
@@ -25,7 +25,7 @@ import { ImageUpdateService } from './services/ImageUpdateService';
|
||||
import { templateService } from './services/TemplateService';
|
||||
import { ErrorParser } from './utils/ErrorParser';
|
||||
import { NodeRegistry } from './services/NodeRegistry';
|
||||
import { LicenseService, type LicenseTier, type LicenseVariant, isLicenseTier, isLicenseVariant, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './services/LicenseService';
|
||||
import { LicenseService, type LicenseTier, type LicenseVariant, isLicenseTier, isLicenseVariant, normalizeTier, normalizeVariant, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './services/LicenseService';
|
||||
import { WebhookService } from './services/WebhookService';
|
||||
import { SSOService } from './services/SSOService';
|
||||
import { SchedulerService } from './services/SchedulerService';
|
||||
@@ -310,10 +310,10 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
|
||||
const tierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
|
||||
const variantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
|
||||
if (isLicenseTier(tierHeader)) {
|
||||
req.proxyTier = tierHeader;
|
||||
req.proxyTier = normalizeTier(tierHeader);
|
||||
}
|
||||
if (isLicenseVariant(variantHeader)) {
|
||||
req.proxyVariant = variantHeader;
|
||||
req.proxyVariant = normalizeVariant(variantHeader);
|
||||
} else if (variantHeader === '') {
|
||||
req.proxyVariant = null;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,8 @@ export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promis
|
||||
version: res.data.version ?? null,
|
||||
capabilities: Array.isArray(res.data.capabilities) ? res.data.capabilities : [],
|
||||
};
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.warn(`[CapabilityRegistry] Failed to fetch meta from ${baseUrl}:`, (err as Error).message);
|
||||
return { version: null, capabilities: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,28 @@ 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[];
|
||||
|
||||
export function isLicenseTier(value: unknown): value is LicenseTier {
|
||||
return typeof value === 'string' && (VALID_TIERS as readonly string[]).includes(value);
|
||||
// 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);
|
||||
}
|
||||
|
||||
export function isLicenseVariant(value: unknown): value is Exclude<LicenseVariant, null> {
|
||||
return typeof value === 'string' && (VALID_VARIANTS as readonly string[]).includes(value);
|
||||
/** 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. */
|
||||
@@ -220,26 +236,34 @@ export class LicenseService {
|
||||
/**
|
||||
* Get the license variant (skipper or admiral) from stored metadata.
|
||||
* Trial licenses default to "skipper"; Admiral features require an Admiral license.
|
||||
* Reads the pre-resolved `license_variant_type` from DB. Falls back to name-based
|
||||
* resolution for pre-existing installs, then persists the result so the fallback
|
||||
* only runs once.
|
||||
*
|
||||
* 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 status = db.getSystemState('license_status');
|
||||
if (status === 'trial') return 'skipper';
|
||||
|
||||
// Primary path: read the pre-resolved type stored at activation/validation
|
||||
const storedType = db.getSystemState('license_variant_type');
|
||||
if (isLicenseVariant(storedType)) return storedType;
|
||||
|
||||
// Backward compat: resolve from variant/product name for pre-existing installs
|
||||
const variantName = db.getSystemState('license_variant_name');
|
||||
if (!variantName) return null;
|
||||
const productName = db.getSystemState('license_product_name') || undefined;
|
||||
const resolved = this.resolveVariantType(variantName, productName);
|
||||
db.setSystemState('license_variant_type', resolved);
|
||||
return resolved;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -707,9 +707,10 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
// Paid tier: auto-refresh every 30s
|
||||
useEffect(() => {
|
||||
if (!isPaid) return;
|
||||
const interval = setInterval(() => fetchOverview(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isPaid, fetchOverview]);
|
||||
const overviewInterval = setInterval(fetchOverview, 30000);
|
||||
const updateInterval = setInterval(fetchUpdateStatus, 120000);
|
||||
return () => { clearInterval(overviewInterval); clearInterval(updateInterval); };
|
||||
}, [isPaid, fetchOverview, fetchUpdateStatus]);
|
||||
|
||||
// Fast poll (5s) when any node is actively updating — uses ref to avoid interval thrashing
|
||||
const hasUpdatingRef = useRef(false);
|
||||
|
||||
@@ -387,13 +387,17 @@ export default function ResourcesView() {
|
||||
apiFetch('/system/orphans'),
|
||||
]);
|
||||
|
||||
setUsage(await usageRes.json());
|
||||
const resources = await resourcesRes.json();
|
||||
setImages(resources.images ?? []);
|
||||
setVolumes(resources.volumes ?? []);
|
||||
setNetworks(resources.networks ?? []);
|
||||
setOrphans(await orphansRes.json());
|
||||
setSelectedOrphans([]);
|
||||
if (usageRes.ok) setUsage(await usageRes.json());
|
||||
if (resourcesRes.ok) {
|
||||
const resources = await resourcesRes.json();
|
||||
setImages(resources.images ?? []);
|
||||
setVolumes(resources.volumes ?? []);
|
||||
setNetworks(resources.networks ?? []);
|
||||
}
|
||||
if (orphansRes.ok) {
|
||||
setOrphans(await orphansRes.json());
|
||||
setSelectedOrphans([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data', err);
|
||||
toast.error('Failed to load resources data');
|
||||
|
||||
Reference in New Issue
Block a user