From e5b1c7b22bf8d9ac9b6850bb647db7ecb054aa33 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 2 May 2026 23:45:44 -0400 Subject: [PATCH] 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. --- .github/workflows/docker-publish.yml | 24 ---- Dockerfile | 39 ------ backend/eslint.config.mjs | 20 ---- .../community-entitlement-provider.test.ts | 72 ----------- backend/src/__tests__/helpers/setupTestDb.ts | 21 ++-- .../load-entitlement-provider.test.ts | 75 ------------ .../src/__tests__/scheduler-service.test.ts | 9 -- backend/src/bootstrap/shutdown.ts | 6 +- backend/src/bootstrap/startup.ts | 13 +- .../CommunityEntitlementProvider.ts | 97 --------------- backend/src/entitlements/loadProvider.ts | 78 ------------ backend/src/entitlements/registry.ts | 49 -------- backend/src/entitlements/types.ts | 113 ------------------ backend/src/middleware/auth.ts | 4 +- backend/src/middleware/tierGates.ts | 8 +- backend/src/proxy/remoteNodeProxy.ts | 6 +- backend/src/routes/dashboard.ts | 2 +- backend/src/routes/fleet.ts | 6 +- backend/src/routes/imageUpdates.ts | 4 +- backend/src/routes/license.ts | 18 +-- backend/src/routes/permissions.ts | 4 +- backend/src/routes/scheduledTasks.ts | 4 +- backend/src/routes/security.ts | 4 +- backend/src/routes/stacks.ts | 10 +- backend/src/routes/templates.ts | 4 +- backend/src/routes/users.ts | 4 +- backend/src/routes/webhooks.ts | 6 +- backend/src/services/BlueprintService.ts | 6 +- backend/src/services/CloudBackupService.ts | 4 +- backend/src/services/LicenseService.ts | 24 ++-- backend/src/services/SSOService.ts | 6 +- backend/src/services/SchedulerService.ts | 4 +- .../license-headers.ts} | 4 - .../license-normalize.ts} | 27 ++--- backend/src/services/license-types.ts | 58 +++++++++ backend/src/types/express.ts | 2 +- backend/src/types/sencho-pro.d.ts | 34 ------ backend/src/websocket/hostConsole.ts | 8 +- backend/src/websocket/remoteForwarder.ts | 8 +- 39 files changed, 150 insertions(+), 735 deletions(-) delete mode 100644 backend/src/__tests__/community-entitlement-provider.test.ts delete mode 100644 backend/src/__tests__/load-entitlement-provider.test.ts delete mode 100644 backend/src/entitlements/CommunityEntitlementProvider.ts delete mode 100644 backend/src/entitlements/loadProvider.ts delete mode 100644 backend/src/entitlements/registry.ts delete mode 100644 backend/src/entitlements/types.ts rename backend/src/{entitlements/headers.ts => services/license-headers.ts} (69%) rename backend/src/{entitlements/normalize.ts => services/license-normalize.ts} (64%) create mode 100644 backend/src/services/license-types.ts delete mode 100644 backend/src/types/sencho-pro.d.ts 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