mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +00:00
refactor(backend): extract EntitlementProvider abstraction (Phase 1) (#878)
* refactor(backend): extract EntitlementProvider abstraction (Phase 1)
Phase 1 of the open-core hybrid extraction described in
docs/internal/adrs/2026-05-02-open-core-hybrid-strategy.md. Introduces
the abstraction without moving any code out of the public repo; Phase
2 will actually move services/LicenseService.ts to a private
@studio-saelix/sencho-pro package.
The new backend/src/entitlements/ module contains:
- types.ts. The EntitlementProvider interface plus all tier/license
types (LicenseTier, LicenseVariant, LicenseInfo, SeatLimits,
ActivationResult, etc.). The interface mirrors the existing
LicenseService public surface so the migration was mechanical.
- registry.ts. Module-scope holder for the active provider with
setEntitlementProvider, getEntitlementProvider, and a test-only
reset helper. getEntitlementProvider throws if called before
bootstrap registers a provider; the throw is intentional fail-fast
on a bootstrap-order bug rather than a silent degradation.
- CommunityEntitlementProvider.ts. Phase 2 fallback that returns
community tier and rejects activate(). NOT instantiated in
production today; a smoke test keeps it covered against bitrot.
- loadProvider.ts. Async resolver. Phase 1 returns
LicenseService.getInstance() directly. The async signature matches
what Phase 2 needs (dynamic import of @studio-saelix/sencho-pro
with a "module not found" vs "construction threw" narrowing); the
call site does not change between phases.
- headers.ts. PROXY_TIER_HEADER and PROXY_VARIANT_HEADER constants.
These are part of the wire contract between Sencho instances and
belong in the public core regardless of which entitlement provider
is bound.
- normalize.ts. isLicenseTier, isLicenseVariant, normalizeTier,
normalizeVariant. Domain knowledge about Sencho's tier model
(legacy name maps from pre-0.38.1 versions), not LemonSqueezy
internals. Phase 2 keeps these in the public core.
services/LicenseService.ts now imports its types from
entitlements/types and adds an "implements EntitlementProvider"
clause. Re-exports the types for back-compat with ~20 type-only
consumers; a follow-up PR will sweep those imports to entitlements/
types directly before Phase 2 deletes the file.
bootstrap/startup.ts awaits loadEntitlementProvider, registers the
result, then calls initialize. shutdown.ts calls
getEntitlementProvider().destroy() instead of the LicenseService
singleton.
middleware/tierGates.ts, the chokepoint for ~154 tier-check call
sites, now reads through getEntitlementProvider. Sixteen other
production files (routes/{fleet,imageUpdates,license,permissions,
scheduledTasks,security,stacks,templates,users,webhooks},
services/{BlueprintService,CloudBackupService,SchedulerService,
SSOService}, proxy/remoteNodeProxy, websocket/{hostConsole,
remoteForwarder}, middleware/auth) had their LicenseService.getInstance
calls and utility-export imports redirected to the entitlements
module. The only remaining LicenseService.getInstance in production
code is in entitlements/loadProvider.ts itself, which is the
intentional Phase-1 binding site.
Test infrastructure: setupTestDb registers
LicenseService.getInstance() as the active provider so existing
test files using the helper need no changes. The mocking pattern
many tests use, vi.spyOn(LicenseService.getInstance(), 'getTier'),
keeps working because LicenseService.getInstance() and
getEntitlementProvider() return the same singleton in Phase 1.
scheduler-service.test.ts is the only test that does not use
setupTestDb but exercises tier-gating; it now mocks
entitlements/registry alongside its existing LicenseService mock.
Adds a smoke test for CommunityEntitlementProvider so the Phase 2
fallback class stays covered.
Adds an architecture doc at
docs/internal/architecture/entitlement-provider.md covering the
runtime registry, bootstrap order invariants, and the Phase 1 vs
Phase 2 binding table.
Test results: 89/89 backend test files pass, 1657 passing tests, 5
pre-existing skips. The pre-existing database-metrics > handles
1000+ metrics stress test continues to flake under parallel load
and pass when re-run solo, same flake observed in PRs #862, #863.
* chore(backend): drop unused entitlement type imports from LicenseService
Phase 1 of the EntitlementProvider extraction left five type imports
(ActivationResult, BillingPortalError, BillingPortalResult,
DeactivationResult, ValidationResult) unreferenced after the runtime
methods that produced them began inferring their result shapes via the
EntitlementProvider interface contract. ESLint's no-unused-vars rule
flagged them as errors and failed the lint step in CI.
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<voi
|
||||
res.status(400).json({ error: 'A valid license key is required' });
|
||||
return;
|
||||
}
|
||||
const result = await LicenseService.getInstance().activate(license_key.trim());
|
||||
const result = await getEntitlementProvider().activate(license_key.trim());
|
||||
if (result.success) {
|
||||
res.json({ success: true, license: LicenseService.getInstance().getLicenseInfo() });
|
||||
res.json({ success: true, license: getEntitlementProvider().getLicenseInfo() });
|
||||
} else {
|
||||
res.status(400).json({ error: result.error });
|
||||
}
|
||||
@@ -43,9 +43,9 @@ licenseRouter.post('/deactivate', async (req: Request, res: Response): Promise<v
|
||||
if (rejectApiTokenScope(req, res, LICENSE_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const result = await LicenseService.getInstance().deactivate();
|
||||
const result = await getEntitlementProvider().deactivate();
|
||||
if (result.success) {
|
||||
res.json({ success: true, license: LicenseService.getInstance().getLicenseInfo() });
|
||||
res.json({ success: true, license: getEntitlementProvider().getLicenseInfo() });
|
||||
} else {
|
||||
res.status(500).json({ error: result.error });
|
||||
}
|
||||
@@ -57,8 +57,8 @@ licenseRouter.post('/deactivate', async (req: Request, res: Response): Promise<v
|
||||
|
||||
licenseRouter.post('/validate', async (_req: Request, res: Response): Promise<void> => {
|
||||
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<vo
|
||||
|
||||
licenseRouter.get('/billing-portal', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const result = await LicenseService.getInstance().getBillingPortalUrl();
|
||||
const result = await getEntitlementProvider().getBillingPortalUrl();
|
||||
if ('error' in result) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user