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
-24
View File
@@ -118,22 +118,8 @@ jobs:
platforms: linux/amd64
tags: localhost/sencho:release-scan
cache-from: type=registry,ref=saelix/sencho:buildcache
# PRO_PACKAGE_VERSION pins @studio-saelix/sencho-pro to a
# specific SemVer so the scan build and the publish build
# below resolve identically. Bumping the pro package: bump
# this string in the same PR that ships the matching public
# Sencho release; release-please does not coordinate the two.
build-args: |
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
PRO_PACKAGE_VERSION=0.1.0
# GITHUB_TOKEN carries packages:read scope by default, which
# is enough to install @studio-saelix/sencho-pro from
# GitHub Packages during the prod-deps stage. Passing it via
# BuildKit secret keeps it out of image layers; the
# Dockerfile reads /run/secrets/github_token only inside the
# one RUN that authenticates to npm.pkg.github.com.
secrets: |
github_token=${{ secrets.GITHUB_TOKEN }}
- name: Re-scan release image for vulnerabilities (Trivy)
# Gates the release on the same HIGH/CRITICAL policy as the PR scan.
@@ -193,18 +179,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=saelix/sencho:buildcache
cache-to: type=registry,ref=saelix/sencho:buildcache,mode=max
# PRO_PACKAGE_VERSION must match the value passed to the
# scan build above so Trivy and the smoke test exercise the
# same package version that ships.
build-args: |
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
PRO_PACKAGE_VERSION=0.1.0
# Same secret as the pre-publish scan above so the published
# image carries @studio-saelix/sencho-pro identical to what
# Trivy and the smoke test exercised. BuildKit keeps the
# token out of the published layers.
secrets: |
github_token=${{ secrets.GITHUB_TOKEN }}
# SBOM + provenance attestations are embedded as OCI referrers on the
# published image. Inspect with: docker buildx imagetools inspect <img>
sbom: true
-39
View File
@@ -90,45 +90,6 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
npm ci --omit=dev; \
fi
# Add the private @studio-saelix/sencho-pro package on top of the
# production dependencies installed above. The package contains the
# Lemon Squeezy validation client; the public Sencho core's
# loadEntitlementProvider() dynamic-imports it at runtime when present
# and falls back to the in-tree LicenseService when it is not.
#
# Authentication uses a BuildKit secret rather than a build arg so the
# token never lands in an image layer. The github_token secret is
# expected to carry packages:read scope against the Studio-Saelix org;
# CI passes the auto-provisioned GITHUB_TOKEN, which has that scope
# automatically because the public Sencho repo and the private package
# are in the same GitHub org. Moving the package to a different org
# would silently break this contract; the auto-token would lose scope
# and every build would fall through to the empty-secret branch.
# Local builds without the secret skip the install entirely and the
# resulting image runs through the loader's in-tree fallback.
#
# The install is pure-JS (axios is the only runtime dep, no native
# modules) so cross-compilation env vars are not needed here. We use
# `npm install --no-save` so package.json and package-lock.json stay
# unchanged in the source tree; the package is added to node_modules
# only inside this build layer.
#
# PRO_PACKAGE_VERSION is pinned by CI at build time (a literal SemVer
# like `0.1.0`) so the scan build and the publish build resolve to the
# same package version. Defaulting to `latest` keeps local builds
# convenient; CI overrides this so production never tracks a moving
# tag.
ARG PRO_PACKAGE_VERSION=latest
RUN --mount=type=secret,id=github_token \
if [ -s /run/secrets/github_token ]; then \
printf '@studio-saelix:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=%s\n' "$(cat /run/secrets/github_token)" > /root/.npmrc && \
npm install --no-save --omit=dev "@studio-saelix/sencho-pro@${PRO_PACKAGE_VERSION}" && \
rm -f /root/.npmrc && \
rm -rf /root/.npm; \
else \
echo "[Sencho] No github_token secret provided; @studio-saelix/sencho-pro will not be installed. Loader will use the in-tree LicenseService fallback at runtime."; \
fi
# Stage 4a: Build Docker CLI from source against Go 1.26.2
#
# CLI v29.4.1 ships otel/sdk v1.43.0, resolving CVE-2026-39883 (BSD kenv) and
-20
View File
@@ -25,26 +25,6 @@ export default tseslint.config(
// New in ESLint 10 recommended - existing patterns, suppress until addressed
'no-useless-assignment': 'warn',
'preserve-caught-error': 'warn',
// The private @studio-saelix/sencho-pro package is loaded at
// runtime via dynamic import in entitlements/loadProvider.ts.
// A static import would (a) bundle the package into the public
// BSL build via TypeScript's module resolution, defeating the
// privacy split, and (b) break in Community-only environments
// where the package is not installed. Use loadEntitlementProvider()
// and the registry instead.
// no-restricted-imports flags ES `import` statements only by
// default; dynamic `import()` expressions are not flagged unless
// we set `allowDynamicImports: false`. The loader uses
// `await import('@studio-saelix/sencho-pro')` and is therefore
// not affected without an extra opt-in.
'no-restricted-imports': ['error', {
patterns: [
{
group: ['@studio-saelix/sencho-pro', '@studio-saelix/sencho-pro/*'],
message: 'Do not statically import the private package. Use loadEntitlementProvider() and getEntitlementProvider() from src/entitlements/.',
},
],
}],
},
},
)
@@ -1,72 +0,0 @@
/**
* 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);
});
});
+7 -14
View File
@@ -54,22 +54,15 @@ export async function setupTestDb(): Promise<string> {
// 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
// Force the LicenseService singleton to materialize on the test DB. 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."
// path by importing modules directly. Without this prime, the first
// tier check in a test runs `LicenseService.getInstance()` against a
// singleton whose lazy-init never ran. The mocking pattern many tests
// use (`vi.spyOn(LicenseService.getInstance(), 'getTier')`) continues
// to work against the same singleton.
const { LicenseService } = await import('../../services/LicenseService');
const { setEntitlementProvider } = await import('../../entitlements/registry');
setEntitlementProvider(LicenseService.getInstance());
LicenseService.getInstance();
return tmpDir;
}
@@ -1,75 +0,0 @@
/**
* Tests for the loader's module-not-found discrimination. The
* predicate is a security boundary: a too-wide match silently
* downgrades a paid install to community on a transitive-dependency
* bug; a too-narrow match crashes bootstrap on a legitimate
* Community-only build. Keep these fixtures aligned with the runtime
* codes Node and bundlers actually produce.
*/
import { describe, it, expect } from 'vitest';
import { isProPackageNotInstalled } from '../entitlements/loadProvider';
function withCode<T extends Error>(err: T, code: string): T {
(err as Error & { code?: string }).code = code;
return err;
}
describe('isProPackageNotInstalled', () => {
it('returns false for non-Error values', () => {
expect(isProPackageNotInstalled(undefined)).toBe(false);
expect(isProPackageNotInstalled(null)).toBe(false);
expect(isProPackageNotInstalled('not an error')).toBe(false);
expect(isProPackageNotInstalled({})).toBe(false);
});
it('returns false for Error without a code', () => {
expect(isProPackageNotInstalled(new Error('something broke'))).toBe(false);
});
it('returns true for ERR_MODULE_NOT_FOUND on the private package (Node ESM)', () => {
const err = withCode(
new Error("Cannot find package '@studio-saelix/sencho-pro' imported from /app/dist/entitlements/loadProvider.js"),
'ERR_MODULE_NOT_FOUND',
);
expect(isProPackageNotInstalled(err)).toBe(true);
});
it('returns true for MODULE_NOT_FOUND on the private package (CJS / older Node)', () => {
const err = withCode(
new Error("Cannot find module '@studio-saelix/sencho-pro'"),
'MODULE_NOT_FOUND',
);
expect(isProPackageNotInstalled(err)).toBe(true);
});
it('returns false for MODULE_NOT_FOUND on a transitive dep of the private package', () => {
// The private package was installed but one of its dependencies is
// missing. We must NOT classify this as "package not installed";
// re-raising surfaces the bug instead of silently downgrading to
// community.
const err = withCode(
new Error("Cannot find module 'axios'"),
'MODULE_NOT_FOUND',
);
expect(isProPackageNotInstalled(err)).toBe(false);
});
it('returns false for ERR_PACKAGE_PATH_NOT_EXPORTED', () => {
// Package was resolved but its exports map does not include the
// path we asked for; this is a packaging bug, not a missing
// package, and should re-raise.
const err = withCode(
new Error("Package subpath './internal' is not defined by exports in @studio-saelix/sencho-pro/package.json"),
'ERR_PACKAGE_PATH_NOT_EXPORTED',
);
expect(isProPackageNotInstalled(err)).toBe(false);
});
it('returns false for an unrelated runtime error during construction', () => {
// The package loaded successfully but threw inside its
// constructor. The loader must re-raise; silently falling back
// would be a license-bypass surface.
const err = new TypeError('Cannot read property of undefined');
expect(isProPackageNotInstalled(err)).toBe(false);
});
});
@@ -98,15 +98,6 @@ 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: {
+3 -3
View File
@@ -1,6 +1,6 @@
import type { Server } from 'http';
import { DatabaseService } from '../services/DatabaseService';
import { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from '../services/LicenseService';
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 { getEntitlementProvider().destroy(); } catch (e) {
console.warn('[Shutdown] EntitlementProvider cleanup failed:', (e as Error).message);
try { LicenseService.getInstance().destroy(); } catch (e) {
console.warn('[Shutdown] LicenseService cleanup failed:', (e as Error).message);
}
try { MonitorService.getInstance().stop(); } catch (e) {
console.warn('[Shutdown] MonitorService cleanup failed:', (e as Error).message);
+3 -10
View File
@@ -1,8 +1,7 @@
import type { Server } from 'http';
import { FileSystemService } from '../services/FileSystemService';
import { NodeRegistry } from '../services/NodeRegistry';
import { loadEntitlementProvider } from '../entitlements/loadProvider';
import { setEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from '../services/LicenseService';
import SelfUpdateService from '../services/SelfUpdateService';
import { MonitorService } from '../services/MonitorService';
import { AutoHealService } from '../services/AutoHealService';
@@ -32,14 +31,8 @@ export async function startServer(server: Server): Promise<void> {
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();
// Initialize the license service before any tier-gated code can run.
LicenseService.getInstance().initialize();
// Synchronous starts: schedule background timers and continue. None of
// these fire their first tick for at least a few seconds, so they
@@ -1,97 +0,0 @@
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<BillingPortalResult | BillingPortalError> {
return { error: 'Billing portal is not available in this build.' };
}
public destroy(): void {
// No timers, no subscriptions; nothing to clean up.
}
}
-78
View File
@@ -1,78 +0,0 @@
import { DatabaseService } from '../services/DatabaseService';
import { LicenseService } from '../services/LicenseService';
import type { EntitlementProvider } from './types';
const PRO_PACKAGE = '@studio-saelix/sencho-pro';
/**
* Resolve the EntitlementProvider implementation for this build.
*
* Phase 2: try to dynamic-import `@studio-saelix/sencho-pro` first.
*
* - **Module-not-found** (the package is not installed in this build,
* e.g. the public BSL Community-only image): fall back to the
* in-tree `LicenseService.getInstance()` so existing functionality
* is preserved during this transition window. A later cleanup PR
* will switch this fallback to `CommunityEntitlementProvider` and
* remove `services/LicenseService.ts` from the public repo
* entirely; until then, the in-tree fallback keeps the public BSL
* repo runnable on its own.
*
* - **Module loaded but threw during construction**: re-raise. A
* silent fallback to community would be a license-bypass surface
* (a paid install whose private package init bug downgrades them
* to community without surfacing the failure). The bootstrap
* should crash and the operator investigates.
*
* The function is async so the dynamic import does not change the
* call-site contract.
*/
export async function loadEntitlementProvider(): Promise<EntitlementProvider> {
try {
const mod = await import(PRO_PACKAGE);
// Explicit typing so a structural drift between Sencho's
// DatabaseService and the package's DatabaseAdapter (e.g. a
// future widening of the adapter interface) fails type-check
// here rather than only at production-CI dual-image build
// time. Sencho's DatabaseService satisfies the adapter shape
// structurally today.
const db: import('@studio-saelix/sencho-pro').DatabaseAdapter = DatabaseService.getInstance();
return new mod.LemonSqueezyEntitlementProvider(db);
} catch (err) {
if (isProPackageNotInstalled(err)) {
// Phase 2 transitional fallback: keep the in-tree
// LicenseService binding so a Community-only build (no
// private package installed) still runs through the
// existing LemonSqueezy path. A follow-up PR will replace
// this with a CommunityEntitlementProvider and remove
// services/LicenseService.ts from the public repo.
return LicenseService.getInstance();
}
throw err;
}
}
/**
* Distinguish "the private package itself is not installed" (legitimate
* fallback path) from "the package loaded but threw / a transitive dep
* is missing" (re-raise so the operator sees the failure).
*
* The error code alone is not specific enough: a missing transitive
* dependency in an installed paid package surfaces with the same
* `MODULE_NOT_FOUND` code as the package itself missing. We additionally
* require the error message to mention the private package by name, so
* the fallback only triggers when Node could not resolve
* `@studio-saelix/sencho-pro` at the top level.
*
* Codes covered: `ERR_MODULE_NOT_FOUND` (Node >=20 ESM), `MODULE_NOT_FOUND`
* (CJS / older Node). `ERR_PACKAGE_PATH_NOT_EXPORTED` is intentionally
* NOT treated as "not installed": that fires when the package was
* resolved but its exports map does not include the path we asked for,
* which is a packaging bug we want to surface, not silently downgrade.
*/
export function isProPackageNotInstalled(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const code = (err as Error & { code?: string }).code;
if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') return false;
return err.message.includes(PRO_PACKAGE);
}
-49
View File
@@ -1,49 +0,0 @@
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;
}
-113
View File
@@ -1,113 +0,0 @@
/**
* 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<ActivationResult>;
/** Deactivate the current license, reverting to community. */
deactivate(): Promise<DeactivationResult>;
/** Re-validate the current license against the backing license service. */
validate(): Promise<ValidationResult>;
/** Pre-signed billing portal URL, if applicable. */
getBillingPortalUrl(): Promise<BillingPortalResult | BillingPortalError>;
/** Cleanup on shutdown. Stops periodic-validation timers, etc. */
destroy(): void;
}
+2 -2
View File
@@ -8,13 +8,13 @@ import {
type ApiTokenScope,
} from '../services/DatabaseService';
import { getErrorMessage } from '../utils/errors';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
import {
isLicenseTier,
isLicenseVariant,
normalizeTier,
normalizeVariant,
} from '../entitlements/normalize';
} from '../services/license-normalize';
import { isDebugEnabled } from '../utils/debug';
import {
COOKIE_NAME,
+4 -4
View File
@@ -1,6 +1,6 @@
import type { Request, Response } from 'express';
import { getEntitlementProvider } from '../entitlements/registry';
import type { LicenseTier, LicenseVariant } from '../entitlements/types';
import { LicenseService } from '../services/LicenseService';
import type { LicenseTier, LicenseVariant } from '../services/license-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
@@ -15,11 +15,11 @@ 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 ?? getEntitlementProvider().getTier();
req.proxyTier ?? LicenseService.getInstance().getTier();
/** Effective license variant for this request (proxy header if trusted, else local). */
export const effectiveVariant = (req: Request): LicenseVariant =>
req.proxyVariant ?? getEntitlementProvider().getVariant();
req.proxyVariant ?? LicenseService.getInstance().getVariant();
const deny = (res: Response, code: string, error: string): false => {
res.status(403).json({ error, code });
+3 -3
View File
@@ -1,8 +1,8 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { NodeRegistry } from '../services/NodeRegistry';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers';
import { getEntitlementProvider } from '../entitlements/registry';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
import { isProxyExemptPath } from '../helpers/proxyExemptPaths';
import { getErrorMessage } from '../utils/errors';
@@ -48,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 = getEntitlementProvider().getProxyHeaders();
const headers = LicenseService.getInstance().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
+1 -1
View File
@@ -3,7 +3,7 @@ import { DatabaseService } from '../services/DatabaseService';
import { CloudBackupService } from '../services/CloudBackupService';
import { authMiddleware } from '../middleware/auth';
import { effectiveTier, effectiveVariant } from '../middleware/tierGates';
import type { LicenseTier, LicenseVariant } from '../entitlements/types';
import type { LicenseTier, LicenseVariant } from '../services/license-types';
export const dashboardRouter = Router();
+3 -3
View File
@@ -26,8 +26,8 @@ import { sanitizeForLog } from '../utils/safeLog';
import { CloudBackupService } from '../services/CloudBackupService';
import { NotificationService } from '../services/NotificationService';
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers';
import { getEntitlementProvider } from '../entitlements/registry';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
const updateTracker = FleetUpdateTrackerService.getInstance();
const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@@ -331,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 = getEntitlementProvider();
const ls = LicenseService.getInstance();
const localTier = ls.getTier();
const localVariant = ls.getVariant();
+2 -2
View File
@@ -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 { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from '../services/LicenseService';
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 = getEntitlementProvider().getTier() === 'paid';
const atomic = LicenseService.getInstance().getTier() === 'paid';
const results: string[] = [];
for (const stackName of stackNames) {
+9 -9
View File
@@ -1,5 +1,5 @@
import { Router, type Request, type Response } from 'express';
import { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from '../services/LicenseService';
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 = getEntitlementProvider().getLicenseInfo();
const info = LicenseService.getInstance().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 getEntitlementProvider().activate(license_key.trim());
const result = await LicenseService.getInstance().activate(license_key.trim());
if (result.success) {
res.json({ success: true, license: getEntitlementProvider().getLicenseInfo() });
res.json({ success: true, license: LicenseService.getInstance().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 getEntitlementProvider().deactivate();
const result = await LicenseService.getInstance().deactivate();
if (result.success) {
res.json({ success: true, license: getEntitlementProvider().getLicenseInfo() });
res.json({ success: true, license: LicenseService.getInstance().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 getEntitlementProvider().validate();
res.json({ ...result, license: getEntitlementProvider().getLicenseInfo() });
const result = await LicenseService.getInstance().validate();
res.json({ ...result, license: LicenseService.getInstance().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 getEntitlementProvider().getBillingPortalUrl();
const result = await LicenseService.getInstance().getBillingPortalUrl();
if ('error' in result) {
res.status(404).json({ error: result.error });
return;
+2 -2
View File
@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from 'express';
import { DatabaseService } from '../services/DatabaseService';
import { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from '../services/LicenseService';
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: getEntitlementProvider().getVariant() === 'admiral',
isAdmiral: LicenseService.getInstance().getVariant() === 'admiral',
});
} catch (error) {
console.error('[Permissions] Error:', error);
+2 -2
View File
@@ -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 { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from '../services/LicenseService';
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 = getEntitlementProvider();
const ls = LicenseService.getInstance();
if (ls.getVariant() !== 'admiral') {
tasks = tasks.filter(t => t.action === 'update');
}
+2 -2
View File
@@ -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 { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from '../services/LicenseService';
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 ?? getEntitlementProvider().getTier(),
tier: req.proxyTier ?? LicenseService.getInstance().getTier(),
aVulns: aVulns.length,
bVulns: bVulns.length,
added: added.length,
+5 -5
View File
@@ -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 { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from '../services/LicenseService';
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 = getEntitlementProvider().getTier() === 'paid';
const atomic = LicenseService.getInstance().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 = getEntitlementProvider().getTier() === 'paid';
const rolledBack = LicenseService.getInstance().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 = getEntitlementProvider().getTier() === 'paid';
const atomic = LicenseService.getInstance().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 = getEntitlementProvider().getTier() === 'paid';
const rolledBack = LicenseService.getInstance().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 });
+2 -2
View File
@@ -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 { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from '../services/LicenseService';
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 = getEntitlementProvider().getTier() === 'paid';
const atomic = LicenseService.getInstance().getTier() === 'paid';
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic);
invalidateNodeCaches(req.nodeId);
console.log(`[Templates] Deploy completed: ${stackName}`);
+2 -2
View File
@@ -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 { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from '../services/LicenseService';
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 = getEntitlementProvider().getSeatLimits();
const seatLimits = LicenseService.getInstance().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;
+3 -3
View File
@@ -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 { getEntitlementProvider } from '../entitlements/registry';
import { LicenseService } from '../services/LicenseService';
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 (getEntitlementProvider().getTier() !== 'paid') {
if (LicenseService.getInstance().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 = getEntitlementProvider().getTier() === 'paid';
const atomic = LicenseService.getInstance().getTier() === 'paid';
svc.execute(id, action, triggerSource, atomic).catch(err => {
console.error(`[Webhooks] Execution error for webhook ${id}:`, err);
});
+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;
@@ -4,10 +4,6 @@
* 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';
@@ -1,33 +1,28 @@
import type { LicenseTier, LicenseVariant } from './types';
import type { LicenseTier, LicenseVariant } from './license-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:
* 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`) 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
* - 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.
*
* 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.
* 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 versions of Sencho
* or older proxy headers; normalized to the current names on read.
* 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',
+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;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { UserRole, ApiTokenScope } from '../services/DatabaseService';
import type { LicenseTier, LicenseVariant } from '../entitlements/types';
import type { LicenseTier, LicenseVariant } from '../services/license-types';
// Extend Express Request type for user and node context.
// This file is imported for its side effects only (ambient declaration).
-34
View File
@@ -1,34 +0,0 @@
/**
* Ambient declaration for the private `@studio-saelix/sencho-pro`
* package so the public Sencho core's TypeScript build passes whether
* or not the package is installed locally. The runtime shape is
* defined by the package itself; this stub mirrors the surface the
* loader uses and nothing more.
*
* In production CI the Dockerfile installs the real package; the stub
* is shadowed by the package's own types and any drift fails the
* build. Local development without GitHub Packages auth falls back to
* the stub plus the loader's in-tree LicenseService binding, which
* keeps `npm install` and `tsc` working for everyone.
*
* The `implements EntitlementProvider` clause carries the full method
* surface; we do not redeclare individual methods here. If the real
* package ever ships a narrower return type than the interface
* permits, the local stub would over-widen and lose call-site type
* info but call sites only consume the interface (via the registry),
* so that risk does not materialise.
*/
declare module '@studio-saelix/sencho-pro' {
import type { EntitlementProvider } from '../entitlements/types';
/** Minimal database surface the provider needs. The public core's
* `DatabaseService` satisfies this structurally. */
export interface DatabaseAdapter {
getSystemState(key: string): string | null;
setSystemState(key: string, value: string): void;
}
export class LemonSqueezyEntitlementProvider implements EntitlementProvider {
constructor(db: DatabaseAdapter);
}
}
+4 -4
View File
@@ -5,14 +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 { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
import {
isLicenseTier,
isLicenseVariant,
normalizeTier,
normalizeVariant,
} from '../entitlements/normalize';
import { getEntitlementProvider } from '../entitlements/registry';
} from '../services/license-normalize';
import { LicenseService } from '../services/LicenseService';
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
import type { UserRole } from '../services/DatabaseService';
import { getErrorMessage } from '../utils/errors';
@@ -64,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 = getEntitlementProvider();
const ls = LicenseService.getInstance();
const consoleTier = (isConsoleSession && isLicenseTier(consoleTierHeader))
? normalizeTier(consoleTierHeader)
: ls.getTier();
+4 -4
View File
@@ -1,8 +1,8 @@
import type { IncomingMessage } from 'http';
import type { Duplex } from 'stream';
import type { Node } from '../services/DatabaseService';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers';
import { getEntitlementProvider } from '../entitlements/registry';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
import { wsProxyServer } from '../proxy/websocketProxy';
import { getErrorMessage } from '../utils/errors';
import { rejectUpgrade as reject } from './reject';
@@ -37,7 +37,7 @@ export async function handleRemoteForwarder(
let bearerTokenForProxy = node.api_token;
if (isInteractiveConsolePath) {
try {
const consoleHeaders = getEntitlementProvider().getProxyHeaders();
const consoleHeaders = LicenseService.getInstance().getProxyHeaders();
const tokenRes = await fetch(`${node.api_url.replace(/\/$/, '')}/api/system/console-token`, {
method: 'POST',
headers: {
@@ -64,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 = getEntitlementProvider().getProxyHeaders();
const fwdHeaders = LicenseService.getInstance().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