mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
refactor(backend): extract EntitlementProvider abstraction (Phase 1) (#878)
* refactor(backend): extract EntitlementProvider abstraction (Phase 1)
Phase 1 of the open-core hybrid extraction described in
docs/internal/adrs/2026-05-02-open-core-hybrid-strategy.md. Introduces
the abstraction without moving any code out of the public repo; Phase
2 will actually move services/LicenseService.ts to a private
@studio-saelix/sencho-pro package.
The new backend/src/entitlements/ module contains:
- types.ts. The EntitlementProvider interface plus all tier/license
types (LicenseTier, LicenseVariant, LicenseInfo, SeatLimits,
ActivationResult, etc.). The interface mirrors the existing
LicenseService public surface so the migration was mechanical.
- registry.ts. Module-scope holder for the active provider with
setEntitlementProvider, getEntitlementProvider, and a test-only
reset helper. getEntitlementProvider throws if called before
bootstrap registers a provider; the throw is intentional fail-fast
on a bootstrap-order bug rather than a silent degradation.
- CommunityEntitlementProvider.ts. Phase 2 fallback that returns
community tier and rejects activate(). NOT instantiated in
production today; a smoke test keeps it covered against bitrot.
- loadProvider.ts. Async resolver. Phase 1 returns
LicenseService.getInstance() directly. The async signature matches
what Phase 2 needs (dynamic import of @studio-saelix/sencho-pro
with a "module not found" vs "construction threw" narrowing); the
call site does not change between phases.
- headers.ts. PROXY_TIER_HEADER and PROXY_VARIANT_HEADER constants.
These are part of the wire contract between Sencho instances and
belong in the public core regardless of which entitlement provider
is bound.
- normalize.ts. isLicenseTier, isLicenseVariant, normalizeTier,
normalizeVariant. Domain knowledge about Sencho's tier model
(legacy name maps from pre-0.38.1 versions), not LemonSqueezy
internals. Phase 2 keeps these in the public core.
services/LicenseService.ts now imports its types from
entitlements/types and adds an "implements EntitlementProvider"
clause. Re-exports the types for back-compat with ~20 type-only
consumers; a follow-up PR will sweep those imports to entitlements/
types directly before Phase 2 deletes the file.
bootstrap/startup.ts awaits loadEntitlementProvider, registers the
result, then calls initialize. shutdown.ts calls
getEntitlementProvider().destroy() instead of the LicenseService
singleton.
middleware/tierGates.ts, the chokepoint for ~154 tier-check call
sites, now reads through getEntitlementProvider. Sixteen other
production files (routes/{fleet,imageUpdates,license,permissions,
scheduledTasks,security,stacks,templates,users,webhooks},
services/{BlueprintService,CloudBackupService,SchedulerService,
SSOService}, proxy/remoteNodeProxy, websocket/{hostConsole,
remoteForwarder}, middleware/auth) had their LicenseService.getInstance
calls and utility-export imports redirected to the entitlements
module. The only remaining LicenseService.getInstance in production
code is in entitlements/loadProvider.ts itself, which is the
intentional Phase-1 binding site.
Test infrastructure: setupTestDb registers
LicenseService.getInstance() as the active provider so existing
test files using the helper need no changes. The mocking pattern
many tests use, vi.spyOn(LicenseService.getInstance(), 'getTier'),
keeps working because LicenseService.getInstance() and
getEntitlementProvider() return the same singleton in Phase 1.
scheduler-service.test.ts is the only test that does not use
setupTestDb but exercises tier-gating; it now mocks
entitlements/registry alongside its existing LicenseService mock.
Adds a smoke test for CommunityEntitlementProvider so the Phase 2
fallback class stays covered.
Adds an architecture doc at
docs/internal/architecture/entitlement-provider.md covering the
runtime registry, bootstrap order invariants, and the Phase 1 vs
Phase 2 binding table.
Test results: 89/89 backend test files pass, 1657 passing tests, 5
pre-existing skips. The pre-existing database-metrics > handles
1000+ metrics stress test continues to flake under parallel load
and pass when re-run solo, same flake observed in PRs #862, #863.
* chore(backend): drop unused entitlement type imports from LicenseService
Phase 1 of the EntitlementProvider extraction left five type imports
(ActivationResult, BillingPortalError, BillingPortalResult,
DeactivationResult, ValidationResult) unreferenced after the runtime
methods that produced them began inferring their result shapes via the
EntitlementProvider interface contract. ESLint's no-unused-vars rule
flagged them as errors and failed the lint step in CI.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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,6 +54,23 @@ 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
|
||||
// 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."
|
||||
const { LicenseService } = await import('../../services/LicenseService');
|
||||
const { setEntitlementProvider } = await import('../../entitlements/registry');
|
||||
setEntitlementProvider(LicenseService.getInstance());
|
||||
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,16 @@ 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: {
|
||||
getInstance: () => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Server } from 'http';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
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 { LicenseService.getInstance().destroy(); } catch (e) {
|
||||
console.warn('[Shutdown] LicenseService cleanup failed:', (e as Error).message);
|
||||
try { getEntitlementProvider().destroy(); } catch (e) {
|
||||
console.warn('[Shutdown] EntitlementProvider cleanup failed:', (e as Error).message);
|
||||
}
|
||||
try { MonitorService.getInstance().stop(); } catch (e) {
|
||||
console.warn('[Shutdown] MonitorService cleanup failed:', (e as Error).message);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Server } from 'http';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { loadEntitlementProvider } from '../entitlements/loadProvider';
|
||||
import { setEntitlementProvider } from '../entitlements/registry';
|
||||
import SelfUpdateService from '../services/SelfUpdateService';
|
||||
import { MonitorService } from '../services/MonitorService';
|
||||
import { AutoHealService } from '../services/AutoHealService';
|
||||
@@ -31,10 +32,18 @@ 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();
|
||||
|
||||
// Synchronous starts: schedule background timers and continue. None of
|
||||
// these fire their first tick for at least a few seconds, so they
|
||||
// safely run alongside the async initializers below.
|
||||
LicenseService.getInstance().initialize();
|
||||
MonitorService.getInstance().start();
|
||||
AutoHealService.getInstance().start();
|
||||
ImageUpdateService.getInstance().start();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* HTTP header names used for Distributed License Enforcement between
|
||||
* Sencho instances. A primary instance proxies tier-gated requests to
|
||||
* 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';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import type { EntitlementProvider } from './types';
|
||||
|
||||
/**
|
||||
* Resolve the EntitlementProvider implementation for this build.
|
||||
*
|
||||
* Phase 1 (today): the in-tree `LicenseService` (the existing Lemon
|
||||
* Squeezy client) implements `EntitlementProvider`. We return its
|
||||
* singleton directly. No dynamic import, no fallback path.
|
||||
*
|
||||
* Phase 2: this function will switch to a dynamic import that
|
||||
* distinguishes "package not installed" (fall back to Community) from
|
||||
* "package loaded but threw during construction" (re-raise). The
|
||||
* narrowing matters because silently downgrading a paid install to
|
||||
* Community on a load-time bug would be a license-bypass surface.
|
||||
*
|
||||
* The function is async today so the Phase-2 swap doesn't change the
|
||||
* signature; bootstrap already awaits it.
|
||||
*/
|
||||
export async function loadEntitlementProvider(): Promise<EntitlementProvider> {
|
||||
return LicenseService.getInstance();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { LicenseTier, LicenseVariant } from './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:
|
||||
*
|
||||
* - 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
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
const LEGACY_VARIANT_MAP: Record<string, Exclude<LicenseVariant, null>> = {
|
||||
personal: 'skipper',
|
||||
team: 'admiral',
|
||||
};
|
||||
|
||||
/** Check if value is a recognized tier (current or legacy name). */
|
||||
export function isLicenseTier(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
((VALID_TIERS as readonly string[]).includes(value) || value in LEGACY_TIER_MAP)
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if value is a recognized variant (current or legacy name). */
|
||||
export function isLicenseVariant(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
((VALID_VARIANTS as readonly string[]).includes(value) || value in LEGACY_VARIANT_MAP)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a tier value, mapping legacy names to current equivalents.
|
||||
* Must be called after `isLicenseTier` validation.
|
||||
*/
|
||||
export function normalizeTier(value: string): LicenseTier {
|
||||
return LEGACY_TIER_MAP[value] ?? (value as LicenseTier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a variant value, mapping legacy names to current
|
||||
* equivalents. Must be called after `isLicenseVariant` validation.
|
||||
*/
|
||||
export function normalizeVariant(value: string): Exclude<LicenseVariant, null> {
|
||||
return LEGACY_VARIANT_MAP[value] ?? (value as Exclude<LicenseVariant, null>);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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,14 +8,13 @@ import {
|
||||
type ApiTokenScope,
|
||||
} from '../services/DatabaseService';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers';
|
||||
import {
|
||||
isLicenseTier,
|
||||
isLicenseVariant,
|
||||
normalizeTier,
|
||||
normalizeVariant,
|
||||
PROXY_TIER_HEADER,
|
||||
PROXY_VARIANT_HEADER,
|
||||
} from '../services/LicenseService';
|
||||
} from '../entitlements/normalize';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import {
|
||||
COOKIE_NAME,
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { LicenseService, type LicenseTier, type LicenseVariant } from '../services/LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
import type { LicenseTier, LicenseVariant } from '../entitlements/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
|
||||
// return value and `return;` on false.
|
||||
//
|
||||
// Guards trust req.proxyTier/proxyVariant (set by authMiddleware for
|
||||
// node_proxy tokens) ahead of the local LicenseService so a primary Sencho
|
||||
// instance can assert license state for its remote fleet nodes.
|
||||
// node_proxy tokens) ahead of the local entitlement provider so a primary
|
||||
// Sencho instance can assert license state for its remote fleet nodes.
|
||||
|
||||
const PAID_MESSAGE = 'This feature requires a Skipper or Admiral license.';
|
||||
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 ?? LicenseService.getInstance().getTier();
|
||||
req.proxyTier ?? getEntitlementProvider().getTier();
|
||||
|
||||
/** Effective license variant for this request (proxy header if trusted, else local). */
|
||||
export const effectiveVariant = (req: Request): LicenseVariant =>
|
||||
req.proxyVariant ?? LicenseService.getInstance().getVariant();
|
||||
req.proxyVariant ?? getEntitlementProvider().getVariant();
|
||||
|
||||
const deny = (res: Response, code: string, error: string): false => {
|
||||
res.status(403).json({ error, code });
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import {
|
||||
LicenseService,
|
||||
PROXY_TIER_HEADER,
|
||||
PROXY_VARIANT_HEADER,
|
||||
} from '../services/LicenseService';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
import { isProxyExemptPath } from '../helpers/proxyExemptPaths';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
@@ -51,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 = LicenseService.getInstance().getProxyHeaders();
|
||||
const headers = getEntitlementProvider().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
|
||||
|
||||
@@ -26,7 +26,8 @@ import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { CloudBackupService } from '../services/CloudBackupService';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
|
||||
import { LicenseService, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/LicenseService';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
|
||||
const updateTracker = FleetUpdateTrackerService.getInstance();
|
||||
const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -330,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 = LicenseService.getInstance();
|
||||
const ls = getEntitlementProvider();
|
||||
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 { LicenseService } from '../services/LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
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 = LicenseService.getInstance().getTier() === 'paid';
|
||||
const atomic = getEntitlementProvider().getTier() === 'paid';
|
||||
const results: string[] = [];
|
||||
|
||||
for (const stackName of stackNames) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
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 = LicenseService.getInstance().getLicenseInfo();
|
||||
const info = getEntitlementProvider().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 LicenseService.getInstance().activate(license_key.trim());
|
||||
const result = await getEntitlementProvider().activate(license_key.trim());
|
||||
if (result.success) {
|
||||
res.json({ success: true, license: LicenseService.getInstance().getLicenseInfo() });
|
||||
res.json({ success: true, license: getEntitlementProvider().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 LicenseService.getInstance().deactivate();
|
||||
const result = await getEntitlementProvider().deactivate();
|
||||
if (result.success) {
|
||||
res.json({ success: true, license: LicenseService.getInstance().getLicenseInfo() });
|
||||
res.json({ success: true, license: getEntitlementProvider().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 LicenseService.getInstance().validate();
|
||||
res.json({ ...result, license: LicenseService.getInstance().getLicenseInfo() });
|
||||
const result = await getEntitlementProvider().validate();
|
||||
res.json({ ...result, license: getEntitlementProvider().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 LicenseService.getInstance().getBillingPortalUrl();
|
||||
const result = await getEntitlementProvider().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 { LicenseService } from '../services/LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
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: LicenseService.getInstance().getVariant() === 'admiral',
|
||||
isAdmiral: getEntitlementProvider().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 { LicenseService } from '../services/LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
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 = LicenseService.getInstance();
|
||||
const ls = getEntitlementProvider();
|
||||
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 { LicenseService } from '../services/LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
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 ?? LicenseService.getInstance().getTier(),
|
||||
tier: req.proxyTier ?? getEntitlementProvider().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 { LicenseService } from '../services/LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
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 = LicenseService.getInstance().getTier() === 'paid';
|
||||
const atomic = getEntitlementProvider().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 = LicenseService.getInstance().getTier() === 'paid';
|
||||
const rolledBack = getEntitlementProvider().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 = LicenseService.getInstance().getTier() === 'paid';
|
||||
const atomic = getEntitlementProvider().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 = LicenseService.getInstance().getTier() === 'paid';
|
||||
const rolledBack = getEntitlementProvider().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 { LicenseService } from '../services/LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
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 = LicenseService.getInstance().getTier() === 'paid';
|
||||
const atomic = getEntitlementProvider().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 { LicenseService } from '../services/LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
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 = LicenseService.getInstance().getSeatLimits();
|
||||
const seatLimits = getEntitlementProvider().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 { LicenseService } from '../services/LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
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 (LicenseService.getInstance().getTier() !== 'paid') {
|
||||
if (getEntitlementProvider().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 = LicenseService.getInstance().getTier() === 'paid';
|
||||
const atomic = getEntitlementProvider().getTier() === 'paid';
|
||||
svc.execute(id, action, triggerSource, atomic).catch(err => {
|
||||
console.error(`[Webhooks] Execution error for webhook ${id}:`, err);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { LicenseService, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './LicenseService';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
|
||||
const MARKER_FILENAME = '.blueprint.json';
|
||||
const COMPOSE_FILENAME = 'docker-compose.yml';
|
||||
@@ -359,7 +360,7 @@ export class BlueprintService {
|
||||
// ---- remote primitives ----
|
||||
|
||||
private remoteHeaders(apiToken: string): Record<string, string> {
|
||||
const proxy = LicenseService.getInstance().getProxyHeaders();
|
||||
const proxy = getEntitlementProvider().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 { LicenseService } from './LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
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 = LicenseService.getInstance().getVariant();
|
||||
const variant = getEntitlementProvider().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;
|
||||
|
||||
@@ -1,63 +1,33 @@
|
||||
import crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import type {
|
||||
EntitlementProvider,
|
||||
LicenseInfo,
|
||||
LicenseStatus,
|
||||
LicenseTier,
|
||||
LicenseVariant,
|
||||
SeatLimits,
|
||||
} from '../entitlements/types';
|
||||
|
||||
export type LicenseTier = 'community' | 'paid';
|
||||
export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled';
|
||||
// Back-compat re-exports. The canonical type definitions live in
|
||||
// `entitlements/types.ts`; this re-export keeps existing imports
|
||||
// (`import type { LicenseTier } from '@/services/LicenseService'`)
|
||||
// working until consumers migrate. A follow-up PR titled
|
||||
// `refactor(entitlements): migrate type-only consumers to entitlements/types`
|
||||
// will sweep the remaining ~20 consumers; Phase 2 deletes this file.
|
||||
export type { LicenseInfo, LicenseStatus, LicenseTier, LicenseVariant, SeatLimits };
|
||||
|
||||
export type LicenseVariant = 'skipper' | 'admiral' | null;
|
||||
|
||||
const VALID_TIERS: readonly string[] = ['community', 'paid'] satisfies readonly LicenseTier[];
|
||||
const VALID_VARIANTS: readonly string[] = ['skipper', 'admiral'] satisfies readonly LicenseVariant[];
|
||||
|
||||
// Legacy names from pre-0.38.1 versions. Accepted on input and normalized to current names.
|
||||
const LEGACY_TIER_MAP: Record<string, LicenseTier> = { pro: 'paid' };
|
||||
const LEGACY_VARIANT_MAP: Record<string, Exclude<LicenseVariant, null>> = { personal: 'skipper', team: 'admiral' };
|
||||
|
||||
/** Check if value is a recognized tier (current or legacy name). */
|
||||
export function isLicenseTier(value: unknown): value is string {
|
||||
return typeof value === 'string' && ((VALID_TIERS as readonly string[]).includes(value) || value in LEGACY_TIER_MAP);
|
||||
}
|
||||
|
||||
/** Check if value is a recognized variant (current or legacy name). */
|
||||
export function isLicenseVariant(value: unknown): value is string {
|
||||
return typeof value === 'string' && ((VALID_VARIANTS as readonly string[]).includes(value) || value in LEGACY_VARIANT_MAP);
|
||||
}
|
||||
|
||||
/** Normalize a tier value, mapping legacy names to current equivalents. Must be called after isLicenseTier validation. */
|
||||
export function normalizeTier(value: string): LicenseTier {
|
||||
return LEGACY_TIER_MAP[value] ?? (value as LicenseTier);
|
||||
}
|
||||
|
||||
/** Normalize a variant value, mapping legacy names to current equivalents. Must be called after isLicenseVariant validation. */
|
||||
export function normalizeVariant(value: string): Exclude<LicenseVariant, null> {
|
||||
return LEGACY_VARIANT_MAP[value] ?? (value as Exclude<LicenseVariant, null>);
|
||||
}
|
||||
|
||||
/** Header names used for Distributed License Enforcement between nodes. */
|
||||
export const PROXY_TIER_HEADER = 'x-sencho-tier';
|
||||
export const PROXY_VARIANT_HEADER = 'x-sencho-variant';
|
||||
|
||||
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;
|
||||
}
|
||||
// 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';
|
||||
|
||||
// LicenseInfo and SeatLimits live in `entitlements/types.ts` and are
|
||||
// re-exported above. The literal seat-limit table for paid variants
|
||||
// stays here because it is specific to the LemonSqueezy implementation.
|
||||
const SEAT_LIMITS: Record<string, SeatLimits> = {
|
||||
skipper: { maxAdmins: 1, maxViewers: 3 },
|
||||
admiral: { maxAdmins: null, maxViewers: null },
|
||||
@@ -180,7 +150,13 @@ export function resolveSenchoVariantFromMeta(
|
||||
// net against any future bypass rather than a load-bearing freshness bound.
|
||||
const PROXY_HEADERS_CACHE_TTL_MS = 30_000;
|
||||
|
||||
export class LicenseService {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class LicenseService implements EntitlementProvider {
|
||||
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 { LicenseService } from './LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
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 = LicenseService.getInstance().getSeatLimits();
|
||||
const seatLimits = getEntitlementProvider().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 = LicenseService.getInstance().getSeatLimits();
|
||||
const seatLimits = getEntitlementProvider().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 { LicenseService } from './LicenseService';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
import DockerController from './DockerController';
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
@@ -192,7 +192,7 @@ export class SchedulerService {
|
||||
}
|
||||
await this.maybeRedetectTrivy();
|
||||
|
||||
const ls = LicenseService.getInstance();
|
||||
const ls = getEntitlementProvider();
|
||||
const isPaid = ls.getTier() === 'paid';
|
||||
const isAdmiral = isPaid && ls.getVariant() === 'admiral';
|
||||
if (!isPaid) return;
|
||||
|
||||
@@ -5,15 +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 {
|
||||
LicenseService,
|
||||
isLicenseTier,
|
||||
isLicenseVariant,
|
||||
normalizeTier,
|
||||
normalizeVariant,
|
||||
PROXY_TIER_HEADER,
|
||||
PROXY_VARIANT_HEADER,
|
||||
} from '../services/LicenseService';
|
||||
} from '../entitlements/normalize';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
|
||||
import type { UserRole } from '../services/DatabaseService';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
@@ -65,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 = LicenseService.getInstance();
|
||||
const ls = getEntitlementProvider();
|
||||
const consoleTier = (isConsoleSession && isLicenseTier(consoleTierHeader))
|
||||
? normalizeTier(consoleTierHeader)
|
||||
: ls.getTier();
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type { Duplex } from 'stream';
|
||||
import type { Node } from '../services/DatabaseService';
|
||||
import {
|
||||
LicenseService,
|
||||
PROXY_TIER_HEADER,
|
||||
PROXY_VARIANT_HEADER,
|
||||
} from '../services/LicenseService';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../entitlements/headers';
|
||||
import { getEntitlementProvider } from '../entitlements/registry';
|
||||
import { wsProxyServer } from '../proxy/websocketProxy';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { rejectUpgrade as reject } from './reject';
|
||||
@@ -40,7 +37,7 @@ export async function handleRemoteForwarder(
|
||||
let bearerTokenForProxy = node.api_token;
|
||||
if (isInteractiveConsolePath) {
|
||||
try {
|
||||
const consoleHeaders = LicenseService.getInstance().getProxyHeaders();
|
||||
const consoleHeaders = getEntitlementProvider().getProxyHeaders();
|
||||
const tokenRes = await fetch(`${node.api_url.replace(/\/$/, '')}/api/system/console-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -67,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 = LicenseService.getInstance().getProxyHeaders();
|
||||
const fwdHeaders = getEntitlementProvider().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