diff --git a/backend/src/__tests__/community-entitlement-provider.test.ts b/backend/src/__tests__/community-entitlement-provider.test.ts new file mode 100644 index 00000000..c5aa5d6f --- /dev/null +++ b/backend/src/__tests__/community-entitlement-provider.test.ts @@ -0,0 +1,72 @@ +/** + * Smoke test for `CommunityEntitlementProvider`. The class is the Phase + * 2 fallback for a build that ships without `@studio-saelix/sencho-pro` + * and is NOT instantiated in production today. This test exists to keep + * the class covered against bitrot: every test run proves the + * EntitlementProvider interface still matches and the Community + * implementation still satisfies the contract. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +describe('CommunityEntitlementProvider', () => { + it('reports community tier and null variant', async () => { + const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider'); + const provider = new CommunityEntitlementProvider(); + + expect(provider.getTier()).toBe('community'); + expect(provider.getVariant()).toBeNull(); + expect(provider.getProxyHeaders()).toEqual({ tier: 'community', variant: null }); + }); + + it('returns single-admin seat limits matching the Community plan', async () => { + const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider'); + const provider = new CommunityEntitlementProvider(); + expect(provider.getSeatLimits()).toEqual({ maxAdmins: 1, maxViewers: 0 }); + }); + + it('rejects activate() with a clear install-the-private-package message', async () => { + const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider'); + const provider = new CommunityEntitlementProvider(); + const result = await provider.activate('any-key'); + expect(result.success).toBe(false); + expect(result.error).toContain('@studio-saelix/sencho-pro'); + }); + + it('deactivate() is a successful no-op', async () => { + const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider'); + const provider = new CommunityEntitlementProvider(); + const result = await provider.deactivate(); + expect(result.success).toBe(true); + }); + + it('initialize() seeds a persistent instance_id when one is missing', async () => { + const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider'); + const { DatabaseService } = await import('../services/DatabaseService'); + + const db = DatabaseService.getInstance(); + db.setSystemState('instance_id', ''); + new CommunityEntitlementProvider().initialize(); + expect(db.getSystemState('instance_id')).toBeTruthy(); + }); + + it('getLicenseInfo() returns Community-only state', async () => { + const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider'); + const info = new CommunityEntitlementProvider().getLicenseInfo(); + expect(info.tier).toBe('community'); + expect(info.status).toBe('community'); + expect(info.variant).toBeNull(); + expect(info.maskedKey).toBeNull(); + expect(info.isLifetime).toBe(false); + }); +}); diff --git a/backend/src/__tests__/helpers/setupTestDb.ts b/backend/src/__tests__/helpers/setupTestDb.ts index 80030c71..9f5e3b3f 100644 --- a/backend/src/__tests__/helpers/setupTestDb.ts +++ b/backend/src/__tests__/helpers/setupTestDb.ts @@ -54,6 +54,23 @@ export async function setupTestDb(): Promise { // path-traversal or 404 on missing files. Realign here. db.getDb().prepare('UPDATE nodes SET compose_dir = ? WHERE is_default = 1').run(composeDir); + // Register the in-tree LicenseService as the active EntitlementProvider + // so tier-gated middleware can resolve a provider during the test. In + // production this is wired by `bootstrap/startup.ts`; tests bypass that + // path by importing modules directly, so the registry would otherwise + // throw on first tier check. The mocking pattern many tests use + // (`vi.spyOn(LicenseService.getInstance(), 'getTier')`) continues to + // work because LicenseService.getInstance() and getEntitlementProvider() + // return the same singleton in Phase 1. + // + // The registry binding is module-scope and survives across test files + // within the same Vitest worker. Avoid `vi.resetModules()` in this + // codebase; it would drop the binding and cause subsequent tier + // checks to throw with "EntitlementProvider not initialized." + const { LicenseService } = await import('../../services/LicenseService'); + const { setEntitlementProvider } = await import('../../entitlements/registry'); + setEntitlementProvider(LicenseService.getInstance()); + return tmpDir; } diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index ad6105f9..49b97046 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -98,6 +98,16 @@ vi.mock('../services/LicenseService', () => ({ }, })); +// SchedulerService now talks to the entitlement registry rather than +// LicenseService.getInstance() directly. Mock the registry to return the +// same shape the test was already mocking on LicenseService. +vi.mock('../entitlements/registry', () => ({ + getEntitlementProvider: () => ({ + getTier: mockGetTier, + getVariant: mockGetVariant, + }), +})); + vi.mock('../services/DockerController', () => ({ default: { getInstance: () => ({ diff --git a/backend/src/bootstrap/shutdown.ts b/backend/src/bootstrap/shutdown.ts index 74254b40..d10b69f0 100644 --- a/backend/src/bootstrap/shutdown.ts +++ b/backend/src/bootstrap/shutdown.ts @@ -1,6 +1,6 @@ import type { Server } from 'http'; import { DatabaseService } from '../services/DatabaseService'; -import { LicenseService } from '../services/LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import { MonitorService } from '../services/MonitorService'; import { AutoHealService } from '../services/AutoHealService'; import { DockerEventManager } from '../services/DockerEventManager'; @@ -22,8 +22,8 @@ export function installShutdownHandlers(server: Server): void { server.close(() => { console.log('[Shutdown] HTTP server closed'); - try { LicenseService.getInstance().destroy(); } catch (e) { - console.warn('[Shutdown] LicenseService cleanup failed:', (e as Error).message); + try { getEntitlementProvider().destroy(); } catch (e) { + console.warn('[Shutdown] EntitlementProvider cleanup failed:', (e as Error).message); } try { MonitorService.getInstance().stop(); } catch (e) { console.warn('[Shutdown] MonitorService cleanup failed:', (e as Error).message); diff --git a/backend/src/bootstrap/startup.ts b/backend/src/bootstrap/startup.ts index 6dd217fd..19720f98 100644 --- a/backend/src/bootstrap/startup.ts +++ b/backend/src/bootstrap/startup.ts @@ -1,7 +1,8 @@ import type { Server } from 'http'; import { FileSystemService } from '../services/FileSystemService'; import { NodeRegistry } from '../services/NodeRegistry'; -import { LicenseService } from '../services/LicenseService'; +import { loadEntitlementProvider } from '../entitlements/loadProvider'; +import { setEntitlementProvider } from '../entitlements/registry'; import SelfUpdateService from '../services/SelfUpdateService'; import { MonitorService } from '../services/MonitorService'; import { AutoHealService } from '../services/AutoHealService'; @@ -31,10 +32,18 @@ export async function startServer(server: Server): Promise { console.error('Migration failed:', error); } + // Resolve the EntitlementProvider before any tier-gated code can run. + // Phase 1 returns the in-tree LicenseService singleton; Phase 2 will + // dynamic-import the private package and fall back to Community. + // Awaited because the loader is async-by-signature (so the Phase-2 + // swap doesn't change this call site). + const entitlementProvider = await loadEntitlementProvider(); + setEntitlementProvider(entitlementProvider); + entitlementProvider.initialize(); + // Synchronous starts: schedule background timers and continue. None of // these fire their first tick for at least a few seconds, so they // safely run alongside the async initializers below. - LicenseService.getInstance().initialize(); MonitorService.getInstance().start(); AutoHealService.getInstance().start(); ImageUpdateService.getInstance().start(); diff --git a/backend/src/entitlements/CommunityEntitlementProvider.ts b/backend/src/entitlements/CommunityEntitlementProvider.ts new file mode 100644 index 00000000..941fcbeb --- /dev/null +++ b/backend/src/entitlements/CommunityEntitlementProvider.ts @@ -0,0 +1,97 @@ +import crypto from 'crypto'; +import { DatabaseService } from '../services/DatabaseService'; +import type { + BillingPortalError, + BillingPortalResult, + EntitlementProvider, + LicenseInfo, + LicenseTier, + LicenseVariant, + SeatLimits, +} from './types'; + +/** + * Entitlement provider that returns Community-only state regardless of + * any stored license. Used as the fallback when `loadEntitlementProvider()` + * cannot resolve `@studio-saelix/sencho-pro` (a build that ships without + * the private package). + * + * Behavior: + * - `getTier()` always returns `'community'`. + * - `getVariant()` always returns `null`. + * - `activate()` rejects with a clear message pointing the operator + * at the private-package install path. + * - `getLicenseInfo()` reflects a fresh-install Community state, with + * a persistent `instance_id` UUID so heartbeat / log identifiers + * stay stable across restarts of the same install. + * + * Phase 1 never instantiates this class in production: the in-tree + * `LicenseService` is used directly. It exists today for parity with + * the Phase 2 plan and so that `loadEntitlementProvider()` has a + * non-throwing fallback path the day the private package is the + * primary binding. + */ +export class CommunityEntitlementProvider implements EntitlementProvider { + public initialize(): void { + const db = DatabaseService.getInstance(); + if (!db.getSystemState('instance_id')) { + db.setSystemState('instance_id', crypto.randomUUID()); + } + } + + public getTier(): LicenseTier { + return 'community'; + } + + public getVariant(): LicenseVariant { + return null; + } + + public getProxyHeaders(): { tier: LicenseTier; variant: LicenseVariant } { + return { tier: 'community', variant: null }; + } + + public getSeatLimits(): SeatLimits { + return { maxAdmins: 1, maxViewers: 0 }; + } + + public getLicenseInfo(): LicenseInfo { + const instanceId = DatabaseService.getInstance().getSystemState('instance_id') || ''; + return { + tier: 'community', + status: 'community', + variant: null, + customerName: null, + productName: null, + maskedKey: null, + validUntil: null, + trialDaysRemaining: null, + instanceId, + portalUrl: null, + isLifetime: false, + }; + } + + public async activate(_licenseKey: string): Promise<{ success: false; error: string }> { + return { + success: false, + error: 'License activation is not available in this build. Install @studio-saelix/sencho-pro to activate Skipper or Admiral.', + }; + } + + public async deactivate(): Promise<{ success: true }> { + return { success: true }; + } + + public async validate(): Promise<{ success: false; error: string }> { + return { success: false, error: 'No active license to validate' }; + } + + public async getBillingPortalUrl(): Promise { + return { error: 'Billing portal is not available in this build.' }; + } + + public destroy(): void { + // No timers, no subscriptions; nothing to clean up. + } +} diff --git a/backend/src/entitlements/headers.ts b/backend/src/entitlements/headers.ts new file mode 100644 index 00000000..e4ea55c8 --- /dev/null +++ b/backend/src/entitlements/headers.ts @@ -0,0 +1,13 @@ +/** + * HTTP header names used for Distributed License Enforcement between + * Sencho instances. A primary instance proxies tier-gated requests to + * its remote fleet nodes and asserts the license state via these + * headers; the remote node trusts the headers when the request is + * authenticated as a node_proxy bearer. + * + * The header names are part of the wire contract between Sencho + * instances and live in the public core so the contract is visible + * regardless of which entitlement provider is bound. + */ +export const PROXY_TIER_HEADER = 'x-sencho-tier'; +export const PROXY_VARIANT_HEADER = 'x-sencho-variant'; diff --git a/backend/src/entitlements/loadProvider.ts b/backend/src/entitlements/loadProvider.ts new file mode 100644 index 00000000..8381db67 --- /dev/null +++ b/backend/src/entitlements/loadProvider.ts @@ -0,0 +1,22 @@ +import { LicenseService } from '../services/LicenseService'; +import type { EntitlementProvider } from './types'; + +/** + * Resolve the EntitlementProvider implementation for this build. + * + * Phase 1 (today): the in-tree `LicenseService` (the existing Lemon + * Squeezy client) implements `EntitlementProvider`. We return its + * singleton directly. No dynamic import, no fallback path. + * + * Phase 2: this function will switch to a dynamic import that + * distinguishes "package not installed" (fall back to Community) from + * "package loaded but threw during construction" (re-raise). The + * narrowing matters because silently downgrading a paid install to + * Community on a load-time bug would be a license-bypass surface. + * + * The function is async today so the Phase-2 swap doesn't change the + * signature; bootstrap already awaits it. + */ +export async function loadEntitlementProvider(): Promise { + return LicenseService.getInstance(); +} diff --git a/backend/src/entitlements/normalize.ts b/backend/src/entitlements/normalize.ts new file mode 100644 index 00000000..2827f181 --- /dev/null +++ b/backend/src/entitlements/normalize.ts @@ -0,0 +1,67 @@ +import type { LicenseTier, LicenseVariant } from './types'; + +/** + * Tier and variant guards / normalizers. These are domain knowledge + * about Sencho's tier model (which strings are accepted on input, how + * legacy names map to current names), not LemonSqueezy implementation + * details. They live in the public core so that: + * + * - The proxy layer (`auth.ts`, `remoteNodeProxy.ts`) can parse and + * validate tier/variant headers from inbound forwarded requests + * without depending on the entitlement provider implementation. + * - The host-console upgrade handler can decode trusted proxy tier + * claims attached to bearer tokens. + * + * Phase 2 deletes `services/LicenseService.ts` but leaves these utility + * exports here, untouched. + */ + +const VALID_TIERS: readonly string[] = ['community', 'paid'] satisfies readonly LicenseTier[]; +const VALID_VARIANTS: readonly string[] = ['skipper', 'admiral'] satisfies readonly LicenseVariant[]; + +/** + * Legacy tier name accepted on input from older versions of Sencho or + * older proxy headers; normalized to the current name on read. + */ +const LEGACY_TIER_MAP: Record = { pro: 'paid' }; + +/** + * Legacy variant names accepted on input from older versions of Sencho + * or older proxy headers; normalized to the current names on read. + */ +const LEGACY_VARIANT_MAP: Record> = { + 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 { + return LEGACY_VARIANT_MAP[value] ?? (value as Exclude); +} diff --git a/backend/src/entitlements/registry.ts b/backend/src/entitlements/registry.ts new file mode 100644 index 00000000..943380f8 --- /dev/null +++ b/backend/src/entitlements/registry.ts @@ -0,0 +1,49 @@ +import type { EntitlementProvider } from './types'; + +/** + * Module-scope holder for the active `EntitlementProvider`. Bootstrap + * calls `setEntitlementProvider()` exactly once after `loadEntitlementProvider()` + * resolves; consumers call `getEntitlementProvider()` synchronously + * thereafter (the registry is a sync read of an already-resolved + * singleton). + * + * Splitting this from `loadProvider.ts` keeps the loader's async API + * separate from the consumer-facing sync API. Tier-gating middleware + * runs on every request and cannot afford the cost of awaiting an + * import on each call. + */ +let provider: EntitlementProvider | null = null; + +/** + * Set the active entitlement provider. Called once during bootstrap. + * Calling it again replaces the provider; the previous instance's + * `destroy()` is the caller's responsibility (bootstrap currently + * never replaces). + */ +export function setEntitlementProvider(p: EntitlementProvider): void { + provider = p; +} + +/** + * Get the active entitlement provider. Throws if called before + * bootstrap registers one. The throw is intentional: a missing + * provider is a programming error (forgot to wire `loadEntitlementProvider`), + * not a runtime condition we want to silently degrade. + */ +export function getEntitlementProvider(): EntitlementProvider { + if (!provider) { + throw new Error( + 'EntitlementProvider not initialized. Call loadEntitlementProvider() and setEntitlementProvider() during bootstrap before any tier-gated code runs.', + ); + } + return provider; +} + +/** + * Test-only: reset the registry so subsequent tests can install a + * fresh provider. Not exported from the package barrel; tests import + * directly from this file. + */ +export function resetEntitlementProviderForTests(): void { + provider = null; +} diff --git a/backend/src/entitlements/types.ts b/backend/src/entitlements/types.ts new file mode 100644 index 00000000..e367ce2a --- /dev/null +++ b/backend/src/entitlements/types.ts @@ -0,0 +1,113 @@ +/** + * Tier / variant types and the EntitlementProvider interface that the + * public Sencho core depends on. The interface is the abstraction + * boundary that lets a future build pull the Lemon Squeezy validation + * client out of the public BSL repo and into a private package + * (`@studio-saelix/sencho-pro`) without touching consumer call sites. + * + * Today the LemonSqueezy implementation lives in-tree at + * `services/LicenseService.ts`. A `CommunityEntitlementProvider` lives + * alongside this file as the Phase-2 fallback for a build that ships + * without the private package. + * + * See `docs/internal/adrs/2026-05-02-open-core-hybrid-strategy.md` for + * the full design. + */ + +export type LicenseTier = 'community' | 'paid'; +export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled'; +export type LicenseVariant = 'skipper' | 'admiral' | null; + +export interface ActivationResult { + success: boolean; + error?: string; +} + +export interface DeactivationResult { + success: boolean; + error?: string; +} + +export interface ValidationResult { + success: boolean; + error?: string; +} + +export interface BillingPortalResult { + url: string; +} + +export interface BillingPortalError { + error: string; +} + +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; +} + +/** + * The contract every backend tier-gating consumer talks to. Phase-1 + * binding is `services/LicenseService.ts` (the existing class now + * implements this interface). Phase 2 will dynamically load + * `@studio-saelix/sencho-pro` and use its `LemonSqueezyEntitlementProvider`, + * falling back to `CommunityEntitlementProvider` if the package is + * absent. + * + * Method signatures mirror the existing LicenseService surface so the + * Phase-1 migration is mechanical (consumers call the same methods on a + * different binding). + */ +export interface EntitlementProvider { + /** Idempotent. Called once during bootstrap. */ + initialize(): void; + + /** Synchronous tier read. Backed by cached DB state. */ + getTier(): LicenseTier; + + /** Synchronous variant read. Backed by cached DB state. */ + getVariant(): LicenseVariant; + + /** + * Cached tier+variant snapshot for the remote-node proxy hot path, + * which reads tier/variant on every forwarded request. Cache TTL + * is short and is invalidated on every license-status write. + */ + getProxyHeaders(): { tier: LicenseTier; variant: LicenseVariant }; + + /** Seat limits derived from the current variant. */ + getSeatLimits(): SeatLimits; + + /** Full license info for the API response. */ + getLicenseInfo(): LicenseInfo; + + /** Activate a license key against the backing license service. */ + activate(licenseKey: string): Promise; + + /** Deactivate the current license, reverting to community. */ + deactivate(): Promise; + + /** Re-validate the current license against the backing license service. */ + validate(): Promise; + + /** Pre-signed billing portal URL, if applicable. */ + getBillingPortalUrl(): Promise; + + /** Cleanup on shutdown. Stops periodic-validation timers, etc. */ + destroy(): void; +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 437eefe4..38210a3d 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -8,14 +8,13 @@ import { type ApiTokenScope, } from '../services/DatabaseService'; import { getErrorMessage } from '../utils/errors'; +import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers'; import { isLicenseTier, isLicenseVariant, normalizeTier, normalizeVariant, - PROXY_TIER_HEADER, - PROXY_VARIANT_HEADER, -} from '../services/LicenseService'; +} from '../entitlements/normalize'; import { isDebugEnabled } from '../utils/debug'; import { COOKIE_NAME, diff --git a/backend/src/middleware/tierGates.ts b/backend/src/middleware/tierGates.ts index 1dd49d24..c9d763ef 100644 --- a/backend/src/middleware/tierGates.ts +++ b/backend/src/middleware/tierGates.ts @@ -1,24 +1,25 @@ import type { Request, Response } from 'express'; -import { LicenseService, type LicenseTier, type LicenseVariant } from '../services/LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; +import type { LicenseTier, LicenseVariant } from '../entitlements/types'; // Tier-based route guards. Each returns true when the request may proceed and // false after sending the appropriate 403 response. Callers MUST check the // return value and `return;` on false. // // Guards trust req.proxyTier/proxyVariant (set by authMiddleware for -// node_proxy tokens) ahead of the local LicenseService so a primary Sencho -// instance can assert license state for its remote fleet nodes. +// node_proxy tokens) ahead of the local entitlement provider so a primary +// Sencho instance can assert license state for its remote fleet nodes. const PAID_MESSAGE = 'This feature requires a Skipper or Admiral license.'; const ADMIRAL_MESSAGE = 'This feature requires a Sencho Admiral license.'; /** Effective license tier for this request (proxy header if trusted, else local). */ export const effectiveTier = (req: Request): LicenseTier => - req.proxyTier ?? LicenseService.getInstance().getTier(); + req.proxyTier ?? getEntitlementProvider().getTier(); /** Effective license variant for this request (proxy header if trusted, else local). */ export const effectiveVariant = (req: Request): LicenseVariant => - req.proxyVariant ?? LicenseService.getInstance().getVariant(); + req.proxyVariant ?? getEntitlementProvider().getVariant(); const deny = (res: Response, code: string, error: string): false => { res.status(403).json({ error, code }); diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index 7a34493d..34e039aa 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -1,11 +1,8 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { NodeRegistry } from '../services/NodeRegistry'; -import { - LicenseService, - PROXY_TIER_HEADER, - PROXY_VARIANT_HEADER, -} from '../services/LicenseService'; +import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers'; +import { getEntitlementProvider } from '../entitlements/registry'; import { isProxyExemptPath } from '../helpers/proxyExemptPaths'; import { getErrorMessage } from '../utils/errors'; @@ -51,7 +48,7 @@ export function createRemoteProxyMiddleware(): RequestHandler { // 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(); + const headers = getEntitlementProvider().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 diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index ddc85d03..8ee0f24f 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -26,7 +26,8 @@ import { sanitizeForLog } from '../utils/safeLog'; import { CloudBackupService } from '../services/CloudBackupService'; import { NotificationService } from '../services/NotificationService'; import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard'; -import { LicenseService, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/LicenseService'; +import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers'; +import { getEntitlementProvider } from '../entitlements/registry'; const updateTracker = FleetUpdateTrackerService.getInstance(); const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes @@ -330,7 +331,7 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp const db = DatabaseService.getInstance(); const nodes = db.getNodes(); const userId = req.user?.userId ?? 0; - const ls = LicenseService.getInstance(); + const ls = getEntitlementProvider(); const localTier = ls.getTier(); const localVariant = ls.getVariant(); diff --git a/backend/src/routes/imageUpdates.ts b/backend/src/routes/imageUpdates.ts index de6ebf5f..f9251f75 100644 --- a/backend/src/routes/imageUpdates.ts +++ b/backend/src/routes/imageUpdates.ts @@ -6,7 +6,7 @@ import { CacheService } from '../services/CacheService'; import { ImageUpdateService } from '../services/ImageUpdateService'; import { FileSystemService } from '../services/FileSystemService'; import { ComposeService } from '../services/ComposeService'; -import { LicenseService } from '../services/LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import { NotificationService } from '../services/NotificationService'; import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; import { authMiddleware } from '../middleware/auth'; @@ -215,7 +215,7 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp const imageUpdateService = ImageUpdateService.getInstance(); const compose = ComposeService.getInstance(req.nodeId); const db = DatabaseService.getInstance(); - const atomic = LicenseService.getInstance().getTier() === 'paid'; + const atomic = getEntitlementProvider().getTier() === 'paid'; const results: string[] = []; for (const stackName of stackNames) { diff --git a/backend/src/routes/license.ts b/backend/src/routes/license.ts index bf8414c0..71bdce03 100644 --- a/backend/src/routes/license.ts +++ b/backend/src/routes/license.ts @@ -1,5 +1,5 @@ import { Router, type Request, type Response } from 'express'; -import { LicenseService } from '../services/LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import SelfUpdateService from '../services/SelfUpdateService'; import { requireAdmin } from '../middleware/tierGates'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; @@ -10,7 +10,7 @@ export const licenseRouter = Router(); licenseRouter.get('/', (_req: Request, res: Response): void => { try { - const info = LicenseService.getInstance().getLicenseInfo(); + const info = getEntitlementProvider().getLicenseInfo(); res.json(info); } catch (error) { console.error('[License] Error getting license info:', error); @@ -27,9 +27,9 @@ licenseRouter.post('/activate', async (req: Request, res: Response): Promise => { try { - const result = await LicenseService.getInstance().validate(); - res.json({ ...result, license: LicenseService.getInstance().getLicenseInfo() }); + const result = await getEntitlementProvider().validate(); + res.json({ ...result, license: getEntitlementProvider().getLicenseInfo() }); } catch (error) { console.error('[License] Validation error:', error); res.status(500).json({ error: 'License validation failed' }); @@ -67,7 +67,7 @@ licenseRouter.post('/validate', async (_req: Request, res: Response): Promise => { try { - const result = await LicenseService.getInstance().getBillingPortalUrl(); + const result = await getEntitlementProvider().getBillingPortalUrl(); if ('error' in result) { res.status(404).json({ error: result.error }); return; diff --git a/backend/src/routes/permissions.ts b/backend/src/routes/permissions.ts index 8dfb9872..412614dd 100644 --- a/backend/src/routes/permissions.ts +++ b/backend/src/routes/permissions.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { DatabaseService } from '../services/DatabaseService'; -import { LicenseService } from '../services/LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import { authMiddleware } from '../middleware/auth'; import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions'; @@ -30,7 +30,7 @@ permissionsRouter.get('/me', authMiddleware, (req: Request, res: Response): void globalRole, globalPermissions, scopedPermissions, - isAdmiral: LicenseService.getInstance().getVariant() === 'admiral', + isAdmiral: getEntitlementProvider().getVariant() === 'admiral', }); } catch (error) { console.error('[Permissions] Error:', error); diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index c13072a5..8aa1fae4 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -1,7 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { CronExpressionParser } from 'cron-parser'; import { DatabaseService, type ScheduledTask } from '../services/DatabaseService'; -import { LicenseService } from '../services/LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import { SchedulerService } from '../services/SchedulerService'; import { requirePaid, requireAdmin, requireScheduledTaskTier } from '../middleware/tierGates'; import { escapeCsvField } from '../utils/csv'; @@ -78,7 +78,7 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => { try { let tasks = DatabaseService.getInstance().getScheduledTasks(); // Skipper users only see 'update' tasks; Admiral sees all. - const ls = LicenseService.getInstance(); + const ls = getEntitlementProvider(); if (ls.getVariant() !== 'admiral') { tasks = tasks.filter(t => t.action === 'update'); } diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index ab9e6ce2..48aaa157 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -6,7 +6,7 @@ import TrivyService, { SbomFormat } from '../services/TrivyService'; import TrivyInstaller from '../services/TrivyInstaller'; import { DatabaseService, parsePolicyEvaluation, type VulnerabilityScan } from '../services/DatabaseService'; import { FleetSyncService } from '../services/FleetSyncService'; -import { LicenseService } from '../services/LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import { validateImageRef } from '../utils/image-ref'; import { applySuppressions } from '../utils/suppression-filter'; import { generateSarif } from '../services/SarifExporter'; @@ -647,7 +647,7 @@ securityRouter.get('/compare', authMiddleware, (req: Request, res: Response): vo scanId1, scanId2, reqNodeId: req.nodeId, - tier: req.proxyTier ?? LicenseService.getInstance().getTier(), + tier: req.proxyTier ?? getEntitlementProvider().getTier(), aVulns: aVulns.length, bVulns: bVulns.length, added: added.length, diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 1c103464..65c3142c 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -7,7 +7,7 @@ import { ComposeService } from '../services/ComposeService'; import DockerController from '../services/DockerController'; import { DatabaseService } from '../services/DatabaseService'; import { CacheService } from '../services/CacheService'; -import { LicenseService } from '../services/LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import { UpdatePreviewService } from '../services/UpdatePreviewService'; import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService'; import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; @@ -587,7 +587,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => { try { if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return; const debug = isDebugEnabled(); - const atomic = LicenseService.getInstance().getTier() === 'paid'; + const atomic = getEntitlementProvider().getTier() === 'paid'; if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId }); const t0 = Date.now(); await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic); @@ -601,7 +601,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => { ); } catch (error: unknown) { console.error('[Stacks] Deploy failed: %s', sanitizeForLog(stackName), error); - const rolledBack = LicenseService.getInstance().getTier() === 'paid'; + const rolledBack = getEntitlementProvider().getTier() === 'paid'; if (rolledBack) console.warn('[Stacks] Deploy failed, rolled back: %s', sanitizeForLog(stackName)); const message = getErrorMessage(error, 'Failed to deploy stack'); notifyActionFailure('deploy', stackName, error); @@ -746,7 +746,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => { try { if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return; const debug = isDebugEnabled(); - const atomic = LicenseService.getInstance().getTier() === 'paid'; + const atomic = getEntitlementProvider().getTier() === 'paid'; if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId }); const t0 = Date.now(); await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(), atomic); @@ -761,7 +761,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => { ); } catch (error: unknown) { console.error('[Stacks] Update failed: %s', sanitizeForLog(stackName), error); - const rolledBack = LicenseService.getInstance().getTier() === 'paid'; + const rolledBack = getEntitlementProvider().getTier() === 'paid'; if (rolledBack) console.warn(`[Stacks] Update failed, rolled back: ${sanitizeForLog(stackName)}`); notifyActionFailure('update', stackName, error); res.status(500).json({ error: getErrorMessage(error, 'Failed to update'), rolledBack }); diff --git a/backend/src/routes/templates.ts b/backend/src/routes/templates.ts index f9af25c9..4671fd00 100644 --- a/backend/src/routes/templates.ts +++ b/backend/src/routes/templates.ts @@ -7,7 +7,7 @@ import { templateService } from '../services/TemplateService'; import { FileSystemService } from '../services/FileSystemService'; import { ComposeService } from '../services/ComposeService'; import { DatabaseService } from '../services/DatabaseService'; -import { LicenseService } from '../services/LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import { ErrorParser } from '../utils/ErrorParser'; import { isValidStackName, isPathWithinBase } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; @@ -124,7 +124,7 @@ templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Respon } return; } - const atomic = LicenseService.getInstance().getTier() === 'paid'; + const atomic = getEntitlementProvider().getTier() === 'paid'; await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic); invalidateNodeCaches(req.nodeId); console.log(`[Templates] Deploy completed: ${stackName}`); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index fc34e24a..82bf99ff 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -1,7 +1,7 @@ import { Router, type Request, type Response } from 'express'; import bcrypt from 'bcrypt'; import { DatabaseService, type UserRole, type ResourceType } from '../services/DatabaseService'; -import { LicenseService } from '../services/LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import { authMiddleware } from '../middleware/auth'; import { requirePaid, requireAdmin, requireAdmiral } from '../middleware/tierGates'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; @@ -84,7 +84,7 @@ usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promi } // Enforce seat limits based on license variant. - const seatLimits = LicenseService.getInstance().getSeatLimits(); + const seatLimits = getEntitlementProvider().getSeatLimits(); if (role === 'admin' && seatLimits.maxAdmins !== null && db.getAdminCount() >= seatLimits.maxAdmins) { res.status(403).json({ error: `Your license allows a maximum of ${seatLimits.maxAdmins} admin account${seatLimits.maxAdmins === 1 ? '' : 's'}. Upgrade to Admiral for unlimited accounts.` }); return; diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index 5285e8ef..30756d9b 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express'; import { DatabaseService } from '../services/DatabaseService'; import { WebhookService } from '../services/WebhookService'; import { GitSourceService } from '../services/GitSourceService'; -import { LicenseService } from '../services/LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import { authMiddleware } from '../middleware/auth'; import { requirePaid, requireAdmin } from '../middleware/tierGates'; import { webhookTriggerLimiter } from '../middleware/rateLimiters'; @@ -122,7 +122,7 @@ webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request, } // Trigger only works with an active Skipper or Admiral license. - if (LicenseService.getInstance().getTier() !== 'paid') { + if (getEntitlementProvider().getTier() !== 'paid') { res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' }); return; } @@ -147,7 +147,7 @@ webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request, // Execute asynchronously; return 202 immediately. res.status(202).json({ message: 'Webhook accepted', action }); - const atomic = LicenseService.getInstance().getTier() === 'paid'; + const atomic = getEntitlementProvider().getTier() === 'paid'; svc.execute(id, action, triggerSource, atomic).catch(err => { console.error(`[Webhooks] Execution error for webhook ${id}:`, err); }); diff --git a/backend/src/services/BlueprintService.ts b/backend/src/services/BlueprintService.ts index 6a986568..3202a6b6 100644 --- a/backend/src/services/BlueprintService.ts +++ b/backend/src/services/BlueprintService.ts @@ -11,7 +11,8 @@ import { import { ComposeService } from './ComposeService'; import { FileSystemService } from './FileSystemService'; import { NodeRegistry } from './NodeRegistry'; -import { LicenseService, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './LicenseService'; +import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers'; +import { getEntitlementProvider } from '../entitlements/registry'; const MARKER_FILENAME = '.blueprint.json'; const COMPOSE_FILENAME = 'docker-compose.yml'; @@ -359,7 +360,7 @@ export class BlueprintService { // ---- remote primitives ---- private remoteHeaders(apiToken: string): Record { - const proxy = LicenseService.getInstance().getProxyHeaders(); + const proxy = getEntitlementProvider().getProxyHeaders(); return { Authorization: `Bearer ${apiToken}`, [PROXY_TIER_HEADER]: proxy.tier, diff --git a/backend/src/services/CloudBackupService.ts b/backend/src/services/CloudBackupService.ts index e8eacd61..0744200b 100644 --- a/backend/src/services/CloudBackupService.ts +++ b/backend/src/services/CloudBackupService.ts @@ -17,7 +17,7 @@ import * as tar from 'tar-stream'; import axios from 'axios'; import { DatabaseService, type FleetSnapshotFile } from './DatabaseService'; import { CryptoService } from './CryptoService'; -import { LicenseService } from './LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; @@ -185,7 +185,7 @@ export class CloudBackupService { const licenseKey = db.getSystemState('license_key'); if (!licenseKey) return { success: false, error: 'No license key found. Activate an Admiral license first.' }; - const variant = LicenseService.getInstance().getVariant(); + const variant = getEntitlementProvider().getVariant(); if (variant !== 'admiral') return { success: false, error: 'Sencho Cloud Backup requires the Admiral tier.' }; const apiBase = process.env.SENCHO_CLOUD_BACKUP_API || SENCHO_CLOUD_BACKUP_API_DEFAULT; diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index 6f05350b..1166a087 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -1,63 +1,33 @@ import crypto from 'crypto'; import axios from 'axios'; import { DatabaseService } from './DatabaseService'; +import type { + EntitlementProvider, + LicenseInfo, + LicenseStatus, + LicenseTier, + LicenseVariant, + SeatLimits, +} from '../entitlements/types'; -export type LicenseTier = 'community' | 'paid'; -export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled'; +// Back-compat re-exports. The canonical type definitions live in +// `entitlements/types.ts`; this re-export keeps existing imports +// (`import type { LicenseTier } from '@/services/LicenseService'`) +// working until consumers migrate. A follow-up PR titled +// `refactor(entitlements): migrate type-only consumers to entitlements/types` +// will sweep the remaining ~20 consumers; Phase 2 deletes this file. +export type { LicenseInfo, LicenseStatus, LicenseTier, LicenseVariant, SeatLimits }; -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 = { pro: 'paid' }; -const LEGACY_VARIANT_MAP: Record> = { 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 { - return LEGACY_VARIANT_MAP[value] ?? (value as Exclude); -} - -/** 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; -} +// Header constants and tier/variant normalizers previously exported +// from this file moved to `../entitlements/headers` and +// `../entitlements/normalize` respectively, where they live in the +// public core regardless of which entitlement provider is bound. +// LicenseService imports them back for internal use. +import { isLicenseVariant, normalizeVariant } from '../entitlements/normalize'; +// LicenseInfo and SeatLimits live in `entitlements/types.ts` and are +// re-exported above. The literal seat-limit table for paid variants +// stays here because it is specific to the LemonSqueezy implementation. const SEAT_LIMITS: Record = { skipper: { maxAdmins: 1, maxViewers: 3 }, admiral: { maxAdmins: null, maxViewers: null }, @@ -180,7 +150,13 @@ export function resolveSenchoVariantFromMeta( // net against any future bypass rather than a load-bearing freshness bound. const PROXY_HEADERS_CACHE_TTL_MS = 30_000; -export class LicenseService { +/** + * Implements `EntitlementProvider` so the public core can talk to it + * via `getEntitlementProvider()` without naming this class directly. + * Phase 2 will move this entire file to `@studio-saelix/sencho-pro`, + * at which point the public core only sees the interface. + */ +export class LicenseService implements EntitlementProvider { private static instance: LicenseService; private validationTimer: ReturnType | null = null; private cachedProxyHeaders: { value: { tier: LicenseTier; variant: LicenseVariant }; expiresAt: number } | null = null; diff --git a/backend/src/services/SSOService.ts b/backend/src/services/SSOService.ts index e4b46995..0d02d5bf 100644 --- a/backend/src/services/SSOService.ts +++ b/backend/src/services/SSOService.ts @@ -12,7 +12,7 @@ import { } from 'openid-client'; import { DatabaseService, User, AuthProvider } from './DatabaseService'; import { CryptoService } from './CryptoService'; -import { LicenseService } from './LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import { CacheService } from './CacheService'; import { isDebugEnabled } from '../utils/debug'; @@ -590,7 +590,7 @@ export class SSOService { // Sync role from identity provider on every login if (params.role !== existing.role) { if (params.role === 'admin') { - const seatLimits = LicenseService.getInstance().getSeatLimits(); + const seatLimits = getEntitlementProvider().getSeatLimits(); if (seatLimits.maxAdmins === null || db.getAdminCount() < seatLimits.maxAdmins) { updates.role = params.role; } else if (debug) { @@ -613,7 +613,7 @@ export class SSOService { // Check seat limits let { role } = params; - const seatLimits = LicenseService.getInstance().getSeatLimits(); + const seatLimits = getEntitlementProvider().getSeatLimits(); if (role === 'admin' && seatLimits.maxAdmins !== null && db.getAdminCount() >= seatLimits.maxAdmins) { console.warn(`[SSO] Admin seat limit reached; provisioning ${params.preferredUsername} as viewer instead of admin`); role = 'viewer'; diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index b38027c3..df1065c6 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -1,7 +1,7 @@ import { CronExpressionParser } from 'cron-parser'; import { DatabaseService } from './DatabaseService'; import type { ScheduledTask } from './DatabaseService'; -import { LicenseService } from './LicenseService'; +import { getEntitlementProvider } from '../entitlements/registry'; import DockerController from './DockerController'; import { ComposeService } from './ComposeService'; import { FileSystemService } from './FileSystemService'; @@ -192,7 +192,7 @@ export class SchedulerService { } await this.maybeRedetectTrivy(); - const ls = LicenseService.getInstance(); + const ls = getEntitlementProvider(); const isPaid = ls.getTier() === 'paid'; const isAdmiral = isPaid && ls.getVariant() === 'admiral'; if (!isPaid) return; diff --git a/backend/src/websocket/hostConsole.ts b/backend/src/websocket/hostConsole.ts index 516b9169..c384b78e 100644 --- a/backend/src/websocket/hostConsole.ts +++ b/backend/src/websocket/hostConsole.ts @@ -5,15 +5,14 @@ import path from 'path'; import { FileSystemService } from '../services/FileSystemService'; import { NodeRegistry } from '../services/NodeRegistry'; import { HostTerminalService } from '../services/HostTerminalService'; +import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers'; import { - LicenseService, isLicenseTier, isLicenseVariant, normalizeTier, normalizeVariant, - PROXY_TIER_HEADER, - PROXY_VARIANT_HEADER, -} from '../services/LicenseService'; +} from '../entitlements/normalize'; +import { getEntitlementProvider } from '../entitlements/registry'; import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions'; import type { UserRole } from '../services/DatabaseService'; import { getErrorMessage } from '../utils/errors'; @@ -65,7 +64,7 @@ export function handleHostConsoleWs( const consoleTierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined; const consoleVariantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined; - const ls = LicenseService.getInstance(); + const ls = getEntitlementProvider(); const consoleTier = (isConsoleSession && isLicenseTier(consoleTierHeader)) ? normalizeTier(consoleTierHeader) : ls.getTier(); diff --git a/backend/src/websocket/remoteForwarder.ts b/backend/src/websocket/remoteForwarder.ts index 36380817..467e4c87 100644 --- a/backend/src/websocket/remoteForwarder.ts +++ b/backend/src/websocket/remoteForwarder.ts @@ -1,11 +1,8 @@ import type { IncomingMessage } from 'http'; import type { Duplex } from 'stream'; import type { Node } from '../services/DatabaseService'; -import { - LicenseService, - PROXY_TIER_HEADER, - PROXY_VARIANT_HEADER, -} from '../services/LicenseService'; +import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers'; +import { getEntitlementProvider } from '../entitlements/registry'; import { wsProxyServer } from '../proxy/websocketProxy'; import { getErrorMessage } from '../utils/errors'; import { rejectUpgrade as reject } from './reject'; @@ -40,7 +37,7 @@ export async function handleRemoteForwarder( let bearerTokenForProxy = node.api_token; if (isInteractiveConsolePath) { try { - const consoleHeaders = LicenseService.getInstance().getProxyHeaders(); + const consoleHeaders = getEntitlementProvider().getProxyHeaders(); const tokenRes = await fetch(`${node.api_url.replace(/\/$/, '')}/api/system/console-token`, { method: 'POST', headers: { @@ -67,7 +64,7 @@ export async function handleRemoteForwarder( // and would fail verification on the remote. Auth is handled exclusively // via the Bearer token. delete req.headers['cookie']; - const fwdHeaders = LicenseService.getInstance().getProxyHeaders(); + const fwdHeaders = getEntitlementProvider().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