mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
3324616e59
* 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.
116 lines
4.4 KiB
TypeScript
116 lines
4.4 KiB
TypeScript
import type { IncomingMessage } from 'http';
|
|
import type { Duplex } from 'stream';
|
|
import WebSocket, { WebSocketServer } from 'ws';
|
|
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 {
|
|
isLicenseTier,
|
|
isLicenseVariant,
|
|
normalizeTier,
|
|
normalizeVariant,
|
|
} 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';
|
|
import { rejectUpgrade as reject } from './reject';
|
|
|
|
interface HostConsoleContext {
|
|
nodeId: number;
|
|
decoded: { scope?: string; username?: string };
|
|
isProxyToken: boolean;
|
|
wsResolvedUser: { username: string; role: UserRole; token_version: number } | undefined;
|
|
stackParam: string | null;
|
|
}
|
|
|
|
/**
|
|
* Handle `/api/system/host-console` WebSocket upgrades.
|
|
*
|
|
* Enforces three gates before spawning the host PTY:
|
|
* 1. Machine-credential rejection: node_proxy tokens cannot reach an
|
|
* interactive host shell.
|
|
* 2. RBAC: user session tokens require the `system:console` permission.
|
|
* console_session tokens are pre-gated at issuance (see
|
|
* `routes/console.ts`) and skip this check.
|
|
* 3. License: host console requires paid + admiral. For console_session
|
|
* tokens the tier/variant is trusted from the gateway-supplied headers;
|
|
* otherwise the local LicenseService is consulted.
|
|
*/
|
|
export function handleHostConsoleWs(
|
|
req: IncomingMessage,
|
|
socket: Duplex,
|
|
head: Buffer,
|
|
ctx: HostConsoleContext,
|
|
): void {
|
|
const { nodeId, decoded, isProxyToken, wsResolvedUser, stackParam } = ctx;
|
|
|
|
if (isProxyToken) return reject(socket, 403, 'Forbidden');
|
|
|
|
const isConsoleSession = decoded.scope === 'console_session';
|
|
if (!isConsoleSession) {
|
|
const userRole = wsResolvedUser?.role;
|
|
const consolePermission: PermissionAction = 'system:console';
|
|
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes(consolePermission)) {
|
|
console.log('[HostConsole] Access denied: insufficient permissions', {
|
|
username: wsResolvedUser?.username || decoded.username,
|
|
role: userRole,
|
|
});
|
|
return reject(socket, 403, 'Forbidden');
|
|
}
|
|
}
|
|
|
|
const consoleTierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
|
|
const consoleVariantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
|
|
const ls = getEntitlementProvider();
|
|
const consoleTier = (isConsoleSession && isLicenseTier(consoleTierHeader))
|
|
? normalizeTier(consoleTierHeader)
|
|
: ls.getTier();
|
|
const consoleVariant = (isConsoleSession && consoleVariantHeader !== undefined && isLicenseVariant(consoleVariantHeader))
|
|
? normalizeVariant(consoleVariantHeader)
|
|
: ls.getVariant();
|
|
if (consoleTier !== 'paid' || consoleVariant !== 'admiral') {
|
|
return reject(socket, 403, 'Forbidden');
|
|
}
|
|
|
|
const consoleUsername = wsResolvedUser?.username || decoded.username || 'console_session';
|
|
console.log('[HostConsole] WebSocket upgrade accepted', {
|
|
username: consoleUsername,
|
|
nodeId,
|
|
stack: stackParam || '(root)',
|
|
});
|
|
|
|
const hostConsoleWss = new WebSocketServer({ noServer: true });
|
|
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
|
|
hostConsoleWss.close();
|
|
let targetDirectory = '';
|
|
try {
|
|
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
|
|
if (stackParam) {
|
|
const resolved = path.resolve(baseDir, stackParam);
|
|
if (!resolved.startsWith(path.resolve(baseDir))) {
|
|
ws.send('Error: Invalid stack path\r\n');
|
|
ws.close();
|
|
return;
|
|
}
|
|
targetDirectory = resolved;
|
|
} else {
|
|
targetDirectory = baseDir;
|
|
}
|
|
} catch {
|
|
targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir();
|
|
}
|
|
try {
|
|
HostTerminalService.spawnTerminal(ws, targetDirectory, consoleUsername);
|
|
} catch (error) {
|
|
console.error('[HostConsole] Unhandled spawn error:', { user: consoleUsername, error: getErrorMessage(error, 'unknown') });
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send('Error: Failed to start terminal session.\r\n');
|
|
ws.close();
|
|
}
|
|
}
|
|
});
|
|
}
|