refactor(backend): collapse entitlement provider abstraction back to LicenseService (#889)

Removes backend/src/entitlements/ (registry, loadProvider,
CommunityEntitlementProvider, types, headers, normalize) and the two
abstraction-only tests. Relocates headers/normalize/types to
services/license-*.ts. Swaps 22 consumer call sites from
getEntitlementProvider() to LicenseService.getInstance(). Drops the
Dockerfile install step plus PRO_PACKAGE_VERSION build-arg and
github_token BuildKit secret in docker-publish.yml. Removes the now
stale no-restricted-imports rule in backend/eslint.config.mjs.

Net: 37 files changed, ~700 lines removed, no behavior change. Local
dev no longer requires GitHub Packages auth to start the backend.

Rationale and revisit conditions in
docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md.
This commit is contained in:
Anso
2026-05-02 23:45:44 -04:00
committed by GitHub
parent 6929dad540
commit e5b1c7b22b
39 changed files with 150 additions and 735 deletions
+3 -3
View File
@@ -11,8 +11,8 @@ import {
import { ComposeService } from './ComposeService';
import { FileSystemService } from './FileSystemService';
import { NodeRegistry } from './NodeRegistry';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers';
import { getEntitlementProvider } from '../entitlements/registry';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
import { LicenseService } from './LicenseService';
const MARKER_FILENAME = '.blueprint.json';
const COMPOSE_FILENAME = 'docker-compose.yml';
@@ -360,7 +360,7 @@ export class BlueprintService {
// ---- remote primitives ----
private remoteHeaders(apiToken: string): Record<string, string> {
const proxy = getEntitlementProvider().getProxyHeaders();
const proxy = LicenseService.getInstance().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 { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from './LicenseService';
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 = getEntitlementProvider().getVariant();
const variant = LicenseService.getInstance().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;
+7 -17
View File
@@ -2,24 +2,14 @@ import crypto from 'crypto';
import axios from 'axios';
import { DatabaseService } from './DatabaseService';
import type {
EntitlementProvider,
LicenseInfo,
LicenseStatus,
LicenseTier,
LicenseVariant,
SeatLimits,
} from '../entitlements/types';
} from './license-types';
import { isLicenseVariant, normalizeVariant } from './license-normalize';
// 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';
// The seat-limit table for paid variants is specific to the
// LemonSqueezy implementation and stays in this file. Phase 2 moves it
// to `@studio-saelix/sencho-pro` along with the rest of the file.
const SEAT_LIMITS: Record<string, SeatLimits> = {
skipper: { maxAdmins: 1, maxViewers: 3 },
admiral: { maxAdmins: null, maxViewers: null },
@@ -143,12 +133,12 @@ export function resolveSenchoVariantFromMeta(
const PROXY_HEADERS_CACHE_TTL_MS = 30_000;
/**
* 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.
* Single in-tree license service. Owns Lemon Squeezy validation and
* exposes the tier / variant / seat-limit API consumed across the
* backend. See `docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md`
* for the conditions that would justify reintroducing an interface seam.
*/
export class LicenseService implements EntitlementProvider {
export class LicenseService {
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 { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from './LicenseService';
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 = getEntitlementProvider().getSeatLimits();
const seatLimits = LicenseService.getInstance().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 = getEntitlementProvider().getSeatLimits();
const seatLimits = LicenseService.getInstance().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 { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from './LicenseService';
import DockerController from './DockerController';
import { ComposeService } from './ComposeService';
import { FileSystemService } from './FileSystemService';
@@ -192,7 +192,7 @@ export class SchedulerService {
}
await this.maybeRedetectTrivy();
const ls = getEntitlementProvider();
const ls = LicenseService.getInstance();
const isPaid = ls.getTier() === 'paid';
const isAdmiral = isPaid && ls.getVariant() === 'admiral';
if (!isPaid) return;
+9
View File
@@ -0,0 +1,9 @@
/**
* 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.
*/
export const PROXY_TIER_HEADER = 'x-sencho-tier';
export const PROXY_VARIANT_HEADER = 'x-sencho-variant';
+62
View File
@@ -0,0 +1,62 @@
import type { LicenseTier, LicenseVariant } from './license-types';
/**
* Tier and variant guards / normalizers. Domain knowledge about
* Sencho's tier model (which strings are accepted on input, how legacy
* names map to current names). Used by:
*
* - The proxy layer (`auth.ts`, `remoteNodeProxy.ts`) to parse and
* validate tier/variant headers from inbound forwarded requests.
* - The host-console upgrade handler to decode trusted proxy tier
* claims attached to bearer tokens.
*/
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 proxy headers;
* normalized to the current name on read.
*/
const LEGACY_TIER_MAP: Record<string, LicenseTier> = { pro: 'paid' };
/**
* Legacy variant names accepted on input from older proxy headers;
* normalized to the current names on read.
*/
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>);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Tier / variant types and license result shapes consumed across the
* backend. Types live alongside `LicenseService` (their owner) rather
* than behind an abstraction layer, since there is a single in-tree
* implementation today.
*
* If a future build needs a second implementation (e.g. a SaaS
* entitlement source) re-introduce an explicit interface and adapter.
* See `docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md`
* for the trigger conditions.
*/
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;
}