mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user