mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
refactor(backend): collapse entitlement provider abstraction back to LicenseService (#889)
Removes backend/src/entitlements/ (registry, loadProvider, CommunityEntitlementProvider, types, headers, normalize) and the two abstraction-only tests. Relocates headers/normalize/types to services/license-*.ts. Swaps 22 consumer call sites from getEntitlementProvider() to LicenseService.getInstance(). Drops the Dockerfile install step plus PRO_PACKAGE_VERSION build-arg and github_token BuildKit secret in docker-publish.yml. Removes the now stale no-restricted-imports rule in backend/eslint.config.mjs. Net: 37 files changed, ~700 lines removed, no behavior change. Local dev no longer requires GitHub Packages auth to start the backend. Rationale and revisit conditions in docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md.
This commit is contained in:
@@ -118,22 +118,8 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
tags: localhost/sencho:release-scan
|
||||
cache-from: type=registry,ref=saelix/sencho:buildcache
|
||||
# PRO_PACKAGE_VERSION pins @studio-saelix/sencho-pro to a
|
||||
# specific SemVer so the scan build and the publish build
|
||||
# below resolve identically. Bumping the pro package: bump
|
||||
# this string in the same PR that ships the matching public
|
||||
# Sencho release; release-please does not coordinate the two.
|
||||
build-args: |
|
||||
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
|
||||
PRO_PACKAGE_VERSION=0.1.0
|
||||
# GITHUB_TOKEN carries packages:read scope by default, which
|
||||
# is enough to install @studio-saelix/sencho-pro from
|
||||
# GitHub Packages during the prod-deps stage. Passing it via
|
||||
# BuildKit secret keeps it out of image layers; the
|
||||
# Dockerfile reads /run/secrets/github_token only inside the
|
||||
# one RUN that authenticates to npm.pkg.github.com.
|
||||
secrets: |
|
||||
github_token=${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Re-scan release image for vulnerabilities (Trivy)
|
||||
# Gates the release on the same HIGH/CRITICAL policy as the PR scan.
|
||||
@@ -193,18 +179,8 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=saelix/sencho:buildcache
|
||||
cache-to: type=registry,ref=saelix/sencho:buildcache,mode=max
|
||||
# PRO_PACKAGE_VERSION must match the value passed to the
|
||||
# scan build above so Trivy and the smoke test exercise the
|
||||
# same package version that ships.
|
||||
build-args: |
|
||||
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
|
||||
PRO_PACKAGE_VERSION=0.1.0
|
||||
# Same secret as the pre-publish scan above so the published
|
||||
# image carries @studio-saelix/sencho-pro identical to what
|
||||
# Trivy and the smoke test exercised. BuildKit keeps the
|
||||
# token out of the published layers.
|
||||
secrets: |
|
||||
github_token=${{ secrets.GITHUB_TOKEN }}
|
||||
# SBOM + provenance attestations are embedded as OCI referrers on the
|
||||
# published image. Inspect with: docker buildx imagetools inspect <img>
|
||||
sbom: true
|
||||
|
||||
-39
@@ -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
|
||||
|
||||
@@ -25,26 +25,6 @@ export default tseslint.config(
|
||||
// New in ESLint 10 recommended - existing patterns, suppress until addressed
|
||||
'no-useless-assignment': 'warn',
|
||||
'preserve-caught-error': 'warn',
|
||||
// The private @studio-saelix/sencho-pro package is loaded at
|
||||
// runtime via dynamic import in entitlements/loadProvider.ts.
|
||||
// A static import would (a) bundle the package into the public
|
||||
// BSL build via TypeScript's module resolution, defeating the
|
||||
// privacy split, and (b) break in Community-only environments
|
||||
// where the package is not installed. Use loadEntitlementProvider()
|
||||
// and the registry instead.
|
||||
// no-restricted-imports flags ES `import` statements only by
|
||||
// default; dynamic `import()` expressions are not flagged unless
|
||||
// we set `allowDynamicImports: false`. The loader uses
|
||||
// `await import('@studio-saelix/sencho-pro')` and is therefore
|
||||
// not affected without an extra opt-in.
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [
|
||||
{
|
||||
group: ['@studio-saelix/sencho-pro', '@studio-saelix/sencho-pro/*'],
|
||||
message: 'Do not statically import the private package. Use loadEntitlementProvider() and getEntitlementProvider() from src/entitlements/.',
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Smoke test for `CommunityEntitlementProvider`. The class is the Phase
|
||||
* 2 fallback for a build that ships without `@studio-saelix/sencho-pro`
|
||||
* and is NOT instantiated in production today. This test exists to keep
|
||||
* the class covered against bitrot: every test run proves the
|
||||
* EntitlementProvider interface still matches and the Community
|
||||
* implementation still satisfies the contract.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('CommunityEntitlementProvider', () => {
|
||||
it('reports community tier and null variant', async () => {
|
||||
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
|
||||
const provider = new CommunityEntitlementProvider();
|
||||
|
||||
expect(provider.getTier()).toBe('community');
|
||||
expect(provider.getVariant()).toBeNull();
|
||||
expect(provider.getProxyHeaders()).toEqual({ tier: 'community', variant: null });
|
||||
});
|
||||
|
||||
it('returns single-admin seat limits matching the Community plan', async () => {
|
||||
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
|
||||
const provider = new CommunityEntitlementProvider();
|
||||
expect(provider.getSeatLimits()).toEqual({ maxAdmins: 1, maxViewers: 0 });
|
||||
});
|
||||
|
||||
it('rejects activate() with a clear install-the-private-package message', async () => {
|
||||
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
|
||||
const provider = new CommunityEntitlementProvider();
|
||||
const result = await provider.activate('any-key');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('@studio-saelix/sencho-pro');
|
||||
});
|
||||
|
||||
it('deactivate() is a successful no-op', async () => {
|
||||
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
|
||||
const provider = new CommunityEntitlementProvider();
|
||||
const result = await provider.deactivate();
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('initialize() seeds a persistent instance_id when one is missing', async () => {
|
||||
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
db.setSystemState('instance_id', '');
|
||||
new CommunityEntitlementProvider().initialize();
|
||||
expect(db.getSystemState('instance_id')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('getLicenseInfo() returns Community-only state', async () => {
|
||||
const { CommunityEntitlementProvider } = await import('../entitlements/CommunityEntitlementProvider');
|
||||
const info = new CommunityEntitlementProvider().getLicenseInfo();
|
||||
expect(info.tier).toBe('community');
|
||||
expect(info.status).toBe('community');
|
||||
expect(info.variant).toBeNull();
|
||||
expect(info.maskedKey).toBeNull();
|
||||
expect(info.isLifetime).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Server } from 'http';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { loadEntitlementProvider } from '../entitlements/loadProvider';
|
||||
import { setEntitlementProvider } from '../entitlements/registry';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import SelfUpdateService from '../services/SelfUpdateService';
|
||||
import { MonitorService } from '../services/MonitorService';
|
||||
import { AutoHealService } from '../services/AutoHealService';
|
||||
@@ -32,14 +31,8 @@ export async function startServer(server: Server): Promise<void> {
|
||||
console.error('Migration failed:', error);
|
||||
}
|
||||
|
||||
// Resolve the EntitlementProvider before any tier-gated code can run.
|
||||
// Phase 1 returns the in-tree LicenseService singleton; Phase 2 will
|
||||
// dynamic-import the private package and fall back to Community.
|
||||
// Awaited because the loader is async-by-signature (so the Phase-2
|
||||
// swap doesn't change this call site).
|
||||
const entitlementProvider = await loadEntitlementProvider();
|
||||
setEntitlementProvider(entitlementProvider);
|
||||
entitlementProvider.initialize();
|
||||
// Initialize the license service before any tier-gated code can run.
|
||||
LicenseService.getInstance().initialize();
|
||||
|
||||
// Synchronous starts: schedule background timers and continue. None of
|
||||
// these fire their first tick for at least a few seconds, so they
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import type {
|
||||
BillingPortalError,
|
||||
BillingPortalResult,
|
||||
EntitlementProvider,
|
||||
LicenseInfo,
|
||||
LicenseTier,
|
||||
LicenseVariant,
|
||||
SeatLimits,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Entitlement provider that returns Community-only state regardless of
|
||||
* any stored license. Used as the fallback when `loadEntitlementProvider()`
|
||||
* cannot resolve `@studio-saelix/sencho-pro` (a build that ships without
|
||||
* the private package).
|
||||
*
|
||||
* Behavior:
|
||||
* - `getTier()` always returns `'community'`.
|
||||
* - `getVariant()` always returns `null`.
|
||||
* - `activate()` rejects with a clear message pointing the operator
|
||||
* at the private-package install path.
|
||||
* - `getLicenseInfo()` reflects a fresh-install Community state, with
|
||||
* a persistent `instance_id` UUID so heartbeat / log identifiers
|
||||
* stay stable across restarts of the same install.
|
||||
*
|
||||
* Phase 1 never instantiates this class in production: the in-tree
|
||||
* `LicenseService` is used directly. It exists today for parity with
|
||||
* the Phase 2 plan and so that `loadEntitlementProvider()` has a
|
||||
* non-throwing fallback path the day the private package is the
|
||||
* primary binding.
|
||||
*/
|
||||
export class CommunityEntitlementProvider implements EntitlementProvider {
|
||||
public initialize(): void {
|
||||
const db = DatabaseService.getInstance();
|
||||
if (!db.getSystemState('instance_id')) {
|
||||
db.setSystemState('instance_id', crypto.randomUUID());
|
||||
}
|
||||
}
|
||||
|
||||
public getTier(): LicenseTier {
|
||||
return 'community';
|
||||
}
|
||||
|
||||
public getVariant(): LicenseVariant {
|
||||
return null;
|
||||
}
|
||||
|
||||
public getProxyHeaders(): { tier: LicenseTier; variant: LicenseVariant } {
|
||||
return { tier: 'community', variant: null };
|
||||
}
|
||||
|
||||
public getSeatLimits(): SeatLimits {
|
||||
return { maxAdmins: 1, maxViewers: 0 };
|
||||
}
|
||||
|
||||
public getLicenseInfo(): LicenseInfo {
|
||||
const instanceId = DatabaseService.getInstance().getSystemState('instance_id') || '';
|
||||
return {
|
||||
tier: 'community',
|
||||
status: 'community',
|
||||
variant: null,
|
||||
customerName: null,
|
||||
productName: null,
|
||||
maskedKey: null,
|
||||
validUntil: null,
|
||||
trialDaysRemaining: null,
|
||||
instanceId,
|
||||
portalUrl: null,
|
||||
isLifetime: false,
|
||||
};
|
||||
}
|
||||
|
||||
public async activate(_licenseKey: string): Promise<{ success: false; error: string }> {
|
||||
return {
|
||||
success: false,
|
||||
error: 'License activation is not available in this build. Install @studio-saelix/sencho-pro to activate Skipper or Admiral.',
|
||||
};
|
||||
}
|
||||
|
||||
public async deactivate(): Promise<{ success: true }> {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async validate(): Promise<{ success: false; error: string }> {
|
||||
return { success: false, error: 'No active license to validate' };
|
||||
}
|
||||
|
||||
public async getBillingPortalUrl(): Promise<BillingPortalResult | BillingPortalError> {
|
||||
return { error: 'Billing portal is not available in this build.' };
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
// No timers, no subscriptions; nothing to clean up.
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import type { EntitlementProvider } from './types';
|
||||
|
||||
const PRO_PACKAGE = '@studio-saelix/sencho-pro';
|
||||
|
||||
/**
|
||||
* Resolve the EntitlementProvider implementation for this build.
|
||||
*
|
||||
* Phase 2: try to dynamic-import `@studio-saelix/sencho-pro` first.
|
||||
*
|
||||
* - **Module-not-found** (the package is not installed in this build,
|
||||
* e.g. the public BSL Community-only image): fall back to the
|
||||
* in-tree `LicenseService.getInstance()` so existing functionality
|
||||
* is preserved during this transition window. A later cleanup PR
|
||||
* will switch this fallback to `CommunityEntitlementProvider` and
|
||||
* remove `services/LicenseService.ts` from the public repo
|
||||
* entirely; until then, the in-tree fallback keeps the public BSL
|
||||
* repo runnable on its own.
|
||||
*
|
||||
* - **Module loaded but threw during construction**: re-raise. A
|
||||
* silent fallback to community would be a license-bypass surface
|
||||
* (a paid install whose private package init bug downgrades them
|
||||
* to community without surfacing the failure). The bootstrap
|
||||
* should crash and the operator investigates.
|
||||
*
|
||||
* The function is async so the dynamic import does not change the
|
||||
* call-site contract.
|
||||
*/
|
||||
export async function loadEntitlementProvider(): Promise<EntitlementProvider> {
|
||||
try {
|
||||
const mod = await import(PRO_PACKAGE);
|
||||
// Explicit typing so a structural drift between Sencho's
|
||||
// DatabaseService and the package's DatabaseAdapter (e.g. a
|
||||
// future widening of the adapter interface) fails type-check
|
||||
// here rather than only at production-CI dual-image build
|
||||
// time. Sencho's DatabaseService satisfies the adapter shape
|
||||
// structurally today.
|
||||
const db: import('@studio-saelix/sencho-pro').DatabaseAdapter = DatabaseService.getInstance();
|
||||
return new mod.LemonSqueezyEntitlementProvider(db);
|
||||
} catch (err) {
|
||||
if (isProPackageNotInstalled(err)) {
|
||||
// Phase 2 transitional fallback: keep the in-tree
|
||||
// LicenseService binding so a Community-only build (no
|
||||
// private package installed) still runs through the
|
||||
// existing LemonSqueezy path. A follow-up PR will replace
|
||||
// this with a CommunityEntitlementProvider and remove
|
||||
// services/LicenseService.ts from the public repo.
|
||||
return LicenseService.getInstance();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguish "the private package itself is not installed" (legitimate
|
||||
* fallback path) from "the package loaded but threw / a transitive dep
|
||||
* is missing" (re-raise so the operator sees the failure).
|
||||
*
|
||||
* The error code alone is not specific enough: a missing transitive
|
||||
* dependency in an installed paid package surfaces with the same
|
||||
* `MODULE_NOT_FOUND` code as the package itself missing. We additionally
|
||||
* require the error message to mention the private package by name, so
|
||||
* the fallback only triggers when Node could not resolve
|
||||
* `@studio-saelix/sencho-pro` at the top level.
|
||||
*
|
||||
* Codes covered: `ERR_MODULE_NOT_FOUND` (Node >=20 ESM), `MODULE_NOT_FOUND`
|
||||
* (CJS / older Node). `ERR_PACKAGE_PATH_NOT_EXPORTED` is intentionally
|
||||
* NOT treated as "not installed": that fires when the package was
|
||||
* resolved but its exports map does not include the path we asked for,
|
||||
* which is a packaging bug we want to surface, not silently downgrade.
|
||||
*/
|
||||
export function isProPackageNotInstalled(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const code = (err as Error & { code?: string }).code;
|
||||
if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') return false;
|
||||
return err.message.includes(PRO_PACKAGE);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Tier / variant types and the EntitlementProvider interface that the
|
||||
* public Sencho core depends on. The interface is the abstraction
|
||||
* boundary that lets a future build pull the Lemon Squeezy validation
|
||||
* client out of the public BSL repo and into a private package
|
||||
* (`@studio-saelix/sencho-pro`) without touching consumer call sites.
|
||||
*
|
||||
* Today the LemonSqueezy implementation lives in-tree at
|
||||
* `services/LicenseService.ts`. A `CommunityEntitlementProvider` lives
|
||||
* alongside this file as the Phase-2 fallback for a build that ships
|
||||
* without the private package.
|
||||
*
|
||||
* See `docs/internal/adrs/2026-05-02-open-core-hybrid-strategy.md` for
|
||||
* the full design.
|
||||
*/
|
||||
|
||||
export type LicenseTier = 'community' | 'paid';
|
||||
export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled';
|
||||
export type LicenseVariant = 'skipper' | 'admiral' | null;
|
||||
|
||||
export interface ActivationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DeactivationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BillingPortalResult {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BillingPortalError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface LicenseInfo {
|
||||
tier: LicenseTier;
|
||||
status: LicenseStatus;
|
||||
variant: LicenseVariant;
|
||||
customerName: string | null;
|
||||
productName: string | null;
|
||||
maskedKey: string | null;
|
||||
validUntil: string | null;
|
||||
trialDaysRemaining: number | null;
|
||||
instanceId: string;
|
||||
portalUrl: string | null;
|
||||
isLifetime: boolean;
|
||||
}
|
||||
|
||||
/** Seat limits per variant. null = unlimited. */
|
||||
export interface SeatLimits {
|
||||
maxAdmins: number | null;
|
||||
maxViewers: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The contract every backend tier-gating consumer talks to. Phase-1
|
||||
* binding is `services/LicenseService.ts` (the existing class now
|
||||
* implements this interface). Phase 2 will dynamically load
|
||||
* `@studio-saelix/sencho-pro` and use its `LemonSqueezyEntitlementProvider`,
|
||||
* falling back to `CommunityEntitlementProvider` if the package is
|
||||
* absent.
|
||||
*
|
||||
* Method signatures mirror the existing LicenseService surface so the
|
||||
* Phase-1 migration is mechanical (consumers call the same methods on a
|
||||
* different binding).
|
||||
*/
|
||||
export interface EntitlementProvider {
|
||||
/** Idempotent. Called once during bootstrap. */
|
||||
initialize(): void;
|
||||
|
||||
/** Synchronous tier read. Backed by cached DB state. */
|
||||
getTier(): LicenseTier;
|
||||
|
||||
/** Synchronous variant read. Backed by cached DB state. */
|
||||
getVariant(): LicenseVariant;
|
||||
|
||||
/**
|
||||
* Cached tier+variant snapshot for the remote-node proxy hot path,
|
||||
* which reads tier/variant on every forwarded request. Cache TTL
|
||||
* is short and is invalidated on every license-status write.
|
||||
*/
|
||||
getProxyHeaders(): { tier: LicenseTier; variant: LicenseVariant };
|
||||
|
||||
/** Seat limits derived from the current variant. */
|
||||
getSeatLimits(): SeatLimits;
|
||||
|
||||
/** Full license info for the API response. */
|
||||
getLicenseInfo(): LicenseInfo;
|
||||
|
||||
/** Activate a license key against the backing license service. */
|
||||
activate(licenseKey: string): Promise<ActivationResult>;
|
||||
|
||||
/** Deactivate the current license, reverting to community. */
|
||||
deactivate(): Promise<DeactivationResult>;
|
||||
|
||||
/** Re-validate the current license against the backing license service. */
|
||||
validate(): Promise<ValidationResult>;
|
||||
|
||||
/** Pre-signed billing portal URL, if applicable. */
|
||||
getBillingPortalUrl(): Promise<BillingPortalResult | BillingPortalError>;
|
||||
|
||||
/** Cleanup on shutdown. Stops periodic-validation timers, etc. */
|
||||
destroy(): void;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import SelfUpdateService from '../services/SelfUpdateService';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
@@ -10,7 +10,7 @@ export const licenseRouter = Router();
|
||||
|
||||
licenseRouter.get('/', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const info = getEntitlementProvider().getLicenseInfo();
|
||||
const info = LicenseService.getInstance().getLicenseInfo();
|
||||
res.json(info);
|
||||
} catch (error) {
|
||||
console.error('[License] Error getting license info:', error);
|
||||
@@ -27,9 +27,9 @@ licenseRouter.post('/activate', async (req: Request, res: Response): Promise<voi
|
||||
res.status(400).json({ error: 'A valid license key is required' });
|
||||
return;
|
||||
}
|
||||
const result = await getEntitlementProvider().activate(license_key.trim());
|
||||
const result = await LicenseService.getInstance().activate(license_key.trim());
|
||||
if (result.success) {
|
||||
res.json({ success: true, license: getEntitlementProvider().getLicenseInfo() });
|
||||
res.json({ success: true, license: LicenseService.getInstance().getLicenseInfo() });
|
||||
} else {
|
||||
res.status(400).json({ error: result.error });
|
||||
}
|
||||
@@ -43,9 +43,9 @@ licenseRouter.post('/deactivate', async (req: Request, res: Response): Promise<v
|
||||
if (rejectApiTokenScope(req, res, LICENSE_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const result = await getEntitlementProvider().deactivate();
|
||||
const result = await LicenseService.getInstance().deactivate();
|
||||
if (result.success) {
|
||||
res.json({ success: true, license: getEntitlementProvider().getLicenseInfo() });
|
||||
res.json({ success: true, license: LicenseService.getInstance().getLicenseInfo() });
|
||||
} else {
|
||||
res.status(500).json({ error: result.error });
|
||||
}
|
||||
@@ -57,8 +57,8 @@ licenseRouter.post('/deactivate', async (req: Request, res: Response): Promise<v
|
||||
|
||||
licenseRouter.post('/validate', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const result = await getEntitlementProvider().validate();
|
||||
res.json({ ...result, license: getEntitlementProvider().getLicenseInfo() });
|
||||
const result = await LicenseService.getInstance().validate();
|
||||
res.json({ ...result, license: LicenseService.getInstance().getLicenseInfo() });
|
||||
} catch (error) {
|
||||
console.error('[License] Validation error:', error);
|
||||
res.status(500).json({ error: 'License validation failed' });
|
||||
@@ -67,7 +67,7 @@ licenseRouter.post('/validate', async (_req: Request, res: Response): Promise<vo
|
||||
|
||||
licenseRouter.get('/billing-portal', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const result = await getEntitlementProvider().getBillingPortalUrl();
|
||||
const result = await LicenseService.getInstance().getBillingPortalUrl();
|
||||
if ('error' in result) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
|
||||
import { LicenseService } from './LicenseService';
|
||||
|
||||
const MARKER_FILENAME = '.blueprint.json';
|
||||
const COMPOSE_FILENAME = 'docker-compose.yml';
|
||||
@@ -360,7 +360,7 @@ export class BlueprintService {
|
||||
// ---- remote primitives ----
|
||||
|
||||
private remoteHeaders(apiToken: string): Record<string, string> {
|
||||
const proxy = getEntitlementProvider().getProxyHeaders();
|
||||
const proxy = LicenseService.getInstance().getProxyHeaders();
|
||||
return {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
[PROXY_TIER_HEADER]: proxy.tier,
|
||||
|
||||
@@ -17,7 +17,7 @@ import * as tar from 'tar-stream';
|
||||
import axios from 'axios';
|
||||
import { DatabaseService, type FleetSnapshotFile } from './DatabaseService';
|
||||
import { CryptoService } from './CryptoService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
import { LicenseService } from './LicenseService';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
@@ -185,7 +185,7 @@ export class CloudBackupService {
|
||||
const licenseKey = db.getSystemState('license_key');
|
||||
if (!licenseKey) return { success: false, error: 'No license key found. Activate an Admiral license first.' };
|
||||
|
||||
const variant = getEntitlementProvider().getVariant();
|
||||
const variant = LicenseService.getInstance().getVariant();
|
||||
if (variant !== 'admiral') return { success: false, error: 'Sencho Cloud Backup requires the Admiral tier.' };
|
||||
|
||||
const apiBase = process.env.SENCHO_CLOUD_BACKUP_API || SENCHO_CLOUD_BACKUP_API_DEFAULT;
|
||||
|
||||
@@ -2,24 +2,14 @@ import crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import type {
|
||||
EntitlementProvider,
|
||||
LicenseInfo,
|
||||
LicenseStatus,
|
||||
LicenseTier,
|
||||
LicenseVariant,
|
||||
SeatLimits,
|
||||
} from '../entitlements/types';
|
||||
} from './license-types';
|
||||
import { isLicenseVariant, normalizeVariant } from './license-normalize';
|
||||
|
||||
// Header constants and tier/variant normalizers previously exported
|
||||
// from this file moved to `../entitlements/headers` and
|
||||
// `../entitlements/normalize` respectively, where they live in the
|
||||
// public core regardless of which entitlement provider is bound.
|
||||
// LicenseService imports them back for internal use.
|
||||
import { isLicenseVariant, normalizeVariant } from '../entitlements/normalize';
|
||||
|
||||
// The seat-limit table for paid variants is specific to the
|
||||
// LemonSqueezy implementation and stays in this file. Phase 2 moves it
|
||||
// to `@studio-saelix/sencho-pro` along with the rest of the file.
|
||||
const SEAT_LIMITS: Record<string, SeatLimits> = {
|
||||
skipper: { maxAdmins: 1, maxViewers: 3 },
|
||||
admiral: { maxAdmins: null, maxViewers: null },
|
||||
@@ -143,12 +133,12 @@ export function resolveSenchoVariantFromMeta(
|
||||
const PROXY_HEADERS_CACHE_TTL_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Implements `EntitlementProvider` so the public core can talk to it
|
||||
* via `getEntitlementProvider()` without naming this class directly.
|
||||
* Phase 2 will move this entire file to `@studio-saelix/sencho-pro`,
|
||||
* at which point the public core only sees the interface.
|
||||
* Single in-tree license service. Owns Lemon Squeezy validation and
|
||||
* exposes the tier / variant / seat-limit API consumed across the
|
||||
* backend. See `docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md`
|
||||
* for the conditions that would justify reintroducing an interface seam.
|
||||
*/
|
||||
export class LicenseService implements EntitlementProvider {
|
||||
export class LicenseService {
|
||||
private static instance: LicenseService;
|
||||
private validationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private cachedProxyHeaders: { value: { tier: LicenseTier; variant: LicenseVariant }; expiresAt: number } | null = null;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'openid-client';
|
||||
import { DatabaseService, User, AuthProvider } from './DatabaseService';
|
||||
import { CryptoService } from './CryptoService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
import { LicenseService } from './LicenseService';
|
||||
import { CacheService } from './CacheService';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
@@ -590,7 +590,7 @@ export class SSOService {
|
||||
// Sync role from identity provider on every login
|
||||
if (params.role !== existing.role) {
|
||||
if (params.role === 'admin') {
|
||||
const seatLimits = getEntitlementProvider().getSeatLimits();
|
||||
const seatLimits = LicenseService.getInstance().getSeatLimits();
|
||||
if (seatLimits.maxAdmins === null || db.getAdminCount() < seatLimits.maxAdmins) {
|
||||
updates.role = params.role;
|
||||
} else if (debug) {
|
||||
@@ -613,7 +613,7 @@ export class SSOService {
|
||||
|
||||
// Check seat limits
|
||||
let { role } = params;
|
||||
const seatLimits = getEntitlementProvider().getSeatLimits();
|
||||
const seatLimits = LicenseService.getInstance().getSeatLimits();
|
||||
if (role === 'admin' && seatLimits.maxAdmins !== null && db.getAdminCount() >= seatLimits.maxAdmins) {
|
||||
console.warn(`[SSO] Admin seat limit reached; provisioning ${params.preferredUsername} as viewer instead of admin`);
|
||||
role = 'viewer';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import type { ScheduledTask } from './DatabaseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
import { LicenseService } from './LicenseService';
|
||||
import DockerController from './DockerController';
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
@@ -192,7 +192,7 @@ export class SchedulerService {
|
||||
}
|
||||
await this.maybeRedetectTrivy();
|
||||
|
||||
const ls = getEntitlementProvider();
|
||||
const ls = LicenseService.getInstance();
|
||||
const isPaid = ls.getTier() === 'paid';
|
||||
const isAdmiral = isPaid && ls.getVariant() === 'admiral';
|
||||
if (!isPaid) return;
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
* its remote fleet nodes and asserts the license state via these
|
||||
* headers; the remote node trusts the headers when the request is
|
||||
* authenticated as a node_proxy bearer.
|
||||
*
|
||||
* The header names are part of the wire contract between Sencho
|
||||
* instances and live in the public core so the contract is visible
|
||||
* regardless of which entitlement provider is bound.
|
||||
*/
|
||||
export const PROXY_TIER_HEADER = 'x-sencho-tier';
|
||||
export const PROXY_VARIANT_HEADER = 'x-sencho-variant';
|
||||
@@ -1,33 +1,28 @@
|
||||
import type { LicenseTier, LicenseVariant } from './types';
|
||||
import type { LicenseTier, LicenseVariant } from './license-types';
|
||||
|
||||
/**
|
||||
* Tier and variant guards / normalizers. These are domain knowledge
|
||||
* about Sencho's tier model (which strings are accepted on input, how
|
||||
* legacy names map to current names), not LemonSqueezy implementation
|
||||
* details. They live in the public core so that:
|
||||
* Tier and variant guards / normalizers. Domain knowledge about
|
||||
* Sencho's tier model (which strings are accepted on input, how legacy
|
||||
* names map to current names). Used by:
|
||||
*
|
||||
* - The proxy layer (`auth.ts`, `remoteNodeProxy.ts`) can parse and
|
||||
* validate tier/variant headers from inbound forwarded requests
|
||||
* without depending on the entitlement provider implementation.
|
||||
* - The host-console upgrade handler can decode trusted proxy tier
|
||||
* - The proxy layer (`auth.ts`, `remoteNodeProxy.ts`) to parse and
|
||||
* validate tier/variant headers from inbound forwarded requests.
|
||||
* - The host-console upgrade handler to decode trusted proxy tier
|
||||
* claims attached to bearer tokens.
|
||||
*
|
||||
* Phase 2 deletes `services/LicenseService.ts` but leaves these utility
|
||||
* exports here, untouched.
|
||||
*/
|
||||
|
||||
const VALID_TIERS: readonly string[] = ['community', 'paid'] satisfies readonly LicenseTier[];
|
||||
const VALID_VARIANTS: readonly string[] = ['skipper', 'admiral'] satisfies readonly LicenseVariant[];
|
||||
|
||||
/**
|
||||
* Legacy tier name accepted on input from older versions of Sencho or
|
||||
* older proxy headers; normalized to the current name on read.
|
||||
* Legacy tier name accepted on input from older proxy headers;
|
||||
* normalized to the current name on read.
|
||||
*/
|
||||
const LEGACY_TIER_MAP: Record<string, LicenseTier> = { pro: 'paid' };
|
||||
|
||||
/**
|
||||
* Legacy variant names accepted on input from older versions of Sencho
|
||||
* or older proxy headers; normalized to the current names on read.
|
||||
* Legacy variant names accepted on input from older proxy headers;
|
||||
* normalized to the current names on read.
|
||||
*/
|
||||
const LEGACY_VARIANT_MAP: Record<string, Exclude<LicenseVariant, null>> = {
|
||||
personal: 'skipper',
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Tier / variant types and license result shapes consumed across the
|
||||
* backend. Types live alongside `LicenseService` (their owner) rather
|
||||
* than behind an abstraction layer, since there is a single in-tree
|
||||
* implementation today.
|
||||
*
|
||||
* If a future build needs a second implementation (e.g. a SaaS
|
||||
* entitlement source) re-introduce an explicit interface and adapter.
|
||||
* See `docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md`
|
||||
* for the trigger conditions.
|
||||
*/
|
||||
|
||||
export type LicenseTier = 'community' | 'paid';
|
||||
export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled';
|
||||
export type LicenseVariant = 'skipper' | 'admiral' | null;
|
||||
|
||||
export interface ActivationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DeactivationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BillingPortalResult {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BillingPortalError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface LicenseInfo {
|
||||
tier: LicenseTier;
|
||||
status: LicenseStatus;
|
||||
variant: LicenseVariant;
|
||||
customerName: string | null;
|
||||
productName: string | null;
|
||||
maskedKey: string | null;
|
||||
validUntil: string | null;
|
||||
trialDaysRemaining: number | null;
|
||||
instanceId: string;
|
||||
portalUrl: string | null;
|
||||
isLifetime: boolean;
|
||||
}
|
||||
|
||||
/** Seat limits per variant. null = unlimited. */
|
||||
export interface SeatLimits {
|
||||
maxAdmins: number | null;
|
||||
maxViewers: number | null;
|
||||
}
|
||||
@@ -1,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).
|
||||
|
||||
Vendored
-34
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user