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:
Anso
2026-05-02 05:07:00 -04:00
committed by GitHub
parent 72919ccd1b
commit 3324616e59
31 changed files with 566 additions and 126 deletions
+3 -2
View File
@@ -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<string, string> {
const proxy = LicenseService.getInstance().getProxyHeaders();
const proxy = getEntitlementProvider().getProxyHeaders();
return {
Authorization: `Bearer ${apiToken}`,
[PROXY_TIER_HEADER]: proxy.tier,
+2 -2
View File
@@ -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;
+31 -55
View File
@@ -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<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);
}
/** 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. */
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<string, SeatLimits> = {
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<typeof setInterval> | null = null;
private cachedProxyHeaders: { value: { tier: LicenseTier; variant: LicenseVariant }; expiresAt: number } | null = null;
+3 -3
View File
@@ -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';
+2 -2
View File
@@ -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;