feat(pricing): collapse to two tiers (#1309)

* feat(pricing): collapse to two tiers (Community + Admiral)

Collapse Sencho's pricing from three tiers (Community / Skipper / Admiral)
to two: a generous free Community tier and a single paid Admiral tier. The
Skipper tier is removed.

Now free in Community: auto-heal, auto-update, scheduled operations,
webhooks, notification routing, Fleet Actions and bulk operations, SSO
preset providers (Google / GitHub / Okta), unlimited users with admin and
viewer roles, and deploy safety (atomic deploys, auto-rollback, and
one-click rollback).

Admiral (paid) is focused on running and governing a fleet: blueprints,
Fleet Secrets, deploy enforcement, vulnerability report export, audit log,
host console, private registries, mesh networking, node cordon, managed
cloud backup, LDAP / Active Directory SSO, and the advanced RBAC roles
(deployer, node-admin, auditor) with per-resource scoped assignments.

Internally the license variant distinction is removed so tier is binary
(community / paid). License validation still verifies the Lemon Squeezy
store and product before granting paid status.

Docs and the contributor guide are updated to the two-tier model.

* docs(pricing): correct licensing page to two-tier pricing and tidy stale tier wording

The licensing docs page kept the old Admiral pricing plus a Founder
Lifetime column and an Enterprise paragraph after the two-tier collapse.
Update it to $12/month or $99/year, drop the lifetime and Enterprise
content, and link to the pricing page for current pricing.

Also fix stale "Skipper" wording in CLA.md, SUPPORT.md, one test title,
and three test comments. Historical CHANGELOG entries and the
retired-Skipper license-guard test are intentionally left as-is.

* docs: align licensing and SSO pages with the two-tier model

Correct the SSO overview so the Google, GitHub, and Okta presets read as
available on every tier, matching the provider table; only LDAP and Active
Directory require Sencho Admiral. Remove the lifetime-plan references from the
licensing, settings, and troubleshooting pages so they reflect subscription-only
Admiral pricing.

* fix(rbac): omit scoped permissions from /me on the Community tier

Scoped role assignments only take effect on the paid tier, but GET /api/permissions/me returned them unconditionally, so a downgraded instance with leftover assignments rendered per-resource affordances the API then rejected with 403. The endpoint now mirrors the permission middleware and includes scoped permissions only on the paid tier. Adds a regression test covering the downgrade case.

* docs: use custom-pricing wording on the contact page

The two-tier model has no Enterprise tier; reword the contact page's enterprise pricing/deals to custom pricing/deals so it does not imply a tier that no longer exists.
This commit is contained in:
Anso
2026-06-04 17:45:53 -04:00
committed by GitHub
parent 7b78cb9cc9
commit 865d792874
187 changed files with 1164 additions and 2437 deletions
+7 -14
View File
@@ -6,7 +6,7 @@ import { ContainerHealthSnapshot } from './DockerEventService';
import { LicenseService } from './LicenseService';
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
import { PROXY_TIER_HEADER } from './license-headers';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
@@ -111,14 +111,12 @@ export class AutoHealService {
}
/**
* From a paid controlling instance, ping each enrolled remote node's auto-heal
* list endpoint so the remote renews its proxy entitlement lease. Without this,
* a Community-tier remote stops evaluating its policies a few minutes after the
* operator last opened the Auto-Heal sheet. Best-effort and per-node isolated:
* a single unreachable node never blocks the others or throws.
* Ping each enrolled remote node's auto-heal list endpoint so the remote
* renews its proxy entitlement lease, keeping its policies evaluating
* between operator visits to the Auto-Heal sheet. Best-effort and per-node
* isolated: a single unreachable node never blocks the others or throws.
*/
private async refreshRemoteLeases(): Promise<void> {
if (LicenseService.getInstance().getTier() !== 'paid') return;
const remotes = DatabaseService.getInstance().getNodes().filter(n => n.type === 'remote');
if (remotes.length === 0) return;
@@ -148,7 +146,6 @@ export class AutoHealService {
headers: {
'Authorization': `Bearer ${target.apiToken}`,
[PROXY_TIER_HEADER]: proxyHeaders.tier,
[PROXY_VARIANT_HEADER]: proxyHeaders.variant ?? '',
},
signal: AbortSignal.timeout(LEASE_REFRESH_TIMEOUT_MS),
});
@@ -186,19 +183,15 @@ export class AutoHealService {
if (this.isProcessing) return;
this.isProcessing = true;
try {
const localPaid = LicenseService.getInstance().getTier() === 'paid';
const db = DatabaseService.getInstance();
// Evaluate only on local nodes (remote nodes self-monitor via their own instance)
const nodes = db.getNodes().filter(n => n.type === 'local');
const now = Date.now();
if (isDebugEnabled()) {
console.log(`[AutoHeal:diag] evaluate: ${nodes.length} local node(s), localPaid=${localPaid}`);
console.log(`[AutoHeal:diag] evaluate: ${nodes.length} local node(s)`);
}
for (const node of nodes) {
const policies = db.getAutoHealPolicies(undefined, node.id).filter(p =>
p.enabled === 1 && (localPaid || p.proxy_entitled_until > now)
);
const policies = db.getAutoHealPolicies(undefined, node.id).filter(p => p.enabled === 1);
this.pruneInactivePolicyHistory(node.id, policies);
if (policies.length === 0) continue;
if (isDebugEnabled()) {
+1 -2
View File
@@ -11,7 +11,7 @@ import {
import { ComposeService } from './ComposeService';
import { FileSystemService } from './FileSystemService';
import { NodeRegistry } from './NodeRegistry';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
import { PROXY_TIER_HEADER } from './license-headers';
import { LicenseService } from './LicenseService';
import { assertPolicyGateAllows, buildSystemPolicyGateOptions, triggerPostDeployScan } from '../helpers/policyGate';
import { enforcePolicyForImageRefs } from './PolicyEnforcement';
@@ -442,7 +442,6 @@ export class BlueprintService {
return {
Authorization: `Bearer ${apiToken}`,
[PROXY_TIER_HEADER]: proxy.tier,
[PROXY_VARIANT_HEADER]: proxy.variant ?? '',
'Content-Type': 'application/json',
};
}
+4 -3
View File
@@ -21,7 +21,7 @@ import { LicenseService } from './LicenseService';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
// Cloud backup is opt-in (Skipper+ feature) and the AWS SDK v3 client pulls
// Cloud backup is opt-in (paid feature) and the AWS SDK v3 client pulls
// in dozens of @smithy/* and @aws-sdk/middleware-* packages, so installs
// without cloud backup configured pay a real boot-parse cost they never use.
// The package is declared as an optionalDependency: present in the default
@@ -185,8 +185,9 @@ 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();
if (variant !== 'admiral') return { success: false, error: 'Sencho Cloud Backup requires the Admiral tier.' };
if (LicenseService.getInstance().getTier() !== 'paid') {
return { success: false, error: 'Sencho Cloud Backup requires the Admiral tier.' };
}
const apiBase = process.env.SENCHO_CLOUD_BACKUP_API || SENCHO_CLOUD_BACKUP_API_DEFAULT;
try {
-8
View File
@@ -2668,14 +2668,6 @@ export class DatabaseService {
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number })?.count || 0;
}
public getViewerCount(): number {
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'viewer'").get() as { count: number })?.count || 0;
}
public getNonAdminCount(): number {
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role != 'admin'").get() as { count: number })?.count || 0;
}
public bumpTokenVersion(userId: number): void {
this.db.prepare('UPDATE users SET token_version = token_version + 1, updated_at = ? WHERE id = ?').run(Date.now(), userId);
}
+30 -168
View File
@@ -5,15 +5,7 @@ import type {
LicenseInfo,
LicenseStatus,
LicenseTier,
LicenseVariant,
SeatLimits,
} from './license-types';
import { isLicenseVariant, normalizeVariant } from './license-normalize';
const SEAT_LIMITS: Record<string, SeatLimits> = {
skipper: { maxAdmins: 1, maxViewers: 3 },
admiral: { maxAdmins: null, maxViewers: null },
};
interface LemonSqueezyActivationResponse {
activated: boolean;
@@ -77,56 +69,36 @@ const VALIDATION_INTERVAL_MS = 72 * 60 * 60 * 1000; // 72 hours
const OFFLINE_GRACE_DAYS = 30;
/**
* Lemon Squeezy catalog identifiers Sencho is willing to honor. Without these
* checks, a license issued for any other LS store or product could activate
* Lemon Squeezy catalog identifiers Sencho is willing to honor. Without this
* check, a license issued for any other LS store or product could activate
* Sencho, because LS's /licenses/validate endpoint returns valid: true for
* any well-formed license key regardless of which product it belongs to.
*
* The validate response contains store_id / product_id / variant_id under
* meta; resolveSenchoVariantFromMeta() rejects any combination not listed
* here. variant_id also serves as the canonical source for tier resolution,
* replacing the older substring match against variant_name / product_name.
*
* If a new tier or billing cadence is added in the LS dashboard, this map
* must be updated in the same release.
* The validate response carries store_id / product_id under meta;
* isSenchoLicenseMeta() rejects any license that is not the Sencho paid
* product. If the paid product changes in the LS dashboard, update this in
* the same release.
*/
export const SENCHO_LS_STORE_ID = 321715;
export const SENCHO_LS_PRODUCT_ID_SKIPPER = 924135;
export const SENCHO_LS_PRODUCT_ID_ADMIRAL = 924153;
const SENCHO_LS_PRODUCT_IDS: ReadonlySet<number> = new Set([
SENCHO_LS_PRODUCT_ID_SKIPPER,
SENCHO_LS_PRODUCT_ID_ADMIRAL,
]);
const SENCHO_LS_VARIANT_TO_TYPE: ReadonlyMap<number, Exclude<LicenseVariant, null>> = new Map([
[1453178, 'skipper'], // Skipper Monthly
[1453197, 'skipper'], // Skipper Annual
[1453198, 'skipper'], // Skipper Lifetime
[1453209, 'admiral'], // Admiral Monthly
[1453212, 'admiral'], // Admiral Annual
[1453217, 'admiral'], // Admiral Lifetime
]);
/**
* Resolve a Lemon Squeezy validate / activate response's meta block to a
* Sencho variant. Returns null when the meta is missing, the store does not
* match, the product is not a Sencho product, or the variant is unknown.
*
* Callers must reject the activation/validation when this returns null.
* Persisting any state from a non-matching response would let foreign LS
* licenses unlock paid features.
* True only when a Lemon Squeezy validate / activate meta block belongs to
* the Sencho paid product. Callers must reject the activation/validation
* when this returns false; persisting state from a non-matching response
* would let a foreign LS license unlock paid features.
*/
export function resolveSenchoVariantFromMeta(
meta: { store_id?: number; product_id?: number; variant_id?: number } | undefined,
): Exclude<LicenseVariant, null> | null {
if (!meta) return null;
if (meta.store_id !== SENCHO_LS_STORE_ID) return null;
if (meta.product_id === undefined || !SENCHO_LS_PRODUCT_IDS.has(meta.product_id)) return null;
if (meta.variant_id === undefined) return null;
return SENCHO_LS_VARIANT_TO_TYPE.get(meta.variant_id) ?? null;
export function isSenchoLicenseMeta(
meta: { store_id?: number; product_id?: number } | undefined,
): boolean {
if (!meta) return false;
if (meta.store_id !== SENCHO_LS_STORE_ID) return false;
if (meta.product_id !== SENCHO_LS_PRODUCT_ID_ADMIRAL) return false;
return true;
}
// Short TTL for the proxy-headers cache. The remote-node proxy reads tier
// and variant on every forwarded request; without caching, each call hits
// Short TTL for the proxy-headers cache. The remote-node proxy reads the
// tier on every forwarded request; without caching, each call hits
// system_state 5+ times. Every license_status write goes through
// setLicenseStatus() which invalidates the cache, so the TTL is a safety
// net against any future bypass rather than a load-bearing freshness bound.
@@ -134,14 +106,14 @@ const PROXY_HEADERS_CACHE_TTL_MS = 30_000;
/**
* 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`
* exposes the tier 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 {
private static instance: LicenseService;
private validationTimer: ReturnType<typeof setInterval> | null = null;
private cachedProxyHeaders: { value: { tier: LicenseTier; variant: LicenseVariant }; expiresAt: number } | null = null;
private cachedProxyHeaders: { value: { tier: LicenseTier }; expiresAt: number } | null = null;
private constructor() { }
@@ -233,110 +205,18 @@ export class LicenseService {
}
/**
* Resolve Lemon Squeezy metadata to the internal variant type from string
* metadata. Used as a fallback when license_variant_id is unavailable.
*
* With the catalog guard in resolveSenchoVariantFromMeta(), every new
* activation stores license_variant_id, so production callers always hit
* the variant_id path in getVariant(). This substring fallback is retained
* for test fixtures that drive getVariant() without a variant_id present.
* Once the per-Directive-20 cleanup branch lands, this method and its two
* call sites can be deleted.
*/
private resolveVariantType(variantName: string, productName?: string): 'skipper' | 'admiral' {
const combined = `${variantName} ${productName || ''}`.toLowerCase();
if (combined.includes('team') || combined.includes('admiral')) return 'admiral';
if (combined.includes('personal') || combined.includes('skipper')) return 'skipper';
return 'skipper';
}
/**
* Persist variant metadata from a Lemon Squeezy response to the DB.
*
* When `resolvedType` is supplied (always, in production paths via
* resolveSenchoVariantFromMeta), it wins over the legacy substring match
* against variant_name / product_name. The substring fallback is kept for
* tests that exercise the legacy path and as a defensive default; new
* activations always carry a resolved type.
*/
private storeVariantMeta(
db: DatabaseService,
meta: { variant_name?: string; variant_id?: number; product_name?: string },
resolvedType?: Exclude<LicenseVariant, null>,
): void {
if (meta.variant_name) {
db.setSystemState('license_variant_name', meta.variant_name);
const type = resolvedType ?? this.resolveVariantType(meta.variant_name, meta.product_name);
db.setSystemState('license_variant_type', type);
}
if (meta.variant_id) {
db.setSystemState('license_variant_id', String(meta.variant_id));
}
}
/**
* Get the license variant (skipper or admiral) from stored metadata.
* Trial and active licenses both resolve via Lemon Squeezy metadata stored by activate();
* trial-granted variant is whatever Lemon Squeezy returned for the trial variant.
*
* Self-healing: on every call, cross-checks the stored variant_type against what
* resolveVariantType() produces from the current product/variant names. If they
* disagree (e.g. stale cache from a previous buggy version), re-resolves and
* persists the corrected value.
*/
public getVariant(): LicenseVariant {
const db = DatabaseService.getInstance();
const variantIdStr = db.getSystemState('license_variant_id');
const storedType = db.getSystemState('license_variant_type');
// Prefer variant_id-based resolution. variant_id is a stable LS catalog
// identifier, while variant_name / product_name are display strings that
// can be edited in the LS dashboard. Activation already rejected any
// unrecognized variant_id, so a hit here is always trustworthy.
if (variantIdStr) {
const variantId = parseInt(variantIdStr, 10);
if (Number.isFinite(variantId)) {
const fromId = SENCHO_LS_VARIANT_TO_TYPE.get(variantId);
if (fromId) {
if (fromId !== storedType) {
db.setSystemState('license_variant_type', fromId);
}
return fromId;
}
}
}
// Fall back to name-based resolution for any state without a
// variant_id (test fixtures, partial DB writes from older code paths).
const variantName = db.getSystemState('license_variant_name');
const productName = db.getSystemState('license_product_name') || undefined;
if (variantName) {
const resolved = this.resolveVariantType(variantName, productName);
if (resolved !== storedType) {
db.setSystemState('license_variant_type', resolved);
}
return resolved;
}
// No source metadata available; trust the stored type if it parses.
if (isLicenseVariant(storedType)) return normalizeVariant(storedType);
return null;
}
/**
* Tier + variant snapshot for the remote-node proxy headers, cached for
* Tier snapshot for the remote-node proxy headers, cached for
* a short window to spare the proxy hot path from re-running getTier()
* and getVariant() on every forwarded request. All license-status writes
* route through setLicenseStatus(), which invalidates this cache, so
* tier changes take effect within one proxy call.
* on every forwarded request. All license-status writes route through
* setLicenseStatus(), which invalidates this cache, so tier changes take
* effect within one proxy call.
*/
public getProxyHeaders(): { tier: LicenseTier; variant: LicenseVariant } {
public getProxyHeaders(): { tier: LicenseTier } {
const now = Date.now();
if (this.cachedProxyHeaders && this.cachedProxyHeaders.expiresAt > now) {
return this.cachedProxyHeaders.value;
}
const value = { tier: this.getTier(), variant: this.getVariant() };
const value = { tier: this.getTier() };
this.cachedProxyHeaders = { value, expiresAt: now + PROXY_HEADERS_CACHE_TTL_MS };
return value;
}
@@ -353,15 +233,6 @@ export class LicenseService {
this.cachedProxyHeaders = null;
}
/**
* Get seat limits for the current license variant.
*/
public getSeatLimits(): SeatLimits {
const variant = this.getVariant();
if (!variant) return { maxAdmins: 1, maxViewers: 0 }; // community
return SEAT_LIMITS[variant] || SEAT_LIMITS.skipper;
}
/**
* Get full license information for the API response.
*/
@@ -384,7 +255,6 @@ export class LicenseService {
return {
tier: this.getTier(),
status,
variant: this.getVariant(),
customerName: db.getSystemState('license_customer_name'),
productName: db.getSystemState('license_product_name'),
maskedKey: key ? `****-****-****-${key.slice(-4)}` : null,
@@ -421,8 +291,7 @@ export class LicenseService {
// Reject licenses that don't belong to the Sencho LS catalog.
// LS's /activate succeeds for any product in any store, so without
// this check a license bought elsewhere could unlock Sencho.
const variantType = resolveSenchoVariantFromMeta(data.meta);
if (variantType === null) {
if (!isSenchoLicenseMeta(data.meta)) {
console.warn('[License] Activation rejected: license does not match the Sencho catalog.');
return { success: false, error: 'This license key is not valid for Sencho.' };
}
@@ -461,9 +330,6 @@ export class LicenseService {
if (data.meta?.product_name) {
db.setSystemState('license_product_name', data.meta.product_name);
}
if (data.meta) {
this.storeVariantMeta(db, data.meta, variantType);
}
if (data.meta?.customer_id) {
db.setSystemState('customer_id', String(data.meta.customer_id));
}
@@ -574,8 +440,7 @@ export class LicenseService {
// entry (e.g. variant_id removed, product moved). Same defense as
// activate(): without this, any LS license can pass periodic
// validation and keep paid features unlocked.
const variantType = resolveSenchoVariantFromMeta(data.meta);
if (variantType === null) {
if (!isSenchoLicenseMeta(data.meta)) {
this.setLicenseStatus('disabled');
console.warn('[License] Validation rejected: license does not match the Sencho catalog.');
return { success: false, error: 'License is not valid for Sencho.' };
@@ -613,9 +478,6 @@ export class LicenseService {
if (data.meta?.product_name) {
db.setSystemState('license_product_name', data.meta.product_name);
}
if (data.meta) {
this.storeVariantMeta(db, data.meta, variantType);
}
if (data.meta?.customer_id && !db.getSystemState('customer_id')) {
db.setSystemState('customer_id', String(data.meta.customer_id));
}
@@ -10,7 +10,7 @@ import { isDebugEnabled } from '../utils/debug';
import { PilotMetrics } from './PilotMetrics';
import type { MeshActivityType } from './MeshService';
import { LicenseService } from './LicenseService';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
import { PROXY_TIER_HEADER } from './license-headers';
/**
* Central-side dialer for proxy-mode mesh tunnels.
@@ -236,14 +236,14 @@ export class MeshProxyTunnelDialer extends EventEmitter {
// the peer falls back to its local DB default (always 1) and treats
// cross-node aliases as same-node.
const wsUrl = httpUrlToWs(target.apiUrl) + `/api/mesh/proxy-tunnel?nodeId=${nodeId}`;
// Forward central's tier and variant so the receiver enforces Admiral
// Forward central's tier so the receiver enforces the paid gate
// against the *central's* license (matching the HTTP mesh routes,
// which all gate on `requireAdmiral` against `req.proxyTier`). Without
// these the receiver falls back to its own local license, which would
// both reject Admiral centrals talking to Community remotes and let
// Community centrals dial locally-Admiral remotes. The headers are
// trusted on the receiver only when the WS carries a node_proxy /
// pilot_tunnel credential (see middleware/auth.ts:117-135).
// which all gate on `requirePaid` against `req.proxyTier`). Without
// this the receiver falls back to its own local license, which would
// both reject paid centrals talking to Community remotes and let
// Community centrals dial locally-paid remotes. The header is trusted
// on the receiver only when the WS carries a node_proxy / pilot_tunnel
// credential (see middleware/auth.ts).
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
let ws: WebSocket;
try {
@@ -251,7 +251,6 @@ export class MeshProxyTunnelDialer extends EventEmitter {
headers: {
Authorization: `Bearer ${target.apiToken}`,
[PROXY_TIER_HEADER]: proxyHeaders.tier,
[PROXY_VARIANT_HEADER]: proxyHeaders.variant || '',
},
handshakeTimeout: HANDSHAKE_TIMEOUT_MS,
maxPayload: MAX_FRAME_SIZE_BYTES,
+4 -5
View File
@@ -8,7 +8,7 @@ import { DatabaseService, type NodeMode } from './DatabaseService';
import DockerController from './DockerController';
import { FileSystemService } from './FileSystemService';
import { LicenseService } from './LicenseService';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
import { PROXY_TIER_HEADER } from './license-headers';
import { MeshForwarder, type MeshForwarderHost } from './MeshForwarder';
import { NodeRegistry } from './NodeRegistry';
import { PilotTunnelManager } from './PilotTunnelManager';
@@ -2232,9 +2232,9 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
/**
* Build a `fetch` against a remote Sencho's API with the bearer token
* and the proxy tier/variant headers in place. Centralizes the header
* shape so a future addition (license header, audit context) only
* needs to land in one place.
* and the proxy tier header in place. Centralizes the header shape so a
* future addition (license header, audit context) only needs to land in
* one place.
*
* `x-node-id` is deliberately NOT set: callers target the remote
* Sencho's own routes, which operate against the remote's local node
@@ -2255,7 +2255,6 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
if (target.apiToken) headers['Authorization'] = `Bearer ${target.apiToken}`;
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
headers[PROXY_TIER_HEADER] = proxyHeaders.tier;
headers[PROXY_VARIANT_HEADER] = proxyHeaders.variant || '';
return await fetch(url, {
method,
headers,
+2 -24
View File
@@ -12,7 +12,6 @@ import {
} from 'openid-client';
import { DatabaseService, User, AuthProvider } from './DatabaseService';
import { CryptoService } from './CryptoService';
import { LicenseService } from './LicenseService';
import { CacheService } from './CacheService';
import { isDebugEnabled } from '../utils/debug';
@@ -589,19 +588,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();
if (seatLimits.maxAdmins === null || db.getAdminCount() < seatLimits.maxAdmins) {
updates.role = params.role;
} else if (debug) {
console.debug('[SSO:debug] Admin seat limit reached; keeping current role for existing user', {
userId: existing.id, username: existing.username,
});
}
} else {
// Always allow demotion (e.g., removed from admin group)
updates.role = params.role;
}
updates.role = params.role;
}
if (Object.keys(updates).length > 0) {
@@ -611,16 +598,7 @@ export class SSOService {
return db.getUser(existing.id) || existing;
}
// Check seat limits
let { role } = params;
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';
}
if (role === 'viewer' && seatLimits.maxViewers !== null && db.getViewerCount() >= seatLimits.maxViewers) {
throw new Error('User seat limit reached. Contact your administrator to increase your license.');
}
const { role } = params;
// Generate unique username
let username = params.preferredUsername.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 50);
+5 -29
View File
@@ -2,7 +2,7 @@ import { CronExpressionParser } from 'cron-parser';
import { DatabaseService } from './DatabaseService';
import type { ScheduledTask } from './DatabaseService';
import { LicenseService } from './LicenseService';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
import { PROXY_TIER_HEADER } from './license-headers';
import DockerController from './DockerController';
import { ComposeService } from './ComposeService';
import { FileSystemService } from './FileSystemService';
@@ -215,8 +215,7 @@ export class SchedulerService {
try {
const db = DatabaseService.getInstance();
// Vulnerability scanning is available on every tier, so the stale-scan sweep
// and Trivy re-detect run before the paid-tier gate below.
// Sweep stale vulnerability scans and re-detect Trivy on every tick.
try {
const staleScans = db.markStaleScansAsFailed(STALE_SCAN_THRESHOLD_MS);
if (staleScans > 0) {
@@ -229,9 +228,6 @@ export class SchedulerService {
}
await this.maybeRedetectTrivy();
const ls = LicenseService.getInstance();
if (ls.getTier() !== 'paid') return;
const now = Date.now();
const dueTasks = db.getDueScheduledTasks(now);
@@ -291,21 +287,6 @@ export class SchedulerService {
triggered_by: triggeredBy,
});
// Defense in depth: every entry point that reaches here is already paid-gated
// (the route's requirePaid and the tick's tier check), but guard again so a
// task can never run on an unpaid licence regardless of the caller. Record the
// skip as a failed run so a manual trigger (which already returned 202 to the
// operator) shows in run history rather than vanishing silently.
if (LicenseService.getInstance().getTier() !== 'paid') {
console.warn(`[SchedulerService] Skipping task "${task.name}" (id=${task.id}): licence is not paid`);
db.updateScheduledTaskRun(runId, {
completed_at: Date.now(),
status: 'failure',
error: 'Scheduled tasks require a paid licence; task was not run.',
});
return;
}
try {
// Pre-check: ensure target node exists and is reachable
if (task.node_id != null && task.action !== 'snapshot') {
@@ -751,7 +732,6 @@ export class SchedulerService {
'Content-Type': 'application/json',
'Authorization': `Bearer ${proxyTarget.apiToken}`,
[PROXY_TIER_HEADER]: proxyHeaders.tier,
[PROXY_VARIANT_HEADER]: proxyHeaders.variant ?? '',
},
body: JSON.stringify({ target }),
signal: AbortSignal.timeout(300_000), // 5 minute timeout for long updates
@@ -793,7 +773,6 @@ export class SchedulerService {
'Content-Type': 'application/json',
'Authorization': `Bearer ${proxyTarget.apiToken}`,
[PROXY_TIER_HEADER]: proxyHeaders.tier,
[PROXY_VARIANT_HEADER]: proxyHeaders.variant ?? '',
},
signal: AbortSignal.timeout(300_000),
});
@@ -871,12 +850,9 @@ export class SchedulerService {
'Auto-update',
`/api/scheduled-tasks/auto-update/${stackName}`,
);
// Atomic backup/rollback is a paid capability. Every path that reaches
// this method is already paid-gated (the scheduler tick and the manual
// run route both require a paid licence), but the flag is resolved from
// the licence here so the tier intent is explicit at the call site and
// survives any future refactor that introduces another caller.
const atomic = LicenseService.getInstance().getTier() === 'paid';
// Atomic backup/rollback is the default deploy mode: take a pre-op
// backup and roll back on failure for every scheduled auto-update.
const atomic = true;
await compose.updateStack(stackName, undefined, atomic);
db.clearStackUpdateStatus(nodeId, stackName);
+7 -8
View File
@@ -4,7 +4,7 @@ import { DatabaseService, type Webhook } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { GitSourceService } from './GitSourceService';
import { LicenseService } from './LicenseService';
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
import { PROXY_TIER_HEADER } from './license-headers';
import { NodeRegistry } from './NodeRegistry';
import { getErrorMessage } from '../utils/errors';
import { redactSensitiveText } from '../utils/safeLog';
@@ -19,10 +19,10 @@ const REMOTE_WEBHOOK_REQUEST_TIMEOUT_MS = 30_000;
export class WebhookService {
private static instance: WebhookService;
// Stable per-process decoy secret used to keep HMAC work non-skippable on
// reject paths (unknown webhook id, disabled, non-paid tier, etc.). Never
// accepts a signature: the trigger handler decides the final 202 / 404
// outcome from independent conditions and only consults the HMAC result
// when every other check has already passed.
// reject paths (unknown webhook id, disabled, etc.). Never accepts a
// signature: the trigger handler decides the final 202 / 404 outcome from
// independent conditions and only consults the HMAC result when every
// other check has already passed.
private static decoySecret: string | null = null;
public static getInstance(): WebhookService {
@@ -52,7 +52,7 @@ export class WebhookService {
// wrong-secret case through repeated near-rate-limit probes with a
// large attacker-controlled body. Timing now depends only on the
// size of `payload`, which the attacker already controls and which
// does not reveal anything about the webhook id or licence tier.
// does not reveal anything about the webhook id.
const expected = crypto.createHmac('sha256', secret).update(payload).digest();
const provided = Buffer.alloc(32);
let formatOk = false;
@@ -252,7 +252,6 @@ export class WebhookService {
const licenseHeaders = LicenseService.getInstance().getProxyHeaders();
headers[PROXY_TIER_HEADER] = licenseHeaders.tier;
headers[PROXY_VARIANT_HEADER] = licenseHeaders.variant || '';
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REMOTE_WEBHOOK_REQUEST_TIMEOUT_MS);
@@ -311,7 +310,7 @@ export class WebhookService {
durationMs: number,
error: string | null,
): void {
// Execution history is readable by any paid user; scrub bearer tokens,
// Execution history is readable in the UI; scrub bearer tokens,
// JWTs, URL credentials, and homedir paths before persisting so a
// compose / remote-node error surfacing on the dashboard cannot leak
// operator secrets or infrastructure details.
-1
View File
@@ -6,4 +6,3 @@
* authenticated as a node_proxy bearer.
*/
export const PROXY_TIER_HEADER = 'x-sencho-tier';
export const PROXY_VARIANT_HEADER = 'x-sencho-variant';
+5 -31
View File
@@ -1,18 +1,17 @@
import type { LicenseTier, LicenseVariant } from './license-types';
import type { LicenseTier } from './license-types';
/**
* 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:
* Tier guards / normalizers. Domain knowledge about Sencho's tier model
* (which strings are accepted on input, how the legacy name maps to the
* current name). Used by:
*
* - The proxy layer (`auth.ts`, `remoteNodeProxy.ts`) to parse and
* validate tier/variant headers from inbound forwarded requests.
* validate the tier header from inbound forwarded requests.
* - The host-console upgrade handler to decode trusted proxy tier
* claims attached to bearer tokens.
*/
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 proxy headers;
@@ -20,15 +19,6 @@ const VALID_VARIANTS: readonly string[] = ['skipper', 'admiral'] satisfies reado
*/
const LEGACY_TIER_MAP: Record<string, LicenseTier> = { pro: 'paid' };
/**
* 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',
team: 'admiral',
};
/** Check if value is a recognized tier (current or legacy name). */
export function isLicenseTier(value: unknown): value is string {
return (
@@ -37,14 +27,6 @@ export function isLicenseTier(value: unknown): value is string {
);
}
/** 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.
@@ -52,11 +34,3 @@ export function isLicenseVariant(value: unknown): value is string {
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>);
}
-8
View File
@@ -12,7 +12,6 @@
export type LicenseTier = 'community' | 'paid';
export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled';
export type LicenseVariant = 'skipper' | 'admiral' | null;
export interface ActivationResult {
success: boolean;
@@ -40,7 +39,6 @@ export interface BillingPortalError {
export interface LicenseInfo {
tier: LicenseTier;
status: LicenseStatus;
variant: LicenseVariant;
customerName: string | null;
productName: string | null;
maskedKey: string | null;
@@ -50,9 +48,3 @@ export interface LicenseInfo {
portalUrl: string | null;
isLifetime: boolean;
}
/** Seat limits per variant. null = unlimited. */
export interface SeatLimits {
maxAdmins: number | null;
maxViewers: number | null;
}