mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
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:
@@ -7,7 +7,7 @@ In order to clarify the intellectual property license granted with Contributions
|
||||
By signing this CLA (which is handled automatically on your first Pull Request via a GitHub comment), you agree to the following terms:
|
||||
|
||||
1. **Grant of License:** You grant Sencho and its maintainers a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
|
||||
2. **Commercial Use:** You understand and agree that your contributions may be used in commercial products and services, including those locked behind tier gates (Skipper, Admiral), and you grant the project the right to do so under the project's licensing model (Business Source License 1.1 / Apache 2.0).
|
||||
2. **Commercial Use:** You understand and agree that your contributions may be used in commercial products and services, including those locked behind tier gates (Admiral), and you grant the project the right to do so under the project's licensing model (Business Source License 1.1 / Apache 2.0).
|
||||
3. **Original Work:** You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer.
|
||||
|
||||
This agreement does NOT transfer ownership of your code. You retain ownership of the Copyright in Your Contributions and have the same rights to use or license the Contributions which you would have had without entering into this Agreement.
|
||||
|
||||
+3
-4
@@ -51,20 +51,19 @@ The project uses `strict: true`. Write code that compiles without `any` casts or
|
||||
|
||||
## Tier-Gated Features
|
||||
|
||||
Sencho has three tiers: Community, Skipper, and Admiral. We welcome contributions to all tiers! Often, enterprise users will contribute features they need for their own infrastructure.
|
||||
Sencho has two tiers: Community and Admiral. We welcome contributions to both! Often, enterprise users will contribute features they need for their own infrastructure.
|
||||
|
||||
If your change adds a feature that belongs behind a tier gate, use the guards from `backend/src/middleware/tierGates.ts`:
|
||||
|
||||
```typescript
|
||||
if (!requirePaid(req, res)) return; // Skipper and above
|
||||
if (!requireAdmiral(req, res)) return; // Admiral only
|
||||
if (!requirePaid(req, res)) return; // Admiral (paid) only
|
||||
```
|
||||
|
||||
Call the guard at the top of the route handler with an early return. Both guards handle proxy-forwarded tier headers automatically.
|
||||
|
||||
**Note on Tiers and Monetization:**
|
||||
- **Community Tier:** If you contribute a feature to the free/Community tier, it stays in the Community tier. We will never take your community contribution and move it behind a paywall.
|
||||
- **Commercial Tiers:** By contributing to a Skipper or Admiral feature, you acknowledge that your code will be part of Sencho's commercial offering.
|
||||
- **Commercial Tier:** By contributing to an Admiral feature, you acknowledge that your code will be part of Sencho's commercial offering.
|
||||
|
||||
Before writing code for a new gated feature, please open an issue to discuss it with the maintainers. You will also be required to sign our Contributor License Agreement (CLA) when you open your first Pull Request.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ It runs as a single container on your hardware and gives you a UI for the work y
|
||||
|
||||
A Sencho instance is autonomous. To manage another machine, you install a second Sencho on it and connect them with a long-lived API token; the primary dashboard then acts as an authenticated HTTP and WebSocket proxy across your fleet. Use TLS, a VPN, or a private network for any untrusted link. Each node still uses its local Docker socket (see Quick start), but Sencho does not require SSH and does not expose a remote Docker socket on the network. For nodes behind NAT or strict firewalls, the Pilot Agent establishes a single outbound WebSocket tunnel to the primary, so the remote host opens no inbound port at all.
|
||||
|
||||
Most capabilities are free in the Community tier. A few advanced automation and fleet-control features ship in paid tiers; pricing lives at [sencho.io/pricing](https://sencho.io/pricing).
|
||||
Most capabilities are free in the Community tier. A few advanced governance, security, and fleet-control features ship in the paid Admiral tier; pricing lives at [sencho.io/pricing](https://sencho.io/pricing).
|
||||
|
||||
## What Sencho is not (yet)
|
||||
|
||||
@@ -55,7 +55,7 @@ See [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md) for the current limitation list
|
||||
|
||||
---
|
||||
|
||||
**Tier coverage:** All bullets below are available in the free Community tier unless marked with `(Skipper)` for paid mid-tier or `(Admiral)` for paid top-tier. Full breakdown at [sencho.io/pricing](https://sencho.io/pricing).
|
||||
**Tier coverage:** All bullets below are available in the free Community tier unless marked `(Admiral)`. Full breakdown at [sencho.io/pricing](https://sencho.io/pricing).
|
||||
|
||||
## Capabilities
|
||||
|
||||
@@ -82,19 +82,19 @@ See [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md) for the current limitation list
|
||||
- Node compatibility checks before deploying
|
||||
|
||||
### Automation
|
||||
- [Auto-heal policies](https://docs.sencho.io/features/auto-heal-policies) for failed containers **(Skipper)**
|
||||
- [Auto-update policies](https://docs.sencho.io/features/auto-update-policies) for image rollouts **(Skipper)**
|
||||
- [Scheduled operations](https://docs.sencho.io/features/scheduled-operations) on cron **(Skipper)**
|
||||
- [Blueprints](https://docs.sencho.io/features/blueprint-model): declarative fleet templates with drift detection **(Skipper)**
|
||||
- [Webhooks](https://docs.sencho.io/features/webhooks) on stack lifecycle events **(Skipper)**
|
||||
- Encrypted [Fleet Secrets](https://docs.sencho.io/features/fleet-secrets) pushed to labeled nodes **(Skipper)**
|
||||
- [Auto-heal policies](https://docs.sencho.io/features/auto-heal-policies) for failed containers
|
||||
- [Auto-update policies](https://docs.sencho.io/features/auto-update-policies) for image rollouts
|
||||
- [Scheduled operations](https://docs.sencho.io/features/scheduled-operations) on cron
|
||||
- [Webhooks](https://docs.sencho.io/features/webhooks) on stack lifecycle events
|
||||
- [Blueprints](https://docs.sencho.io/features/blueprint-model): declarative fleet templates with drift detection **(Admiral)**
|
||||
- Encrypted [Fleet Secrets](https://docs.sencho.io/features/fleet-secrets) pushed to labeled nodes **(Admiral)**
|
||||
|
||||
### Security
|
||||
- [SSO](https://docs.sencho.io/features/sso): custom OIDC, presets for Google, GitHub, and Okta, plus LDAP and Active Directory
|
||||
- [Two-factor authentication](https://docs.sencho.io/features/two-factor-authentication) with TOTP and backup codes
|
||||
- [RBAC](https://docs.sencho.io/features/rbac) with five roles: admin (full control), viewer (read-only), deployer (deploy and restart, no edits), node-admin (admin scoped to specific nodes), and auditor (read-only with audit-log access)
|
||||
- [Vulnerability scanning](https://docs.sencho.io/features/vulnerability-scanning) via Trivy on every tier with VEX-based suppression; SARIF export and SBOM upload **(Skipper)**
|
||||
- [Private registries](https://docs.sencho.io/features/private-registries) **(Admiral)** and [deploy enforcement](https://docs.sencho.io/features/deploy-enforcement) **(Skipper)** for non-compliant images
|
||||
- [RBAC](https://docs.sencho.io/features/rbac) with admin (full control) and viewer (read-only) roles; deployer, node-admin, and auditor roles plus scoped permissions **(Admiral)**
|
||||
- [Vulnerability scanning](https://docs.sencho.io/features/vulnerability-scanning) via Trivy on every tier with VEX-based suppression; SARIF export and SBOM upload **(Admiral)**
|
||||
- [Private registries](https://docs.sencho.io/features/private-registries) and [deploy enforcement](https://docs.sencho.io/features/deploy-enforcement) for non-compliant images **(Admiral)**
|
||||
- [API tokens](https://docs.sencho.io/features/api-tokens) for automation
|
||||
|
||||
### Operations
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ Sencho is built by a single maintainer. Volunteer responses target:
|
||||
- Feature requests: triaged but not always replied to individually; check the roadmap
|
||||
- Discussions questions: community-first; the maintainer joins when possible
|
||||
|
||||
There is no SLA. Paid tiers (Skipper, Admiral) get priority for licensing-related issues through the email above. A formal paid-support tier may follow 1.0.
|
||||
There is no SLA. The paid Admiral tier gets priority for licensing-related issues through the email above. A formal paid-support tier may follow 1.0.
|
||||
|
||||
## What is in scope
|
||||
|
||||
|
||||
@@ -64,21 +64,10 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe('AutoHealService.evaluate', () => {
|
||||
it('does not evaluate existing policies on Community tier', async () => {
|
||||
it('evaluates existing policies on the Community tier (no paid gate)', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
makePolicy(db);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const dockerSpy = vi.spyOn(DockerController, 'getInstance');
|
||||
|
||||
await resetAutoHealSingleton().evaluate();
|
||||
|
||||
expect(dockerSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('evaluates trusted proxy-entitled policies on a Community runtime node', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
makePolicy(db, { proxy_entitled_until: Date.now() + 60_000 });
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const getAllContainers = vi.fn().mockResolvedValue([]);
|
||||
const getInstance = vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getAllContainers,
|
||||
@@ -411,9 +400,7 @@ describe('AutoHealService.evaluate', () => {
|
||||
api_token: 'tok',
|
||||
});
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getProxyHeaders').mockReturnValue(
|
||||
{ tier: 'paid', variant: 'admiral' } as ReturnType<ReturnType<typeof LicenseService.getInstance>['getProxyHeaders']>,
|
||||
);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getProxyHeaders').mockReturnValue({ tier: 'paid' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'http://remote:1852',
|
||||
apiToken: 'tok',
|
||||
@@ -429,8 +416,16 @@ describe('AutoHealService.evaluate', () => {
|
||||
expect((opts as RequestInit).headers).toMatchObject({ 'x-sencho-tier': 'paid' });
|
||||
});
|
||||
|
||||
it('does not refresh remote leases when the controlling instance is not paid', async () => {
|
||||
it('forwards the controlling instance tier header when it is community', async () => {
|
||||
// The lease refresh is not self-gated on the local tier: it always runs and
|
||||
// forwards the controlling instance's tier so the remote runtime decides
|
||||
// entitlement from the trusted header.
|
||||
const db = DatabaseService.getInstance();
|
||||
// Start from a clean remote set so the single-node fetch count is deterministic
|
||||
// (the shared beforeEach clears policies but not nodes).
|
||||
for (const n of db.getNodes().filter(n => n.type === 'remote')) {
|
||||
if (n.id !== undefined) db.deleteNode(n.id);
|
||||
}
|
||||
db.addNode({
|
||||
name: 'lease-remote-community',
|
||||
type: 'remote',
|
||||
@@ -440,12 +435,19 @@ describe('AutoHealService.evaluate', () => {
|
||||
api_token: 'tok2',
|
||||
});
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getProxyHeaders').mockReturnValue({ tier: 'community' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'http://remote2:1852',
|
||||
apiToken: 'tok2',
|
||||
});
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response);
|
||||
|
||||
const service = resetAutoHealSingleton();
|
||||
await (service as unknown as { refreshRemoteLeases: () => Promise<void> }).refreshRemoteLeases();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [, opts] = fetchSpy.mock.calls[0];
|
||||
expect((opts as RequestInit).headers).toMatchObject({ 'x-sencho-tier': 'community' });
|
||||
});
|
||||
|
||||
it('keeps refreshing other remotes when one node is unreachable', async () => {
|
||||
@@ -467,9 +469,7 @@ describe('AutoHealService.evaluate', () => {
|
||||
api_token: 'tok',
|
||||
});
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getProxyHeaders').mockReturnValue(
|
||||
{ tier: 'paid', variant: 'admiral' } as ReturnType<ReturnType<typeof LicenseService.getInstance>['getProxyHeaders']>,
|
||||
);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getProxyHeaders').mockReturnValue({ tier: 'paid' });
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockImplementation(((input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.includes('bad-host')) return Promise.reject(new Error('ECONNREFUSED'));
|
||||
@@ -498,9 +498,7 @@ describe('AutoHealService.evaluate', () => {
|
||||
api_token: '',
|
||||
});
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getProxyHeaders').mockReturnValue(
|
||||
{ tier: 'paid', variant: 'admiral' } as ReturnType<ReturnType<typeof LicenseService.getInstance>['getProxyHeaders']>,
|
||||
);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getProxyHeaders').mockReturnValue({ tier: 'paid' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue(null);
|
||||
const fetchSpy = vi.spyOn(global, 'fetch');
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
@@ -19,8 +19,6 @@ beforeAll(async () => {
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
@@ -17,11 +17,9 @@ beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
// Mock LicenseService so Admiral-gated routes are accessible
|
||||
// Mock LicenseService so paid-gated routes are accessible
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
|
||||
@@ -42,8 +42,6 @@ beforeAll(async () => {
|
||||
// Suite runs at Community tier to prove token routes work without a paid license.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(null);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* race a concurrent deploy/update on the same stack (and vice versa).
|
||||
* - Rollback releases the lock after both success and failure.
|
||||
* - Rollback dispatches a notification on success/failure.
|
||||
* - The backup-metadata read and the rollback action are paid-gated, matching
|
||||
* the frontend, which only fetches backup state on a licensed instance.
|
||||
* - The backup-metadata read and the rollback action are available on every
|
||||
* tier (deploy safety is free).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -238,20 +238,22 @@ describe('Developer Mode logging matrix', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tier gating parity (M-3)', () => {
|
||||
it('rejects rollback on community with PAID_REQUIRED', async () => {
|
||||
describe('Deploy safety is available on every tier', () => {
|
||||
it('allows rollback on community', async () => {
|
||||
mockTier('community');
|
||||
mockGetBackupInfo.mockResolvedValue({ exists: true, timestamp: 1700000000000 });
|
||||
mockDeployStack.mockResolvedValue(undefined);
|
||||
const res = await request(app).post('/api/stacks/web/rollback').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(mockDeployStack).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockDeployStack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects GET /backup on community with PAID_REQUIRED', async () => {
|
||||
it('returns backup metadata on community', async () => {
|
||||
mockTier('community');
|
||||
mockGetBackupInfo.mockResolvedValue({ exists: true, timestamp: 1700000000000 });
|
||||
const res = await request(app).get('/api/stacks/web/backup').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ exists: true, timestamp: 1700000000000 });
|
||||
});
|
||||
|
||||
it('returns backup metadata on paid', async () => {
|
||||
|
||||
@@ -32,11 +32,9 @@ beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
// Mock LicenseService to return paid/admiral for audit log access
|
||||
// Mock LicenseService to return the paid tier for audit log access
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
@@ -414,10 +412,9 @@ describe('DatabaseService audit methods', () => {
|
||||
// ---- API endpoint tests ----
|
||||
|
||||
describe('GET /api/audit-log', () => {
|
||||
it('returns 403 without Admiral license', async () => {
|
||||
it('returns 403 without a paid license', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValueOnce(null);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log')
|
||||
@@ -567,10 +564,9 @@ describe('GET /api/audit-log', () => {
|
||||
});
|
||||
|
||||
describe('GET /api/audit-log/stats', () => {
|
||||
it('returns 403 without Admiral license', async () => {
|
||||
it('returns 403 without a paid license', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValueOnce(null);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/stats')
|
||||
@@ -595,10 +591,9 @@ describe('GET /api/audit-log/stats', () => {
|
||||
});
|
||||
|
||||
describe('GET /api/audit-log/export', () => {
|
||||
it('returns 403 without Admiral license', async () => {
|
||||
it('returns 403 without a paid license', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValueOnce(null);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/audit-log/export?format=json')
|
||||
|
||||
@@ -96,11 +96,10 @@ describe('authMiddleware', () => {
|
||||
// ─── Protected endpoint: console-token ───────────────────────────────────────
|
||||
|
||||
describe('POST /api/system/console-token', () => {
|
||||
// Console-token requires Admiral tier — mock LicenseService for the happy-path test
|
||||
// Console-token requires the paid tier — mock LicenseService for the happy-path test
|
||||
beforeAll(async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import crypto from 'crypto';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb';
|
||||
import { generateApiToken } from '../utils/apiTokenFormat';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
@@ -58,7 +58,6 @@ beforeAll(async () => {
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
|
||||
const viewerHash = await bcrypt.hash('password123', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'route-viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
@@ -70,7 +69,6 @@ beforeEach(() => {
|
||||
DatabaseService.getInstance().getDb().prepare('DELETE FROM auto_heal_history').run();
|
||||
DatabaseService.getInstance().getDb().prepare('DELETE FROM auto_heal_policies').run();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -79,15 +77,15 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe('/api/auto-heal routes', () => {
|
||||
it('rejects Community tier access', async () => {
|
||||
it('allows Community tier access', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/auto-heal/policies')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('marks trusted proxy-created policies with a lease on a Community runtime node', async () => {
|
||||
@@ -98,7 +96,6 @@ describe('/api/auto-heal routes', () => {
|
||||
.post('/api/auto-heal/policies')
|
||||
.set('Authorization', `Bearer ${proxyToken}`)
|
||||
.set(PROXY_TIER_HEADER, 'paid')
|
||||
.set(PROXY_VARIANT_HEADER, 'admiral')
|
||||
.send({
|
||||
stack_name: 'proxy-runtime-stack',
|
||||
unhealthy_duration_mins: 5,
|
||||
@@ -108,7 +105,7 @@ describe('/api/auto-heal routes', () => {
|
||||
expect(res.body.proxy_entitled_until).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('allows admins to create node-scoped policies on paid tier', async () => {
|
||||
it('allows admins to create node-scoped policies', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-heal/policies')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* Authorization parity tests for /api/blueprints.
|
||||
*
|
||||
* The Blueprints UI gates affordances on license tier (Skipper / Admiral) and
|
||||
* admin role; these tests pin the matching server-side guards so a UI gate and a
|
||||
* route guard cannot silently drift apart. Specifically:
|
||||
* - PUT /:id/pin requires Admiral tier AND admin role (the admin-role half is
|
||||
* The Blueprints UI gates affordances on the paid tier and admin role; these
|
||||
* tests pin the matching server-side guards so a UI gate and a route guard
|
||||
* cannot silently drift apart. Specifically:
|
||||
* - PUT /:id/pin requires the paid tier AND admin role (the admin-role half is
|
||||
* the parity gap the Federation pin control was hardened to match).
|
||||
* - The mutation routes require admin role.
|
||||
* - The read routes require paid tier but NOT admin role.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { LicenseTier, LicenseVariant } from '../services/license-types';
|
||||
import type { LicenseTier } from '../services/license-types';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
@@ -23,9 +23,8 @@ let adminCookie: string;
|
||||
let viewerCookie: string;
|
||||
let counter = 0;
|
||||
|
||||
function setLicense(tier: LicenseTier, variant: LicenseVariant): void {
|
||||
function setLicense(tier: LicenseTier): void {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(variant);
|
||||
}
|
||||
|
||||
function seedNode(): { id: number; name: string } {
|
||||
@@ -73,8 +72,6 @@ beforeAll(async () => {
|
||||
({ BlueprintReconciler } = await import('../services/BlueprintReconciler'));
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
@@ -85,8 +82,7 @@ afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setLicense('paid', 'admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
setLicense('paid');
|
||||
// Neutralize the post-pin background reconcile so the 200 path has no side effects.
|
||||
vi.spyOn(BlueprintReconciler.getInstance(), 'reconcileOne').mockResolvedValue(undefined);
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
@@ -96,7 +92,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
it('allows an admin on an Admiral license to pin a blueprint', async () => {
|
||||
it('allows an admin on a paid license to pin a blueprint', async () => {
|
||||
const node = seedNode();
|
||||
const bp = seedBlueprint([node.id]);
|
||||
|
||||
@@ -109,7 +105,7 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
expect(res.body.pinned_node_id).toBe(node.id);
|
||||
});
|
||||
|
||||
it('allows an admin on an Admiral license to unpin (nodeId null)', async () => {
|
||||
it('allows an admin on a paid license to unpin (nodeId null)', async () => {
|
||||
const node = seedNode();
|
||||
const bp = seedBlueprint([node.id]);
|
||||
DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, node.id);
|
||||
@@ -123,22 +119,8 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
expect(res.body.pinned_node_id).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an admin on a Skipper license with ADMIRAL_REQUIRED', async () => {
|
||||
setLicense('paid', 'skipper');
|
||||
const node = seedNode();
|
||||
const bp = seedBlueprint([node.id]);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/blueprints/${bp.id}/pin`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ nodeId: node.id });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects an admin on a Community license with PAID_REQUIRED', async () => {
|
||||
setLicense('community', null);
|
||||
setLicense('community');
|
||||
const node = seedNode();
|
||||
const bp = seedBlueprint([node.id]);
|
||||
|
||||
@@ -151,7 +133,7 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects a non-admin on an Admiral license with ADMIN_REQUIRED', async () => {
|
||||
it('rejects a non-admin on a paid license with ADMIN_REQUIRED', async () => {
|
||||
const node = seedNode();
|
||||
const bp = seedBlueprint([node.id]);
|
||||
|
||||
@@ -166,9 +148,9 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
});
|
||||
|
||||
describe('Blueprint mutation routes require admin role', () => {
|
||||
// Tier is paid+admiral in beforeEach, so requirePaid passes and the admin
|
||||
// guard is what rejects. The gate short-circuits before id parsing, so dummy
|
||||
// ids are sufficient to prove the role boundary.
|
||||
// Tier is paid in beforeEach, so requirePaid passes and the admin guard is
|
||||
// what rejects. The gate short-circuits before id parsing, so dummy ids are
|
||||
// sufficient to prove the role boundary.
|
||||
const mutations: Array<{ name: string; method: 'post' | 'put' | 'delete'; path: string }> = [
|
||||
{ name: 'create', method: 'post', path: '/api/blueprints' },
|
||||
{ name: 'update', method: 'put', path: '/api/blueprints/1' },
|
||||
@@ -185,7 +167,7 @@ describe('Blueprint mutation routes require admin role', () => {
|
||||
});
|
||||
|
||||
it('rejects an admin on a Community license from creating with PAID_REQUIRED', async () => {
|
||||
setLicense('community', null);
|
||||
setLicense('community');
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
@@ -228,7 +210,7 @@ describe('Blueprint read routes require paid tier but not admin role', () => {
|
||||
});
|
||||
|
||||
it('rejects an admin on a Community license from listing with PAID_REQUIRED', async () => {
|
||||
setLicense('community', null);
|
||||
setLicense('community');
|
||||
const res = await request(app).get('/api/blueprints').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
|
||||
@@ -50,8 +50,6 @@ beforeAll(async () => {
|
||||
({ BlueprintService } = await import('../services/BlueprintService'));
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
@@ -62,8 +60,6 @@ afterAll(() => cleanupTestDb(tmpDir));
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
// Reset developer mode so the diagnostics matrix below is order-independent.
|
||||
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '0');
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
|
||||
@@ -16,8 +16,6 @@ beforeAll(async () => {
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
@@ -28,8 +26,6 @@ afterAll(() => cleanupTestDb(tmpDir));
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
db.prepare('DELETE FROM blueprint_deployments').run();
|
||||
db.prepare('DELETE FROM blueprints').run();
|
||||
|
||||
@@ -24,8 +24,6 @@ beforeAll(async () => {
|
||||
({ BlueprintService } = await import('../services/BlueprintService'));
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
@@ -36,8 +34,6 @@ afterAll(() => cleanupTestDb(tmpDir));
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
db.prepare('DELETE FROM blueprint_deployments').run();
|
||||
db.prepare('DELETE FROM blueprints').run();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Tests for /api/cloud-backup routes — tier gating (community/skipper/admiral),
|
||||
* Tests for /api/cloud-backup routes — tier gating (community vs paid),
|
||||
* admin gating, config CRUD round-trip with secret encryption, audit logging.
|
||||
* The S3 SDK is mocked at the module level so no network calls happen.
|
||||
*/
|
||||
@@ -31,7 +31,6 @@ beforeAll(async () => {
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
|
||||
({ app } = await import('../index'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
@@ -60,19 +59,13 @@ beforeEach(() => {
|
||||
|
||||
// Sticky mocks (mockReturnValue, not mockReturnValueOnce) so a test that does
|
||||
// not actually hit a tier-gated codepath doesn't leak its persona into later
|
||||
// tests. The afterEach hook resets back to the Admiral baseline.
|
||||
// tests. The afterEach hook resets back to the paid baseline.
|
||||
function mockCommunity() {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
}
|
||||
|
||||
function mockSkipper() {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
});
|
||||
|
||||
const customConfigBody = {
|
||||
@@ -96,31 +89,19 @@ describe('Cloud backup tier gating', () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('GET /config is readable on Skipper', async () => {
|
||||
mockSkipper();
|
||||
const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('GET /config is readable on Admiral', async () => {
|
||||
it('GET /config is readable on the paid tier', async () => {
|
||||
const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('provider', 'disabled');
|
||||
});
|
||||
|
||||
// PUT /config: 'custom' is available on every tier, 'sencho' is Admiral-only.
|
||||
// PUT /config: 'custom' is available on every tier, 'sencho' is paid-only.
|
||||
it('PUT /config with provider=custom succeeds on Community', async () => {
|
||||
mockCommunity();
|
||||
const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send(customConfigBody);
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it('PUT /config with provider=custom succeeds on Skipper', async () => {
|
||||
mockSkipper();
|
||||
const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send(customConfigBody);
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it('PUT /config with provider=sencho is rejected on Community with PAID_REQUIRED', async () => {
|
||||
mockCommunity();
|
||||
const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send({ provider: 'sencho' });
|
||||
@@ -128,14 +109,7 @@ describe('Cloud backup tier gating', () => {
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('PUT /config with provider=sencho is rejected on Skipper with ADMIRAL_REQUIRED', async () => {
|
||||
mockSkipper();
|
||||
const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send({ provider: 'sencho' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
|
||||
// POST /provision is Admiral-only by definition (Sencho Cloud Backup activation).
|
||||
// POST /provision is paid-only by definition (Sencho Cloud Backup activation).
|
||||
it('POST /provision is rejected on Community', async () => {
|
||||
mockCommunity();
|
||||
const res = await request(app).post('/api/cloud-backup/provision').set('Cookie', authCookie);
|
||||
@@ -143,26 +117,13 @@ describe('Cloud backup tier gating', () => {
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('POST /provision is rejected on Skipper', async () => {
|
||||
mockSkipper();
|
||||
const res = await request(app).post('/api/cloud-backup/provision').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
|
||||
// GET /usage is Admiral-only (sencho-specific endpoint).
|
||||
// GET /usage is paid-only (sencho-specific endpoint).
|
||||
it('GET /usage is rejected on Community', async () => {
|
||||
mockCommunity();
|
||||
const res = await request(app).get('/api/cloud-backup/usage').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /usage is rejected on Skipper', async () => {
|
||||
mockSkipper();
|
||||
const res = await request(app).get('/api/cloud-backup/usage').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
// POST /test, GET /snapshots, POST /upload, GET /status, GET /object/.../download,
|
||||
// DELETE /object are gated by the *currently saved* provider.
|
||||
it('POST /test reaches handler on Community when saved provider is custom', async () => {
|
||||
@@ -194,8 +155,8 @@ describe('Cloud backup tier gating', () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
// Admiral retains access to every endpoint.
|
||||
it('Admiral can configure provider=sencho', async () => {
|
||||
// The paid tier retains access to every endpoint.
|
||||
it('a paid admin can configure provider=sencho', async () => {
|
||||
const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send({ provider: 'sencho' });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* Covers:
|
||||
* - Both endpoints reject unauthenticated requests (global authGate).
|
||||
* - GET /api/dashboard/configuration returns the documented shape and
|
||||
* applies tier-correct `locked` flags for Community, Skipper, and
|
||||
* Admiral personas (toggled via LicenseService spies).
|
||||
* applies tier-correct `locked` flags for the Community and paid
|
||||
* personas (toggled via LicenseService spies).
|
||||
* - GET /api/dashboard/stack-restarts clamps the `days` query parameter
|
||||
* to [1, 30] and falls back to 7 for invalid inputs.
|
||||
* - Neither endpoint leaks secret material (agent URLs, tokens) in the
|
||||
@@ -26,12 +26,9 @@ beforeAll(async () => {
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
// Default the app to a paid+admiral tier so the import sees a fully
|
||||
// populated license; individual tests override with vi.spyOn before
|
||||
// hitting the route.
|
||||
// Default the app to the paid tier so the import sees a fully populated
|
||||
// license; individual tests override with vi.spyOn before hitting the route.
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
@@ -40,10 +37,9 @@ beforeAll(async () => {
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset to the default Admiral baseline before each test; individual
|
||||
// tests below re-spy as needed for Community/Skipper personas.
|
||||
// Reset to the default paid baseline before each test; individual tests
|
||||
// below re-spy as needed for the Community persona.
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
});
|
||||
|
||||
describe('GET /api/dashboard/configuration', () => {
|
||||
@@ -74,35 +70,22 @@ describe('GET /api/dashboard/configuration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('flags routingRules / webhooks / scheduledTasks / scanPolicies as locked for Community', async () => {
|
||||
it('keeps freed rows unlocked and only scanPolicies locked for Community', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(null);
|
||||
|
||||
const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notifications.routingRules.locked).toBe(true);
|
||||
expect(res.body.automation.webhooks.locked).toBe(true);
|
||||
expect(res.body.automation.scheduledTasks.locked).toBe(true);
|
||||
// routing rules, webhooks, and scheduled tasks are free.
|
||||
expect(res.body.notifications.routingRules.locked).toBe(false);
|
||||
expect(res.body.automation.webhooks.locked).toBe(false);
|
||||
expect(res.body.automation.scheduledTasks.locked).toBe(false);
|
||||
// Scan policies stay paid-gated.
|
||||
expect(res.body.security.scanPolicies.locked).toBe(true);
|
||||
});
|
||||
|
||||
it('unlocks paid-tier rows but keeps Admiral-only rows locked for Skipper', async () => {
|
||||
it('unlocks every gated row for the paid tier', async () => {
|
||||
// The beforeEach already sets the paid tier; reassert for clarity.
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
|
||||
const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.notifications.routingRules.locked).toBe(false);
|
||||
expect(res.body.automation.webhooks.locked).toBe(false);
|
||||
expect(res.body.security.scanPolicies.locked).toBe(false);
|
||||
// Scheduled tasks remain Admiral-only.
|
||||
expect(res.body.automation.scheduledTasks.locked).toBe(true);
|
||||
});
|
||||
|
||||
it('unlocks every gated row for Admiral', async () => {
|
||||
// The beforeEach already sets Admiral; reassert for clarity.
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
|
||||
const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -24,24 +24,21 @@ const signToken = (payload: Record<string, unknown>, expiresIn: string | number
|
||||
jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: expiresIn as jwt.SignOptions['expiresIn'] });
|
||||
|
||||
// We need a Paid-gated route that doesn't depend on Docker or remote nodes.
|
||||
// /api/webhooks is Paid-gated and just reads from the DB; returns an empty array
|
||||
// if no webhooks exist.
|
||||
const PAID_ROUTE = '/api/webhooks';
|
||||
// /api/webhooks/... triggers are public, but the management routes are
|
||||
// admin-gated, so we use a Paid-gated route that just reads from the DB.
|
||||
// /api/audit-log is paid-gated and reads from the DB.
|
||||
const PAID_ROUTE = '/api/audit-log';
|
||||
|
||||
// For Admiral routes, /api/audit-log is Admiral-gated and reads from the DB.
|
||||
const ADMIRAL_ROUTE = '/api/audit-log';
|
||||
|
||||
// ─── authMiddleware: proxyTier/proxyVariant propagation ─────────────────────
|
||||
// ─── authMiddleware: proxyTier propagation ──────────────────────────────────
|
||||
|
||||
describe('authMiddleware - distributed license headers', () => {
|
||||
it('sets proxyTier/proxyVariant for node_proxy tokens with valid tier headers', async () => {
|
||||
it('sets proxyTier for node_proxy tokens with a valid tier header', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
// Hit a Paid-gated route with tier assertion - should be allowed
|
||||
const res = await request(app)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'skipper');
|
||||
.set('x-sencho-tier', 'paid');
|
||||
|
||||
// Should NOT get 403 PAID_REQUIRED; the proxy tier assertion grants access
|
||||
expect(res.status).not.toBe(403);
|
||||
@@ -49,12 +46,11 @@ describe('authMiddleware - distributed license headers', () => {
|
||||
|
||||
it('ignores tier headers for user session tokens', async () => {
|
||||
const token = signToken({ username: TEST_USERNAME, role: 'admin' });
|
||||
// Even with tier headers set, a user session should use local license (community)
|
||||
// Even with a tier header set, a user session should use the local license (community)
|
||||
const res = await request(app)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'admiral');
|
||||
.set('x-sencho-tier', 'paid');
|
||||
|
||||
// Local license is community in test env → should get 403
|
||||
expect(res.status).toBe(403);
|
||||
@@ -66,8 +62,7 @@ describe('authMiddleware - distributed license headers', () => {
|
||||
const res = await request(app)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'enterprise') // invalid value
|
||||
.set('x-sencho-variant', 'mega'); // invalid value
|
||||
.set('x-sencho-tier', 'enterprise'); // invalid value
|
||||
|
||||
// Invalid tier header → proxyTier not set → falls back to local (community) → 403
|
||||
expect(res.status).toBe(403);
|
||||
@@ -94,8 +89,7 @@ describe('requirePaid - distributed license', () => {
|
||||
const res = await request(app)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', '');
|
||||
.set('x-sencho-tier', 'paid');
|
||||
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
@@ -122,66 +116,15 @@ describe('requirePaid - distributed license', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requireAdmiral guard ───────────────────────────────────────────────────
|
||||
|
||||
describe('requireAdmiral - distributed license', () => {
|
||||
it('allows access when proxy asserts paid tier with admiral variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'admiral');
|
||||
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it('blocks when proxy asserts paid tier with skipper variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'skipper');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
|
||||
it('blocks when proxy asserts community tier', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'community');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('blocks when proxy asserts paid tier with empty variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', '');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Security: header injection prevention ──────────────────────────────────
|
||||
|
||||
describe('Security - tier header injection', () => {
|
||||
it('cannot elevate access via tier headers on a user session', async () => {
|
||||
const token = signToken({ username: TEST_USERNAME, role: 'admin' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'admiral');
|
||||
.set('x-sencho-tier', 'paid');
|
||||
|
||||
// User session → tier headers ignored → local community tier → 403
|
||||
expect(res.status).toBe(403);
|
||||
@@ -190,8 +133,7 @@ describe('Security - tier header injection', () => {
|
||||
it('cannot elevate access via tier headers without any auth', async () => {
|
||||
const res = await request(app)
|
||||
.get(PAID_ROUTE)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'admiral');
|
||||
.set('x-sencho-tier', 'paid');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
@@ -98,7 +98,6 @@ function mockFetch(handler: (url: string, init?: RequestInit) => Response | Prom
|
||||
|
||||
function mockPaidTier() {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
}
|
||||
|
||||
function seedLabel(nodeId: number, name: string): number {
|
||||
|
||||
@@ -21,11 +21,10 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
|
||||
beforeAll(async () => {
|
||||
vi.restoreAllMocks();
|
||||
tmpDir = await setupTestDb();
|
||||
// Host console requires paid + admiral; mock the license so the tier gate
|
||||
// Host console requires the paid tier; mock the license so the tier gate
|
||||
// passes for the admin/accepted cases. Individual tests override as needed.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
getTierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
const mod = await import('../index');
|
||||
server = mod.server;
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
@@ -79,8 +78,8 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
|
||||
expect(await expectRejected(ws)).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects an admin on a sub-Admiral tier (403)', async () => {
|
||||
getTierSpy.mockReturnValueOnce('free');
|
||||
it('rejects an admin on the Community tier (403)', async () => {
|
||||
getTierSpy.mockReturnValueOnce('community');
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${adminToken()}` } });
|
||||
expect(await expectRejected(ws)).toBe(403);
|
||||
});
|
||||
|
||||
@@ -16,10 +16,9 @@ let app: import('express').Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
// Mock LicenseService so Admiral-gated endpoints accept requests
|
||||
// Mock LicenseService so paid-gated endpoints accept requests
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
@@ -22,8 +20,6 @@ beforeAll(async () => {
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
@@ -136,14 +132,13 @@ describe('POST /api/image-updates/fleet/refresh', () => {
|
||||
expect(CacheService.getInstance().get('fleet-updates')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('downgrades to 402-style upgrade response when license is community', async () => {
|
||||
it('still serves a community-licensed admin (no paid gate)', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
const res = await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', adminCookie);
|
||||
// requirePaid responds with a non-2xx status carrying an upgrade payload.
|
||||
expect(res.status).not.toBe(200);
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.triggered)).toBe(true);
|
||||
} finally {
|
||||
tierSpy.mockRestore();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
@@ -165,9 +160,10 @@ describe('POST /api/auto-update/execute', () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a Community-tier admin with 403 PAID_REQUIRED', async () => {
|
||||
// Auto-update execution is a paid capability; an admin on a Community
|
||||
// license must not be able to drive it directly through the API.
|
||||
it('serves a community-licensed admin (no paid gate)', async () => {
|
||||
// Auto-update execution is free; an admin on a Community license drives it
|
||||
// directly through the API. With no stacks on the fresh instance the handler
|
||||
// returns the "no stacks found" summary rather than a 403.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
@@ -175,31 +171,6 @@ describe('POST /api/auto-update/execute', () => {
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ target: '*' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
} finally {
|
||||
tierSpy.mockRestore();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
|
||||
it('honors a paid proxy tier header from a node_proxy caller on a Community runtime', async () => {
|
||||
// The scheduler dispatches to a remote's /execute with a node_proxy Bearer
|
||||
// token and the controlling instance's tier header. A Community-licensed
|
||||
// remote runtime must still run the update because the trusted header, not
|
||||
// the local license, decides entitlement.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const proxyToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '5m' });
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Authorization', `Bearer ${proxyToken}`)
|
||||
.set(PROXY_TIER_HEADER, 'paid')
|
||||
.set(PROXY_VARIANT_HEADER, 'admiral')
|
||||
.send({ target: '*' });
|
||||
// Gate passes: no stacks on the fresh instance, so the handler returns
|
||||
// the "no stacks found" summary rather than a 403.
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.result).toBe('string');
|
||||
} finally {
|
||||
@@ -208,26 +179,6 @@ describe('POST /api/auto-update/execute', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a node_proxy caller whose tier header is community with 403', async () => {
|
||||
// The trusted header, not the local license, decides entitlement: a paid
|
||||
// local runtime must still 403 when the controlling instance is Community.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
const proxyToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '5m' });
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Authorization', `Bearer ${proxyToken}`)
|
||||
.set(PROXY_TIER_HEADER, 'community')
|
||||
.send({ target: '*' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
} finally {
|
||||
tierSpy.mockRestore();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects missing target with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* Confirms Stack Labels CRUD + per-stack assignment is reachable on the
|
||||
* Community tier. The per-label bulk-action endpoint stays Skipper+ and is
|
||||
* exercised here too to guard against an accidental gate removal in the
|
||||
* future.
|
||||
* Confirms Stack Labels CRUD + per-stack assignment + the per-label
|
||||
* bulk-action endpoint are all reachable on the Community tier.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -241,16 +239,25 @@ describe('Stack Labels Developer Mode logging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stack Labels bulk-action endpoint stays Skipper+', () => {
|
||||
describe('Stack Labels bulk-action endpoint is available on Community', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POST /api/labels/:id/action returns 403 on community', async () => {
|
||||
it('POST /api/labels/:id/action is reachable on community (no longer paid-gated)', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/labels/1/action')
|
||||
const created = await request(app)
|
||||
.post('/api/labels')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ action: 'deploy' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
.send({ name: 'bulk-action-free', color: 'teal' });
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/labels/${created.body.id}/action`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ action: 'restart' });
|
||||
|
||||
// No stacks are assigned to the label, so the bulk action succeeds with an
|
||||
// empty result instead of the old 403 PAID_REQUIRED.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,78 +3,56 @@
|
||||
* and validate(). Without this guard, any LS license (from any store, any
|
||||
* product) returns valid: true on /v1/licenses/validate and unlocks Sencho.
|
||||
*
|
||||
* The pure-function tests below exercise resolveSenchoVariantFromMeta()
|
||||
* directly. The activate() / validate() tests mock axios and DatabaseService
|
||||
* so we can drive each rejection branch and assert that no DB writes happen
|
||||
* on a non-matching response.
|
||||
* The pure-function tests below exercise isSenchoLicenseMeta() directly. The
|
||||
* activate() / validate() tests mock axios and DatabaseService so we can drive
|
||||
* each rejection branch and assert that no DB writes happen on a non-matching
|
||||
* response.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
resolveSenchoVariantFromMeta,
|
||||
isSenchoLicenseMeta,
|
||||
SENCHO_LS_STORE_ID,
|
||||
SENCHO_LS_PRODUCT_ID_SKIPPER,
|
||||
SENCHO_LS_PRODUCT_ID_ADMIRAL,
|
||||
} from '../services/LicenseService';
|
||||
|
||||
// LS catalog used in the live store. Tests reference these directly so a future
|
||||
// catalog change forces an explicit test update rather than silently passing.
|
||||
const VARIANT_SKIPPER_MONTHLY = 1453178;
|
||||
const VARIANT_SKIPPER_ANNUAL = 1453197;
|
||||
const VARIANT_SKIPPER_LIFETIME = 1453198;
|
||||
const VARIANT_ADMIRAL_MONTHLY = 1453209;
|
||||
const VARIANT_ADMIRAL_ANNUAL = 1453212;
|
||||
const VARIANT_ADMIRAL_LIFETIME = 1453217;
|
||||
// The retired Skipper product id. Greenfield: it is no longer honored, so the
|
||||
// guard must reject it. Referenced explicitly so a future catalog change forces
|
||||
// an intentional test update.
|
||||
const RETIRED_SKIPPER_PRODUCT_ID = 924135;
|
||||
|
||||
const buildMeta = (overrides: Partial<{ store_id: number; product_id: number; variant_id: number }> = {}) => ({
|
||||
const buildMeta = (overrides: Partial<{ store_id: number; product_id: number }> = {}) => ({
|
||||
store_id: SENCHO_LS_STORE_ID,
|
||||
product_id: SENCHO_LS_PRODUCT_ID_SKIPPER,
|
||||
variant_id: VARIANT_SKIPPER_MONTHLY,
|
||||
product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('resolveSenchoVariantFromMeta()', () => {
|
||||
it('returns null for undefined meta', () => {
|
||||
expect(resolveSenchoVariantFromMeta(undefined)).toBeNull();
|
||||
describe('isSenchoLicenseMeta()', () => {
|
||||
it('returns false for undefined meta', () => {
|
||||
expect(isSenchoLicenseMeta(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when store_id is missing', () => {
|
||||
expect(resolveSenchoVariantFromMeta({ product_id: SENCHO_LS_PRODUCT_ID_SKIPPER, variant_id: VARIANT_SKIPPER_MONTHLY })).toBeNull();
|
||||
it('returns false when store_id is missing', () => {
|
||||
expect(isSenchoLicenseMeta({ product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when store_id does not match the Sencho store', () => {
|
||||
expect(resolveSenchoVariantFromMeta(buildMeta({ store_id: 999999 }))).toBeNull();
|
||||
it('returns false when store_id does not match the Sencho store', () => {
|
||||
expect(isSenchoLicenseMeta(buildMeta({ store_id: 999999 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when product_id is missing', () => {
|
||||
expect(resolveSenchoVariantFromMeta({ store_id: SENCHO_LS_STORE_ID, variant_id: VARIANT_SKIPPER_MONTHLY })).toBeNull();
|
||||
it('returns false when product_id is missing', () => {
|
||||
expect(isSenchoLicenseMeta({ store_id: SENCHO_LS_STORE_ID })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when product_id is not a recognized Sencho product', () => {
|
||||
expect(resolveSenchoVariantFromMeta(buildMeta({ product_id: 555555 }))).toBeNull();
|
||||
it('returns false when product_id is not the Sencho paid product', () => {
|
||||
expect(isSenchoLicenseMeta(buildMeta({ product_id: 555555 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when variant_id is missing', () => {
|
||||
expect(resolveSenchoVariantFromMeta({ store_id: SENCHO_LS_STORE_ID, product_id: SENCHO_LS_PRODUCT_ID_SKIPPER })).toBeNull();
|
||||
it('returns false for the retired Skipper product (greenfield)', () => {
|
||||
expect(isSenchoLicenseMeta(buildMeta({ product_id: RETIRED_SKIPPER_PRODUCT_ID }))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when variant_id is unknown', () => {
|
||||
expect(resolveSenchoVariantFromMeta(buildMeta({ variant_id: 1 }))).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Skipper Monthly', VARIANT_SKIPPER_MONTHLY],
|
||||
['Skipper Annual', VARIANT_SKIPPER_ANNUAL],
|
||||
['Skipper Lifetime', VARIANT_SKIPPER_LIFETIME],
|
||||
])('resolves %s variant to skipper', (_label, variantId) => {
|
||||
expect(resolveSenchoVariantFromMeta(buildMeta({ product_id: SENCHO_LS_PRODUCT_ID_SKIPPER, variant_id: variantId }))).toBe('skipper');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Admiral Monthly', VARIANT_ADMIRAL_MONTHLY],
|
||||
['Admiral Annual', VARIANT_ADMIRAL_ANNUAL],
|
||||
['Admiral Lifetime', VARIANT_ADMIRAL_LIFETIME],
|
||||
])('resolves %s variant to admiral', (_label, variantId) => {
|
||||
expect(resolveSenchoVariantFromMeta(buildMeta({ product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL, variant_id: variantId }))).toBe('admiral');
|
||||
it('returns true for the Sencho paid (Admiral) product', () => {
|
||||
expect(isSenchoLicenseMeta(buildMeta())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,16 +116,16 @@ describe('LicenseService.activate() - catalog ID guard', () => {
|
||||
expect(mockSetSystemState).not.toHaveBeenCalledWith('license_status', 'active');
|
||||
});
|
||||
|
||||
it('rejects activation when product_id is not a Sencho product', async () => {
|
||||
it('rejects activation when product_id is not the Sencho paid product', async () => {
|
||||
mockAxiosPost.mockResolvedValueOnce(buildActivationResponse(buildMeta({ product_id: 555555 })));
|
||||
const result = await svc.activate('OTHER-PRODUCT-KEY');
|
||||
expect(result.success).toBe(false);
|
||||
expect(mockSetSystemState).not.toHaveBeenCalledWith('license_status', 'active');
|
||||
});
|
||||
|
||||
it('rejects activation when variant_id is unknown', async () => {
|
||||
mockAxiosPost.mockResolvedValueOnce(buildActivationResponse(buildMeta({ variant_id: 1 })));
|
||||
const result = await svc.activate('UNKNOWN-VARIANT-KEY');
|
||||
it('rejects activation for a retired Skipper-product license (greenfield)', async () => {
|
||||
mockAxiosPost.mockResolvedValueOnce(buildActivationResponse(buildMeta({ product_id: RETIRED_SKIPPER_PRODUCT_ID })));
|
||||
const result = await svc.activate('OLD-SKIPPER-KEY');
|
||||
expect(result.success).toBe(false);
|
||||
expect(mockSetSystemState).not.toHaveBeenCalledWith('license_status', 'active');
|
||||
});
|
||||
@@ -162,10 +140,9 @@ describe('LicenseService.activate() - catalog ID guard', () => {
|
||||
license_key: { id: 1, status: 'active', key: 'k', activation_limit: 1, activation_usage: 1, created_at: '2026-01-01', expires_at: null },
|
||||
meta: {
|
||||
store_id: SENCHO_LS_STORE_ID,
|
||||
product_id: SENCHO_LS_PRODUCT_ID_SKIPPER,
|
||||
variant_id: VARIANT_SKIPPER_MONTHLY,
|
||||
variant_name: 'Skipper Monthly',
|
||||
product_name: 'Sencho Skipper',
|
||||
product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL,
|
||||
variant_name: 'Admiral Monthly',
|
||||
product_name: 'Sencho Admiral',
|
||||
},
|
||||
// instance: omitted on purpose
|
||||
},
|
||||
@@ -185,7 +162,6 @@ describe('LicenseService.activate() - catalog ID guard', () => {
|
||||
meta: {
|
||||
store_id: SENCHO_LS_STORE_ID,
|
||||
product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL,
|
||||
variant_id: VARIANT_ADMIRAL_LIFETIME,
|
||||
variant_name: 'Admiral Lifetime',
|
||||
product_name: 'Sencho Admiral',
|
||||
},
|
||||
@@ -206,32 +182,17 @@ describe('LicenseService.activate() - catalog ID guard', () => {
|
||||
expect(mockSetSystemState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('succeeds and stores admiral variant for an Admiral Lifetime license', async () => {
|
||||
it('succeeds and goes active for a valid Sencho paid license', async () => {
|
||||
mockAxiosPost.mockResolvedValueOnce(buildActivationResponse({
|
||||
store_id: SENCHO_LS_STORE_ID,
|
||||
product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL,
|
||||
variant_id: VARIANT_ADMIRAL_LIFETIME,
|
||||
variant_name: 'Admiral Lifetime',
|
||||
product_name: 'Sencho Admiral',
|
||||
}));
|
||||
const result = await svc.activate('GOOD-ADMIRAL-KEY');
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('license_status', 'active');
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('license_variant_type', 'admiral');
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('license_variant_id', String(VARIANT_ADMIRAL_LIFETIME));
|
||||
});
|
||||
|
||||
it('succeeds and stores skipper variant for a Skipper Monthly license', async () => {
|
||||
mockAxiosPost.mockResolvedValueOnce(buildActivationResponse({
|
||||
store_id: SENCHO_LS_STORE_ID,
|
||||
product_id: SENCHO_LS_PRODUCT_ID_SKIPPER,
|
||||
variant_id: VARIANT_SKIPPER_MONTHLY,
|
||||
variant_name: 'Skipper Monthly',
|
||||
product_name: 'Sencho Skipper',
|
||||
}));
|
||||
const result = await svc.activate('GOOD-SKIPPER-KEY');
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('license_variant_type', 'skipper');
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('license_key', 'GOOD-ADMIRAL-KEY');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,14 +239,12 @@ describe('LicenseService.validate() - catalog ID guard', () => {
|
||||
mockAxiosPost.mockResolvedValueOnce(buildValidationResponse({
|
||||
store_id: SENCHO_LS_STORE_ID,
|
||||
product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL,
|
||||
variant_id: VARIANT_ADMIRAL_ANNUAL,
|
||||
variant_name: 'Admiral Annual',
|
||||
product_name: 'Sencho Admiral',
|
||||
}));
|
||||
const result = await svc.validate();
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('license_status', 'active');
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('license_variant_type', 'admiral');
|
||||
});
|
||||
|
||||
it('marks the license expired when LS reports key_status=expired even with matching meta', async () => {
|
||||
@@ -295,10 +254,9 @@ describe('LicenseService.validate() - catalog ID guard', () => {
|
||||
license_key: { id: 1, status: 'expired', key: 'k', activation_limit: 1, activation_usage: 1, created_at: '2026-01-01', expires_at: '2026-04-01' },
|
||||
meta: {
|
||||
store_id: SENCHO_LS_STORE_ID,
|
||||
product_id: SENCHO_LS_PRODUCT_ID_SKIPPER,
|
||||
variant_id: VARIANT_SKIPPER_MONTHLY,
|
||||
variant_name: 'Skipper Monthly',
|
||||
product_name: 'Sencho Skipper',
|
||||
product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL,
|
||||
variant_name: 'Admiral Monthly',
|
||||
product_name: 'Sencho Admiral',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -317,7 +275,6 @@ describe('LicenseService.validate() - catalog ID guard', () => {
|
||||
meta: {
|
||||
store_id: SENCHO_LS_STORE_ID,
|
||||
product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL,
|
||||
variant_id: VARIANT_ADMIRAL_LIFETIME,
|
||||
variant_name: 'Admiral Lifetime',
|
||||
product_name: 'Sencho Admiral',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Tests for LicenseService: variant resolution, tier computation, lifetime detection,
|
||||
* and getLicenseInfo() output across all license states.
|
||||
* Tests for LicenseService: tier computation, lifetime detection, and
|
||||
* getLicenseInfo() output across all license states.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
@@ -25,8 +25,7 @@ function setLicenseState(overrides: Record<string, string>) {
|
||||
const keys = [
|
||||
'license_status', 'license_key', 'license_valid_until',
|
||||
'license_last_validated', 'license_customer_name',
|
||||
'license_product_name', 'license_variant_name',
|
||||
'license_variant_type', 'license_variant_id',
|
||||
'license_product_name',
|
||||
'billing_portal_url', 'billing_portal_expires',
|
||||
];
|
||||
for (const key of keys) {
|
||||
@@ -37,92 +36,6 @@ function setLicenseState(overrides: Record<string, string>) {
|
||||
}
|
||||
}
|
||||
|
||||
describe('LicenseService.getVariant()', () => {
|
||||
it('returns null for trial status with no stored variant metadata', () => {
|
||||
// Trial status without LS-issued variant metadata resolves to null; a real
|
||||
// LS-issued trial would carry variant_name and resolve through the normal path.
|
||||
setLicenseState({ license_status: 'trial' });
|
||||
expect(svc.getVariant()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns "admiral" for trial with LS-stored Admiral variant metadata', () => {
|
||||
setLicenseState({ license_status: 'trial', license_variant_name: 'Admiral Monthly', license_product_name: 'Sencho Admiral' });
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
});
|
||||
|
||||
it('returns null when no variant name is stored', () => {
|
||||
setLicenseState({ license_status: 'active' });
|
||||
expect(svc.getVariant()).toBeNull();
|
||||
});
|
||||
|
||||
it('reads pre-resolved variant type from DB (admiral)', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_type: 'admiral' });
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
});
|
||||
|
||||
it('reads pre-resolved variant type from DB (skipper)', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_type: 'skipper' });
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
|
||||
it('falls back to name resolution and persists type (Team -> admiral)', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Team' });
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
expect(DatabaseService.getInstance().getSystemState('license_variant_type')).toBe('admiral');
|
||||
});
|
||||
|
||||
it('falls back to name resolution and persists type (Personal -> skipper)', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Personal' });
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
expect(DatabaseService.getInstance().getSystemState('license_variant_type')).toBe('skipper');
|
||||
});
|
||||
|
||||
it('maps "Admiral" variant name to "admiral"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Admiral' });
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
});
|
||||
|
||||
it('maps "Admiral Lifetime" variant name to "admiral"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Admiral Lifetime' });
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
});
|
||||
|
||||
it('maps "Skipper" variant name to "skipper"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Skipper' });
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
|
||||
it('maps "Skipper Lifetime" variant name to "skipper"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Skipper Lifetime' });
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
|
||||
it('defaults unknown variant names to "skipper"', () => {
|
||||
setLicenseState({ license_status: 'active', license_variant_name: 'Unknown Variant' });
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
});
|
||||
|
||||
it('resolves from product_name when variant_name has no tier info (Admiral)', () => {
|
||||
setLicenseState({
|
||||
license_status: 'active',
|
||||
license_variant_name: 'Lifetime',
|
||||
license_product_name: 'Sencho Admiral',
|
||||
});
|
||||
expect(svc.getVariant()).toBe('admiral');
|
||||
expect(DatabaseService.getInstance().getSystemState('license_variant_type')).toBe('admiral');
|
||||
});
|
||||
|
||||
it('resolves from product_name when variant_name has no tier info (Skipper)', () => {
|
||||
setLicenseState({
|
||||
license_status: 'active',
|
||||
license_variant_name: 'Monthly',
|
||||
license_product_name: 'Sencho Skipper',
|
||||
});
|
||||
expect(svc.getVariant()).toBe('skipper');
|
||||
expect(DatabaseService.getInstance().getSystemState('license_variant_type')).toBe('skipper');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LicenseService.getTier()', () => {
|
||||
it('returns "community" when no status is set', () => {
|
||||
setLicenseState({});
|
||||
@@ -230,11 +143,10 @@ describe('LicenseService.getLicenseInfo() - isLifetime', () => {
|
||||
});
|
||||
|
||||
describe('LicenseService.getLicenseInfo() - full scenarios', () => {
|
||||
it('returns correct info for an Admiral lifetime license', () => {
|
||||
it('returns correct info for a paid lifetime license', () => {
|
||||
setLicenseState({
|
||||
license_status: 'active',
|
||||
license_key: 'ABCD-EFGH-IJKL-MN5D',
|
||||
license_variant_name: 'Admiral Lifetime',
|
||||
license_customer_name: 'Test User',
|
||||
license_product_name: 'Sencho Admiral',
|
||||
license_last_validated: Date.now().toString(),
|
||||
@@ -242,7 +154,6 @@ describe('LicenseService.getLicenseInfo() - full scenarios', () => {
|
||||
const info = svc.getLicenseInfo();
|
||||
expect(info.tier).toBe('paid');
|
||||
expect(info.status).toBe('active');
|
||||
expect(info.variant).toBe('admiral');
|
||||
expect(info.isLifetime).toBe(true);
|
||||
expect(info.trialDaysRemaining).toBeNull();
|
||||
expect(info.customerName).toBe('Test User');
|
||||
@@ -250,22 +161,20 @@ describe('LicenseService.getLicenseInfo() - full scenarios', () => {
|
||||
expect(info.maskedKey).toBe('****-****-****-MN5D');
|
||||
});
|
||||
|
||||
it('returns correct info for a Skipper subscription', () => {
|
||||
it('returns correct info for a paid subscription', () => {
|
||||
const future = new Date();
|
||||
future.setDate(future.getDate() + 30);
|
||||
setLicenseState({
|
||||
license_status: 'active',
|
||||
license_key: 'ABCD-EFGH-IJKL-SK5D',
|
||||
license_variant_name: 'Skipper Monthly',
|
||||
license_customer_name: 'Another User',
|
||||
license_product_name: 'Sencho Skipper',
|
||||
license_product_name: 'Sencho Admiral',
|
||||
license_valid_until: future.toISOString(),
|
||||
license_last_validated: Date.now().toString(),
|
||||
});
|
||||
const info = svc.getLicenseInfo();
|
||||
expect(info.tier).toBe('paid');
|
||||
expect(info.status).toBe('active');
|
||||
expect(info.variant).toBe('skipper');
|
||||
expect(info.isLifetime).toBe(false);
|
||||
expect(info.trialDaysRemaining).toBeNull();
|
||||
expect(info.customerName).toBe('Another User');
|
||||
|
||||
@@ -94,7 +94,6 @@ describe('MeshService.inspectStackServices dispatch (C-3 fix)', () => {
|
||||
const headers = (call[1] as { headers: Record<string, string> }).headers;
|
||||
expect(headers['Authorization']).toBe('Bearer remote-tok');
|
||||
expect(headers).toHaveProperty('x-sencho-tier');
|
||||
expect(headers).toHaveProperty('x-sencho-variant');
|
||||
|
||||
db.deleteNode(remoteNodeId);
|
||||
});
|
||||
|
||||
@@ -92,7 +92,6 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => {
|
||||
expect(init.method).toBe('GET');
|
||||
expect(init.headers['Authorization']).toBe('Bearer remote-tok');
|
||||
expect(init.headers).toHaveProperty('x-sencho-tier');
|
||||
expect(init.headers).toHaveProperty('x-sencho-variant');
|
||||
|
||||
db.deleteNode(remoteNodeId);
|
||||
});
|
||||
|
||||
@@ -62,7 +62,6 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => {
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(init.headers['Authorization']).toBe('Bearer remote-tok');
|
||||
expect(init.headers).toHaveProperty('x-sencho-tier');
|
||||
expect(init.headers).toHaveProperty('x-sencho-variant');
|
||||
|
||||
db.deleteNode(remoteNodeId);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Gate coverage for the mesh router.
|
||||
*
|
||||
* Every /api/mesh route is tier-gated (requireAdmiral). The five operator
|
||||
* Every /api/mesh route is tier-gated (requirePaid). The five operator
|
||||
* mutations are additionally role-gated (requireAdmin): node enable/disable,
|
||||
* stack opt-in/opt-out, and the override regen. The operator read routes
|
||||
* (status, aliases, activity, diagnostics) stay reachable for any Admiral-tier
|
||||
* (status, aliases, activity, diagnostics) stay reachable for any paid-tier
|
||||
* user regardless of role, which is what lets a non-admin see a read-only
|
||||
* Routing tab. The node-to-node routes that central calls over the proxy on the
|
||||
* operator's behalf (local-override PUT/DELETE, alias test) are Admiral-gated
|
||||
* operator's behalf (local-override PUT/DELETE, alias test) are paid-gated
|
||||
* but intentionally not admin-gated. These tests lock that split so the backend
|
||||
* can never silently diverge from the matching frontend render gate (a button
|
||||
* that 403s, or a feature an owner cannot see).
|
||||
@@ -30,9 +30,8 @@ function userToken(username: string): string {
|
||||
return jwt.sign({ username, role: user.role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' });
|
||||
}
|
||||
|
||||
function setTier(tier: 'community' | 'paid', variant: 'skipper' | 'admiral' | null): void {
|
||||
function setTier(tier: 'community' | 'paid'): void {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(variant);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -48,9 +47,9 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Default every test to a fully entitled Admiral instance; tier-rejection
|
||||
// Default every test to a fully entitled paid instance; tier-rejection
|
||||
// tests override this locally.
|
||||
setTier('paid', 'admiral');
|
||||
setTier('paid');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -58,9 +57,9 @@ afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('mesh tier gate (requireAdmiral)', () => {
|
||||
describe('mesh tier gate (requirePaid)', () => {
|
||||
it('rejects Community tier with PAID_REQUIRED', async () => {
|
||||
setTier('community', null);
|
||||
setTier('community');
|
||||
const res = await request(app)
|
||||
.get('/api/mesh/aliases')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
@@ -68,17 +67,8 @@ describe('mesh tier gate (requireAdmiral)', () => {
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects a paid non-Admiral variant with ADMIRAL_REQUIRED', async () => {
|
||||
setTier('paid', 'skipper');
|
||||
const res = await request(app)
|
||||
.get('/api/mesh/aliases')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects Community tier on a mutation before the role gate runs', async () => {
|
||||
setTier('community', null);
|
||||
setTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/mesh/regen-overrides')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
@@ -87,7 +77,7 @@ describe('mesh tier gate (requireAdmiral)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mesh read routes are visible to a non-admin Admiral user', () => {
|
||||
describe('mesh read routes are visible to a non-admin paid user', () => {
|
||||
it('returns aliases to a viewer', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/mesh/aliases')
|
||||
@@ -123,7 +113,7 @@ describe('mesh mutation routes require the admin role (requireAdmin)', () => {
|
||||
];
|
||||
|
||||
for (const route of mutationRoutes) {
|
||||
it(`${route.name} rejects a non-admin Admiral user with ADMIN_REQUIRED`, async () => {
|
||||
it(`${route.name} rejects a non-admin paid user with ADMIN_REQUIRED`, async () => {
|
||||
const res = await request(app)
|
||||
.post(route.path())
|
||||
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
|
||||
@@ -132,7 +122,7 @@ describe('mesh mutation routes require the admin role (requireAdmin)', () => {
|
||||
});
|
||||
}
|
||||
|
||||
it('lets an Admiral admin pass both gates on regen-overrides', async () => {
|
||||
it('lets a paid admin pass both gates on regen-overrides', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/mesh/regen-overrides')
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
@@ -140,7 +130,7 @@ describe('mesh mutation routes require the admin role (requireAdmin)', () => {
|
||||
expect(res.body).toHaveProperty('regenerated');
|
||||
});
|
||||
|
||||
it('lets an Admiral admin past both gates on a node mutation (not gate-rejected)', async () => {
|
||||
it('lets a paid admin past both gates on a node mutation (not gate-rejected)', async () => {
|
||||
// Locks the guard order (tier before role) for a mutation other than
|
||||
// regen-overrides: an admin must never be rejected by either gate. The
|
||||
// handler may still 4xx/5xx for other reasons in the test environment;
|
||||
@@ -149,7 +139,6 @@ describe('mesh mutation routes require the admin role (requireAdmin)', () => {
|
||||
.post(`/api/mesh/nodes/${defaultNodeId}/enable`)
|
||||
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
expect(res.body.code).not.toBe('ADMIRAL_REQUIRED');
|
||||
expect(res.body.code).not.toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,8 +26,6 @@ beforeAll(async () => {
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
@@ -68,11 +68,9 @@ beforeAll(async () => {
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ MfaService } = await import('../services/MfaService'));
|
||||
|
||||
// Mock LicenseService to return paid/admiral so the admin routes pass gates
|
||||
// Mock LicenseService to return paid so the admin routes pass gates
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
*
|
||||
* These guard the same boundary the NodeCard cordon control renders against, so
|
||||
* a UI gate and a route guard cannot silently drift apart. Cordon/uncordon
|
||||
* require Admiral tier AND the node:manage permission (held by admin and
|
||||
* require the paid tier AND the node:manage permission (held by admin and
|
||||
* node-admin roles). The guard order is:
|
||||
* rejectApiTokenScope (SCOPE_DENIED) -> requirePermission (PERMISSION_DENIED)
|
||||
* -> requireAdmiral (PAID_REQUIRED / ADMIRAL_REQUIRED) -> invalid-id 400
|
||||
* -> requirePaid (PAID_REQUIRED) -> invalid-id 400
|
||||
* -> reason 400 (cordon only) -> 404.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { LicenseTier, LicenseVariant } from '../services/license-types';
|
||||
import type { LicenseTier } from '../services/license-types';
|
||||
import type { UserRole } from '../services/DatabaseService';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_USERNAME } from './helpers/setupTestDb';
|
||||
import { createTestApiToken } from './helpers/apiTokenTestHelper';
|
||||
@@ -26,9 +26,8 @@ let adminUserId: number;
|
||||
const roleCookie: Record<string, string> = {};
|
||||
let counter = 0;
|
||||
|
||||
function setLicense(tier: LicenseTier, variant: LicenseVariant): void {
|
||||
function setLicense(tier: LicenseTier): void {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(variant);
|
||||
}
|
||||
|
||||
function seedNode(): { id: number; name: string } {
|
||||
@@ -60,8 +59,6 @@ beforeAll(async () => {
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
@@ -79,8 +76,7 @@ afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setLicense('paid', 'admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
setLicense('paid');
|
||||
DatabaseService.getInstance().getDb().prepare('DELETE FROM nodes WHERE is_default = 0').run();
|
||||
});
|
||||
|
||||
@@ -119,19 +115,8 @@ describe('POST /api/nodes/:id/cordon authorization', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an admin on a Skipper license with ADMIRAL_REQUIRED', async () => {
|
||||
setLicense('paid', 'skipper');
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects an admin on a Community license with PAID_REQUIRED', async () => {
|
||||
setLicense('community', null);
|
||||
setLicense('community');
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
@@ -205,8 +190,8 @@ describe('POST /api/nodes/:id/cordon authorization', () => {
|
||||
expect(res.body.cordoned_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('checks node:manage before the tier gate (Skipper viewer gets PERMISSION_DENIED, not ADMIRAL_REQUIRED)', async () => {
|
||||
setLicense('paid', 'skipper');
|
||||
it('checks node:manage before the tier gate (Community viewer gets PERMISSION_DENIED, not PAID_REQUIRED)', async () => {
|
||||
setLicense('community');
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
@@ -258,15 +243,15 @@ describe('POST /api/nodes/:id/uncordon authorization', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an admin on a Skipper license with ADMIRAL_REQUIRED', async () => {
|
||||
setLicense('paid', 'skipper');
|
||||
it('rejects an admin on a Community license with PAID_REQUIRED', async () => {
|
||||
setLicense('community');
|
||||
const node = seedCordonedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/uncordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects a full-admin API token with SCOPE_DENIED', async () => {
|
||||
|
||||
@@ -18,12 +18,11 @@ beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
// Mock LicenseService so Admiral-gated routes are accessible
|
||||
// Mock LicenseService; notification routes are free, so the suite runs at
|
||||
// the Community tier to prove they work without a paid license.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
licenseService = LicenseService.getInstance();
|
||||
vi.spyOn(licenseService, 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(licenseService, 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(licenseService, 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
vi.spyOn(licenseService, 'getTier').mockReturnValue('community');
|
||||
|
||||
({ app } = await import('../index'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
@@ -90,96 +89,44 @@ describe('Notification Routes - auth enforcement', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Tier enforcement (Skipper or Admiral) ---
|
||||
// --- No tier gate (notification routing is free) ---
|
||||
//
|
||||
// Skipper-positive tests exist per endpoint so that a future regression
|
||||
// reverting any single handler to `requireAdmiral` is caught: with the
|
||||
// default mock returning `admiral`, a stray `requireAdmiral` would still
|
||||
// pass the Community-negative tests below (Community fails on tier
|
||||
// before variant is checked), so only Skipper-positive coverage proves
|
||||
// the gate is `requirePaid`. `afterEach` restores the suite defaults so
|
||||
// per-test mock overrides cannot leak across tests.
|
||||
// Notification routing is available on every tier. These tests prove a
|
||||
// Community admin reaches each endpoint (the gate that rejects is the admin
|
||||
// role, not the tier). The suite default is the Community tier.
|
||||
|
||||
describe('Notification Routes - tier enforcement', () => {
|
||||
afterEach(() => {
|
||||
vi.spyOn(licenseService, 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(licenseService, 'getVariant').mockReturnValue('admiral');
|
||||
});
|
||||
|
||||
it('GET /api/notification-routes returns 200 when the variant is Skipper', async () => {
|
||||
vi.spyOn(licenseService, 'getVariant').mockReturnValue('skipper');
|
||||
describe('Notification Routes - available on the Community tier', () => {
|
||||
it('GET /api/notification-routes returns 200 on the Community tier', async () => {
|
||||
const res = await request(app).get('/api/notification-routes').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/notification-routes returns 201 when the variant is Skipper', async () => {
|
||||
vi.spyOn(licenseService, 'getVariant').mockReturnValue('skipper');
|
||||
it('POST /api/notification-routes returns 201 on the Community tier', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ name: 'skipper-positive', stack_patterns: ['app'], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' });
|
||||
.send({ name: 'community-positive', stack_patterns: ['app'], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' });
|
||||
expect(res.status).toBe(201);
|
||||
if (typeof res.body?.id === 'number') {
|
||||
DatabaseService.getInstance().deleteNotificationRoute(res.body.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('PUT /api/notification-routes/:id returns 404 (gate passed) when the variant is Skipper', async () => {
|
||||
vi.spyOn(licenseService, 'getVariant').mockReturnValue('skipper');
|
||||
it('PUT /api/notification-routes/:id returns 404 (gate passed) on the Community tier', async () => {
|
||||
const res = await request(app).put('/api/notification-routes/99999').set('Cookie', authCookie).send({ name: 'x' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('DELETE /api/notification-routes/:id returns 404 (gate passed) when the variant is Skipper', async () => {
|
||||
vi.spyOn(licenseService, 'getVariant').mockReturnValue('skipper');
|
||||
it('DELETE /api/notification-routes/:id returns 404 (gate passed) on the Community tier', async () => {
|
||||
const res = await request(app).delete('/api/notification-routes/99999').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/notification-routes/:id/test returns 404 (gate passed) when the variant is Skipper', async () => {
|
||||
vi.spyOn(licenseService, 'getVariant').mockReturnValue('skipper');
|
||||
it('POST /api/notification-routes/:id/test returns 404 (gate passed) on the Community tier', async () => {
|
||||
const res = await request(app).post('/api/notification-routes/99999/test').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/notification-routes returns 403 PAID_REQUIRED on Community', async () => {
|
||||
vi.spyOn(licenseService, 'getTier').mockReturnValue('community');
|
||||
const res = await request(app).get('/api/notification-routes').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('POST /api/notification-routes returns 403 PAID_REQUIRED on Community', async () => {
|
||||
vi.spyOn(licenseService, 'getTier').mockReturnValue('community');
|
||||
const res = await request(app)
|
||||
.post('/api/notification-routes')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ name: 'x', stack_patterns: ['app'], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('PUT /api/notification-routes/:id returns 403 PAID_REQUIRED on Community', async () => {
|
||||
vi.spyOn(licenseService, 'getTier').mockReturnValue('community');
|
||||
const res = await request(app).put('/api/notification-routes/1').set('Cookie', authCookie).send({ name: 'x' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('DELETE /api/notification-routes/:id returns 403 PAID_REQUIRED on Community', async () => {
|
||||
vi.spyOn(licenseService, 'getTier').mockReturnValue('community');
|
||||
const res = await request(app).delete('/api/notification-routes/1').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('POST /api/notification-routes/:id/test returns 403 PAID_REQUIRED on Community', async () => {
|
||||
vi.spyOn(licenseService, 'getTier').mockReturnValue('community');
|
||||
const res = await request(app).post('/api/notification-routes/1/test').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Agents Auth (now requires authMiddleware) ---
|
||||
@@ -534,9 +481,8 @@ describe('DELETE /api/notifications/:id - validation', () => {
|
||||
describe('GET /api/notifications - history', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// Restore the license spies the suite relies on after a full mock reset.
|
||||
vi.spyOn(licenseService, 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(licenseService, 'getVariant').mockReturnValue('admiral');
|
||||
// Restore the license spy the suite relies on after a full mock reset.
|
||||
vi.spyOn(licenseService, 'getTier').mockReturnValue('community');
|
||||
});
|
||||
|
||||
it('returns 200 with an array for an authenticated user', async () => {
|
||||
|
||||
@@ -30,13 +30,12 @@ describe('console_session token parity (HTTP route vs mint helper)', () => {
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
// POST /api/system/console-token is Admiral-gated. Seed an active Admiral
|
||||
// license so the parity assertion can observe the token the route returns.
|
||||
// The license_last_validated fallback is skipped when the state key is
|
||||
// absent, so we only need the two keys that drive requireAdmiral.
|
||||
// POST /api/system/console-token is paid-gated. Seed an active license so the
|
||||
// parity assertion can observe the token the route returns. The
|
||||
// license_last_validated fallback is skipped when the state key is absent, so
|
||||
// the active status alone drives the paid tier.
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
DatabaseService.getInstance().setSystemState('license_status', 'active');
|
||||
DatabaseService.getInstance().setSystemState('license_variant_type', 'admiral');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -100,7 +100,6 @@ beforeAll(async () => {
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(null);
|
||||
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let adminCookie: string;
|
||||
let viewerCookie: string;
|
||||
let variantSpy: ReturnType<typeof vi.spyOn>;
|
||||
let tierSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -22,8 +21,6 @@ beforeAll(async () => {
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
variantSpy = vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
@@ -41,7 +38,7 @@ beforeEach(() => {
|
||||
// Start each test with an empty scheduled_tasks table.
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
db.prepare('DELETE FROM scheduled_tasks').run();
|
||||
variantSpy.mockReturnValue('admiral');
|
||||
tierSpy.mockReturnValue('paid');
|
||||
});
|
||||
|
||||
describe('GET /api/scheduled-tasks', () => {
|
||||
@@ -91,7 +88,7 @@ describe('GET /api/scheduled-tasks', () => {
|
||||
expect(Array.isArray(res.body[0].next_runs)).toBe(true);
|
||||
});
|
||||
|
||||
it('shows every action to Skipper users', async () => {
|
||||
it('shows every action to admins', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const now = Date.now();
|
||||
db.createScheduledTask({
|
||||
@@ -151,7 +148,6 @@ describe('GET /api/scheduled-tasks', () => {
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
});
|
||||
variantSpy.mockReturnValue('individual');
|
||||
|
||||
const res = await request(app).get('/api/scheduled-tasks').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -410,32 +406,32 @@ describe('POST /api/scheduled-tasks - new lifecycle actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/scheduled-tasks - Skipper tier gating', () => {
|
||||
describe('POST /api/scheduled-tasks - available on the Community tier', () => {
|
||||
beforeEach(() => {
|
||||
variantSpy.mockReturnValue('skipper');
|
||||
tierSpy.mockReturnValue('community');
|
||||
});
|
||||
|
||||
it('allows Skipper admins to create update tasks', async () => {
|
||||
it('allows Community admins to create update tasks', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: 'skipper-update', target_type: 'stack', target_id: 'my-stack', node_id: 1,
|
||||
name: 'community-update', target_type: 'stack', target_id: 'my-stack', node_id: 1,
|
||||
action: 'update', cron_expression: '0 3 * * *', enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.action).toBe('update');
|
||||
});
|
||||
|
||||
it('allows Skipper admins to create scan tasks', async () => {
|
||||
it('allows Community admins to create scan tasks', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: 'skipper-scan', target_type: 'system', node_id: 1,
|
||||
name: 'community-scan', target_type: 'system', node_id: 1,
|
||||
action: 'scan', cron_expression: '0 0 * * *', enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.action).toBe('scan');
|
||||
});
|
||||
|
||||
it('allows Skipper admins to create snapshot tasks', async () => {
|
||||
it('allows Community admins to create snapshot tasks', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: 'skipper-snapshot', target_type: 'fleet', node_id: 1,
|
||||
name: 'community-snapshot', target_type: 'fleet', node_id: 1,
|
||||
action: 'snapshot', cron_expression: '0 1 * * *', enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
@@ -443,9 +439,9 @@ describe('POST /api/scheduled-tasks - Skipper tier gating', () => {
|
||||
});
|
||||
|
||||
for (const action of ['restart', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start']) {
|
||||
it(`allows Skipper admins to create ${action} tasks`, async () => {
|
||||
it(`allows Community admins to create ${action} tasks`, async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: `skipper-${action}`, target_type: 'stack', target_id: 'my-stack', node_id: 1,
|
||||
name: `community-${action}`, target_type: 'stack', target_id: 'my-stack', node_id: 1,
|
||||
action, cron_expression: '0 3 * * *', enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
@@ -453,24 +449,14 @@ describe('POST /api/scheduled-tasks - Skipper tier gating', () => {
|
||||
});
|
||||
}
|
||||
|
||||
it('allows Skipper admins to create prune tasks', async () => {
|
||||
it('allows Community admins to create prune tasks', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: 'skipper-prune', target_type: 'system', node_id: 1,
|
||||
name: 'community-prune', target_type: 'system', node_id: 1,
|
||||
action: 'prune', cron_expression: '0 4 * * *', enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.action).toBe('prune');
|
||||
});
|
||||
|
||||
it('rejects Community admins from creating any scheduled task with 403', async () => {
|
||||
tierSpy.mockReturnValueOnce('community');
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: 'community-update', target_type: 'stack', target_id: 'my-stack', node_id: 1,
|
||||
action: 'update', cron_expression: '0 3 * * *', enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ const {
|
||||
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode,
|
||||
mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus,
|
||||
mockMarkStaleRunsAsFailed, mockDeleteOldScans,
|
||||
mockGetTier, mockGetVariant,
|
||||
mockGetTier,
|
||||
mockDispatchAlert,
|
||||
mockGetProxyTarget,
|
||||
mockIsTrivyAvailable,
|
||||
@@ -34,7 +34,6 @@ const {
|
||||
mockMarkStaleRunsAsFailed: vi.fn().mockReturnValue(0),
|
||||
mockDeleteOldScans: vi.fn().mockReturnValue(0),
|
||||
mockGetTier: vi.fn().mockReturnValue('paid'),
|
||||
mockGetVariant: vi.fn().mockReturnValue('admiral'),
|
||||
mockDispatchAlert: vi.fn().mockResolvedValue(undefined),
|
||||
mockGetProxyTarget: vi.fn().mockReturnValue(null),
|
||||
mockIsTrivyAvailable: vi.fn().mockReturnValue(true),
|
||||
@@ -65,7 +64,6 @@ vi.mock('../services/LicenseService', () => ({
|
||||
LicenseService: {
|
||||
getInstance: () => ({
|
||||
getTier: mockGetTier,
|
||||
getVariant: mockGetVariant,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -12,7 +12,7 @@ const {
|
||||
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode,
|
||||
mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus,
|
||||
mockMarkStaleRunsAsFailed, mockDeleteOldScans,
|
||||
mockGetTier, mockGetVariant, mockGetProxyHeaders,
|
||||
mockGetTier, mockGetProxyHeaders,
|
||||
mockGetContainersByStack, mockRestartContainer, mockPruneSystem,
|
||||
mockUpdateStack,
|
||||
mockGetStacks, mockGetStackContent, mockGetEnvContent,
|
||||
@@ -42,8 +42,7 @@ const {
|
||||
mockMarkStaleRunsAsFailed: vi.fn().mockReturnValue(0),
|
||||
mockDeleteOldScans: vi.fn().mockReturnValue(0),
|
||||
mockGetTier: vi.fn().mockReturnValue('paid'),
|
||||
mockGetVariant: vi.fn().mockReturnValue('admiral'),
|
||||
mockGetProxyHeaders: vi.fn().mockReturnValue({ tier: 'paid', variant: 'admiral' }),
|
||||
mockGetProxyHeaders: vi.fn().mockReturnValue({ tier: 'paid' }),
|
||||
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
|
||||
mockRestartContainer: vi.fn().mockResolvedValue(undefined),
|
||||
mockPruneSystem: vi.fn().mockResolvedValue({ success: true, reclaimedBytes: 0 }),
|
||||
@@ -102,7 +101,6 @@ vi.mock('../services/LicenseService', () => ({
|
||||
LicenseService: {
|
||||
getInstance: () => ({
|
||||
getTier: mockGetTier,
|
||||
getVariant: mockGetVariant,
|
||||
getProxyHeaders: mockGetProxyHeaders,
|
||||
}),
|
||||
},
|
||||
@@ -196,12 +194,11 @@ import { SchedulerService } from '../services/SchedulerService';
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// clearAllMocks only clears call history, not implementations, so restore the
|
||||
// mocks that individual tests mutate (tier, variant, node lookup, proxy
|
||||
// target) to their documented defaults. Without this a test that points
|
||||
// getNode at a remote node or drops the tier leaks that state into every
|
||||
// later test in the file.
|
||||
// mocks that individual tests mutate (tier, node lookup, proxy target) to
|
||||
// their documented defaults. Without this a test that points getNode at a
|
||||
// remote node or drops the tier leaks that state into every later test in the
|
||||
// file.
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetNode.mockReturnValue({ id: 1, name: 'local', type: 'local', status: 'online' });
|
||||
mockGetProxyTarget.mockReturnValue(null);
|
||||
// Default: the scan-policy gate allows. Individual tests override to a block.
|
||||
@@ -265,7 +262,7 @@ describe('SchedulerService - calculateRunsWithin', () => {
|
||||
|
||||
// ── License gating ─────────────────────────────────────────────────────
|
||||
|
||||
describe('SchedulerService - license gating', () => {
|
||||
describe('SchedulerService - scheduled tasks run on every tier', () => {
|
||||
function makeTask(overrides: Partial<any> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -281,19 +278,20 @@ describe('SchedulerService - license gating', () => {
|
||||
};
|
||||
}
|
||||
|
||||
it('skips all tasks when tier is not pro', async () => {
|
||||
it('runs tasks on the Community tier (no paid gate)', async () => {
|
||||
mockGetTier.mockReturnValue('community');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask()]);
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await (svc as any).tick();
|
||||
|
||||
expect(mockCreateScheduledTaskRun).not.toHaveBeenCalled();
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows update tasks for non-admiral pro', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('individual');
|
||||
it('runs update tasks on the Community tier', async () => {
|
||||
mockGetTier.mockReturnValue('community');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'update' })]);
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: false });
|
||||
@@ -306,22 +304,8 @@ describe('SchedulerService - license gating', () => {
|
||||
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('executes restart tasks for non-admiral pro (Skipper)', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('individual');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]);
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await (svc as any).tick();
|
||||
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows snapshot tasks for non-admiral pro (Skipper)', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('individual');
|
||||
it('runs snapshot tasks on the Community tier', async () => {
|
||||
mockGetTier.mockReturnValue('community');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'snapshot', target_type: 'fleet' })]);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
@@ -331,9 +315,8 @@ describe('SchedulerService - license gating', () => {
|
||||
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows all actions for admiral (pro + team)', async () => {
|
||||
it('runs tasks on the paid tier', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]);
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
||||
|
||||
@@ -350,7 +333,6 @@ describe('SchedulerService - license gating', () => {
|
||||
describe('SchedulerService - concurrent task prevention', () => {
|
||||
it('does not execute a task that is already in runningTasks', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetDueScheduledTasks.mockReturnValue([{
|
||||
id: 42,
|
||||
name: 'running-task',
|
||||
@@ -375,7 +357,6 @@ describe('SchedulerService - concurrent task prevention', () => {
|
||||
|
||||
it('removes task from runningTasks after completion', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
@@ -629,12 +610,9 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
expect(mockClearStackUpdateStatus).toHaveBeenCalledWith(1, 'web-app');
|
||||
});
|
||||
|
||||
it('does not run a scheduled update on the community tier', async () => {
|
||||
// Scheduled tasks are paid-only at every entry point (the tick tier check and
|
||||
// the manual-run route both require paid), and executeTask guards again, so a
|
||||
// community licence never runs the update. Hub-driven updates to a community
|
||||
// remote worker take a different path (the /auto-update/execute route, which
|
||||
// derives atomicity from the proxy tier header) and are unaffected.
|
||||
it('runs a scheduled update on the community tier (no paid gate)', async () => {
|
||||
// Scheduled tasks are free, so a community licence runs the update like any
|
||||
// other tier.
|
||||
mockGetTier.mockReturnValue('community');
|
||||
mockGetScheduledTask.mockReturnValue({
|
||||
id: 82,
|
||||
@@ -647,14 +625,16 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
created_by: 'admin',
|
||||
last_status: null,
|
||||
});
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true });
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(82);
|
||||
|
||||
expect(mockUpdateStack).not.toHaveBeenCalled();
|
||||
expect(mockUpdateStack).toHaveBeenCalledWith('web-app', undefined, true);
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ status: 'failure' }),
|
||||
expect.objectContaining({ status: 'success' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1280,7 +1260,6 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
describe('SchedulerService - cleanup', () => {
|
||||
it('calls cleanupOldTaskRuns(30) on every tick', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
mockGetDueScheduledTasks.mockReturnValue([]);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
@@ -1294,18 +1273,17 @@ describe('SchedulerService - cleanup', () => {
|
||||
|
||||
describe('SchedulerService - isProcessing guard', () => {
|
||||
it('skips tick if already processing', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
(svc as any).isProcessing = true;
|
||||
|
||||
await (svc as any).tick();
|
||||
|
||||
expect(mockGetTier).not.toHaveBeenCalled();
|
||||
// Short-circuits before fetching due tasks (the first DB call inside tick).
|
||||
expect(mockGetDueScheduledTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets isProcessing after tick completes (even on error)', async () => {
|
||||
mockGetTier.mockImplementationOnce(() => { throw new Error('boom'); });
|
||||
mockGetDueScheduledTasks.mockImplementationOnce(() => { throw new Error('boom'); });
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await (svc as any).tick();
|
||||
@@ -1489,7 +1467,6 @@ describe('SchedulerService - executeUpdateRemote', () => {
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': 'Bearer test-token',
|
||||
'x-sencho-tier': 'paid',
|
||||
'x-sencho-variant': 'admiral',
|
||||
}),
|
||||
body: JSON.stringify({ target: 'web-app' }),
|
||||
})
|
||||
@@ -1606,9 +1583,8 @@ describe('SchedulerService - lifecycle actions', () => {
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'failure' }));
|
||||
});
|
||||
|
||||
it('non-admiral paid tier executes lifecycle actions', async () => {
|
||||
it('paid tier executes lifecycle actions', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('standard');
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_stop'));
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeLifecycleTask('auto_stop')]);
|
||||
|
||||
@@ -1630,7 +1606,6 @@ describe('SchedulerService - lifecycle remote proxy', () => {
|
||||
const remoteHeaders = expect.objectContaining({
|
||||
'Authorization': 'Bearer tkn',
|
||||
'x-sencho-tier': 'paid',
|
||||
'x-sencho-variant': 'admiral',
|
||||
});
|
||||
|
||||
function stubRemote(okBody: unknown = { success: true }) {
|
||||
@@ -1783,17 +1758,16 @@ describe('SchedulerService - lifecycle remote proxy', () => {
|
||||
|
||||
// ── Unpaid-tier guard in executeTask ────────────────────────────────────
|
||||
|
||||
describe('SchedulerService - unpaid tier guard', () => {
|
||||
it('records a failed run and does not execute when the licence is not paid', async () => {
|
||||
describe('SchedulerService - community tier runs lifecycle actions', () => {
|
||||
it('executes a lifecycle action and records success on the community tier', async () => {
|
||||
mockGetTier.mockReturnValue('community');
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_stop'));
|
||||
await SchedulerService.getInstance().triggerTask(300);
|
||||
expect(mockRunCommand).not.toHaveBeenCalled();
|
||||
// The skip is visible in run history rather than silently dropped.
|
||||
expect(mockRunCommand).toHaveBeenCalled();
|
||||
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ status: 'failure', error: expect.stringContaining('paid licence') }),
|
||||
expect.objectContaining({ status: 'success' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,8 +76,6 @@ beforeAll(async () => {
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* The route flips the global `deploy_block_honor_suppressions` setting that the
|
||||
* pre-deploy policy gate reads to decide whether a suppressed CVE still counts
|
||||
* toward a block-on-deploy policy. It must be reachable only by an admin on a
|
||||
* paid (Skipper or Admiral) tier, matching the trivy-auto-update toggle.
|
||||
* paid (Admiral) tier, matching the trivy-auto-update toggle.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -23,8 +23,6 @@ beforeAll(async () => {
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* The route flips the global `trivy_auto_update` setting that the scheduler
|
||||
* reads every 24h to decide whether to pull newer Trivy binary releases.
|
||||
* It must be reachable only by an admin on a paid (Skipper or Admiral) tier.
|
||||
* It must be reachable only by an admin on a paid (Admiral) tier.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -22,8 +22,6 @@ beforeAll(async () => {
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
@@ -20,8 +20,6 @@ beforeAll(async () => {
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
@@ -274,12 +272,12 @@ describe('PATCH /api/settings (bulk update)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admiral-only setting keys (audit_retention_days)', () => {
|
||||
// audit_retention_days configures the Admiral-only audit log, so its write is
|
||||
// gated by requireAdmiral in addition to the admin role. beforeAll mocks a
|
||||
// paid Admiral license; individual tests override the variant to simulate an
|
||||
// admin whose license is not Admiral.
|
||||
it('allows an Admiral admin to write audit_retention_days', async () => {
|
||||
describe('Paid-only setting keys (audit_retention_days)', () => {
|
||||
// audit_retention_days configures the paid audit log, so its write is
|
||||
// gated by requirePaid in addition to the admin role. beforeAll mocks a
|
||||
// paid license; individual tests override the tier to simulate a Community
|
||||
// admin.
|
||||
it('allows a paid admin to write audit_retention_days', async () => {
|
||||
const res = await request(app)
|
||||
.patch('/api/settings')
|
||||
.set('Cookie', adminCookie)
|
||||
@@ -288,43 +286,43 @@ describe('Admiral-only setting keys (audit_retention_days)', () => {
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().audit_retention_days).toBe('120');
|
||||
});
|
||||
|
||||
it('rejects an audit_retention_days PATCH from a non-Admiral admin (403) and does not apply it', async () => {
|
||||
it('rejects an audit_retention_days PATCH from a Community admin (403) and does not apply it', async () => {
|
||||
const before = DatabaseService.getInstance().getGlobalSettings().audit_retention_days;
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const spy = vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.patch('/api/settings')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ audit_retention_days: 200 });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().audit_retention_days).toBe(before);
|
||||
} finally {
|
||||
spy.mockReturnValue('admiral');
|
||||
spy.mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an audit_retention_days single-key POST from a non-Admiral admin (403) and does not apply it', async () => {
|
||||
it('rejects an audit_retention_days single-key POST from a Community admin (403) and does not apply it', async () => {
|
||||
const before = DatabaseService.getInstance().getGlobalSettings().audit_retention_days;
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const spy = vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/settings')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ key: 'audit_retention_days', value: '300' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().audit_retention_days).toBe(before);
|
||||
} finally {
|
||||
spy.mockReturnValue('admiral');
|
||||
spy.mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
|
||||
it('still lets a non-Admiral admin write non-Admiral keys via PATCH and POST (gate is per-key)', async () => {
|
||||
it('still lets a Community admin write non-paid keys via PATCH and POST (gate is per-key)', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const spy = vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
const patchRes = await request(app)
|
||||
.patch('/api/settings')
|
||||
@@ -337,7 +335,7 @@ describe('Admiral-only setting keys (audit_retention_days)', () => {
|
||||
.send({ key: 'host_ram_limit', value: '55' });
|
||||
expect(postRes.status).toBe(200);
|
||||
} finally {
|
||||
spy.mockReturnValue('admiral');
|
||||
spy.mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('SSO Config Endpoints (Protected)', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET /api/sso/config returns 200 with admin token (no Admiral required)', async () => {
|
||||
it('GET /api/sso/config returns 200 with admin token (no paid tier required)', async () => {
|
||||
const res = await supertest(app)
|
||||
.get('/api/sso/config')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
@@ -113,12 +113,6 @@ describe('SSO OIDC Callback', () => {
|
||||
});
|
||||
|
||||
describe('SSO User Provisioning', () => {
|
||||
// Mock LicenseService to return team variant (unlimited seats) for provisioning tests
|
||||
beforeAll(async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -294,11 +288,6 @@ describe('Database migration - SSO columns', () => {
|
||||
});
|
||||
|
||||
describe('SSO Role Sync on Re-Login', () => {
|
||||
beforeAll(async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -352,50 +341,6 @@ describe('SSO Role Sync on Re-Login', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSO Seat Limit Enforcement', () => {
|
||||
it('downgrades new admin to viewer when admin seats are full', async () => {
|
||||
const { SSOService } = await import('../services/SSOService');
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
|
||||
// Mock: 1 admin seat max (already used by testadmin)
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: 1, maxViewers: null });
|
||||
|
||||
const sso = SSOService.getInstance();
|
||||
const user = sso.provisionUser({
|
||||
authProvider: 'oidc_google',
|
||||
providerId: 'seat-limit-admin-test',
|
||||
preferredUsername: 'seatlimit_admin',
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
// Should be downgraded to viewer since admin seat is taken
|
||||
expect(user.role).toBe('viewer');
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('throws when all viewer seats are full', async () => {
|
||||
const { SSOService } = await import('../services/SSOService');
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
// Count existing viewers to set a tight limit
|
||||
const currentViewers = db.getViewerCount();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: 1, maxViewers: currentViewers });
|
||||
|
||||
const sso = SSOService.getInstance();
|
||||
expect(() => sso.provisionUser({
|
||||
authProvider: 'oidc_google',
|
||||
providerId: 'seat-limit-viewer-test',
|
||||
preferredUsername: 'seatlimit_viewer',
|
||||
role: 'viewer',
|
||||
})).toThrow('User seat limit reached');
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LDAP Filter Escaping', () => {
|
||||
it('escapes special characters in LDAP filters', async () => {
|
||||
const { SSOService } = await import('../services/SSOService');
|
||||
@@ -412,12 +357,12 @@ describe('LDAP Filter Escaping', () => {
|
||||
|
||||
describe('SSO Config Validation on PUT', () => {
|
||||
// Validation tests exercise the required-field checks inside PUT. Per-provider
|
||||
// tier gates run before validation, so mock the license to Admiral here to keep
|
||||
// these tests focused on validation logic; tier-gate coverage lives in its own block.
|
||||
// tier gates run before validation, so mock the license to the paid tier here
|
||||
// to keep these tests focused on validation logic; tier-gate coverage lives in
|
||||
// its own block.
|
||||
beforeAll(async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -637,29 +582,27 @@ describe('SSO OIDC Callback - Additional Error Handling', () => {
|
||||
});
|
||||
|
||||
describe('SSO Config Tier Gating (per-provider)', () => {
|
||||
// Per-provider tier rules: Custom OIDC = admin only, preset OIDC (Google/GitHub/Okta) = Skipper+, LDAP = Admiral.
|
||||
// The matrix below covers mutations only; GET /sso/config (list) intentionally stays tier-ungated so
|
||||
// downgraded admins can still see previously-configured providers.
|
||||
// Per-provider tier rules: Custom OIDC and preset OIDC (Google/GitHub/Okta) are
|
||||
// free; only LDAP requires the paid tier. The matrix below covers mutations
|
||||
// only; GET /sso/config (list) intentionally stays tier-ungated so downgraded
|
||||
// admins can still see previously-configured providers.
|
||||
let tierSpy: ReturnType<typeof vi.spyOn>;
|
||||
let variantSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier');
|
||||
variantSpy = vi.spyOn(LicenseService.getInstance(), 'getVariant');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const setTier = (tier: 'community' | 'paid', variant: 'skipper' | 'admiral' | null): void => {
|
||||
const setTier = (tier: 'community' | 'paid'): void => {
|
||||
tierSpy.mockReturnValue(tier);
|
||||
variantSpy.mockReturnValue(variant);
|
||||
};
|
||||
|
||||
describe('community tier', () => {
|
||||
beforeAll(() => setTier('community', null));
|
||||
beforeAll(() => setTier('community'));
|
||||
|
||||
it('PUT oidc_custom succeeds (no tier gate)', async () => {
|
||||
const res = await supertest(app)
|
||||
@@ -669,16 +612,29 @@ describe('SSO Config Tier Gating (per-provider)', () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('PUT oidc_google returns 403 PAID_REQUIRED', async () => {
|
||||
it('PUT oidc_google succeeds (presets are free)', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/oidc_google')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('PUT ldap returns 403 PAID_REQUIRED (tier check precedes variant check)', async () => {
|
||||
it('DELETE oidc_github succeeds (presets are free)', async () => {
|
||||
const res = await supertest(app)
|
||||
.delete('/api/sso/config/oidc_github')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('POST oidc_okta/test reaches the handler (presets are free, not tier-gated)', async () => {
|
||||
const res = await supertest(app)
|
||||
.post('/api/sso/config/oidc_okta/test')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it('PUT ldap returns 403 PAID_REQUIRED', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/ldap')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
@@ -687,22 +643,6 @@ describe('SSO Config Tier Gating (per-provider)', () => {
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('DELETE oidc_github returns 403 PAID_REQUIRED', async () => {
|
||||
const res = await supertest(app)
|
||||
.delete('/api/sso/config/oidc_github')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('POST oidc_okta/test returns 403 PAID_REQUIRED', async () => {
|
||||
const res = await supertest(app)
|
||||
.post('/api/sso/config/oidc_okta/test')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('GET /sso/config (list) still returns 200 — list is tier-ungated', async () => {
|
||||
const res = await supertest(app)
|
||||
.get('/api/sso/config')
|
||||
@@ -712,45 +652,8 @@ describe('SSO Config Tier Gating (per-provider)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('skipper tier', () => {
|
||||
beforeAll(() => setTier('paid', 'skipper'));
|
||||
|
||||
it('PUT oidc_custom succeeds', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/oidc_custom')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('PUT oidc_google succeeds', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/oidc_google')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('PUT ldap returns 403 ADMIRAL_REQUIRED', async () => {
|
||||
const res = await supertest(app)
|
||||
.put('/api/sso/config/ldap')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
|
||||
it('DELETE ldap returns 403 ADMIRAL_REQUIRED', async () => {
|
||||
const res = await supertest(app)
|
||||
.delete('/api/sso/config/ldap')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('admiral tier', () => {
|
||||
beforeAll(() => setTier('paid', 'admiral'));
|
||||
describe('paid tier', () => {
|
||||
beforeAll(() => setTier('paid'));
|
||||
|
||||
it('PUT ldap succeeds', async () => {
|
||||
const res = await supertest(app)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Integration tests for POST /api/stacks/:stackName/backup, the on-demand
|
||||
* stack-files backup trigger. Covers auth, role, paid gating, the success
|
||||
* path, the missing-stack 404, name validation, and error propagation. The
|
||||
* route exists so a scheduled auto_backup can run on a remote node through the
|
||||
* proxy path, and so an operator can take a snapshot on demand.
|
||||
* stack-files backup trigger. Covers auth, role, the success path, the
|
||||
* missing-stack 404, name validation, and error propagation. The route exists
|
||||
* so a scheduled auto_backup can run on a remote node through the proxy path,
|
||||
* and so an operator can take a snapshot on demand. The backup is available on
|
||||
* every tier (no paid gate).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -39,7 +40,6 @@ beforeAll(async () => {
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
@@ -82,11 +82,12 @@ describe('POST /api/stacks/:stackName/backup', () => {
|
||||
expect(mockBackupStackFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 403 on the community tier', async () => {
|
||||
it('backs up on the community tier (no paid gate)', async () => {
|
||||
tierSpy.mockReturnValue('community');
|
||||
const res = await request(app).post('/api/stacks/web/backup').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockBackupStackFiles).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(mockBackupStackFiles).toHaveBeenCalledWith('web');
|
||||
});
|
||||
|
||||
it('returns 404 when the stack does not exist', async () => {
|
||||
|
||||
@@ -330,7 +330,7 @@ describe('POST /api/stacks/bulk execution', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 403 on update action when caller is not on a paid tier', async () => {
|
||||
it('allows the update action on the community tier (bulk ops are free)', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
@@ -339,8 +339,8 @@ describe('POST /api/stacks/bulk execution', () => {
|
||||
.set('Cookie', authCookie)
|
||||
.send({ action: 'update', stackNames: ['web'] });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockUpdateStack).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockUpdateStack).toHaveBeenCalled();
|
||||
} finally {
|
||||
tierSpy.mockRestore();
|
||||
}
|
||||
|
||||
@@ -21,8 +21,6 @@ beforeAll(async () => {
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
@@ -39,8 +37,6 @@ beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined);
|
||||
vi.spyOn(FileSystemService.prototype, 'deleteStack').mockResolvedValue(undefined);
|
||||
|
||||
@@ -101,8 +101,6 @@ beforeAll(async () => {
|
||||
|
||||
// Default: paid tier so most tests pass the tier gate
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
@@ -123,8 +121,6 @@ beforeEach(() => {
|
||||
// overrides via mockReturnValueOnce don't accumulate across tests.
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
});
|
||||
|
||||
// ── GET /:stackName/files ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -219,8 +219,7 @@ describe('deploy_failure notification on /deploy error', () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/myapp/deploy')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'skipper');
|
||||
.set('x-sencho-tier', 'paid');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockDeployStack.mock.calls[0][2]).toBe(true);
|
||||
@@ -349,8 +348,7 @@ describe('deploy_failure notification on /update error', () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/myapp/update')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'skipper');
|
||||
.set('x-sencho-tier', 'paid');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockUpdateStack.mock.calls[0][2]).toBe(true);
|
||||
|
||||
@@ -40,8 +40,6 @@ beforeAll(async () => {
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
@@ -46,19 +46,17 @@ describe('WebSocket upgrade dispatch order', () => {
|
||||
sessionCookie = `sencho_token=${token}`;
|
||||
|
||||
// Existing /api/mesh/proxy-tunnel scope tests assume the receiver
|
||||
// license clears the Admiral check. Set the license to Admiral so
|
||||
// license clears the paid check. Set the license to active (paid) so
|
||||
// the credential-only assertions below still hold; the dedicated
|
||||
// license-gating describe block flips and restores per-test.
|
||||
DatabaseService.getInstance().setSystemState('license_status', 'active');
|
||||
DatabaseService.getInstance().setSystemState('license_variant_type', 'admiral');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clear the Admiral state beforeAll set so this file does not leak
|
||||
// Clear the paid state beforeAll set so this file does not leak
|
||||
// license context into other tests sharing the same test DB.
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
DatabaseService.getInstance().setSystemState('license_status', 'community');
|
||||
DatabaseService.getInstance().setSystemState('license_variant_type', '');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
@@ -235,49 +233,35 @@ describe('WebSocket upgrade dispatch order', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/mesh/proxy-tunnel Admiral entitlement gating', () => {
|
||||
// Admiral entitlement on the WS data plane is decided against the
|
||||
describe('/api/mesh/proxy-tunnel paid entitlement gating', () => {
|
||||
// Paid entitlement on the WS data plane is decided against the
|
||||
// *central's* asserted tier, matching the HTTP mesh routes
|
||||
// (requireAdmiral in routes/mesh.ts reads req.proxyTier from forwarded
|
||||
// (requirePaid in routes/mesh.ts reads req.proxyTier from forwarded
|
||||
// headers off the node_proxy credential). The WS dispatcher trusts
|
||||
// x-sencho-tier / x-sencho-variant only when the upgrade carries a
|
||||
// node_proxy JWT; when no headers are present, or when the credential
|
||||
// is a full-admin api_token (no central is asserting tier), it falls
|
||||
// back to the receiver's own license. These tests pin both branches:
|
||||
// (a) the trusted-header path accepts an Admiral central even on a
|
||||
// Community receiver, and rejects a Community central on an
|
||||
// Admiral receiver;
|
||||
// x-sencho-tier only when the upgrade carries a node_proxy JWT; when no
|
||||
// header is present, or when the credential is a full-admin api_token
|
||||
// (no central is asserting tier), it falls back to the receiver's own
|
||||
// license. These tests pin both branches:
|
||||
// (a) the trusted-header path accepts a paid central even on a
|
||||
// Community receiver, and rejects a Community central on a
|
||||
// paid receiver;
|
||||
// (b) the local-fallback path keeps the receiver-license check intact
|
||||
// for full-admin api_token upgrades and for header-less node_proxy
|
||||
// upgrades.
|
||||
|
||||
async function setLicense(status: string, variantType: string | null): Promise<void> {
|
||||
async function setLicense(status: string): Promise<void> {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
DatabaseService.getInstance().setSystemState('license_status', status);
|
||||
if (variantType === null) {
|
||||
DatabaseService.getInstance().setSystemState('license_variant_type', '');
|
||||
} else {
|
||||
DatabaseService.getInstance().setSystemState('license_variant_type', variantType);
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
// Restore the Admiral state beforeAll established so subsequent
|
||||
// Restore the paid state beforeAll established so subsequent
|
||||
// tests in this file (and the proxy-tunnel scope block) keep passing.
|
||||
await setLicense('active', 'admiral');
|
||||
await setLicense('active');
|
||||
});
|
||||
|
||||
it('rejects a node_proxy Bearer with HTTP 403 when no tier headers and the receiver license is community', async () => {
|
||||
await setLicense('community', null);
|
||||
const nodeProxyToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const ws = connect('/api/mesh/proxy-tunnel', { bearer: nodeProxyToken });
|
||||
const outcome = await waitForOutcome(ws);
|
||||
expect(outcome.kind).toBe('unexpected');
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a node_proxy Bearer with HTTP 403 when no tier headers and the receiver license is paid but Skipper (not Admiral)', async () => {
|
||||
await setLicense('active', 'skipper');
|
||||
await setLicense('community');
|
||||
const nodeProxyToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const ws = connect('/api/mesh/proxy-tunnel', { bearer: nodeProxyToken });
|
||||
const outcome = await waitForOutcome(ws);
|
||||
@@ -286,7 +270,7 @@ describe('WebSocket upgrade dispatch order', () => {
|
||||
});
|
||||
|
||||
it('rejects a full-admin api_token with HTTP 403 when the receiver license is community (forwarded headers ignored on api_token path)', async () => {
|
||||
await setLicense('community', null);
|
||||
await setLicense('community');
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const adminId = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!.id;
|
||||
const rawToken = createTestApiToken({
|
||||
@@ -299,27 +283,27 @@ describe('WebSocket upgrade dispatch order', () => {
|
||||
// local-entitlement credential, not a node_proxy forwarder.
|
||||
const ws = connect('/api/mesh/proxy-tunnel', {
|
||||
bearer: rawToken,
|
||||
extraHeaders: { 'x-sencho-tier': 'paid', 'x-sencho-variant': 'admiral' },
|
||||
extraHeaders: { 'x-sencho-tier': 'paid' },
|
||||
});
|
||||
const outcome = await waitForOutcome(ws);
|
||||
expect(outcome.kind).toBe('unexpected');
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
|
||||
});
|
||||
|
||||
it('accepts a node_proxy Bearer asserting paid+admiral via forwarded headers even when the receiver license is community', async () => {
|
||||
await setLicense('community', null);
|
||||
it('accepts a node_proxy Bearer asserting paid via forwarded headers even when the receiver license is community', async () => {
|
||||
await setLicense('community');
|
||||
const nodeProxyToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const ws = connect('/api/mesh/proxy-tunnel', {
|
||||
bearer: nodeProxyToken,
|
||||
extraHeaders: { 'x-sencho-tier': 'paid', 'x-sencho-variant': 'admiral' },
|
||||
extraHeaders: { 'x-sencho-tier': 'paid' },
|
||||
});
|
||||
const outcome = await waitForOutcome(ws);
|
||||
expect(outcome.kind).toBe('open');
|
||||
try { ws.terminate(); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('rejects a node_proxy Bearer asserting community via forwarded headers even when the receiver license is Admiral', async () => {
|
||||
// Receiver state is already Admiral (set by beforeAll / afterEach).
|
||||
it('rejects a node_proxy Bearer asserting community via forwarded headers even when the receiver license is paid', async () => {
|
||||
// Receiver state is already paid (set by beforeAll / afterEach).
|
||||
const nodeProxyToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const ws = connect('/api/mesh/proxy-tunnel', {
|
||||
bearer: nodeProxyToken,
|
||||
@@ -329,17 +313,6 @@ describe('WebSocket upgrade dispatch order', () => {
|
||||
expect(outcome.kind).toBe('unexpected');
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a node_proxy Bearer asserting paid+skipper via forwarded headers (paid but not Admiral)', async () => {
|
||||
const nodeProxyToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const ws = connect('/api/mesh/proxy-tunnel', {
|
||||
bearer: nodeProxyToken,
|
||||
extraHeaders: { 'x-sencho-tier': 'paid', 'x-sencho-variant': 'skipper' },
|
||||
});
|
||||
const outcome = await waitForOutcome(ws);
|
||||
expect(outcome.kind).toBe('unexpected');
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote dispatch via NodeRegistry.getProxyTarget', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Tests for User Management, RBAC permissions, token versioning (session invalidation),
|
||||
* scoped role assignments, password management, seat limits, and last-admin protection.
|
||||
* scoped role assignments, password management, and last-admin protection.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -32,11 +32,9 @@ beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
// Mock LicenseService to return paid/admiral for RBAC tests
|
||||
// Mock LicenseService to return the paid tier for RBAC tests
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
@@ -126,6 +124,32 @@ describe('POST /api/users', () => {
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('SCOPE_DENIED');
|
||||
});
|
||||
|
||||
it('creates an advanced-role user on the paid tier (201)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ username: 'paid-deployer', password: 'password123', role: 'deployer' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.role).toBe('deployer');
|
||||
DatabaseService.getInstance().deleteUser(res.body.id);
|
||||
});
|
||||
|
||||
it('blocks an advanced-role user on the Community tier (403 PAID_REQUIRED)', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const svc = LicenseService.getInstance();
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ username: 'community-deployer', password: 'password123', role: 'deployer' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
} finally {
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/users/:id', () => {
|
||||
@@ -387,6 +411,22 @@ describe('Scoped Role Assignments', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/users/:id/roles is blocked on the Community tier (PAID_REQUIRED)', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const svc = LicenseService.getInstance();
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post(`/api/users/${targetUserId}/roles`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
} finally {
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- GET /api/permissions/me ----
|
||||
@@ -401,42 +441,6 @@ describe('GET /api/permissions/me', () => {
|
||||
expect(Array.isArray(res.body.globalPermissions)).toBe(true);
|
||||
expect(res.body.globalPermissions).toContain('stack:read');
|
||||
expect(res.body.globalPermissions).toContain('system:users');
|
||||
// beforeAll mocks a paid admiral license.
|
||||
expect(res.body.isAdmiral).toBe(true);
|
||||
});
|
||||
|
||||
it('reports isAdmiral=false when the admiral variant is no longer on a paid tier', async () => {
|
||||
// An expired or downgraded admiral license keeps variant='admiral' but the
|
||||
// effective tier drops to community. isAdmiral must track the effective tier
|
||||
// (mirroring the requireAdmiral guard), not the lingering variant, or the
|
||||
// frontend would unlock admiral-only surfaces that the API then 403s.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const svc = LicenseService.getInstance();
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.get('/api/permissions/me')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.isAdmiral).toBe(false);
|
||||
} finally {
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports isAdmiral=false for a paid non-admiral (skipper) license', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const svc = LicenseService.getInstance();
|
||||
vi.spyOn(svc, 'getVariant').mockReturnValue('skipper');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.get('/api/permissions/me')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.isAdmiral).toBe(false);
|
||||
} finally {
|
||||
vi.spyOn(svc, 'getVariant').mockReturnValue('admiral');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
@@ -463,6 +467,31 @@ describe('GET /api/permissions/me', () => {
|
||||
db.deleteRoleAssignmentsByUser(id);
|
||||
db.deleteUser(id);
|
||||
});
|
||||
|
||||
it('omits scoped permissions on the Community tier even when assignments exist', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
const svc = LicenseService.getInstance();
|
||||
const hash = await bcrypt.hash('password123', 1);
|
||||
const id = db.addUser({ username: 'permcheck-community', password_hash: hash, role: 'viewer' });
|
||||
db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' });
|
||||
const user = db.getUserById(id)!;
|
||||
const token = authToken('permcheck-community', 'viewer', user.token_version);
|
||||
|
||||
// Scoped grants only take effect on paid; a downgraded instance must not
|
||||
// advertise per-resource permissions the API will then 403.
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('community');
|
||||
const res = await request(app)
|
||||
.get('/api/permissions/me')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.scopedPermissions).toEqual({});
|
||||
|
||||
// Cleanup
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('paid');
|
||||
db.deleteRoleAssignmentsByUser(id);
|
||||
db.deleteUser(id);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- PUT /api/auth/password ----
|
||||
@@ -524,37 +553,41 @@ describe('PUT /api/auth/password', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Seat Limit Enforcement ----
|
||||
// ---- User creation is uncapped on every tier ----
|
||||
|
||||
describe('Seat limit enforcement', () => {
|
||||
it('rejects new admin when seat limit reached', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: 1, maxViewers: null });
|
||||
|
||||
const res = await request(app)
|
||||
describe('User creation seat caps', () => {
|
||||
it('creates additional admins and viewers without a seat cap', async () => {
|
||||
const adminRes = await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ username: 'extraadmin', password: 'password123', role: 'admin' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain('maximum');
|
||||
expect(adminRes.status).toBe(201);
|
||||
|
||||
// Restore mock
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
});
|
||||
|
||||
it('rejects new viewer when viewer seat limit reached', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: 0 });
|
||||
|
||||
const res = await request(app)
|
||||
const viewerRes = await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ username: 'extraviewer', password: 'password123', role: 'viewer' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain('maximum');
|
||||
expect(viewerRes.status).toBe(201);
|
||||
|
||||
// Restore mock
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
const db = DatabaseService.getInstance();
|
||||
db.deleteUser(db.getUserByUsername('extraadmin')!.id);
|
||||
db.deleteUser(db.getUserByUsername('extraviewer')!.id);
|
||||
});
|
||||
|
||||
it('creates additional users on the Community tier (no seat cap)', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const svc = LicenseService.getInstance();
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ username: 'communityviewer', password: 'password123', role: 'viewer' });
|
||||
expect(res.status).toBe(201);
|
||||
DatabaseService.getInstance().deleteUser(DatabaseService.getInstance().getUserByUsername('communityviewer')!.id);
|
||||
} finally {
|
||||
vi.spyOn(svc, 'getTier').mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -598,35 +631,13 @@ describe('Last-admin protection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Seat Limit Enforcement On Promotion ----
|
||||
// ---- Role Promotion (uncapped) ----
|
||||
|
||||
describe('Seat limit enforcement on role promotion', () => {
|
||||
it('rejects promoting a viewer to admin when the admin seat limit is reached', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const hash = await bcrypt.hash('password123', 1);
|
||||
const viewerId = db.addUser({ username: 'promoteme', password_hash: hash, role: 'viewer' });
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: 1, maxViewers: null });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/users/${viewerId}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ role: 'admin' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain('maximum');
|
||||
// The role must remain unchanged when the cap blocks the promotion.
|
||||
expect(db.getUser(viewerId)!.role).toBe('viewer');
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
db.deleteUser(viewerId);
|
||||
});
|
||||
|
||||
it('allows promoting a viewer to admin when admin seats are unlimited', async () => {
|
||||
describe('Role promotion', () => {
|
||||
it('promotes a viewer to admin without a seat cap', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const hash = await bcrypt.hash('password123', 1);
|
||||
const viewerId = db.addUser({ username: 'promoteok', password_hash: hash, role: 'viewer' });
|
||||
// Global beforeAll mock already returns unlimited seats; the gate must not over-block.
|
||||
const res = await request(app)
|
||||
.put(`/api/users/${viewerId}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
|
||||
@@ -27,8 +27,8 @@ afterAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
// Webhooks are free; run at the Community tier.
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
});
|
||||
|
||||
describe('node-aware Git source webhooks', () => {
|
||||
|
||||
@@ -52,8 +52,9 @@ afterAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
// Webhooks are free; run at the Community tier to prove the trigger and the
|
||||
// management routes work without a paid license.
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
});
|
||||
|
||||
describe('POST /api/webhooks/:id/trigger: uniform unauthenticated 404 (M1, H3)', () => {
|
||||
@@ -85,23 +86,6 @@ describe('POST /api/webhooks/:id/trigger: uniform unauthenticated 404 (M1, H3)',
|
||||
expect(res.body).toEqual(expected);
|
||||
});
|
||||
|
||||
it('returns the same 404 when the licence tier is not paid', async () => {
|
||||
const { id, secret } = createWebhook();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const body = '{}';
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/webhooks/${id}/trigger`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Webhook-Signature', sign(body, secret))
|
||||
.send(body);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual(expected);
|
||||
// The forbidden code from the prior surface must not leak.
|
||||
expect(res.body.code).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the same 404 when the X-Webhook-Signature header is missing', async () => {
|
||||
const { id } = createWebhook();
|
||||
const body = '{}';
|
||||
|
||||
@@ -7,13 +7,8 @@ import {
|
||||
type ApiTokenScope,
|
||||
} from '../services/DatabaseService';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
||||
import {
|
||||
isLicenseTier,
|
||||
isLicenseVariant,
|
||||
normalizeTier,
|
||||
normalizeVariant,
|
||||
} from '../services/license-normalize';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import { isLicenseTier, normalizeTier } from '../services/license-normalize';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import {
|
||||
COOKIE_NAME,
|
||||
@@ -116,15 +111,9 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
|
||||
// Browser sessions and API tokens cannot set these; only a valid node_proxy JWT (signed with
|
||||
// this instance's JWT secret) unlocks the trusted path.
|
||||
const tierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
|
||||
const variantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
|
||||
if (isLicenseTier(tierHeader)) {
|
||||
req.proxyTier = normalizeTier(tierHeader);
|
||||
}
|
||||
if (isLicenseVariant(variantHeader)) {
|
||||
req.proxyVariant = normalizeVariant(variantHeader);
|
||||
} else if (variantHeader === '') {
|
||||
req.proxyVariant = null;
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { Request, Response } from 'express';
|
||||
import { DatabaseService, type UserRole, type ResourceType } from '../services/DatabaseService';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { effectiveVariant } from './tierGates';
|
||||
import { effectiveTier } from './tierGates';
|
||||
|
||||
// --- Scoped RBAC Permission Engine (Admiral) ---
|
||||
// --- Scoped RBAC Permission Engine (paid) ---
|
||||
|
||||
export type PermissionAction =
|
||||
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
|
||||
@@ -34,7 +34,7 @@ export const ROLE_PERMISSIONS: Record<UserRole, PermissionAction[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Core permission resolver. Admin bypasses all checks; scoped assignments only apply on Admiral. */
|
||||
/** Core permission resolver. Admin bypasses all checks; scoped assignments only apply on the paid tier. */
|
||||
export function checkPermission(
|
||||
req: Request,
|
||||
action: PermissionAction,
|
||||
@@ -51,7 +51,7 @@ export function checkPermission(
|
||||
if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true;
|
||||
|
||||
if (!resourceType || !resourceId) return false;
|
||||
if (effectiveVariant(req) !== 'admiral') return false;
|
||||
if (effectiveTier(req) !== 'paid') return false;
|
||||
|
||||
const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId);
|
||||
if (isDebugEnabled()) console.log('[RBAC:diag] Scoped assignments found:', assignments.length, 'for user:', req.user.userId);
|
||||
|
||||
@@ -1,49 +1,32 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import type { LicenseTier, LicenseVariant } from '../services/license-types';
|
||||
import type { LicenseTier } from '../services/license-types';
|
||||
|
||||
// Tier-based route guards. Each returns true when the request may proceed and
|
||||
// false after sending the appropriate 403 response. Callers MUST check the
|
||||
// return value and `return;` on false.
|
||||
//
|
||||
// Guards trust req.proxyTier/proxyVariant (set by authMiddleware for
|
||||
// node_proxy tokens) ahead of the local entitlement provider so a primary
|
||||
// Sencho instance can assert license state for its remote fleet nodes.
|
||||
// Guards trust req.proxyTier (set by authMiddleware for 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.';
|
||||
const PAID_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();
|
||||
|
||||
/** Effective license variant for this request (proxy header if trusted, else local). */
|
||||
export const effectiveVariant = (req: Request): LicenseVariant =>
|
||||
req.proxyVariant ?? LicenseService.getInstance().getVariant();
|
||||
|
||||
const deny = (res: Response, code: string, error: string): false => {
|
||||
res.status(403).json({ error, code });
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Paid feature guard: requires Skipper or Admiral. */
|
||||
/** Paid feature guard: requires a paid (Admiral) license. */
|
||||
export const requirePaid = (req: Request, res: Response): boolean => {
|
||||
if (effectiveTier(req) !== 'paid') return deny(res, 'PAID_REQUIRED', PAID_MESSAGE);
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Admiral feature guard: requires paid tier with the admiral variant. */
|
||||
export const requireAdmiral = (req: Request, res: Response): boolean => {
|
||||
// Resolve both before branching so every caller observes the same
|
||||
// tier/variant pair (the original behavior; tests mock LicenseService
|
||||
// getters and rely on both being consumed per gate invocation).
|
||||
const tier = effectiveTier(req);
|
||||
const variant = effectiveVariant(req);
|
||||
if (tier !== 'paid') return deny(res, 'PAID_REQUIRED', PAID_MESSAGE);
|
||||
if (variant !== 'admiral') return deny(res, 'ADMIRAL_REQUIRED', ADMIRAL_MESSAGE);
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Admin role guard: the request must be authenticated as an `admin` user. */
|
||||
export const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
if (req.user?.role !== 'admin') return deny(res, 'ADMIN_REQUIRED', 'Admin access required.');
|
||||
@@ -74,14 +57,13 @@ export const requireNodeProxy = (req: Request, res: Response): boolean => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Tier gate for SSO providers. The split is by delivery (turnkey vs self-configured), not by
|
||||
* protocol: Custom OIDC stays free so self-hosters can wire any OIDC IdP (Authelia, Keycloak,
|
||||
* Authentik, Zitadel); paid tiers get one-click presets and LDAP/AD.
|
||||
* Tier gate for SSO providers. Custom OIDC and the one-click presets
|
||||
* (Google / GitHub / Okta) are free so self-hosters can wire any OIDC IdP;
|
||||
* only LDAP / Active Directory requires the paid tier.
|
||||
*/
|
||||
export const requireTierForSsoProvider = (provider: string, req: Request, res: Response): boolean => {
|
||||
if (provider === 'oidc_custom') return true;
|
||||
if (provider === 'ldap') return requireAdmiral(req, res);
|
||||
return requirePaid(req, res);
|
||||
if (provider === 'ldap') return requirePaid(req, res);
|
||||
return true;
|
||||
};
|
||||
|
||||
/** 400s when the request has no object body. Used by endpoints that always expect JSON input. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { isProxyExemptPath } from '../helpers/proxyExemptPaths';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
@@ -49,7 +49,6 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
||||
// state changes within one proxy call.
|
||||
const headers = LicenseService.getInstance().getProxyHeaders();
|
||||
proxyReq.setHeader(PROXY_TIER_HEADER, headers.tier);
|
||||
proxyReq.setHeader(PROXY_VARIANT_HEADER, headers.variant || '');
|
||||
// Strip the ?nodeId= query param so the remote's nodeContextMiddleware
|
||||
// doesn't reject the request with 404 ("Node X not found") - the remote
|
||||
// has no record of the gateway's node IDs and should treat the request
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService, AUDIT_ANOMALY_HISTORY_CAP } from '../services/DatabaseService';
|
||||
import { annotateEntries, computeAuditStats, HISTORY_WINDOW_MS } from '../services/AuditAnomalyService';
|
||||
import { requireAdmiral } from '../middleware/tierGates';
|
||||
import { requirePaid } from '../middleware/tierGates';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { escapeCsvField } from '../utils/csv';
|
||||
@@ -10,7 +10,7 @@ import { sanitizeForLog } from '../utils/safeLog';
|
||||
export const auditLogRouter = Router();
|
||||
|
||||
auditLogRouter.get('/', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requirePermission(req, res, 'system:audit')) return;
|
||||
|
||||
try {
|
||||
@@ -48,7 +48,7 @@ auditLogRouter.get('/', async (req: Request, res: Response): Promise<void> => {
|
||||
});
|
||||
|
||||
auditLogRouter.get('/stats', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requirePermission(req, res, 'system:audit')) return;
|
||||
|
||||
try {
|
||||
@@ -64,7 +64,7 @@ auditLogRouter.get('/stats', async (req: Request, res: Response): Promise<void>
|
||||
});
|
||||
|
||||
auditLogRouter.get('/export', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requirePermission(req, res, 'system:audit')) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmin } from '../middleware/tierGates';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { parseIntParam } from '../utils/parseIntParam';
|
||||
|
||||
@@ -30,7 +30,6 @@ function proxyEntitlementUntil(req: Request): number {
|
||||
}
|
||||
|
||||
autoHealRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const stackName = typeof req.query.stackName === 'string' ? req.query.stackName : undefined;
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
@@ -52,7 +51,6 @@ autoHealRouter.get('/policies', authMiddleware, (req: Request, res: Response): v
|
||||
|
||||
autoHealRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const parsed = AutoHealPolicyCreateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' });
|
||||
@@ -85,7 +83,6 @@ autoHealRouter.post('/policies', authMiddleware, (req: Request, res: Response):
|
||||
|
||||
autoHealRouter.patch('/policies/:id', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
const parsed = AutoHealPolicyUpdateSchema.safeParse(req.body);
|
||||
@@ -107,7 +104,6 @@ autoHealRouter.patch('/policies/:id', authMiddleware, (req: Request, res: Respon
|
||||
|
||||
autoHealRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
try {
|
||||
@@ -123,7 +119,6 @@ autoHealRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Respo
|
||||
});
|
||||
|
||||
autoHealRouter.get('/policies/:id/history', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 100);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmiral, requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import {
|
||||
DatabaseService,
|
||||
type BlueprintSelector,
|
||||
@@ -455,7 +455,7 @@ blueprintsRouter.get('/:id/preview', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express';
|
||||
import { CloudBackupService } from '../services/CloudBackupService';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { CryptoService } from '../services/CryptoService';
|
||||
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
||||
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
@@ -11,18 +11,18 @@ const SECRET_REDACTED = '***';
|
||||
const VALID_PROVIDERS = new Set(['disabled', 'sencho', 'custom']);
|
||||
|
||||
// Provider-aware tier gates. The managed Sencho Cloud Backup target requires
|
||||
// Admiral; the bring-your-own-bucket Custom S3 target is available on every
|
||||
// tier. These wrappers short-circuit to requireAdmiral only when the operation
|
||||
// actually touches the 'sencho' provider.
|
||||
// a paid license; the bring-your-own-bucket Custom S3 target is available on
|
||||
// every tier. These wrappers short-circuit to requirePaid only when the
|
||||
// operation actually touches the 'sencho' provider.
|
||||
|
||||
function gateForCurrentProvider(req: Request, res: Response): boolean {
|
||||
const provider = CloudBackupService.getInstance().getProvider();
|
||||
if (provider === 'sencho') return requireAdmiral(req, res);
|
||||
if (provider === 'sencho') return requirePaid(req, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
function gateForRequestedProvider(req: Request, res: Response, requested: string): boolean {
|
||||
if (requested === 'sencho') return requireAdmiral(req, res);
|
||||
if (requested === 'sencho') return requirePaid(req, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ cloudBackupRouter.post('/test', async (req: Request, res: Response): Promise<voi
|
||||
cloudBackupRouter.post('/provision', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const result = await CloudBackupService.getInstance().provisionSenchoCloudBackup();
|
||||
if (!result.success) {
|
||||
@@ -169,7 +169,7 @@ cloudBackupRouter.post('/provision', async (req: Request, res: Response): Promis
|
||||
|
||||
cloudBackupRouter.get('/usage', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const svc = CloudBackupService.getInstance();
|
||||
if (svc.getProvider() !== 'sencho') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
||||
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { mintConsoleSession } from '../helpers/consoleSession';
|
||||
|
||||
@@ -17,7 +17,7 @@ export const consoleRouter = Router();
|
||||
consoleRouter.post('/console-token', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, 'API tokens cannot generate console tokens.')) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
res.json({ token: mintConsoleSession() });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService, type StackRestartSummary } from '../services/DatabaseService';
|
||||
import { CloudBackupService } from '../services/CloudBackupService';
|
||||
import { effectiveTier, effectiveVariant } from '../middleware/tierGates';
|
||||
import { effectiveTier } from '../middleware/tierGates';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import type { LicenseTier, LicenseVariant } from '../services/license-types';
|
||||
import type { LicenseTier } from '../services/license-types';
|
||||
|
||||
export const dashboardRouter = Router();
|
||||
|
||||
@@ -14,23 +14,22 @@ interface AgentStatus {
|
||||
|
||||
export interface ConfigurationStatus {
|
||||
tier: LicenseTier;
|
||||
variant: LicenseVariant;
|
||||
notifications: {
|
||||
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus };
|
||||
alertRules: number;
|
||||
routingRules: { count: number; enabledCount: number; locked: boolean; requiredTier: 'skipper' };
|
||||
routingRules: { count: number; enabledCount: number; locked: boolean };
|
||||
};
|
||||
automation: {
|
||||
autoHeal: { total: number; enabled: number };
|
||||
autoUpdate: { enabled: number; total: number };
|
||||
scheduledTasks: { total: number; enabled: number; locked: boolean; requiredTier: 'admiral' };
|
||||
webhooks: { total: number; enabled: number; locked: boolean; requiredTier: 'skipper' };
|
||||
scheduledTasks: { total: number; enabled: number; locked: boolean };
|
||||
webhooks: { total: number; enabled: number; locked: boolean };
|
||||
};
|
||||
security: {
|
||||
mfaEnabled: boolean | null;
|
||||
ssoEnabled: boolean;
|
||||
ssoProvider: string | null;
|
||||
scanPolicies: { total: number; enabled: number; locked: boolean; requiredTier: 'skipper' };
|
||||
scanPolicies: { total: number; enabled: number; locked: boolean };
|
||||
};
|
||||
thresholds: {
|
||||
cpuLimit: number;
|
||||
@@ -50,11 +49,9 @@ export function buildLocalConfigurationStatus(
|
||||
nodeId: number,
|
||||
userId: number,
|
||||
tier: LicenseTier,
|
||||
variant: LicenseVariant,
|
||||
): ConfigurationStatus {
|
||||
const db = DatabaseService.getInstance();
|
||||
const isPaid = tier === 'paid';
|
||||
const isAdmiral = isPaid && variant === 'admiral';
|
||||
|
||||
const agents = db.getAgents(nodeId);
|
||||
const agentByType = (type: 'discord' | 'slack' | 'webhook'): AgentStatus => {
|
||||
@@ -90,7 +87,6 @@ export function buildLocalConfigurationStatus(
|
||||
|
||||
return {
|
||||
tier,
|
||||
variant,
|
||||
notifications: {
|
||||
agents: {
|
||||
discord: agentByType('discord'),
|
||||
@@ -98,11 +94,11 @@ export function buildLocalConfigurationStatus(
|
||||
webhook: agentByType('webhook'),
|
||||
},
|
||||
alertRules,
|
||||
// Notification routing is available on every tier.
|
||||
routingRules: {
|
||||
count: notifRoutes.length,
|
||||
enabledCount: notifRoutes.filter(r => r.enabled).length,
|
||||
locked: !isPaid,
|
||||
requiredTier: 'skipper',
|
||||
locked: false,
|
||||
},
|
||||
},
|
||||
automation: {
|
||||
@@ -114,28 +110,28 @@ export function buildLocalConfigurationStatus(
|
||||
enabled: autoUpdateEnabled,
|
||||
total: autoUpdateTotal,
|
||||
},
|
||||
// Scheduled operations are available on every tier.
|
||||
scheduledTasks: {
|
||||
total: scheduledTasks.length,
|
||||
enabled: scheduledTasks.filter(t => t.enabled === 1).length,
|
||||
locked: !isAdmiral,
|
||||
requiredTier: 'admiral',
|
||||
locked: false,
|
||||
},
|
||||
// Webhooks are available on every tier.
|
||||
webhooks: {
|
||||
total: webhooks.length,
|
||||
enabled: webhooks.filter(w => w.enabled).length,
|
||||
locked: !isPaid,
|
||||
requiredTier: 'skipper',
|
||||
locked: false,
|
||||
},
|
||||
},
|
||||
security: {
|
||||
mfaEnabled: mfaRow ? mfaRow.enabled === 1 : null,
|
||||
ssoEnabled: !!enabledSso,
|
||||
ssoProvider: enabledSso?.provider ?? null,
|
||||
// Scan policies (deploy enforcement) require a paid license.
|
||||
scanPolicies: {
|
||||
total: scanPolicies.length,
|
||||
enabled: scanPolicies.filter(p => p.enabled === 1).length,
|
||||
locked: !isPaid,
|
||||
requiredTier: 'skipper',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
@@ -147,8 +143,8 @@ export function buildLocalConfigurationStatus(
|
||||
},
|
||||
backup: {
|
||||
// Cloud Backup has a per-provider tier: Custom S3 is open to every
|
||||
// tier; Sencho Cloud Backup requires Admiral. The row is rendered for
|
||||
// every tier because Custom S3 is universally configurable, so no
|
||||
// tier; Sencho Cloud Backup requires a paid license. The row is rendered
|
||||
// for every tier because Custom S3 is universally configurable, so no
|
||||
// dashboard-level lock is meaningful.
|
||||
provider: cloudProvider,
|
||||
autoUpload: cloudAutoUpload,
|
||||
@@ -165,9 +161,8 @@ dashboardRouter.get('/configuration', (req: Request, res: Response): void => {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const userId = req.user?.userId ?? 0;
|
||||
const tier = effectiveTier(req);
|
||||
const variant = effectiveVariant(req);
|
||||
|
||||
const payload = buildLocalConfigurationStatus(nodeId, userId, tier, variant);
|
||||
const payload = buildLocalConfigurationStatus(nodeId, userId, tier);
|
||||
if (debug) {
|
||||
console.debug(
|
||||
`[Dashboard:debug] /configuration built in ${Date.now() - startedAt} ms (nodeId=${nodeId})`,
|
||||
|
||||
@@ -37,7 +37,7 @@ import { invalidateNodeCaches, invalidateRemoteMetaCache } from '../helpers/cach
|
||||
import { activeBulkActions } from './labels';
|
||||
import { runLocalLabelStop, type LabelLocalStopResponse, type StackStopResult } from '../helpers/fleetLabelStop';
|
||||
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
|
||||
const updateTracker = FleetUpdateTrackerService.getInstance();
|
||||
@@ -586,7 +586,6 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp
|
||||
const userId = req.user?.userId ?? 0;
|
||||
const ls = LicenseService.getInstance();
|
||||
const localTier = ls.getTier();
|
||||
const localVariant = ls.getVariant();
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
nodes.map(async (node: Node): Promise<FleetNodeConfiguration> => {
|
||||
@@ -596,7 +595,7 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp
|
||||
name: node.name,
|
||||
type: 'local',
|
||||
status: 'online',
|
||||
configuration: buildLocalConfigurationStatus(node.id, userId, localTier, localVariant),
|
||||
configuration: buildLocalConfigurationStatus(node.id, userId, localTier),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -612,7 +611,6 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp
|
||||
headers: {
|
||||
...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}),
|
||||
[PROXY_TIER_HEADER]: localTier,
|
||||
[PROXY_VARIANT_HEADER]: localVariant ?? '',
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
},
|
||||
@@ -1557,7 +1555,7 @@ fleetRouter.post('/prune/estimate', authMiddleware, async (req: Request, res: Re
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Fleet Snapshots (manual: Community; scheduled: Skipper+) ───
|
||||
// ─── Fleet Snapshots (manual and scheduled: every tier) ───
|
||||
|
||||
fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
@@ -1764,9 +1762,9 @@ async function remoteStackError(action: string, res: Awaited<ReturnType<typeof f
|
||||
}
|
||||
|
||||
// Builds the base URL + proxy headers for a remote node, or null when the node
|
||||
// has no reachable target. Tier/variant headers describe the central instance
|
||||
// and stay unconditional; the Bearer header is gated on a non-empty token
|
||||
// because pilot-loopback dispatch carries auth via the tunnel.
|
||||
// has no reachable target. The tier header describes the central instance and
|
||||
// stays unconditional; the Bearer header is gated on a non-empty token because
|
||||
// pilot-loopback dispatch carries auth via the tunnel.
|
||||
function buildRemoteProxyContext(node: Node): RemoteProxyContext | null {
|
||||
const proxyTarget = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||
if (!proxyTarget) return null;
|
||||
@@ -1774,7 +1772,6 @@ function buildRemoteProxyContext(node: Node): RemoteProxyContext | null {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
[PROXY_TIER_HEADER]: proxyHeaders.tier,
|
||||
[PROXY_VARIANT_HEADER]: proxyHeaders.variant ?? '',
|
||||
};
|
||||
if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`;
|
||||
return { baseUrl: proxyTarget.apiUrl.replace(/\/$/, ''), headers };
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ComposeService } from '../services/ComposeService';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { effectiveTier, requireAdmin, requirePaid } from '../middleware/tierGates';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { buildPolicyGateOptions } from '../helpers/policyGate';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
@@ -118,7 +118,6 @@ imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Respo
|
||||
|
||||
imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(_req, res)) return;
|
||||
if (!requirePaid(_req, res)) return;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
@@ -198,7 +197,6 @@ export const autoUpdateRouter = Router();
|
||||
|
||||
autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { target } = req.body as { target?: string };
|
||||
console.log(`[AutoUpdate] Execute requested: target="${sanitizeForLog(target || '')}"`);
|
||||
@@ -226,7 +224,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 = effectiveTier(req) === 'paid';
|
||||
const atomic = true;
|
||||
const results: string[] = [];
|
||||
|
||||
for (const stackName of stackNames) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import DockerController from '../services/DockerController';
|
||||
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import { requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import { buildPolicyGateOptions } from '../helpers/policyGate';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { VALID_LABEL_COLORS, MAX_LABELS_PER_NODE } from '../helpers/constants';
|
||||
@@ -166,7 +166,6 @@ labelsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response):
|
||||
});
|
||||
|
||||
labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
try {
|
||||
|
||||
+18
-18
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { MeshError, MeshService, type MeshGlobalAlias, type MeshRegenSummary } from '../services/MeshService';
|
||||
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
||||
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
|
||||
@@ -14,7 +14,7 @@ function actorFor(req: Request): string {
|
||||
}
|
||||
|
||||
meshRouter.get('/status', async (_req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(_req, res)) return;
|
||||
if (!requirePaid(_req, res)) return;
|
||||
try {
|
||||
const mesh = MeshService.getInstance();
|
||||
const status = await mesh.getStatus();
|
||||
@@ -33,7 +33,7 @@ meshRouter.get('/status', async (_req: Request, res: Response): Promise<void> =>
|
||||
* path was opt-out + opt-in for every meshed stack on that node.
|
||||
*/
|
||||
meshRouter.post('/regen-overrides', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const actor = actorFor(req);
|
||||
let summary: MeshRegenSummary | null = null;
|
||||
@@ -67,7 +67,7 @@ meshRouter.post('/regen-overrides', async (req: Request, res: Response): Promise
|
||||
});
|
||||
|
||||
meshRouter.post('/nodes/:nodeId/enable', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
|
||||
if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; }
|
||||
@@ -80,7 +80,7 @@ meshRouter.post('/nodes/:nodeId/enable', async (req: Request, res: Response): Pr
|
||||
});
|
||||
|
||||
meshRouter.post('/nodes/:nodeId/disable', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
|
||||
if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; }
|
||||
@@ -100,7 +100,7 @@ meshRouter.post('/nodes/:nodeId/disable', async (req: Request, res: Response): P
|
||||
* cross-fleet alias cache without violating the local-only Dockerode rule.
|
||||
*/
|
||||
meshRouter.get('/local-services/:stackName', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; }
|
||||
try {
|
||||
@@ -120,7 +120,7 @@ meshRouter.get('/local-services/:stackName', async (req: Request, res: Response)
|
||||
* stacks deployed on the remote pilot rather than central's own list.
|
||||
*/
|
||||
meshRouter.get('/local-stacks', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const stacks = await MeshService.getInstance().listLocalStacks();
|
||||
res.json({ stacks });
|
||||
@@ -155,7 +155,7 @@ function parsePortAlias(entry: unknown): MeshGlobalAlias | null {
|
||||
* Sencho's default node id.
|
||||
*/
|
||||
meshRouter.put('/local-override/:stackName', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; }
|
||||
const body = req.body as { aliases?: unknown; portAliases?: unknown };
|
||||
@@ -207,7 +207,7 @@ meshRouter.put('/local-override/:stackName', async (req: Request, res: Response)
|
||||
* linger on the deploying node.
|
||||
*/
|
||||
meshRouter.delete('/local-override/:stackName', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); return; }
|
||||
try {
|
||||
@@ -220,7 +220,7 @@ meshRouter.delete('/local-override/:stackName', async (req: Request, res: Respon
|
||||
});
|
||||
|
||||
meshRouter.get('/nodes/:nodeId/stacks', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
|
||||
if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; }
|
||||
try {
|
||||
@@ -240,7 +240,7 @@ meshRouter.get('/nodes/:nodeId/stacks', async (req: Request, res: Response): Pro
|
||||
});
|
||||
|
||||
meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-in', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
|
||||
const stackName = req.params.stackName as string;
|
||||
@@ -267,7 +267,7 @@ meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-in', async (req: Request,
|
||||
});
|
||||
|
||||
meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-out', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
|
||||
const stackName = req.params.stackName as string;
|
||||
@@ -282,7 +282,7 @@ meshRouter.post('/nodes/:nodeId/stacks/:stackName/opt-out', async (req: Request,
|
||||
});
|
||||
|
||||
meshRouter.get('/aliases', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const aliases = await MeshService.getInstance().listAliases();
|
||||
res.json({ aliases });
|
||||
@@ -293,7 +293,7 @@ meshRouter.get('/aliases', async (req: Request, res: Response): Promise<void> =>
|
||||
});
|
||||
|
||||
meshRouter.get('/aliases/:alias/diagnostic', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const diag = await MeshService.getInstance().getRouteDiagnostic(req.params.alias as string);
|
||||
res.json(diag);
|
||||
@@ -303,7 +303,7 @@ meshRouter.get('/aliases/:alias/diagnostic', async (req: Request, res: Response)
|
||||
});
|
||||
|
||||
meshRouter.post('/aliases/:alias/test', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const sourceNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const result = await MeshService.getInstance().testUpstream(req.params.alias as string, sourceNodeId);
|
||||
@@ -314,7 +314,7 @@ meshRouter.post('/aliases/:alias/test', async (req: Request, res: Response): Pro
|
||||
});
|
||||
|
||||
meshRouter.get('/nodes/:nodeId/diagnostic', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const nodeId = Number.parseInt(req.params.nodeId as string, 10);
|
||||
if (!Number.isFinite(nodeId)) { res.status(400).json({ error: 'Invalid node id' }); return; }
|
||||
try {
|
||||
@@ -326,7 +326,7 @@ meshRouter.get('/nodes/:nodeId/diagnostic', async (req: Request, res: Response):
|
||||
});
|
||||
|
||||
meshRouter.get('/activity', (req: Request, res: Response): void => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const alias = typeof req.query.alias === 'string' ? req.query.alias : undefined;
|
||||
const source = typeof req.query.source === 'string' ? (req.query.source as 'pilot' | 'mesh') : undefined;
|
||||
const level = typeof req.query.level === 'string' ? (req.query.level as 'info' | 'warn' | 'error') : undefined;
|
||||
@@ -336,7 +336,7 @@ meshRouter.get('/activity', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
meshRouter.get('/activity/stream', (req: Request, res: Response): void => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
@@ -4,7 +4,7 @@ import crypto from 'crypto';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { requireAdmin, requireAdmiral, requirePaid } from '../middleware/tierGates';
|
||||
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
||||
import { enrollmentLimiter } from '../middleware/rateLimiters';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
@@ -359,7 +359,7 @@ nodesRouter.post('/:id/cordon', (req: Request, res: Response) => {
|
||||
if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
|
||||
const nodeIdParam = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!/^[1-9]\d*$/.test(nodeIdParam)) {
|
||||
res.status(400).json({ error: 'Invalid node id' });
|
||||
return;
|
||||
@@ -398,7 +398,7 @@ nodesRouter.post('/:id/uncordon', (req: Request, res: Response) => {
|
||||
if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
|
||||
const nodeIdParam = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!/^[1-9]\d*$/.test(nodeIdParam)) {
|
||||
res.status(400).json({ error: 'Invalid node id' });
|
||||
return;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NotificationService, ALL_NOTIFICATION_CATEGORIES } from '../services/No
|
||||
import type { NotificationCategory } from '../services/NotificationService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import {
|
||||
NOTIFICATION_CHANNEL_TYPES,
|
||||
validateHttpsUrl,
|
||||
@@ -120,7 +120,6 @@ export const notificationRoutesRouter = Router();
|
||||
|
||||
notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const routes = DatabaseService.getInstance().getNotificationRoutes();
|
||||
res.json(routes);
|
||||
@@ -132,7 +131,6 @@ notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response):
|
||||
|
||||
notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled } = req.body;
|
||||
|
||||
@@ -189,7 +187,6 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
||||
|
||||
notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'route ID');
|
||||
if (id === null) return;
|
||||
@@ -265,7 +262,6 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
||||
|
||||
notificationRoutesRouter.delete('/:id', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'route ID');
|
||||
if (id === null) return;
|
||||
@@ -282,7 +278,6 @@ notificationRoutesRouter.delete('/:id', authMiddleware, (req: Request, res: Resp
|
||||
|
||||
notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'route ID');
|
||||
if (id === null) return;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
|
||||
import { effectiveTier } from '../middleware/tierGates';
|
||||
|
||||
export const permissionsRouter = Router();
|
||||
|
||||
@@ -16,22 +16,25 @@ permissionsRouter.get('/me', authMiddleware, (req: Request, res: Response): void
|
||||
const db = DatabaseService.getInstance();
|
||||
const globalRole = req.user.role;
|
||||
const globalPermissions = ROLE_PERMISSIONS[globalRole] || [];
|
||||
const assignments = db.getAllRoleAssignments(req.user.userId);
|
||||
|
||||
// Scoped role assignments only take effect on the paid tier (mirrors
|
||||
// checkPermission in middleware/permissions.ts). Returning them to a
|
||||
// Community client would render per-resource affordances the API then 403s,
|
||||
// for example on an instance that held assignments before a downgrade.
|
||||
const scopedPermissions: Record<string, PermissionAction[]> = {};
|
||||
for (const a of assignments) {
|
||||
const key = `${a.resource_type}:${a.resource_id}`;
|
||||
const perms = ROLE_PERMISSIONS[a.role] || [];
|
||||
const existing = scopedPermissions[key] || [];
|
||||
scopedPermissions[key] = [...new Set([...existing, ...perms])];
|
||||
if (effectiveTier(req) === 'paid') {
|
||||
for (const a of db.getAllRoleAssignments(req.user.userId)) {
|
||||
const key = `${a.resource_type}:${a.resource_id}`;
|
||||
const perms = ROLE_PERMISSIONS[a.role] || [];
|
||||
const existing = scopedPermissions[key] || [];
|
||||
scopedPermissions[key] = [...new Set([...existing, ...perms])];
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
globalRole,
|
||||
globalPermissions,
|
||||
scopedPermissions,
|
||||
isAdmiral: LicenseService.getInstance().getTier() === 'paid'
|
||||
&& LicenseService.getInstance().getVariant() === 'admiral',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Permissions] Error:', error);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { RegistryService } from '../services/RegistryService';
|
||||
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
||||
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { parseIntParam } from '../utils/parseIntParam';
|
||||
|
||||
@@ -27,7 +27,7 @@ export const registriesRouter = Router();
|
||||
registriesRouter.get('/', (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
res.json(RegistryService.getInstance().getAll());
|
||||
} catch (error) {
|
||||
@@ -39,7 +39,7 @@ registriesRouter.get('/', (req: Request, res: Response): void => {
|
||||
registriesRouter.post('/', (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { name, url, type, username, secret, aws_region } = req.body;
|
||||
|
||||
@@ -76,7 +76,7 @@ registriesRouter.post('/', (req: Request, res: Response): void => {
|
||||
registriesRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'registry ID');
|
||||
if (id === null) return;
|
||||
@@ -114,7 +114,7 @@ registriesRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
registriesRouter.delete('/:id', (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'registry ID');
|
||||
if (id === null) return;
|
||||
@@ -133,7 +133,7 @@ registriesRouter.delete('/:id', (req: Request, res: Response): void => {
|
||||
registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'registry ID');
|
||||
if (id === null) return;
|
||||
@@ -149,7 +149,7 @@ registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise<
|
||||
registriesRouter.post('/test', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { type, url, username, secret, aws_region } = req.body;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CronExpressionParser } from 'cron-parser';
|
||||
import { DatabaseService, type ScheduledTask } from '../services/DatabaseService';
|
||||
import { SchedulerService } from '../services/SchedulerService';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { requirePaid, requireAdmin } from '../middleware/tierGates';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { escapeCsvField } from '../utils/csv';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { parseIntParam } from '../utils/parseIntParam';
|
||||
@@ -120,7 +120,6 @@ export const scheduledTasksRouter = Router();
|
||||
|
||||
scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
let tasks = DatabaseService.getInstance().getScheduledTasks();
|
||||
// The Scheduled Operations view manages every task type, so it lists all of
|
||||
@@ -154,7 +153,6 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
|
||||
|
||||
scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run } = req.body;
|
||||
|
||||
@@ -229,7 +227,6 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
|
||||
scheduledTasksRouter.get('/:id', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'task ID');
|
||||
if (id === null) return;
|
||||
@@ -244,7 +241,6 @@ scheduledTasksRouter.get('/:id', (req: Request, res: Response): void => {
|
||||
|
||||
scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'task ID');
|
||||
if (id === null) return;
|
||||
@@ -326,7 +322,6 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
|
||||
scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'task ID');
|
||||
if (id === null) return;
|
||||
@@ -347,7 +342,6 @@ scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => {
|
||||
|
||||
scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'task ID');
|
||||
if (id === null) return;
|
||||
@@ -377,7 +371,6 @@ scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void =>
|
||||
|
||||
scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'task ID');
|
||||
if (id === null) return;
|
||||
@@ -407,7 +400,6 @@ scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => {
|
||||
|
||||
scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'task ID');
|
||||
if (id === null) return;
|
||||
@@ -442,7 +434,6 @@ scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void
|
||||
|
||||
scheduledTasksRouter.get('/:id/runs', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'task ID');
|
||||
if (id === null) return;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
||||
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
||||
|
||||
// Strict allowlist of keys readable and writable via the generic settings
|
||||
// API. This is the single source of truth for what the endpoint exposes:
|
||||
@@ -26,11 +26,10 @@ const ALLOWED_SETTING_KEYS = new Set([
|
||||
'scan_history_per_image_limit',
|
||||
]);
|
||||
|
||||
// Keys whose write requires the Admiral variant, not just an admin role.
|
||||
// audit_retention_days configures the Admiral-only audit log (the audit-log
|
||||
// routes are requireAdmiral and the UI only shows this field to Admiral
|
||||
// operators), so a lower-tier admin must not be able to set it.
|
||||
const ADMIRAL_ONLY_SETTING_KEYS = new Set(['audit_retention_days']);
|
||||
// Keys whose write requires a paid license, not just an admin role.
|
||||
// audit_retention_days configures the paid audit log, so a Community admin
|
||||
// must not be able to set it.
|
||||
const PAID_ONLY_SETTING_KEYS = new Set(['audit_retention_days']);
|
||||
|
||||
// Bulk PATCH schema. All keys optional; present keys are fully validated.
|
||||
const SettingsPatchSchema = z.object({
|
||||
@@ -77,7 +76,7 @@ settingsRouter.post('/', authMiddleware, async (req: Request, res: Response): Pr
|
||||
res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` });
|
||||
return;
|
||||
}
|
||||
if (ADMIRAL_ONLY_SETTING_KEYS.has(key) && !requireAdmiral(req, res)) return;
|
||||
if (PAID_ONLY_SETTING_KEYS.has(key) && !requirePaid(req, res)) return;
|
||||
if (value === undefined || value === null) {
|
||||
res.status(400).json({ error: 'Setting value is required' });
|
||||
return;
|
||||
@@ -133,7 +132,7 @@ settingsRouter.patch('/', authMiddleware, async (req: Request, res: Response): P
|
||||
res.status(400).json({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors });
|
||||
return;
|
||||
}
|
||||
if (Object.keys(parsed.data).some(k => ADMIRAL_ONLY_SETTING_KEYS.has(k)) && !requireAdmiral(req, res)) return;
|
||||
if (Object.keys(parsed.data).some(k => PAID_ONLY_SETTING_KEYS.has(k)) && !requirePaid(req, res)) return;
|
||||
const db = DatabaseService.getInstance();
|
||||
const updateMany = db.getDb().transaction((entries: [string, string][]) => {
|
||||
for (const [k, v] of entries) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { UpdatePreviewService } from '../services/UpdatePreviewService';
|
||||
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
|
||||
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { requirePermission, checkPermission } from '../middleware/permissions';
|
||||
import { requirePaid, effectiveTier } from '../middleware/tierGates';
|
||||
import { NotificationService, type NotificationCategory } from '../services/NotificationService';
|
||||
import { StackOpLockService, type StackOpAction } from '../services/StackOpLockService';
|
||||
import { StackOpMetricsService, type StackOpAction as StackMetricAction } from '../services/StackOpMetricsService';
|
||||
@@ -348,7 +347,7 @@ async function runStackBulkOp(
|
||||
code: 'policy_blocked',
|
||||
};
|
||||
}
|
||||
const atomic = effectiveTier(req) === 'paid';
|
||||
const atomic = true;
|
||||
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
NotificationService.getInstance().broadcastEvent({
|
||||
@@ -423,13 +422,6 @@ stacksRouter.post('/bulk', async (req: Request, res: Response) => {
|
||||
return res.status(400).json({ error: 'stackNames must be an array of strings' });
|
||||
}
|
||||
|
||||
// Bulk update is paid-only by deliberate asymmetry with the single-stack
|
||||
// POST /:stackName/update, which is open to all tiers (atomic backup is
|
||||
// separately gated by effectiveTier inside the route). The bulk fan-out
|
||||
// amplifies blast radius enough that we want a hard tier check here even
|
||||
// though the per-stack route does not.
|
||||
if (action === 'update' && !requirePaid(req, res)) return;
|
||||
|
||||
const typedAction = action as BulkLifecycleAction;
|
||||
const typedNames = Array.from(new Set(stackNames as string[]));
|
||||
|
||||
@@ -928,7 +920,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
|
||||
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
||||
const skipScan = req.body?.skip_scan === true;
|
||||
const debug = isDebugEnabled();
|
||||
const atomic = effectiveTier(req) === 'paid';
|
||||
const atomic = true;
|
||||
if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId });
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
@@ -1188,7 +1180,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
|
||||
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
||||
const skipScan = req.body?.skip_scan === true;
|
||||
const debug = isDebugEnabled();
|
||||
const atomic = effectiveTier(req) === 'paid';
|
||||
const atomic = true;
|
||||
if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId });
|
||||
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
@@ -1237,7 +1229,6 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
|
||||
stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
// Rollback restores files and re-deploys, so it must hold the same per-stack
|
||||
// lock deploy/update use. Without it a rollback racing an in-flight deploy
|
||||
// would mutate the compose files and run a second `docker compose up` against
|
||||
@@ -1272,10 +1263,6 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) =>
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName/backup', async (req: Request, res: Response) => {
|
||||
// Backup metadata exists only to drive the paid-only Rollback affordance, so
|
||||
// the read is gated to paid to match the frontend, which only fetches it when
|
||||
// the instance is licensed.
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
const fsSvc = FileSystemService.getInstance(req.nodeId);
|
||||
@@ -1292,10 +1279,9 @@ stacksRouter.post('/:stackName/backup', async (req: Request, res: Response) => {
|
||||
// Triggers a server-side backup of the stack's managed files: the same
|
||||
// rollback snapshot a deploy takes. Exposed so a scheduled backup can run on
|
||||
// a remote node through the proxy path, and so an operator can capture an
|
||||
// on-demand snapshot. Paid-gated to match the rollback feature it feeds.
|
||||
// on-demand snapshot.
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
// The backup slot is shared with the pre-deploy rollback snapshot, so hold the
|
||||
// stack-op lock to keep a backup from interleaving with a concurrent
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express';
|
||||
import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { effectiveTier, requireAdmin } from '../middleware/tierGates';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { templateService } from '../services/TemplateService';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
@@ -134,7 +134,7 @@ templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Respon
|
||||
}
|
||||
return;
|
||||
}
|
||||
const atomic = effectiveTier(req) === 'paid';
|
||||
const atomic = true;
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Templates] Deploy completed: ${stackName}`);
|
||||
|
||||
+11
-50
@@ -1,9 +1,8 @@
|
||||
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 { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
||||
import { requirePaid, requireAdmin } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { BCRYPT_SALT_ROUNDS, MIN_PASSWORD_LENGTH } from '../helpers/constants';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
@@ -17,31 +16,13 @@ const VALID_USER_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin
|
||||
const VALID_ASSIGNMENT_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin'];
|
||||
const VALID_RESOURCE_TYPES: ResourceType[] = ['stack', 'node'];
|
||||
|
||||
// Roles that require an Admiral license. Viewer and admin are available on
|
||||
// all paid tiers; the rest need variant=admiral for per-resource scoping to
|
||||
// be meaningful.
|
||||
function roleRequiresAdmiral(role: UserRole): boolean {
|
||||
// Roles that require a paid license. Viewer and admin are available on the
|
||||
// free tier; the advanced roles unlock per-resource scoping that is only
|
||||
// meaningful on paid.
|
||||
function roleRequiresPaid(role: UserRole): boolean {
|
||||
return role === 'deployer' || role === 'node-admin' || role === 'auditor';
|
||||
}
|
||||
|
||||
// Returns a seat-limit error message if adding an account of `role` would
|
||||
// exceed the current license seat caps, or null when within limits. Counts are
|
||||
// read at call time so the check reflects live state. Used by both user
|
||||
// creation and admin promotion so the cap cannot be bypassed via role change.
|
||||
// Seat caps gate new seat acquisition only (creation, and promotion to admin);
|
||||
// reducing privilege by demoting an admin is never blocked on the viewer cap.
|
||||
function seatLimitError(role: UserRole, db: DatabaseService): string | null {
|
||||
const seatLimits = LicenseService.getInstance().getSeatLimits();
|
||||
if (role === 'admin') {
|
||||
if (seatLimits.maxAdmins !== null && db.getAdminCount() >= seatLimits.maxAdmins) {
|
||||
return `Your license allows a maximum of ${seatLimits.maxAdmins} admin account${seatLimits.maxAdmins === 1 ? '' : 's'}. Upgrade to Admiral for unlimited accounts.`;
|
||||
}
|
||||
} else if (seatLimits.maxViewers !== null && db.getNonAdminCount() >= seatLimits.maxViewers) {
|
||||
return `Your license allows a maximum of ${seatLimits.maxViewers} viewer account${seatLimits.maxViewers === 1 ? '' : 's'}. Upgrade to Admiral for unlimited accounts.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const usersRouter = Router();
|
||||
|
||||
usersRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -65,7 +46,6 @@ usersRouter.get('/', authMiddleware, async (req: Request, res: Response): Promis
|
||||
usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { username, password, role } = req.body;
|
||||
|
||||
@@ -86,7 +66,7 @@ usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promi
|
||||
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' });
|
||||
return;
|
||||
}
|
||||
if (roleRequiresAdmiral(role) && !requireAdmiral(req, res)) return;
|
||||
if (roleRequiresPaid(role) && !requirePaid(req, res)) return;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getUserByUsername(username);
|
||||
@@ -95,13 +75,6 @@ usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promi
|
||||
return;
|
||||
}
|
||||
|
||||
// Enforce seat limits based on license variant.
|
||||
const seatError = seatLimitError(role, db);
|
||||
if (seatError) {
|
||||
res.status(403).json({ error: seatError });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
||||
const id = db.addUser({ username, password_hash: passwordHash, role });
|
||||
console.log('[Users] Created:', sanitizeForLog(username), 'role:', sanitizeForLog(role), 'by:', sanitizeForLog(req.user!.username));
|
||||
@@ -148,23 +121,11 @@ usersRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Pro
|
||||
res.status(400).json({ error: 'Role must be "admin", "viewer", "deployer", "node-admin", or "auditor"' });
|
||||
return;
|
||||
}
|
||||
if (roleRequiresAdmiral(role) && !requireAdmiral(req, res)) return;
|
||||
if (roleRequiresPaid(role) && !requirePaid(req, res)) return;
|
||||
if (user.username === req.user!.username && role !== user.role) {
|
||||
res.status(400).json({ error: 'Cannot change your own role' });
|
||||
return;
|
||||
}
|
||||
// Promoting a non-admin to admin consumes an admin seat; enforce the cap
|
||||
// here the same way user creation does, so a role change cannot exceed it.
|
||||
if (role === 'admin' && user.role !== 'admin') {
|
||||
const seatError = seatLimitError('admin', db);
|
||||
if (isDebugEnabled()) {
|
||||
console.log('[Users:diag] admin-promotion id=', id, 'blocked=', seatError !== null, 'actor=', sanitizeForLog(req.user!.username));
|
||||
}
|
||||
if (seatError) {
|
||||
res.status(403).json({ error: seatError });
|
||||
return;
|
||||
}
|
||||
}
|
||||
updates.role = role;
|
||||
}
|
||||
|
||||
@@ -265,12 +226,12 @@ usersRouter.post('/:id/mfa/reset', authMiddleware, (req: Request, res: Response)
|
||||
}
|
||||
});
|
||||
|
||||
// --- Scoped Role Assignments (Admiral) ---
|
||||
// --- Scoped Role Assignments (paid) ---
|
||||
|
||||
usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const userId = parseInt(req.params.id as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
@@ -289,7 +250,7 @@ usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): voi
|
||||
usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const userId = parseInt(req.params.id as string, 10);
|
||||
const { role, resource_type, resource_id } = req.body;
|
||||
@@ -333,7 +294,7 @@ usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): vo
|
||||
usersRouter.delete('/:id/roles/:assignId', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const userId = parseInt(req.params.id as string, 10);
|
||||
const assignId = parseInt(req.params.assignId as string, 10);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService, type WebhookAction } from '../services/DatabaseService';
|
||||
import { WebhookService } from '../services/WebhookService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmin } from '../middleware/tierGates';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { webhookTriggerLimiter } from '../middleware/rateLimiters';
|
||||
|
||||
const VALID_WEBHOOK_ACTIONS: readonly WebhookAction[] = ['deploy', 'restart', 'stop', 'start', 'pull', 'git-pull'];
|
||||
@@ -16,7 +15,6 @@ function isWebhookAction(value: unknown): value is WebhookAction {
|
||||
export const webhooksRouter = Router();
|
||||
|
||||
webhooksRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const webhooks = DatabaseService.getInstance().getWebhooks();
|
||||
const svc = WebhookService.getInstance();
|
||||
@@ -29,7 +27,6 @@ webhooksRouter.get('/', authMiddleware, async (req: Request, res: Response): Pro
|
||||
|
||||
webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { name, stack_name, action, enabled, node_id } = req.body;
|
||||
if (!name || !stack_name || !action) {
|
||||
@@ -75,7 +72,6 @@ webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Pr
|
||||
|
||||
webhooksRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const webhook = DatabaseService.getInstance().getWebhook(id);
|
||||
@@ -119,7 +115,6 @@ webhooksRouter.put('/:id', authMiddleware, async (req: Request, res: Response):
|
||||
|
||||
webhooksRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
DatabaseService.getInstance().deleteWebhook(id);
|
||||
@@ -131,7 +126,6 @@ webhooksRouter.delete('/:id', authMiddleware, async (req: Request, res: Response
|
||||
});
|
||||
|
||||
webhooksRouter.get('/:id/history', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const executions = DatabaseService.getInstance().getWebhookExecutions(id);
|
||||
@@ -145,17 +139,17 @@ webhooksRouter.get('/:id/history', authMiddleware, async (req: Request, res: Res
|
||||
// Public: authenticated via HMAC signature, not session cookie.
|
||||
//
|
||||
// Every unauthenticated rejection returns the same 404 with the same body so
|
||||
// callers cannot enumerate webhook ids or fingerprint the instance's licence
|
||||
// tier from the response surface. Successful authentication still returns 202.
|
||||
// callers cannot enumerate webhook ids from the response surface. Successful
|
||||
// authentication still returns 202.
|
||||
//
|
||||
// The handler also runs the HMAC computation on every path (using a decoy
|
||||
// secret and an empty buffer when the real ones are missing) so the wall-
|
||||
// clock cost of a reject path matches the wall-clock cost of a real-shape
|
||||
// wrong-secret path. Without this, repeated near-rate-limit probes with a
|
||||
// large attacker-controlled body could distinguish a valid-and-enabled
|
||||
// webhook id on a paid tier from the other reject cases via response
|
||||
// latency. Timing now depends only on the size of the request body, which
|
||||
// the attacker already controls and which reveals nothing webhook-specific.
|
||||
// webhook id from the other reject cases via response latency. Timing now
|
||||
// depends only on the size of the request body, which the attacker already
|
||||
// controls and which reveals nothing webhook-specific.
|
||||
webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
const unauthenticated = (): void => {
|
||||
res.status(404).json({ error: 'Webhook not found or signature invalid' });
|
||||
@@ -165,7 +159,6 @@ webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request,
|
||||
const db = DatabaseService.getInstance();
|
||||
const webhook = db.getWebhook(id);
|
||||
|
||||
const tier = LicenseService.getInstance().getTier();
|
||||
const signature = req.headers['x-webhook-signature'] as string | undefined;
|
||||
|
||||
// Unconditional HMAC. The decoy secret keeps the work non-skippable when
|
||||
@@ -182,7 +175,6 @@ webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request,
|
||||
const sigOk = svc.validateSignature(payload, secretForHmac, signature ?? '');
|
||||
|
||||
if (!webhook || !webhook.enabled) return unauthenticated();
|
||||
if (tier !== 'paid') return unauthenticated();
|
||||
if (!signature) return unauthenticated();
|
||||
if (!req.rawBody) return unauthenticated();
|
||||
if (!sigOk) return unauthenticated();
|
||||
@@ -208,9 +200,8 @@ webhooksRouter.post('/:id/trigger', webhookTriggerLimiter, async (req: Request,
|
||||
// Pass the already-loaded webhook through so execute() never re-fetches
|
||||
// by id. If an admin deletes the row between this line and the async
|
||||
// dispatch the action still completes and recordExecution swallows the
|
||||
// FK error from the CASCADE. atomic is unconditionally true: the tier
|
||||
// gate above already rejected any caller without a Skipper/Admiral
|
||||
// licence, so the deploy/pull paths always run in atomic mode here.
|
||||
// FK error from the CASCADE. atomic is unconditionally true, so the
|
||||
// deploy/pull paths always run in atomic mode here.
|
||||
svc.execute(webhook, action, triggerSource, true).catch(err => {
|
||||
console.error(`[Webhooks] Execution error for webhook ${id}:`, err);
|
||||
});
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { UserRole, ApiTokenScope, ApiToken } from '../services/DatabaseService';
|
||||
import type { LicenseTier, LicenseVariant } from '../services/license-types';
|
||||
import type { LicenseTier } from '../services/license-types';
|
||||
|
||||
// Extend Express Request type for user and node context.
|
||||
// This file is imported for its side effects only (ambient declaration).
|
||||
@@ -15,8 +15,6 @@ declare global {
|
||||
rawBody?: Buffer;
|
||||
/** License tier asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
|
||||
proxyTier?: LicenseTier;
|
||||
/** License variant asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
|
||||
proxyVariant?: LicenseVariant;
|
||||
/** User ID carried by a scoped `mfa_pending` token. Only set while the user is completing the MFA challenge. */
|
||||
mfaPendingUserId?: number;
|
||||
/** True when the pending MFA session originated from an SSO login (LDAP or OIDC) rather than a password login. */
|
||||
|
||||
@@ -4,12 +4,10 @@ import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { HostTerminalService } from '../services/HostTerminalService';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import {
|
||||
isLicenseTier,
|
||||
isLicenseVariant,
|
||||
normalizeTier,
|
||||
normalizeVariant,
|
||||
} from '../services/license-normalize';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
|
||||
@@ -34,8 +32,8 @@ interface HostConsoleContext {
|
||||
* 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;
|
||||
* 3. License: host console requires the paid tier. For console_session
|
||||
* tokens the tier is trusted from the gateway-supplied header;
|
||||
* otherwise the local LicenseService is consulted.
|
||||
*/
|
||||
export function handleHostConsoleWs(
|
||||
@@ -62,15 +60,11 @@ 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 consoleTier = (isConsoleSession && isLicenseTier(consoleTierHeader))
|
||||
? normalizeTier(consoleTierHeader)
|
||||
: ls.getTier();
|
||||
const consoleVariant = (isConsoleSession && consoleVariantHeader !== undefined && isLicenseVariant(consoleVariantHeader))
|
||||
? normalizeVariant(consoleVariantHeader)
|
||||
: ls.getVariant();
|
||||
if (consoleTier !== 'paid' || consoleVariant !== 'admiral') {
|
||||
if (consoleTier !== 'paid') {
|
||||
return reject(socket, 403, 'Forbidden');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type { Duplex } from 'stream';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { wsProxyServer } from '../proxy/websocketProxy';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
@@ -48,7 +48,6 @@ export async function handleRemoteForwarder(
|
||||
headers: {
|
||||
'Authorization': `Bearer ${target.apiToken}`,
|
||||
[PROXY_TIER_HEADER]: consoleHeaders.tier,
|
||||
[PROXY_VARIANT_HEADER]: consoleHeaders.variant || '',
|
||||
},
|
||||
});
|
||||
if (!tokenRes.ok) {
|
||||
@@ -78,7 +77,6 @@ export async function handleRemoteForwarder(
|
||||
delete req.headers['cookie'];
|
||||
const fwdHeaders = LicenseService.getInstance().getProxyHeaders();
|
||||
req.headers[PROXY_TIER_HEADER] = fwdHeaders.tier;
|
||||
req.headers[PROXY_VARIANT_HEADER] = fwdHeaders.variant || '';
|
||||
// Strip nodeId from the forwarded URL so the remote treats the request as
|
||||
// local. The remote has no record of the gateway's nodeId; leaving it would
|
||||
// trigger nodeContext's 404 branch.
|
||||
|
||||
@@ -17,8 +17,8 @@ import { rejectUpgrade as reject } from './reject';
|
||||
import { looksLikeApiToken } from '../utils/apiTokenFormat';
|
||||
import { validateApiToken, touchApiTokenLastUsed } from '../utils/apiTokenAuth';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-headers';
|
||||
import { isLicenseTier, normalizeTier, isLicenseVariant, normalizeVariant } from '../services/license-normalize';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import { isLicenseTier, normalizeTier } from '../services/license-normalize';
|
||||
|
||||
function parseCookies(req: IncomingMessage): Record<string, string> {
|
||||
const header = req.headers.cookie || '';
|
||||
@@ -143,28 +143,24 @@ export function attachUpgrade(
|
||||
// Restricted api_token scopes (read-only, deploy-only) are blocked
|
||||
// earlier by the scope gate above before this branch is reached.
|
||||
//
|
||||
// Admiral entitlement is decided against the *central's* license, not
|
||||
// Mesh entitlement is decided against the *central's* license, not
|
||||
// the receiver's, matching every HTTP mesh route in routes/mesh.ts that
|
||||
// uses `requireAdmiral` / `effectiveTier`. On the node_proxy path the
|
||||
// central forwards `x-sencho-tier` / `x-sencho-variant` and the WS
|
||||
// dispatcher trusts them off the node_proxy credential (same rule as
|
||||
// middleware/auth.ts:117-135 for HTTP). On the full-admin api_token
|
||||
// path no central is asserting tier, so we fall back to the receiver's
|
||||
// own license. Both produce paid+admiral or the upgrade is rejected.
|
||||
// uses `requirePaid` / `effectiveTier`. On the node_proxy path the
|
||||
// central forwards `x-sencho-tier` and the WS dispatcher trusts it off
|
||||
// the node_proxy credential (same rule as middleware/auth.ts for HTTP).
|
||||
// On the full-admin api_token path no central is asserting tier, so we
|
||||
// fall back to the receiver's own license. Both produce paid or the
|
||||
// upgrade is rejected.
|
||||
if (pathname === '/api/mesh/proxy-tunnel') {
|
||||
if (!isProxyToken && wsApiTokenScope !== 'full-admin') {
|
||||
return reject(socket, 403, 'Forbidden');
|
||||
}
|
||||
const license = LicenseService.getInstance();
|
||||
const tunnelTierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
|
||||
const tunnelVariantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
|
||||
const tunnelTier = isProxyToken && isLicenseTier(tunnelTierHeader)
|
||||
? normalizeTier(tunnelTierHeader)
|
||||
: license.getTier();
|
||||
const tunnelVariant = isProxyToken && tunnelVariantHeader !== undefined && isLicenseVariant(tunnelVariantHeader)
|
||||
? normalizeVariant(tunnelVariantHeader)
|
||||
: license.getVariant();
|
||||
if (tunnelTier !== 'paid' || tunnelVariant !== 'admiral') {
|
||||
if (tunnelTier !== 'paid') {
|
||||
return reject(socket, 403, 'Forbidden');
|
||||
}
|
||||
await handleMeshProxyTunnel(req, socket, head);
|
||||
|
||||
@@ -67,8 +67,7 @@ The `code` field is present for specific error types:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `PAID_REQUIRED` | Endpoint requires a Skipper or Admiral license |
|
||||
| `ADMIRAL_REQUIRED` | Endpoint requires an Admiral license |
|
||||
| `PAID_REQUIRED` | Endpoint requires an Admiral license |
|
||||
| `SCOPE_DENIED` | API token scope does not allow this operation |
|
||||
|
||||
## Input validation
|
||||
@@ -117,10 +116,9 @@ Some endpoints are gated by license tier:
|
||||
|
||||
| Tier | Gated features |
|
||||
|------|---------------|
|
||||
| **Skipper+** | Webhooks, Fleet snapshots, Stack rollback |
|
||||
| **Admiral** | Scheduled Tasks |
|
||||
| **Admiral** | Scan policies, Private registries |
|
||||
|
||||
Requests to gated endpoints on a lower tier return `403` with the appropriate error code.
|
||||
Requests to gated endpoints on Community return `403` with the `PAID_REQUIRED` error code.
|
||||
|
||||
## WebSocket endpoints
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Automate scan policies, CVE suppressions, and vulnerability scans f
|
||||
|
||||
The Security API lets you manage scan policies, CVE suppressions, and trigger vulnerability scans from CI pipelines and automation scripts. Every endpoint in this reference is intended for external automation; internal frontend-only endpoints (finding listings, SARIF downloads) are not documented here.
|
||||
|
||||
All endpoints require [Bearer token authentication](/api-reference/overview#authentication). Manual scans, secret and misconfiguration results, scan comparison, and CVE suppressions are available on every tier. Scan policies (with `block_on_deploy` enforcement), SBOM, and SARIF stay on Skipper or Admiral. See the per-endpoint **License** row for details.
|
||||
All endpoints require [Bearer token authentication](/api-reference/overview#authentication). Manual scans, secret and misconfiguration results, scan comparison, and CVE suppressions are available on every tier. Scan policies (with `block_on_deploy` enforcement), SBOM, and SARIF require Admiral. See the per-endpoint **License** row for details.
|
||||
|
||||
## Scan policies
|
||||
|
||||
@@ -17,7 +17,7 @@ Writes are admin-only and rejected on replica nodes (policies are managed on the
|
||||
|
||||
**`GET /api/security/policies`**
|
||||
|
||||
**License:** Skipper or Admiral
|
||||
**License:** Admiral
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
@@ -48,7 +48,7 @@ curl -H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
|
||||
**`POST /api/security/policies`**
|
||||
|
||||
**License:** Skipper or Admiral · **Role:** Admin
|
||||
**License:** Admiral · **Role:** Admin
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|:--------:|-------------|
|
||||
@@ -84,7 +84,7 @@ curl -X POST https://your-sencho-instance:1852/api/security/policies \
|
||||
|
||||
**`PUT /api/security/policies/{id}`**
|
||||
|
||||
**License:** Skipper or Admiral · **Role:** Admin
|
||||
**License:** Admiral · **Role:** Admin
|
||||
|
||||
Any of the create fields can be updated individually. Omitted fields are left unchanged.
|
||||
|
||||
@@ -103,7 +103,7 @@ curl -X PUT https://your-sencho-instance:1852/api/security/policies/1 \
|
||||
|
||||
**`DELETE /api/security/policies/{id}`**
|
||||
|
||||
**License:** Skipper or Admiral · **Role:** Admin
|
||||
**License:** Admiral · **Role:** Admin
|
||||
|
||||
```bash
|
||||
curl -X DELETE https://your-sencho-instance:1852/api/security/policies/1 \
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: Alerts & Notifications
|
||||
description: Threshold and event alerts for your fleet, dispatched to Discord, Slack, or any webhook, with per-stack rules and Skipper routing.
|
||||
description: Threshold and event alerts for your fleet, dispatched to Discord, Slack, or any webhook, with per-stack rules and channel routing.
|
||||
---
|
||||
|
||||
Sencho watches each node it manages for container crashes, host pressure, scheduled-task results, and update availability, then surfaces every signal in two places: the in-app notification bell at the top of the shell and one of three external channels you configure. This page covers everything from configuring channels to writing per-stack threshold rules, routing alerts to dedicated channels with Skipper routing rules, and tuning retention.
|
||||
Sencho watches each node it manages for container crashes, host pressure, scheduled-task results, and update availability, then surfaces every signal in two places: the in-app notification bell at the top of the shell and one of three external channels you configure. This page covers everything from configuring channels to writing per-stack threshold rules, routing alerts to dedicated channels with routing rules, and tuning retention.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/alerts-notifications/notifications-settings.png" alt="Settings · Notifications panel showing the Discord, Slack, and Webhook tabs with the masthead breadcrumb, the CHANNELS 3/3 stat, the active Discord tab with its Enabled toggle on, the Webhook URL input, and the Test and Save actions." />
|
||||
@@ -51,7 +51,7 @@ Each dispatch is a single-shot HTTP POST with a 10-second `AbortSignal.timeout`.
|
||||
## Notification Routing
|
||||
|
||||
<Note>
|
||||
Notification Routing requires a **Sencho Skipper or Admiral** license. Admin role is required to create, edit, or delete routes.
|
||||
Admin role is required to create, edit, or delete routes.
|
||||
</Note>
|
||||
|
||||
Routing lets you direct alerts that match specific criteria to dedicated channels. Production crashes can land in `#prod-incidents` on Slack while staging notifications go to a less urgent Discord channel, all without juggling per-channel webhook URLs across teams.
|
||||
|
||||
@@ -110,7 +110,7 @@ Clicking **Deploy** runs the following sequence:
|
||||
2. **Directory creation.** A new directory is created under `COMPOSE_DIR/<stack-name>` on the active node.
|
||||
3. **File generation.** Sencho writes `compose.yaml` (rendered from the template, with your port and volume overrides) and a `.env` file (when you configured environment variables).
|
||||
4. **Policy gate.** Any [deploy-enforcement policies](/features/deploy-enforcement) configured on the node run against the generated compose. If a rule blocks the deploy, the directory is cleaned up and you get the rule's reason.
|
||||
5. **`docker compose up -d`.** On Skipper and Admiral the deploy is **atomic**: any container that fails to start triggers an automatic rollback to the previous state. On Community the deploy is non-atomic.
|
||||
5. **`docker compose up -d`.** The deploy is **atomic**: any container that fails to start triggers an automatic rollback to the previous state.
|
||||
6. **Outcome.**
|
||||
- On success, you are switched to the editor for the new stack and a success toast confirms the deploy.
|
||||
- On failure, Sencho parses the error. Most failures (image pull, port collision, volume permission, compose validation) trigger a clean rollback: the stack is brought down and the directory is removed. A small set of failures that point to live containers (for example, a startup that crashes after the container is running) leave the stack on disk so you can inspect it. The error toast tells you which.
|
||||
|
||||
@@ -7,10 +7,6 @@ Sencho wraps every protected deploy in a four-step safety net: it copies the cur
|
||||
|
||||
The same backup also powers the **Rollback** action in the stack editor, so you can roll a stack back to its last good configuration on demand.
|
||||
|
||||
<Note>
|
||||
Atomic Deployments require a Sencho **Skipper** or **Admiral** license. Community Edition runs the same compose actions without a backup or automatic rollback.
|
||||
</Note>
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Backup.** Before the action runs, Sencho copies `compose.yaml` (or `compose.yml` / `docker-compose.yaml` / `docker-compose.yml`) and `.env`, if present, into the backup directory. The deploy progress modal streams `=== Backup created for atomic deployment ===` once the copy completes, before any `docker compose` output.
|
||||
@@ -33,13 +29,13 @@ A scheduled image-update task uses the same atomic wrapper as a manual update, s
|
||||
|
||||
## Manual rollback
|
||||
|
||||
The stack editor's action bar carries a **More actions** overflow menu (the three-dot icon next to **Update**). Open it on a Skipper or Admiral instance and you'll see **Rollback** at the top, with the timestamp of the most recent backup rendered beneath the label. Selecting it restores the backed-up files and re-runs `docker compose up -d` non-atomically, to avoid nesting a rollback inside another atomic wrapper and overwriting the good backup with the broken state from the just-failed deploy.
|
||||
The stack editor's action bar carries a **More actions** overflow menu (the three-dot icon next to **Update**). Open it and you'll see **Rollback** at the top, with the timestamp of the most recent backup rendered beneath the label. Selecting it restores the backed-up files and re-runs `docker compose up -d` non-atomically, to avoid nesting a rollback inside another atomic wrapper and overwriting the good backup with the broken state from the just-failed deploy.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/atomic-deployments/rollback-menu.png" alt="Stack editor action bar with the More actions overflow menu open, showing the Rollback entry at the top with the backup timestamp rendered beneath the label, followed by Scan config and Delete entries" />
|
||||
</Frame>
|
||||
|
||||
The menu entry is hidden when no backup exists for the stack, for example on a freshly created stack that has never been deployed atomically, and on a Community Edition instance. The endpoint additionally requires the `stack:deploy` permission, so a user without it will see the menu entry but receive a permission error if they invoke it.
|
||||
The menu entry is hidden when no backup exists for the stack, for example on a freshly created stack that has never been deployed. The endpoint additionally requires the `stack:deploy` permission, so a user without it will see the menu entry but receive a permission error if they invoke it.
|
||||
|
||||
## Where backups are stored
|
||||
|
||||
@@ -49,18 +45,11 @@ Each backup is a flat copy of the compose file Sencho found, plus `.env` if it e
|
||||
|
||||
A restore is a faithful revert, not an overlay. Sencho replaces the compose file and `.env` with the backed-up copies and removes any compose variant or `.env` that was added after the backup was taken, so the stack returns to exactly the file set it had before the run. For example, if a deploy switched the stack from `compose.yaml` to `docker-compose.yml` or introduced a new `.env`, a rollback undoes both. Files Sencho does not manage are left untouched.
|
||||
|
||||
## Community Edition behavior
|
||||
|
||||
On Community Edition, Sencho runs the same `docker compose` commands without the atomic wrapper. There is no backup, no health probe, and no automatic rollback, and the **Rollback** menu entry is hidden. On a Skipper or Admiral license, atomic deployments are active immediately for every protected action; no configuration is required.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="The Rollback option is not in the More actions menu">
|
||||
Sencho hides the entry whenever a rollback is not possible. The most common reasons are:
|
||||
|
||||
- The stack has never been deployed atomically, so no backup file exists yet. Run **Deploy** or **Update** once and the entry will appear.
|
||||
- The instance is on Community Edition. Atomic Deployments require Skipper or Admiral.
|
||||
Sencho hides the entry whenever a rollback is not possible. The most common reason is that the stack has never been deployed, so no backup file exists yet. Run **Deploy** or **Update** once and the entry will appear.
|
||||
|
||||
A user without the `stack:deploy` permission will still see the menu entry; the rejection comes from the backend with a permission error after they click. Ask an admin to grant `stack:deploy` through **Settings · Roles & Access** if that happens.
|
||||
</Accordion>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user