diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 4a659019..bc5eb8c8 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -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
sbom: true
diff --git a/Dockerfile b/Dockerfile
index 99c56da3..27c686e1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs
index 5baddfab..f6ad476f 100644
--- a/backend/eslint.config.mjs
+++ b/backend/eslint.config.mjs
@@ -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/.',
- },
- ],
- }],
},
},
)
diff --git a/backend/src/__tests__/community-entitlement-provider.test.ts b/backend/src/__tests__/community-entitlement-provider.test.ts
deleted file mode 100644
index c5aa5d6f..00000000
--- a/backend/src/__tests__/community-entitlement-provider.test.ts
+++ /dev/null
@@ -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);
- });
-});
diff --git a/backend/src/__tests__/helpers/setupTestDb.ts b/backend/src/__tests__/helpers/setupTestDb.ts
index 9f5e3b3f..fa604f56 100644
--- a/backend/src/__tests__/helpers/setupTestDb.ts
+++ b/backend/src/__tests__/helpers/setupTestDb.ts
@@ -54,22 +54,15 @@ export async function setupTestDb(): Promise {
// 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;
}
diff --git a/backend/src/__tests__/load-entitlement-provider.test.ts b/backend/src/__tests__/load-entitlement-provider.test.ts
deleted file mode 100644
index 81faadd5..00000000
--- a/backend/src/__tests__/load-entitlement-provider.test.ts
+++ /dev/null
@@ -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(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);
- });
-});
diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts
index 49b97046..0ae44ad9 100644
--- a/backend/src/__tests__/scheduler-service.test.ts
+++ b/backend/src/__tests__/scheduler-service.test.ts
@@ -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: {
diff --git a/backend/src/bootstrap/shutdown.ts b/backend/src/bootstrap/shutdown.ts
index d10b69f0..74254b40 100644
--- a/backend/src/bootstrap/shutdown.ts
+++ b/backend/src/bootstrap/shutdown.ts
@@ -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);
diff --git a/backend/src/bootstrap/startup.ts b/backend/src/bootstrap/startup.ts
index 19720f98..81d13d44 100644
--- a/backend/src/bootstrap/startup.ts
+++ b/backend/src/bootstrap/startup.ts
@@ -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 {
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
diff --git a/backend/src/entitlements/CommunityEntitlementProvider.ts b/backend/src/entitlements/CommunityEntitlementProvider.ts
deleted file mode 100644
index 941fcbeb..00000000
--- a/backend/src/entitlements/CommunityEntitlementProvider.ts
+++ /dev/null
@@ -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 {
- return { error: 'Billing portal is not available in this build.' };
- }
-
- public destroy(): void {
- // No timers, no subscriptions; nothing to clean up.
- }
-}
diff --git a/backend/src/entitlements/loadProvider.ts b/backend/src/entitlements/loadProvider.ts
deleted file mode 100644
index ac5e9864..00000000
--- a/backend/src/entitlements/loadProvider.ts
+++ /dev/null
@@ -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 {
- 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);
-}
diff --git a/backend/src/entitlements/registry.ts b/backend/src/entitlements/registry.ts
deleted file mode 100644
index 943380f8..00000000
--- a/backend/src/entitlements/registry.ts
+++ /dev/null
@@ -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;
-}
diff --git a/backend/src/entitlements/types.ts b/backend/src/entitlements/types.ts
deleted file mode 100644
index e367ce2a..00000000
--- a/backend/src/entitlements/types.ts
+++ /dev/null
@@ -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;
-
- /** Deactivate the current license, reverting to community. */
- deactivate(): Promise;
-
- /** Re-validate the current license against the backing license service. */
- validate(): Promise;
-
- /** Pre-signed billing portal URL, if applicable. */
- getBillingPortalUrl(): Promise;
-
- /** Cleanup on shutdown. Stops periodic-validation timers, etc. */
- destroy(): void;
-}
diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts
index 38210a3d..8de38a53 100644
--- a/backend/src/middleware/auth.ts
+++ b/backend/src/middleware/auth.ts
@@ -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,
diff --git a/backend/src/middleware/tierGates.ts b/backend/src/middleware/tierGates.ts
index c9d763ef..81cafac8 100644
--- a/backend/src/middleware/tierGates.ts
+++ b/backend/src/middleware/tierGates.ts
@@ -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 });
diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts
index 34e039aa..e444ac26 100644
--- a/backend/src/proxy/remoteNodeProxy.ts
+++ b/backend/src/proxy/remoteNodeProxy.ts
@@ -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
diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts
index 5a5c722f..bbfef29d 100644
--- a/backend/src/routes/dashboard.ts
+++ b/backend/src/routes/dashboard.ts
@@ -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();
diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts
index 8ee0f24f..7ce4594c 100644
--- a/backend/src/routes/fleet.ts
+++ b/backend/src/routes/fleet.ts
@@ -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();
diff --git a/backend/src/routes/imageUpdates.ts b/backend/src/routes/imageUpdates.ts
index f9251f75..de6ebf5f 100644
--- a/backend/src/routes/imageUpdates.ts
+++ b/backend/src/routes/imageUpdates.ts
@@ -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) {
diff --git a/backend/src/routes/license.ts b/backend/src/routes/license.ts
index 71bdce03..bf8414c0 100644
--- a/backend/src/routes/license.ts
+++ b/backend/src/routes/license.ts
@@ -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 => {
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 => {
try {
- const result = await getEntitlementProvider().getBillingPortalUrl();
+ const result = await LicenseService.getInstance().getBillingPortalUrl();
if ('error' in result) {
res.status(404).json({ error: result.error });
return;
diff --git a/backend/src/routes/permissions.ts b/backend/src/routes/permissions.ts
index 412614dd..8dfb9872 100644
--- a/backend/src/routes/permissions.ts
+++ b/backend/src/routes/permissions.ts
@@ -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);
diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts
index 8aa1fae4..c13072a5 100644
--- a/backend/src/routes/scheduledTasks.ts
+++ b/backend/src/routes/scheduledTasks.ts
@@ -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');
}
diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts
index 48aaa157..ab9e6ce2 100644
--- a/backend/src/routes/security.ts
+++ b/backend/src/routes/security.ts
@@ -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,
diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts
index 65c3142c..1c103464 100644
--- a/backend/src/routes/stacks.ts
+++ b/backend/src/routes/stacks.ts
@@ -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 });
diff --git a/backend/src/routes/templates.ts b/backend/src/routes/templates.ts
index 4671fd00..f9af25c9 100644
--- a/backend/src/routes/templates.ts
+++ b/backend/src/routes/templates.ts
@@ -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}`);
diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts
index 82bf99ff..fc34e24a 100644
--- a/backend/src/routes/users.ts
+++ b/backend/src/routes/users.ts
@@ -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;
diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts
index 30756d9b..5285e8ef 100644
--- a/backend/src/routes/webhooks.ts
+++ b/backend/src/routes/webhooks.ts
@@ -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);
});
diff --git a/backend/src/services/BlueprintService.ts b/backend/src/services/BlueprintService.ts
index 3202a6b6..c4d424d7 100644
--- a/backend/src/services/BlueprintService.ts
+++ b/backend/src/services/BlueprintService.ts
@@ -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 {
- const proxy = getEntitlementProvider().getProxyHeaders();
+ const proxy = LicenseService.getInstance().getProxyHeaders();
return {
Authorization: `Bearer ${apiToken}`,
[PROXY_TIER_HEADER]: proxy.tier,
diff --git a/backend/src/services/CloudBackupService.ts b/backend/src/services/CloudBackupService.ts
index 0744200b..e8eacd61 100644
--- a/backend/src/services/CloudBackupService.ts
+++ b/backend/src/services/CloudBackupService.ts
@@ -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;
diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts
index c33d60ca..23475708 100644
--- a/backend/src/services/LicenseService.ts
+++ b/backend/src/services/LicenseService.ts
@@ -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 = {
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 | null = null;
private cachedProxyHeaders: { value: { tier: LicenseTier; variant: LicenseVariant }; expiresAt: number } | null = null;
diff --git a/backend/src/services/SSOService.ts b/backend/src/services/SSOService.ts
index 0d02d5bf..e4b46995 100644
--- a/backend/src/services/SSOService.ts
+++ b/backend/src/services/SSOService.ts
@@ -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';
diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts
index df1065c6..b38027c3 100644
--- a/backend/src/services/SchedulerService.ts
+++ b/backend/src/services/SchedulerService.ts
@@ -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;
diff --git a/backend/src/entitlements/headers.ts b/backend/src/services/license-headers.ts
similarity index 69%
rename from backend/src/entitlements/headers.ts
rename to backend/src/services/license-headers.ts
index e4ea55c8..09671af9 100644
--- a/backend/src/entitlements/headers.ts
+++ b/backend/src/services/license-headers.ts
@@ -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';
diff --git a/backend/src/entitlements/normalize.ts b/backend/src/services/license-normalize.ts
similarity index 64%
rename from backend/src/entitlements/normalize.ts
rename to backend/src/services/license-normalize.ts
index 2827f181..f04aad08 100644
--- a/backend/src/entitlements/normalize.ts
+++ b/backend/src/services/license-normalize.ts
@@ -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 = { 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> = {
personal: 'skipper',
diff --git a/backend/src/services/license-types.ts b/backend/src/services/license-types.ts
new file mode 100644
index 00000000..67143d1a
--- /dev/null
+++ b/backend/src/services/license-types.ts
@@ -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;
+}
diff --git a/backend/src/types/express.ts b/backend/src/types/express.ts
index d9ca10fe..20bbebc5 100644
--- a/backend/src/types/express.ts
+++ b/backend/src/types/express.ts
@@ -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).
diff --git a/backend/src/types/sencho-pro.d.ts b/backend/src/types/sencho-pro.d.ts
deleted file mode 100644
index ebd9fb23..00000000
--- a/backend/src/types/sencho-pro.d.ts
+++ /dev/null
@@ -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);
- }
-}
diff --git a/backend/src/websocket/hostConsole.ts b/backend/src/websocket/hostConsole.ts
index c384b78e..7f12a80b 100644
--- a/backend/src/websocket/hostConsole.ts
+++ b/backend/src/websocket/hostConsole.ts
@@ -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();
diff --git a/backend/src/websocket/remoteForwarder.ts b/backend/src/websocket/remoteForwarder.ts
index 467e4c87..4e1388b5 100644
--- a/backend/src/websocket/remoteForwarder.ts
+++ b/backend/src/websocket/remoteForwarder.ts
@@ -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