fix(fleet): resolve remote node capability detection failures (#388)

* fix(licensing): backward-compatible tier/variant enforcement and self-healing variant detection

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

* fix(fleet): resolve remote node capability detection failures

Exempt /api/meta and /api/health from the global rate limiter so
capability fetches are never blocked by proxied traffic. Add
backend-side caching (3-min TTL) with stale-while-revalidate to
absorb transient failures. Shorten frontend failure cache to 30s
for faster recovery. Evict meta cache on node deletion.
This commit is contained in:
Anso
2026-04-05 22:46:47 -04:00
committed by GitHub
parent 2bc9ed8273
commit dee7c6685b
3 changed files with 37 additions and 8 deletions
+2
View File
@@ -46,6 +46,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **licensing:** Admiral licenses (including lifetime) are now correctly identified; previously, Lemon Squeezy variant names containing "Admiral" were not matched, causing the tier to display as "Skipper" and Admiral features to remain locked
* **licensing:** "Manage Subscription" button is now hidden for lifetime licenses, which have no billing portal by design
* **licensing:** license card now shows "Duration: Lifetime" for lifetime licenses instead of an empty renewal date
* **fleet:** remote node capability detection now works reliably; `/api/meta` and `/api/health` are exempt from the global rate limiter so they are never blocked by proxied traffic, and a backend-side cache with stale-while-revalidate prevents transient failures from disabling capability-gated features (Auto-Update, Schedules, Audit, Console) on remote nodes
* **fleet:** failed capability fetches now retry after 30 seconds instead of being cached for the full 5-minute TTL
### Changed
+28 -6
View File
@@ -30,7 +30,7 @@ import { WebhookService } from './services/WebhookService';
import { SSOService } from './services/SSOService';
import { SchedulerService } from './services/SchedulerService';
import { RegistryService } from './services/RegistryService';
import { CAPABILITIES, getSenchoVersion, fetchRemoteMeta, getActiveCapabilities } from './services/CapabilityRegistry';
import { CAPABILITIES, getSenchoVersion, fetchRemoteMeta, getActiveCapabilities, type RemoteMeta } from './services/CapabilityRegistry';
import SelfUpdateService from './services/SelfUpdateService';
import semver from 'semver';
import { CronExpressionParser } from 'cron-parser';
@@ -149,6 +149,7 @@ const globalApiLimiter = rateLimit({
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Please try again shortly.' },
skip: (req: Request) => req.path === '/meta' || req.path === '/health',
});
app.use('/api/', globalApiLimiter);
@@ -5451,6 +5452,7 @@ app.delete('/api/nodes/:id', async (req: Request, res: Response) => {
const id = parseInt(nodeIdParam);
DatabaseService.getInstance().deleteNode(id);
NodeRegistry.getInstance().evictConnection(id);
remoteMetaCache.delete(id);
res.json({ success: true });
} catch (error: any) {
console.error('Failed to delete node:', error);
@@ -5471,8 +5473,11 @@ app.post('/api/nodes/:id/test', async (req: Request, res: Response) => {
// Fetch capability metadata for a specific node. For local nodes, returns this
// instance's capabilities directly. For remote nodes, relays GET /api/meta from
// the remote Sencho instance. Returns { version: null, capabilities: [] } on
// failure (old node without /api/meta, or node offline) — never an error status.
// the remote Sencho instance. Backend-side cache shields against rate limit
// contention on the remote; stale data is served on transient failures.
const remoteMetaCache = new Map<number, { data: RemoteMeta; fetchedAt: number }>();
const REMOTE_META_CACHE_TTL = 3 * 60 * 1000;
app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
@@ -5487,7 +5492,12 @@ app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Respons
return;
}
// Remote node — relay GET /api/meta
const cached = remoteMetaCache.get(id);
if (cached && Date.now() - cached.fetchedAt < REMOTE_META_CACHE_TTL) {
res.json(cached.data);
return;
}
const baseUrl = node.api_url?.replace(/\/$/, '');
if (!baseUrl || !node.api_token) {
res.json({ version: null, capabilities: [] });
@@ -5495,10 +5505,22 @@ app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Respons
}
const meta = await fetchRemoteMeta(baseUrl, node.api_token);
// A successful fetch always includes a version; null version means the remote
// was unreachable. Only cache successful responses so transient failures retry.
if (meta.version !== null) {
remoteMetaCache.set(id, { data: meta, fetchedAt: Date.now() });
} else if (cached) {
cached.fetchedAt = Date.now();
res.json(cached.data);
return;
}
res.json(meta);
} catch (error: any) {
} catch (error: unknown) {
console.error('Failed to fetch node meta:', error);
res.status(500).json({ error: error.message || 'Failed to fetch node metadata' });
const message = error instanceof Error ? error.message : 'Failed to fetch node metadata';
res.status(500).json({ error: message });
}
});
+7 -2
View File
@@ -32,7 +32,8 @@ interface NodeContextType {
refreshNodeMeta: (nodeId: number) => Promise<void>;
}
const META_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const META_CACHE_TTL = 5 * 60 * 1000;
const META_FAILURE_TTL = 30 * 1000;
const NodeContext = createContext<NodeContextType | undefined>(undefined);
@@ -50,7 +51,11 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
const fetchNodeMeta = useCallback(async (nodeId: number) => {
const cached = nodeMetaRef.current.get(nodeId);
if (cached && Date.now() - cached.fetchedAt < META_CACHE_TTL) return;
if (cached) {
// Use shorter TTL for failed fetches so we retry quickly after transient errors
const ttl = cached.capabilities.length > 0 ? META_CACHE_TTL : META_FAILURE_TTL;
if (Date.now() - cached.fetchedAt < ttl) return;
}
try {
const res = await apiFetch(`/nodes/${nodeId}/meta`, { localOnly: true });