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

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

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

Rationale and revisit conditions in
docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md.
This commit is contained in:
Anso
2026-05-02 23:45:44 -04:00
committed by GitHub
parent 6929dad540
commit e5b1c7b22b
39 changed files with 150 additions and 735 deletions
@@ -1,72 +0,0 @@
/**
* Smoke test for `CommunityEntitlementProvider`. The class is the Phase
* 2 fallback for a build that ships without `@studio-saelix/sencho-pro`
* and is NOT instantiated in production today. This test exists to keep
* the class covered against bitrot: every test run proves the
* EntitlementProvider interface still matches and the Community
* implementation still satisfies the contract.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('CommunityEntitlementProvider', () => {
it('reports community tier and null variant', async () => {
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
const provider = new CommunityEntitlementProvider();
expect(provider.getTier()).toBe('community');
expect(provider.getVariant()).toBeNull();
expect(provider.getProxyHeaders()).toEqual({ tier: 'community', variant: null });
});
it('returns single-admin seat limits matching the Community plan', async () => {
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
const provider = new CommunityEntitlementProvider();
expect(provider.getSeatLimits()).toEqual({ maxAdmins: 1, maxViewers: 0 });
});
it('rejects activate() with a clear install-the-private-package message', async () => {
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
const provider = new CommunityEntitlementProvider();
const result = await provider.activate('any-key');
expect(result.success).toBe(false);
expect(result.error).toContain('@studio-saelix/sencho-pro');
});
it('deactivate() is a successful no-op', async () => {
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
const provider = new CommunityEntitlementProvider();
const result = await provider.deactivate();
expect(result.success).toBe(true);
});
it('initialize() seeds a persistent instance_id when one is missing', async () => {
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
db.setSystemState('instance_id', '');
new CommunityEntitlementProvider().initialize();
expect(db.getSystemState('instance_id')).toBeTruthy();
});
it('getLicenseInfo() returns Community-only state', async () => {
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
const info = new CommunityEntitlementProvider().getLicenseInfo();
expect(info.tier).toBe('community');
expect(info.status).toBe('community');
expect(info.variant).toBeNull();
expect(info.maskedKey).toBeNull();
expect(info.isLifetime).toBe(false);
});
});
+7 -14
View File
@@ -54,22 +54,15 @@ export async function setupTestDb(): Promise<string> {
// path-traversal or 404 on missing files. Realign here.
db.getDb().prepare('UPDATE nodes SET compose_dir = ? WHERE is_default = 1').run(composeDir);
// Register the in-tree LicenseService as the active EntitlementProvider
// so tier-gated middleware can resolve a provider during the test. In
// Force the LicenseService singleton to materialize on the test DB. In
// production this is wired by `bootstrap/startup.ts`; tests bypass that
// path by importing modules directly, so the registry would otherwise
// throw on first tier check. The mocking pattern many tests use
// (`vi.spyOn(LicenseService.getInstance(), 'getTier')`) continues to
// work because LicenseService.getInstance() and getEntitlementProvider()
// return the same singleton in Phase 1.
//
// The registry binding is module-scope and survives across test files
// within the same Vitest worker. Avoid `vi.resetModules()` in this
// codebase; it would drop the binding and cause subsequent tier
// checks to throw with "EntitlementProvider not initialized."
// path by importing modules directly. Without this prime, the first
// tier check in a test runs `LicenseService.getInstance()` against a
// singleton whose lazy-init never ran. The mocking pattern many tests
// use (`vi.spyOn(LicenseService.getInstance(), 'getTier')`) continues
// to work against the same singleton.
const { LicenseService } = await import('../../services/LicenseService');
const { setEntitlementProvider } = await import('../../entitlements/registry');
setEntitlementProvider(LicenseService.getInstance());
LicenseService.getInstance();
return tmpDir;
}
@@ -1,75 +0,0 @@
/**
* Tests for the loader's module-not-found discrimination. The
* predicate is a security boundary: a too-wide match silently
* downgrades a paid install to community on a transitive-dependency
* bug; a too-narrow match crashes bootstrap on a legitimate
* Community-only build. Keep these fixtures aligned with the runtime
* codes Node and bundlers actually produce.
*/
import { describe, it, expect } from 'vitest';
import { isProPackageNotInstalled } from '../entitlements/loadProvider';
function withCode<T extends Error>(err: T, code: string): T {
(err as Error & { code?: string }).code = code;
return err;
}
describe('isProPackageNotInstalled', () => {
it('returns false for non-Error values', () => {
expect(isProPackageNotInstalled(undefined)).toBe(false);
expect(isProPackageNotInstalled(null)).toBe(false);
expect(isProPackageNotInstalled('not an error')).toBe(false);
expect(isProPackageNotInstalled({})).toBe(false);
});
it('returns false for Error without a code', () => {
expect(isProPackageNotInstalled(new Error('something broke'))).toBe(false);
});
it('returns true for ERR_MODULE_NOT_FOUND on the private package (Node ESM)', () => {
const err = withCode(
new Error("Cannot find package '@studio-saelix/sencho-pro' imported from /app/dist/entitlements/loadProvider.js"),
'ERR_MODULE_NOT_FOUND',
);
expect(isProPackageNotInstalled(err)).toBe(true);
});
it('returns true for MODULE_NOT_FOUND on the private package (CJS / older Node)', () => {
const err = withCode(
new Error("Cannot find module '@studio-saelix/sencho-pro'"),
'MODULE_NOT_FOUND',
);
expect(isProPackageNotInstalled(err)).toBe(true);
});
it('returns false for MODULE_NOT_FOUND on a transitive dep of the private package', () => {
// The private package was installed but one of its dependencies is
// missing. We must NOT classify this as "package not installed";
// re-raising surfaces the bug instead of silently downgrading to
// community.
const err = withCode(
new Error("Cannot find module 'axios'"),
'MODULE_NOT_FOUND',
);
expect(isProPackageNotInstalled(err)).toBe(false);
});
it('returns false for ERR_PACKAGE_PATH_NOT_EXPORTED', () => {
// Package was resolved but its exports map does not include the
// path we asked for; this is a packaging bug, not a missing
// package, and should re-raise.
const err = withCode(
new Error("Package subpath './internal' is not defined by exports in @studio-saelix/sencho-pro/package.json"),
'ERR_PACKAGE_PATH_NOT_EXPORTED',
);
expect(isProPackageNotInstalled(err)).toBe(false);
});
it('returns false for an unrelated runtime error during construction', () => {
// The package loaded successfully but threw inside its
// constructor. The loader must re-raise; silently falling back
// would be a license-bypass surface.
const err = new TypeError('Cannot read property of undefined');
expect(isProPackageNotInstalled(err)).toBe(false);
});
});
@@ -98,15 +98,6 @@ vi.mock('../services/LicenseService', () => ({
},
}));
// SchedulerService now talks to the entitlement registry rather than
// LicenseService.getInstance() directly. Mock the registry to return the
// same shape the test was already mocking on LicenseService.
vi.mock('../entitlements/registry', () => ({
getEntitlementProvider: () => ({
getTier: mockGetTier,
getVariant: mockGetVariant,
}),
}));
vi.mock('../services/DockerController', () => ({
default: {