From 865d792874ba42af23cfe4708c89d9f4f2517c8f Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 4 Jun 2026 17:45:53 -0400 Subject: [PATCH] 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. --- CLA.md | 2 +- CONTRIBUTING.md | 7 +- README.md | 22 +- SUPPORT.md | 2 +- .../AutoHealService.evaluate.test.ts | 44 ++-- backend/src/__tests__/agents-routes.test.ts | 2 - backend/src/__tests__/alerts-api.test.ts | 4 +- backend/src/__tests__/api-tokens.test.ts | 2 - .../__tests__/atomic-deploy-hardening.test.ts | 22 +- backend/src/__tests__/audit-log.test.ts | 13 +- backend/src/__tests__/auth.test.ts | 3 +- .../src/__tests__/auto-heal-routes.test.ts | 13 +- .../src/__tests__/blueprints-authz.test.ts | 50 ++--- .../__tests__/blueprints-edge-cases.test.ts | 4 - .../blueprints-route-validation.test.ts | 4 - .../blueprints-withdraw-route.test.ts | 4 - .../src/__tests__/cloud-backup-routes.test.ts | 55 +---- .../src/__tests__/dashboard-routes.test.ts | 45 ++-- .../src/__tests__/distributed-license.test.ts | 86 ++------ .../fleet-pilot-dispatch-parity.test.ts | 1 - backend/src/__tests__/host-console-ws.test.ts | 7 +- backend/src/__tests__/host-console.test.ts | 3 +- .../__tests__/image-updates-routes.test.ts | 65 +----- .../__tests__/labels-community-tier.test.ts | 29 ++- .../license-service-id-validation.test.ts | 119 ++++------- backend/src/__tests__/license-service.test.ts | 103 +-------- .../src/__tests__/mesh-inspect-remote.test.ts | 1 - .../__tests__/mesh-list-stacks-remote.test.ts | 1 - .../mesh-remove-override-remote.test.ts | 1 - .../src/__tests__/mesh-route-gating.test.ts | 37 ++-- backend/src/__tests__/metrics-routes.test.ts | 2 - backend/src/__tests__/mfa.test.ts | 4 +- .../src/__tests__/nodes-cordon-authz.test.ts | 37 +--- .../__tests__/notification-routes-api.test.ts | 86 ++------ .../__tests__/remote-console-session.test.ts | 9 +- backend/src/__tests__/scan-compare.test.ts | 1 - .../__tests__/scheduled-tasks-routes.test.ts | 42 ++-- .../src/__tests__/scheduler-policy.test.ts | 4 +- .../src/__tests__/scheduler-service.test.ts | 88 +++----- backend/src/__tests__/secrets.test.ts | 2 - ...loy-block-honor-suppressions-route.test.ts | 4 +- .../security-trivy-auto-update-route.test.ts | 4 +- backend/src/__tests__/settings-routes.test.ts | 36 ++-- backend/src/__tests__/sso.test.ts | 155 +++----------- .../src/__tests__/stack-backup-route.test.ts | 17 +- .../src/__tests__/stack-bulk-routes.test.ts | 6 +- ...stack-delete-cascades-mesh-opt-out.test.ts | 4 - .../src/__tests__/stack-files-routes.test.ts | 4 - .../stacks-failure-notifications.test.ts | 6 +- .../__tests__/templates-deploy-rbac.test.ts | 2 - backend/src/__tests__/upgrade-order.test.ts | 73 ++----- backend/src/__tests__/users-rbac.test.ts | 185 ++++++++-------- .../src/__tests__/webhooks-git-source.test.ts | 4 +- .../src/__tests__/webhooks-trigger.test.ts | 22 +- backend/src/middleware/auth.ts | 15 +- backend/src/middleware/permissions.ts | 8 +- backend/src/middleware/tierGates.ts | 40 +--- backend/src/proxy/remoteNodeProxy.ts | 3 +- backend/src/routes/auditLog.ts | 8 +- backend/src/routes/autoHeal.ts | 7 +- backend/src/routes/blueprints.ts | 4 +- backend/src/routes/cloudBackup.ts | 16 +- backend/src/routes/console.ts | 4 +- backend/src/routes/dashboard.ts | 37 ++-- backend/src/routes/fleet.ts | 15 +- backend/src/routes/imageUpdates.ts | 6 +- backend/src/routes/labels.ts | 3 +- backend/src/routes/mesh.ts | 36 ++-- backend/src/routes/nodes.ts | 6 +- backend/src/routes/notifications.ts | 7 +- backend/src/routes/permissions.ts | 21 +- backend/src/routes/registries.ts | 14 +- backend/src/routes/scheduledTasks.ts | 11 +- backend/src/routes/settings.ts | 15 +- backend/src/routes/stacks.ts | 22 +- backend/src/routes/templates.ts | 4 +- backend/src/routes/users.ts | 61 +----- backend/src/routes/webhooks.ts | 25 +-- backend/src/services/AutoHealService.ts | 21 +- backend/src/services/BlueprintService.ts | 3 +- backend/src/services/CloudBackupService.ts | 7 +- backend/src/services/DatabaseService.ts | 8 - backend/src/services/LicenseService.ts | 198 +++--------------- backend/src/services/MeshProxyTunnelDialer.ts | 17 +- backend/src/services/MeshService.ts | 9 +- backend/src/services/SSOService.ts | 26 +-- backend/src/services/SchedulerService.ts | 34 +-- backend/src/services/WebhookService.ts | 15 +- backend/src/services/license-headers.ts | 1 - backend/src/services/license-normalize.ts | 36 +--- backend/src/services/license-types.ts | 8 - backend/src/types/express.ts | 4 +- backend/src/websocket/hostConsole.ts | 14 +- backend/src/websocket/remoteForwarder.ts | 4 +- backend/src/websocket/upgradeHandler.ts | 24 +-- docs/api-reference/overview.mdx | 8 +- docs/api-reference/security.mdx | 10 +- docs/features/alerts-notifications.mdx | 6 +- docs/features/app-store.mdx | 2 +- docs/features/atomic-deployments.mdx | 17 +- docs/features/audit-log.mdx | 4 +- docs/features/auto-heal-policies.mdx | 7 +- docs/features/auto-update-policies.mdx | 4 - docs/features/blueprint-model.mdx | 6 +- docs/features/dashboard.mdx | 8 +- docs/features/deploy-enforcement.mdx | 2 +- docs/features/editor.mdx | 4 +- docs/features/fleet-backups.mdx | 2 +- docs/features/fleet-secrets.mdx | 4 +- docs/features/fleet-view.mdx | 10 +- docs/features/global-search.mdx | 2 +- docs/features/host-console.mdx | 2 +- docs/features/licensing.mdx | 87 +++----- docs/features/multi-node.mdx | 10 +- docs/features/node-compatibility.mdx | 2 +- docs/features/overview.mdx | 28 +-- docs/features/rbac.mdx | 28 ++- docs/features/scheduled-operations.mdx | 2 +- docs/features/sencho-mesh.mdx | 2 +- docs/features/sidebar.mdx | 10 +- docs/features/sso.mdx | 11 +- docs/features/stack-management.mdx | 20 +- docs/features/vulnerability-scanning.mdx | 56 +++-- docs/features/webhooks.mdx | 4 +- docs/getting-started/configuration.mdx | 2 +- docs/getting-started/introduction.mdx | 2 +- docs/getting-started/sso-quickstart.mdx | 2 +- docs/openapi.yaml | 70 +++---- docs/operations/backup.mdx | 2 +- docs/operations/trivy-setup.mdx | 2 +- docs/operations/troubleshooting.mdx | 23 +- docs/operations/two-factor-admin.mdx | 2 +- docs/reference/contact.mdx | 8 +- docs/reference/security.mdx | 51 +++-- docs/reference/settings.mdx | 33 ++- e2e/auto-heal-policies.spec.ts | 4 +- frontend/src/App.tsx | 4 +- frontend/src/components/AdmiralGate.tsx | 15 -- .../components/AutoUpdateReadinessView.tsx | 7 +- .../src/components/DeployFeedbackModal.tsx | 12 -- frontend/src/components/EditorLayout.tsx | 10 - .../components/EditorLayout/EditorView.tsx | 4 +- .../components/EditorLayout/ViewRouter.tsx | 8 +- .../__tests__/useViewNavigationState.test.tsx | 52 ++--- .../hooks/useSidebarContextMenu.ts | 8 +- .../hooks/useStackActions.test.ts | 2 - .../EditorLayout/hooks/useStackActions.ts | 21 +- .../hooks/useViewNavigationState.ts | 8 +- frontend/src/components/FleetSnapshots.tsx | 9 +- frontend/src/components/FleetView.tsx | 21 +- .../src/components/FleetView/NodeCard.tsx | 9 +- .../FleetView/__tests__/NodeCard.test.tsx | 22 +- frontend/src/components/PaidGate.tsx | 8 +- frontend/src/components/RegistriesSection.tsx | 6 +- frontend/src/components/SSOSection.tsx | 10 +- frontend/src/components/StackAlertSheet.tsx | 23 +- frontend/src/components/TierBadge.tsx | 22 +- .../components/__tests__/SSOSection.test.tsx | 3 - .../dashboard/ConfigurationStatus.tsx | 54 +++-- .../__tests__/ConfigurationStatus.test.tsx | 95 ++------- .../__tests__/useConfigurationStatus.test.tsx | 9 +- .../__tests__/useMeshDataPlane.test.tsx | 20 +- .../dashboard/useConfigurationStatus.ts | 9 +- .../components/dashboard/useMeshDataPlane.ts | 18 +- .../components/fleet/FleetConfiguration.tsx | 16 +- .../settings/CloudBackupSection.tsx | 9 +- .../components/settings/DeveloperSection.tsx | 7 +- .../components/settings/LicenseSection.tsx | 17 +- .../src/components/settings/SectionGate.tsx | 4 +- .../src/components/settings/SettingsPage.tsx | 9 +- .../components/settings/SettingsSidebar.tsx | 4 +- .../components/settings/SupportSection.tsx | 14 +- .../src/components/settings/UsersSection.tsx | 15 +- .../components/settings/WebhooksSection.tsx | 4 +- frontend/src/components/settings/registry.ts | 15 +- .../src/components/sidebar/SidebarBulkBar.tsx | 7 +- frontend/src/components/sidebar/StackList.tsx | 4 +- frontend/src/components/sidebar/StackRow.tsx | 1 - .../src/components/sidebar/StackSidebar.tsx | 4 +- .../sidebar/__tests__/StackRow.test.tsx | 5 +- .../src/components/sidebar/sidebar-types.ts | 2 - frontend/src/context/AuthContext.tsx | 1 - frontend/src/context/LicenseContext.tsx | 3 - .../__tests__/useStackMenuItems.test.tsx | 23 +- frontend/src/hooks/useBulkStackActions.ts | 11 +- .../src/hooks/useStackKeyboardShortcuts.ts | 3 +- frontend/src/hooks/useStackMenuItems.tsx | 10 +- 187 files changed, 1164 insertions(+), 2437 deletions(-) delete mode 100644 frontend/src/components/AdmiralGate.tsx diff --git a/CLA.md b/CLA.md index 0a1b70bd..0b70969e 100644 --- a/CLA.md +++ b/CLA.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9844c80..3042588f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index 3df75f3d..7c6183bd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SUPPORT.md b/SUPPORT.md index d68a376f..8b6c7200 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -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 diff --git a/backend/src/__tests__/AutoHealService.evaluate.test.ts b/backend/src/__tests__/AutoHealService.evaluate.test.ts index 4a84caaa..264934bf 100644 --- a/backend/src/__tests__/AutoHealService.evaluate.test.ts +++ b/backend/src/__tests__/AutoHealService.evaluate.test.ts @@ -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['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 }).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['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['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); diff --git a/backend/src/__tests__/agents-routes.test.ts b/backend/src/__tests__/agents-routes.test.ts index 4bb6e7de..da9c4546 100644 --- a/backend/src/__tests__/agents-routes.test.ts +++ b/backend/src/__tests__/agents-routes.test.ts @@ -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); diff --git a/backend/src/__tests__/alerts-api.test.ts b/backend/src/__tests__/alerts-api.test.ts index 566327fc..6d1c3e80 100644 --- a/backend/src/__tests__/alerts-api.test.ts +++ b/backend/src/__tests__/alerts-api.test.ts @@ -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); diff --git a/backend/src/__tests__/api-tokens.test.ts b/backend/src/__tests__/api-tokens.test.ts index e3ae8c4f..568e545a 100644 --- a/backend/src/__tests__/api-tokens.test.ts +++ b/backend/src/__tests__/api-tokens.test.ts @@ -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); diff --git a/backend/src/__tests__/atomic-deploy-hardening.test.ts b/backend/src/__tests__/atomic-deploy-hardening.test.ts index fc740480..aa07d3d3 100644 --- a/backend/src/__tests__/atomic-deploy-hardening.test.ts +++ b/backend/src/__tests__/atomic-deploy-hardening.test.ts @@ -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 () => { diff --git a/backend/src/__tests__/audit-log.test.ts b/backend/src/__tests__/audit-log.test.ts index 77a7d74a..7c7fbd9d 100644 --- a/backend/src/__tests__/audit-log.test.ts +++ b/backend/src/__tests__/audit-log.test.ts @@ -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') diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts index 64971049..412ff3f6 100644 --- a/backend/src/__tests__/auth.test.ts +++ b/backend/src/__tests__/auth.test.ts @@ -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(() => { diff --git a/backend/src/__tests__/auto-heal-routes.test.ts b/backend/src/__tests__/auto-heal-routes.test.ts index c286d233..bfd9b96f 100644 --- a/backend/src/__tests__/auto-heal-routes.test.ts +++ b/backend/src/__tests__/auto-heal-routes.test.ts @@ -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)}`) diff --git a/backend/src/__tests__/blueprints-authz.test.ts b/backend/src/__tests__/blueprints-authz.test.ts index 8fe435b7..74e8fe6b 100644 --- a/backend/src/__tests__/blueprints-authz.test.ts +++ b/backend/src/__tests__/blueprints-authz.test.ts @@ -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'); diff --git a/backend/src/__tests__/blueprints-edge-cases.test.ts b/backend/src/__tests__/blueprints-edge-cases.test.ts index 5cfc420d..c5f3257b 100644 --- a/backend/src/__tests__/blueprints-edge-cases.test.ts +++ b/backend/src/__tests__/blueprints-edge-cases.test.ts @@ -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(); diff --git a/backend/src/__tests__/blueprints-route-validation.test.ts b/backend/src/__tests__/blueprints-route-validation.test.ts index 7bdba372..a91cf2a4 100644 --- a/backend/src/__tests__/blueprints-route-validation.test.ts +++ b/backend/src/__tests__/blueprints-route-validation.test.ts @@ -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(); diff --git a/backend/src/__tests__/blueprints-withdraw-route.test.ts b/backend/src/__tests__/blueprints-withdraw-route.test.ts index b5561763..437877ca 100644 --- a/backend/src/__tests__/blueprints-withdraw-route.test.ts +++ b/backend/src/__tests__/blueprints-withdraw-route.test.ts @@ -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(); diff --git a/backend/src/__tests__/cloud-backup-routes.test.ts b/backend/src/__tests__/cloud-backup-routes.test.ts index 14d6bd22..59e5b932 100644 --- a/backend/src/__tests__/cloud-backup-routes.test.ts +++ b/backend/src/__tests__/cloud-backup-routes.test.ts @@ -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); }); diff --git a/backend/src/__tests__/dashboard-routes.test.ts b/backend/src/__tests__/dashboard-routes.test.ts index d41e278c..1c3fdc8e 100644 --- a/backend/src/__tests__/dashboard-routes.test.ts +++ b/backend/src/__tests__/dashboard-routes.test.ts @@ -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); diff --git a/backend/src/__tests__/distributed-license.test.ts b/backend/src/__tests__/distributed-license.test.ts index b5367449..958c4456 100644 --- a/backend/src/__tests__/distributed-license.test.ts +++ b/backend/src/__tests__/distributed-license.test.ts @@ -24,24 +24,21 @@ const signToken = (payload: Record, 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); }); diff --git a/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts b/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts index bd59e0a8..f6c0de42 100644 --- a/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts +++ b/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts @@ -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 { diff --git a/backend/src/__tests__/host-console-ws.test.ts b/backend/src/__tests__/host-console-ws.test.ts index 27e8d46d..3b0656ce 100644 --- a/backend/src/__tests__/host-console-ws.test.ts +++ b/backend/src/__tests__/host-console-ws.test.ts @@ -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((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); }); diff --git a/backend/src/__tests__/host-console.test.ts b/backend/src/__tests__/host-console.test.ts index 6658a525..fcbc3510 100644 --- a/backend/src/__tests__/host-console.test.ts +++ b/backend/src/__tests__/host-console.test.ts @@ -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')); }); diff --git a/backend/src/__tests__/image-updates-routes.test.ts b/backend/src/__tests__/image-updates-routes.test.ts index d251299b..2689c9bb 100644 --- a/backend/src/__tests__/image-updates-routes.test.ts +++ b/backend/src/__tests__/image-updates-routes.test.ts @@ -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') diff --git a/backend/src/__tests__/labels-community-tier.test.ts b/backend/src/__tests__/labels-community-tier.test.ts index 4e4879e1..9b8072d3 100644 --- a/backend/src/__tests__/labels-community-tier.test.ts +++ b/backend/src/__tests__/labels-community-tier.test.ts @@ -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'); }); }); diff --git a/backend/src/__tests__/license-service-id-validation.test.ts b/backend/src/__tests__/license-service-id-validation.test.ts index c5db3db3..665c0ac4 100644 --- a/backend/src/__tests__/license-service-id-validation.test.ts +++ b/backend/src/__tests__/license-service-id-validation.test.ts @@ -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', }, diff --git a/backend/src/__tests__/license-service.test.ts b/backend/src/__tests__/license-service.test.ts index 6cfee8e5..a61827a9 100644 --- a/backend/src/__tests__/license-service.test.ts +++ b/backend/src/__tests__/license-service.test.ts @@ -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) { 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) { } } -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'); diff --git a/backend/src/__tests__/mesh-inspect-remote.test.ts b/backend/src/__tests__/mesh-inspect-remote.test.ts index 38c82f04..f95c4ea1 100644 --- a/backend/src/__tests__/mesh-inspect-remote.test.ts +++ b/backend/src/__tests__/mesh-inspect-remote.test.ts @@ -94,7 +94,6 @@ describe('MeshService.inspectStackServices dispatch (C-3 fix)', () => { const headers = (call[1] as { headers: Record }).headers; expect(headers['Authorization']).toBe('Bearer remote-tok'); expect(headers).toHaveProperty('x-sencho-tier'); - expect(headers).toHaveProperty('x-sencho-variant'); db.deleteNode(remoteNodeId); }); diff --git a/backend/src/__tests__/mesh-list-stacks-remote.test.ts b/backend/src/__tests__/mesh-list-stacks-remote.test.ts index c265bd0e..1f39d1a7 100644 --- a/backend/src/__tests__/mesh-list-stacks-remote.test.ts +++ b/backend/src/__tests__/mesh-list-stacks-remote.test.ts @@ -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); }); diff --git a/backend/src/__tests__/mesh-remove-override-remote.test.ts b/backend/src/__tests__/mesh-remove-override-remote.test.ts index abcda5f3..5fc15682 100644 --- a/backend/src/__tests__/mesh-remove-override-remote.test.ts +++ b/backend/src/__tests__/mesh-remove-override-remote.test.ts @@ -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); }); diff --git a/backend/src/__tests__/mesh-route-gating.test.ts b/backend/src/__tests__/mesh-route-gating.test.ts index 3a363a16..9d1e79a2 100644 --- a/backend/src/__tests__/mesh-route-gating.test.ts +++ b/backend/src/__tests__/mesh-route-gating.test.ts @@ -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'); }); }); diff --git a/backend/src/__tests__/metrics-routes.test.ts b/backend/src/__tests__/metrics-routes.test.ts index f51987aa..b2809d83 100644 --- a/backend/src/__tests__/metrics-routes.test.ts +++ b/backend/src/__tests__/metrics-routes.test.ts @@ -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); diff --git a/backend/src/__tests__/mfa.test.ts b/backend/src/__tests__/mfa.test.ts index 793b0e77..1f702393 100644 --- a/backend/src/__tests__/mfa.test.ts +++ b/backend/src/__tests__/mfa.test.ts @@ -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')); }); diff --git a/backend/src/__tests__/nodes-cordon-authz.test.ts b/backend/src/__tests__/nodes-cordon-authz.test.ts index a649edb0..bf191389 100644 --- a/backend/src/__tests__/nodes-cordon-authz.test.ts +++ b/backend/src/__tests__/nodes-cordon-authz.test.ts @@ -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 = {}; 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 () => { diff --git a/backend/src/__tests__/notification-routes-api.test.ts b/backend/src/__tests__/notification-routes-api.test.ts index 098ae7ea..c478c937 100644 --- a/backend/src/__tests__/notification-routes-api.test.ts +++ b/backend/src/__tests__/notification-routes-api.test.ts @@ -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 () => { diff --git a/backend/src/__tests__/remote-console-session.test.ts b/backend/src/__tests__/remote-console-session.test.ts index 86e9c8b8..db53cbaa 100644 --- a/backend/src/__tests__/remote-console-session.test.ts +++ b/backend/src/__tests__/remote-console-session.test.ts @@ -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(() => { diff --git a/backend/src/__tests__/scan-compare.test.ts b/backend/src/__tests__/scan-compare.test.ts index 9924fb05..1e260bd7 100644 --- a/backend/src/__tests__/scan-compare.test.ts +++ b/backend/src/__tests__/scan-compare.test.ts @@ -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')); }); diff --git a/backend/src/__tests__/scheduled-tasks-routes.test.ts b/backend/src/__tests__/scheduled-tasks-routes.test.ts index f762bc15..549ca56b 100644 --- a/backend/src/__tests__/scheduled-tasks-routes.test.ts +++ b/backend/src/__tests__/scheduled-tasks-routes.test.ts @@ -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; let tierSpy: ReturnType; 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', () => { diff --git a/backend/src/__tests__/scheduler-policy.test.ts b/backend/src/__tests__/scheduler-policy.test.ts index a1860dec..fc6e55d1 100644 --- a/backend/src/__tests__/scheduler-policy.test.ts +++ b/backend/src/__tests__/scheduler-policy.test.ts @@ -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, }), }, })); diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index c367b138..d81658c7 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -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 = {}) { 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' }), ); }); }); diff --git a/backend/src/__tests__/secrets.test.ts b/backend/src/__tests__/secrets.test.ts index b6357f4a..3e9735cf 100644 --- a/backend/src/__tests__/secrets.test.ts +++ b/backend/src/__tests__/secrets.test.ts @@ -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')); }); diff --git a/backend/src/__tests__/security-deploy-block-honor-suppressions-route.test.ts b/backend/src/__tests__/security-deploy-block-honor-suppressions-route.test.ts index d18e6d2c..e3acc673 100644 --- a/backend/src/__tests__/security-deploy-block-honor-suppressions-route.test.ts +++ b/backend/src/__tests__/security-deploy-block-honor-suppressions-route.test.ts @@ -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); diff --git a/backend/src/__tests__/security-trivy-auto-update-route.test.ts b/backend/src/__tests__/security-trivy-auto-update-route.test.ts index 47433bff..dd91273b 100644 --- a/backend/src/__tests__/security-trivy-auto-update-route.test.ts +++ b/backend/src/__tests__/security-trivy-auto-update-route.test.ts @@ -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); diff --git a/backend/src/__tests__/settings-routes.test.ts b/backend/src/__tests__/settings-routes.test.ts index 20f98009..eb08adb1 100644 --- a/backend/src/__tests__/settings-routes.test.ts +++ b/backend/src/__tests__/settings-routes.test.ts @@ -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'); } }); }); diff --git a/backend/src/__tests__/sso.test.ts b/backend/src/__tests__/sso.test.ts index 6089e556..e4fd35f2 100644 --- a/backend/src/__tests__/sso.test.ts +++ b/backend/src/__tests__/sso.test.ts @@ -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; - let variantSpy: ReturnType; 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) diff --git a/backend/src/__tests__/stack-backup-route.test.ts b/backend/src/__tests__/stack-backup-route.test.ts index dfa39105..13409c95 100644 --- a/backend/src/__tests__/stack-backup-route.test.ts +++ b/backend/src/__tests__/stack-backup-route.test.ts @@ -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 () => { diff --git a/backend/src/__tests__/stack-bulk-routes.test.ts b/backend/src/__tests__/stack-bulk-routes.test.ts index 19203014..eb1018f2 100644 --- a/backend/src/__tests__/stack-bulk-routes.test.ts +++ b/backend/src/__tests__/stack-bulk-routes.test.ts @@ -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(); } diff --git a/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts b/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts index 597a0acb..2b92c17c 100644 --- a/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts +++ b/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts @@ -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); diff --git a/backend/src/__tests__/stack-files-routes.test.ts b/backend/src/__tests__/stack-files-routes.test.ts index 555cb007..9ee8ff93 100644 --- a/backend/src/__tests__/stack-files-routes.test.ts +++ b/backend/src/__tests__/stack-files-routes.test.ts @@ -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 ───────────────────────────────────────────────────── diff --git a/backend/src/__tests__/stacks-failure-notifications.test.ts b/backend/src/__tests__/stacks-failure-notifications.test.ts index e0abf9ab..b208141a 100644 --- a/backend/src/__tests__/stacks-failure-notifications.test.ts +++ b/backend/src/__tests__/stacks-failure-notifications.test.ts @@ -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); diff --git a/backend/src/__tests__/templates-deploy-rbac.test.ts b/backend/src/__tests__/templates-deploy-rbac.test.ts index 4e7ae7ff..76109828 100644 --- a/backend/src/__tests__/templates-deploy-rbac.test.ts +++ b/backend/src/__tests__/templates-deploy-rbac.test.ts @@ -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')); }); diff --git a/backend/src/__tests__/upgrade-order.test.ts b/backend/src/__tests__/upgrade-order.test.ts index b2088ecd..82825014 100644 --- a/backend/src/__tests__/upgrade-order.test.ts +++ b/backend/src/__tests__/upgrade-order.test.ts @@ -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((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 { + async function setLicense(status: string): Promise { 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', () => { diff --git a/backend/src/__tests__/users-rbac.test.ts b/backend/src/__tests__/users-rbac.test.ts index 2604f9d9..a6d01eca 100644 --- a/backend/src/__tests__/users-rbac.test.ts +++ b/backend/src/__tests__/users-rbac.test.ts @@ -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()}`) diff --git a/backend/src/__tests__/webhooks-git-source.test.ts b/backend/src/__tests__/webhooks-git-source.test.ts index 7601611f..7fba6713 100644 --- a/backend/src/__tests__/webhooks-git-source.test.ts +++ b/backend/src/__tests__/webhooks-git-source.test.ts @@ -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', () => { diff --git a/backend/src/__tests__/webhooks-trigger.test.ts b/backend/src/__tests__/webhooks-trigger.test.ts index 72a538a8..45d1a68d 100644 --- a/backend/src/__tests__/webhooks-trigger.test.ts +++ b/backend/src/__tests__/webhooks-trigger.test.ts @@ -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 = '{}'; diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index f3eeddf0..ac6aa2b9 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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; } diff --git a/backend/src/middleware/permissions.ts b/backend/src/middleware/permissions.ts index b485982d..3c558205 100644 --- a/backend/src/middleware/permissions.ts +++ b/backend/src/middleware/permissions.ts @@ -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 = { ], }; -/** 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); diff --git a/backend/src/middleware/tierGates.ts b/backend/src/middleware/tierGates.ts index e0fb8c3d..8c5e2125 100644 --- a/backend/src/middleware/tierGates.ts +++ b/backend/src/middleware/tierGates.ts @@ -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. */ diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index e1bb703a..35d28c9b 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -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 diff --git a/backend/src/routes/auditLog.ts b/backend/src/routes/auditLog.ts index f6db80d5..37a0c329 100644 --- a/backend/src/routes/auditLog.ts +++ b/backend/src/routes/auditLog.ts @@ -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 => { - 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 => { }); auditLogRouter.get('/stats', async (req: Request, res: Response): Promise => { - 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 }); auditLogRouter.get('/export', async (req: Request, res: Response): Promise => { - if (!requireAdmiral(req, res)) return; + if (!requirePaid(req, res)) return; if (!requirePermission(req, res, 'system:audit')) return; try { diff --git a/backend/src/routes/autoHeal.ts b/backend/src/routes/autoHeal.ts index a65abaa5..edf06442 100644 --- a/backend/src/routes/autoHeal.ts +++ b/backend/src/routes/autoHeal.ts @@ -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); diff --git a/backend/src/routes/blueprints.ts b/backend/src/routes/blueprints.ts index 544ffbc4..5287d346 100644 --- a/backend/src/routes/blueprints.ts +++ b/backend/src/routes/blueprints.ts @@ -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 => { - 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'); diff --git a/backend/src/routes/cloudBackup.ts b/backend/src/routes/cloudBackup.ts index 525d91ca..abea7ec9 100644 --- a/backend/src/routes/cloudBackup.ts +++ b/backend/src/routes/cloudBackup.ts @@ -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 => { 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 => { 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') { diff --git a/backend/src/routes/console.ts b/backend/src/routes/console.ts index 48d66370..ae7fbe7f 100644 --- a/backend/src/routes/console.ts +++ b/backend/src/routes/console.ts @@ -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) { diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 3f3a2fcb..f1428ac7 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -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})`, diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index b7494ed4..08940437 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -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 => { @@ -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 => { if (!requireAdmin(req, res)) return; @@ -1764,9 +1762,9 @@ async function remoteStackError(action: string, res: Awaited = { '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 }; diff --git a/backend/src/routes/imageUpdates.ts b/backend/src/routes/imageUpdates.ts index 703d6b48..2fc0ce44 100644 --- a/backend/src/routes/imageUpdates.ts +++ b/backend/src/routes/imageUpdates.ts @@ -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 => { 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 => { 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) { diff --git a/backend/src/routes/labels.ts b/backend/src/routes/labels.ts index fb2459f6..2f57f55a 100644 --- a/backend/src/routes/labels.ts +++ b/backend/src/routes/labels.ts @@ -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 => { - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; if (!requireBody(req, res)) return; try { diff --git a/backend/src/routes/mesh.ts b/backend/src/routes/mesh.ts index 0753e869..9057e38a 100644 --- a/backend/src/routes/mesh.ts +++ b/backend/src/routes/mesh.ts @@ -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 => { - 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 => * path was opt-out + opt-in for every meshed stack on that node. */ meshRouter.post('/regen-overrides', async (req: Request, res: Response): Promise => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => }); meshRouter.get('/aliases/:alias/diagnostic', async (req: Request, res: Response): Promise => { - 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 => { - 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 => { - 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'); diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index 914a0f89..c040e849 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -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; diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index a8eb44b1..e7c58c1a 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -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 => { 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 => { 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 => { if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'route ID'); if (id === null) return; diff --git a/backend/src/routes/permissions.ts b/backend/src/routes/permissions.ts index be56f0c1..f2ef92a1 100644 --- a/backend/src/routes/permissions.ts +++ b/backend/src/routes/permissions.ts @@ -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 = {}; - 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); diff --git a/backend/src/routes/registries.ts b/backend/src/routes/registries.ts index 178c0480..ec756208 100644 --- a/backend/src/routes/registries.ts +++ b/backend/src/routes/registries.ts @@ -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 => { 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 => { 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; diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index 6e606100..f875be9d 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -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; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index b82c0938..0c6edf75 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -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) { diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 293d7e53..7f447e55 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -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 diff --git a/backend/src/routes/templates.ts b/backend/src/routes/templates.ts index a2289175..a83dfb4a 100644 --- a/backend/src/routes/templates.ts +++ b/backend/src/routes/templates.ts @@ -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}`); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index c0992365..191e3a38 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -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 => { @@ -65,7 +46,6 @@ usersRouter.get('/', authMiddleware, async (req: Request, res: Response): Promis usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { 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); diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index ee559fc1..e7435e83 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -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 => { - 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 => { 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 => { 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 => { 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 => { - 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 => { 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); }); diff --git a/backend/src/services/AutoHealService.ts b/backend/src/services/AutoHealService.ts index 85b81e3e..a3d2d4a2 100644 --- a/backend/src/services/AutoHealService.ts +++ b/backend/src/services/AutoHealService.ts @@ -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 { - 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()) { diff --git a/backend/src/services/BlueprintService.ts b/backend/src/services/BlueprintService.ts index 2650e6dd..e35878fb 100644 --- a/backend/src/services/BlueprintService.ts +++ b/backend/src/services/BlueprintService.ts @@ -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', }; } diff --git a/backend/src/services/CloudBackupService.ts b/backend/src/services/CloudBackupService.ts index e8eacd61..7c46b6b7 100644 --- a/backend/src/services/CloudBackupService.ts +++ b/backend/src/services/CloudBackupService.ts @@ -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 { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 77e166c6..39d369fc 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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); } diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index 23475708..a40cf4d5 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -5,15 +5,7 @@ import type { LicenseInfo, LicenseStatus, LicenseTier, - LicenseVariant, - SeatLimits, } from './license-types'; -import { isLicenseVariant, normalizeVariant } from './license-normalize'; - -const SEAT_LIMITS: Record = { - 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 = new Set([ - SENCHO_LS_PRODUCT_ID_SKIPPER, - SENCHO_LS_PRODUCT_ID_ADMIRAL, -]); -const SENCHO_LS_VARIANT_TO_TYPE: ReadonlyMap> = 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 | 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 | 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, - ): 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)); } diff --git a/backend/src/services/MeshProxyTunnelDialer.ts b/backend/src/services/MeshProxyTunnelDialer.ts index 24c07e30..c52578d1 100644 --- a/backend/src/services/MeshProxyTunnelDialer.ts +++ b/backend/src/services/MeshProxyTunnelDialer.ts @@ -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, diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index 0edc4360..7e66abad 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -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, diff --git a/backend/src/services/SSOService.ts b/backend/src/services/SSOService.ts index e4b46995..ac53ddc9 100644 --- a/backend/src/services/SSOService.ts +++ b/backend/src/services/SSOService.ts @@ -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); diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 7f3553e6..3c886eab 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -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); diff --git a/backend/src/services/WebhookService.ts b/backend/src/services/WebhookService.ts index 6e02219c..078112cc 100644 --- a/backend/src/services/WebhookService.ts +++ b/backend/src/services/WebhookService.ts @@ -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. diff --git a/backend/src/services/license-headers.ts b/backend/src/services/license-headers.ts index 09671af9..1044d3ec 100644 --- a/backend/src/services/license-headers.ts +++ b/backend/src/services/license-headers.ts @@ -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'; diff --git a/backend/src/services/license-normalize.ts b/backend/src/services/license-normalize.ts index f04aad08..118f0cd5 100644 --- a/backend/src/services/license-normalize.ts +++ b/backend/src/services/license-normalize.ts @@ -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 = { pro: 'paid' }; -/** - * Legacy variant names accepted on input from older proxy headers; - * normalized to the current names on read. - */ -const LEGACY_VARIANT_MAP: Record> = { - 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 { - return LEGACY_VARIANT_MAP[value] ?? (value as Exclude); -} diff --git a/backend/src/services/license-types.ts b/backend/src/services/license-types.ts index 67143d1a..c556c3c6 100644 --- a/backend/src/services/license-types.ts +++ b/backend/src/services/license-types.ts @@ -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; -} diff --git a/backend/src/types/express.ts b/backend/src/types/express.ts index 3d12c876..9aa552fa 100644 --- a/backend/src/types/express.ts +++ b/backend/src/types/express.ts @@ -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. */ diff --git a/backend/src/websocket/hostConsole.ts b/backend/src/websocket/hostConsole.ts index b786ec56..eaf26ade 100644 --- a/backend/src/websocket/hostConsole.ts +++ b/backend/src/websocket/hostConsole.ts @@ -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'); } diff --git a/backend/src/websocket/remoteForwarder.ts b/backend/src/websocket/remoteForwarder.ts index 8b7a28fd..60fcbd93 100644 --- a/backend/src/websocket/remoteForwarder.ts +++ b/backend/src/websocket/remoteForwarder.ts @@ -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. diff --git a/backend/src/websocket/upgradeHandler.ts b/backend/src/websocket/upgradeHandler.ts index 9bc92297..7245770a 100644 --- a/backend/src/websocket/upgradeHandler.ts +++ b/backend/src/websocket/upgradeHandler.ts @@ -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 { 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); diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index 39316c9c..b9facd8f 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -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 diff --git a/docs/api-reference/security.mdx b/docs/api-reference/security.mdx index a58275f3..258ba00c 100644 --- a/docs/api-reference/security.mdx +++ b/docs/api-reference/security.mdx @@ -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 \ diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index be1895fc..b259dea9 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -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. 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 - 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. 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. diff --git a/docs/features/app-store.mdx b/docs/features/app-store.mdx index 821c44b7..411ee875 100644 --- a/docs/features/app-store.mdx +++ b/docs/features/app-store.mdx @@ -110,7 +110,7 @@ Clicking **Deploy** runs the following sequence: 2. **Directory creation.** A new directory is created under `COMPOSE_DIR/` 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. diff --git a/docs/features/atomic-deployments.mdx b/docs/features/atomic-deployments.mdx index 0e7368f6..fcd4e925 100644 --- a/docs/features/atomic-deployments.mdx +++ b/docs/features/atomic-deployments.mdx @@ -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. - - Atomic Deployments require a Sencho **Skipper** or **Admiral** license. Community Edition runs the same compose actions without a backup or automatic rollback. - - ## 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. 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 -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 - 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. diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx index cf99f46a..a1367018 100644 --- a/docs/features/audit-log.mdx +++ b/docs/features/audit-log.mdx @@ -4,7 +4,7 @@ description: Track every mutating action on your Sencho instance with a searchab --- - The Audit Log requires a Sencho **Admiral** license. Skipper and Community do not include this feature. + The Audit Log requires a Sencho **Admiral** license. @@ -170,7 +170,7 @@ Sensitive database values (such as remote node API tokens) are encrypted at rest - The tab is visible only on **Admiral**, and only to users whose role grants the `system:audit` permission. By default that means **Admin** or **Auditor**. If your license is Community or Skipper, the tab is gated by the audit-log capability and will not render. If you are signed in as a Deployer or Viewer on an Admiral instance, ask an admin to assign you the Auditor role from **Settings · Users**. + The Audit log requires an **Admiral** license, and the tab is shown only to users whose role grants the `system:audit` permission. By default that means **Admin** or **Auditor**. If you are signed in as a Deployer or Viewer on an Admiral instance, ask an admin to assign you the Auditor role from **Settings · Users**. Filters live in **Table view only**. Toggle the segmented control in the card header from **Stream** to **Table** and the search box, method dropdown, and From / To date pickers will appear above the grid. Switching back to Stream clears the filter strip but does not remember the last filter. diff --git a/docs/features/auto-heal-policies.mdx b/docs/features/auto-heal-policies.mdx index 4f601d83..65f6da9b 100644 --- a/docs/features/auto-heal-policies.mdx +++ b/docs/features/auto-heal-policies.mdx @@ -3,10 +3,6 @@ title: "Auto-Heal Policies" description: "Restart containers that fail their Docker healthcheck or crash, with per-policy thresholds and a built-in safety rail set." --- - - Auto-Heal Policies require a **Skipper** or **Admiral** license. - - ## Overview Auto-Heal Policies restart a container when it stays broken for longer than you allow. A policy acts on two conditions: @@ -24,7 +20,6 @@ Policies live next to your stack-level alert rules in the stack's **Monitor** sh - For **healthcheck-based** healing, containers must declare a `HEALTHCHECK` in the Dockerfile or a `healthcheck` block in `docker-compose.yml` so they report a Docker health status. **Crash-based** healing needs no healthcheck: any container that exits with a non-zero code qualifies. - You must be signed in as an admin. -- A Skipper or Admiral license. Crash healing acts on crashes Sencho observes while it is running. A container that crashed before Sencho started is left for you to inspect rather than restarted automatically. @@ -142,6 +137,6 @@ The dashboard's **Configuration status** card surfaces an **Auto-heal policies** - Auto-Heal Policies require a Skipper or Admiral license and an admin sign-in. If the **Auto-heal** tab is not present on the **Monitor** sheet, confirm your license tier under **Settings → License** and that you are signed in as an admin. + Configuring Auto-Heal Policies requires an admin sign-in. If the **Auto-heal** tab is not present on the **Monitor** sheet, confirm you are signed in as an admin. diff --git a/docs/features/auto-update-policies.mdx b/docs/features/auto-update-policies.mdx index d1afef03..8f1ce188 100644 --- a/docs/features/auto-update-policies.mdx +++ b/docs/features/auto-update-policies.mdx @@ -3,10 +3,6 @@ title: "Auto-Update Policies" description: "Review pending container updates across your fleet, with risk badges, changelogs, and scheduled run times, before applying." --- - - Auto-Update Policies require a **Skipper** or **Admiral** license. - - Auto-Update is hub-only and is hidden from the nav strip when a remote node is the active selection. See [Multi-Node Management](/features/multi-node#what-top-level-views-show-when-a-remote-node-is-active). diff --git a/docs/features/blueprint-model.mdx b/docs/features/blueprint-model.mdx index 26f2ed67..8cf17f0c 100644 --- a/docs/features/blueprint-model.mdx +++ b/docs/features/blueprint-model.mdx @@ -8,7 +8,7 @@ A **Blueprint** bundles a `docker-compose.yml` with a node selector and a drift Blueprints live under **Fleet · Deployments**. - Blueprints require a Sencho **Skipper** or **Admiral** license. Creating, editing, and withdrawing blueprints requires an admin role; operators and viewers can read the catalog and the detail sheet. Pinning a blueprint to a single node requires Admiral. + Blueprints require a Sencho **Admiral** license. Creating, editing, withdrawing, and pinning blueprints requires an admin role; operators and viewers can read the catalog and the detail sheet. @@ -47,7 +47,7 @@ Drift detection runs on every tick for every Active deployment regardless of pol | Requirement | Detail | |---|---| -| License tier | **Skipper** or **Admiral** to read, create, edit, and withdraw blueprints. **Admiral** to pin a blueprint to a node from the Federation tab. | +| License tier | **Admiral** to read, create, edit, withdraw, and pin blueprints. | | User role | **Admin** to create, edit, withdraw, accept, and pin. Operators and viewers can read the catalog and the detail sheet. | | Nodes | At least one node that the selector resolves to. Remote nodes need a healthy proxy connection; see [Multi-node management](/features/multi-node) and [Pilot Agent](/features/pilot-agent) for enrollment. | | Compose YAML | Valid `docker-compose.yml`, 96 KiB or fewer. | @@ -251,7 +251,7 @@ Both events route through the standard alert pipeline. Configure delivery channe ## Security and trust boundaries -**Who can do what.** The license tier and the user role together determine the available actions. Reading the catalog, the detail sheet, and the deployment status requires Skipper or Admiral. Creating, editing, withdrawing, accepting a stateful deploy, and applying on demand require the admin role on top of the tier. Pinning a blueprint requires Admiral plus the admin role. +**Who can do what.** The license tier and the user role together determine the available actions. Reading the catalog, the detail sheet, and the deployment status requires Admiral. Creating, editing, withdrawing, accepting a stateful deploy, applying on demand, and pinning a blueprint require the admin role on top of the Admiral tier. **The marker file is the trust root.** The reconciler will only deploy into, modify, or withdraw a directory that carries a `.blueprint.json` marker whose blueprint ID matches. A pre-existing directory with no marker, or a marker referencing a different blueprint, surfaces as **Name conflict** and is never modified. diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index 0bd4d2ec..2615161c 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -93,14 +93,12 @@ The card is divided into four sections. ### Automation -The Automation block only renders on Skipper or Admiral. - | Row | What it shows | |-----|---------------| | **Auto-heal policies** | ` / active` for crash-recovery policies across all stacks; reads `None` when no policies exist | | **Auto-update schedules** | ` / active` count of `Auto-update Stack` / `Auto-update All Stacks` rows configured for this node; reads `None` when none are configured | -| **Webhooks** (Skipper) | Active inbound deploy webhooks tied to Git Sources or stacks, formatted ` actives` | -| **Scheduled tasks** (Admiral) | Active scheduled operations (backups, restarts, scripts), formatted ` actives` | +| **Webhooks** | Active inbound deploy webhooks tied to Git Sources or stacks, formatted ` actives` | +| **Scheduled tasks** | Active scheduled operations (backups, restarts, scripts), formatted ` actives` | ### Security @@ -108,7 +106,7 @@ The Automation block only renders on Skipper or Admiral. |-----|---------------| | **MFA** | `On` when TOTP is configured for the signed-in operator, `Off` when configured but disabled, `Not set up` when there is no MFA secret on file | | **SSO** | The active SSO provider name (`OIDC`, `Google`, `GitHub`, `Okta`, `LDAP`); reads `Off` when SSO is not enabled | -| **Vulnerability scanning** (Skipper) | Count of enabled scan policies on the active node; reads `None` when no policy is enabled | +| **Vulnerability scanning** (Admiral) | Count of enabled scan policies on the active node; reads `None` when no policy is enabled | ### Backups & Thresholds diff --git a/docs/features/deploy-enforcement.mdx b/docs/features/deploy-enforcement.mdx index 1ae31d49..ac31d221 100644 --- a/docs/features/deploy-enforcement.mdx +++ b/docs/features/deploy-enforcement.mdx @@ -6,7 +6,7 @@ description: "Block deploys that violate a scan policy before docker compose up Deploy enforcement is the pre-flight half of Sencho's vulnerability workflow. When a [scan policy](/features/vulnerability-scanning#scan-policies) with **Block on deploy** enabled matches a stack, Sencho scans every image referenced by the stack's compose file before starting any container. If any image meets or exceeds the policy's severity threshold, the deploy is rejected and the stack never starts. Detection always continues post-deploy and on a schedule, so images that develop new vulnerabilities after the initial deploy still surface through alerts. - Deploy enforcement and scan policies require a **Skipper** or **Admiral** license. + Deploy enforcement and scan policies require an **Admiral** license. ## Configuring a block policy diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index 42d9baec..8a3453b0 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -37,8 +37,8 @@ The action bar runs every state transition for the whole stack. The primary butt The kebab dropdown carries: -- **Rollback ``** restores the previous deployment using the stored snapshot. Visible only when a backup exists. Skipper or Admiral. -- **Scan config** runs Trivy against the compose configuration and surfaces misconfigurations inline. Visible only when Trivy is reachable. Admin role required. Skipper or Admiral. +- **Rollback ``** restores the previous deployment using the stored snapshot. Visible only when a backup exists. +- **Scan config** runs Trivy against the compose configuration and surfaces misconfigurations inline. Visible only when Trivy is reachable. Admin role required. - **Delete** stops the stack and removes its compose directory. Requires the `stack:delete` permission. ## Containers list diff --git a/docs/features/fleet-backups.mdx b/docs/features/fleet-backups.mdx index af94aeba..eea93e2b 100644 --- a/docs/features/fleet-backups.mdx +++ b/docs/features/fleet-backups.mdx @@ -4,7 +4,7 @@ description: Snapshot compose files across all nodes for disaster recovery and a --- - Manual fleet snapshots are available on every tier (admin role required). Scheduled fleet snapshots are a Skipper or Admiral feature. + Manual and scheduled fleet snapshots are available on every tier (admin role required). Create point-in-time snapshots of every `compose.yaml` and `.env` file across your entire fleet, local and remote nodes alike. Snapshots are stored centrally in Sencho's database and can be browsed, previewed, and restored at any time. diff --git a/docs/features/fleet-secrets.mdx b/docs/features/fleet-secrets.mdx index cadac1a3..9b822ec2 100644 --- a/docs/features/fleet-secrets.mdx +++ b/docs/features/fleet-secrets.mdx @@ -12,7 +12,7 @@ The unit of work is the **bundle**. One bundle has one current `kv` payload; pus -Fleet Secrets is a Skipper feature. Every action requires an admin user role. +Fleet Secrets is an Admiral feature. Every action requires an admin user role. ## What Fleet Secrets covers (and what it doesn't) @@ -38,7 +38,7 @@ A **push** is a separate action. It reads the bundle's current version, walks ev | Requirement | Why it matters | |---|---| -| Skipper or Admiral license on the control instance | The tab and the underlying actions are paid; this is the same gate that opens Blueprints and Fleet Federation | +| Admiral license on the control instance | Fleet Secrets is an Admiral feature | | Admin user role | Bundle CRUD and push run as the signed-in operator and write authored-by rows into the audit log | | At least one stack on at least one node | Pushes target an existing stack directory; the wizard does not create stacks | | The target stack's compose declares the env file via `env_file:` | The env-file dropdown in the push wizard reads `env_file:` entries from a representative node's compose; a stack with only an inline `environment:` block will not show up | diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 5d79d171..12c87793 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -43,13 +43,13 @@ The Fleet view is a tab strip. Four tab triggers are visible to every tier; the | Tab | Tier | What it does | |-----|------|--------------| | **Overview** | Community | The grid or topology view of every node and its health. Covered in the next section. | -| **Snapshots** | Community (manual) / Skipper (scheduled) | Snapshot every compose file across the fleet. See [Fleet-Wide Backups](/features/fleet-backups). | +| **Snapshots** | Community | Snapshot every compose file across the fleet. See [Fleet-Wide Backups](/features/fleet-backups). | | **Status** | Community | One card per node summarising which automations and security features are configured. Covered below. | -| **Deployments** | Skipper | Blueprint deployments and reconciler state. See [Blueprints](/features/blueprint-model). | +| **Deployments** | Admiral | Blueprint deployments and reconciler state. See [Blueprints](/features/blueprint-model). | | **Routing** | Admiral | Cross-node service routing via Sencho Mesh. See [Sencho Mesh](/features/sencho-mesh). | | **Federation** | Admiral | Cordon nodes and pin blueprints to specific hosts. See [Fleet Federation](/features/fleet-federation). | | **Fleet Actions** | Community (admin role) | Fleet-wide bulk operations: stop stacks by label, bulk-assign labels, prune Docker resources. See [Fleet Actions](/features/fleet-actions). | -| **Secrets** | Skipper | Encrypted env-var bundles you push to labeled nodes. See [Fleet Secrets](/features/fleet-secrets). | +| **Secrets** | Admiral | Encrypted env-var bundles you push to labeled nodes. See [Fleet Secrets](/features/fleet-secrets). | ### Action buttons @@ -133,7 +133,7 @@ The graph is interactive: drag the canvas to pan, scroll to zoom, drag a node to The graph re-lays out only when nodes are added, removed, or change type. Live metric updates on existing nodes do not move the layout, so an operator who has dragged nodes into a custom arrangement keeps it. -#### Topology layout modes (Skipper+) +#### Topology layout modes A toolbar at the top of the topology canvas offers three layouts. Pick the one that matches how you reason about your fleet. @@ -191,7 +191,7 @@ Online nodes render a two-column summary grid with up to eight rows: |-----|---------------|------------| | **Agents** | Active notification agents, formatted ` active` or `None` | Always | | **Alert rules** | Per-stack alert rule count, formatted ` rule(s)` | Always | -| **Auto-heal** | Enabled / total auto-heal policies, formatted `/` | Skipper or Admiral | +| **Auto-heal** | Enabled / total auto-heal policies, formatted `/` | Always | | **Webhooks** | Active outbound webhooks, formatted ` active` | Always (when not gated) | | **MFA** | `On`, `Off`, or `Not set` | Local node only | | **Scanning** | Active vulnerability-scan policies, formatted ` policy/policies` | Always (when not gated) | diff --git a/docs/features/global-search.mdx b/docs/features/global-search.mdx index 2cef5df9..75f4463c 100644 --- a/docs/features/global-search.mdx +++ b/docs/features/global-search.mdx @@ -25,7 +25,7 @@ The palette groups results into three sections. | Group | What it contains | What happens when you pick one | |-------|------------------|--------------------------------| -| **Pages** | The same set of destinations the top bar shows you. Home, Fleet, Resources, App Store, and Logs always appear; Auto-Update appears for admins on Skipper or higher; Console and Schedules appear for admins on Admiral; Audit appears for any role on Admiral with the audit permission. | Navigates to that page | +| **Pages** | The same set of destinations the top bar shows you. Home, Fleet, Resources, App Store, and Logs always appear; Auto-Update and Schedules appear for admins; Console appears for admins on Admiral; Audit appears for any role on Admiral with the audit permission. | Navigates to that page | | **Nodes** | Every node in your fleet, with a green dot for online and a grey dot for offline. The currently active node carries a small **ACTIVE** chip on the right. | Switches the active node without leaving the current page | | **Stacks** | Every compose stack on every online node, matched on the compose filename (extension included). | Switches to the stack's node and opens it in the editor | diff --git a/docs/features/host-console.mdx b/docs/features/host-console.mdx index 92fe4106..8012abe0 100644 --- a/docs/features/host-console.mdx +++ b/docs/features/host-console.mdx @@ -75,7 +75,7 @@ Environment variables whose names suggest secrets (passwords, tokens, keys, cred The Host Console is one of the most powerful features in Sencho and is treated as such: - **Admin role required.** Only users with the **admin** role can open a console session. -- **Admiral license required.** The Console tab is not available on the Community or Skipper tiers. +- **Admiral license required.** The Host Console is an Admiral feature. - **Browser sessions only.** Console sessions are only available from a signed-in browser session, not from API tokens. - **Audited.** Every console session is recorded in the audit log. Opening and closing a session each write an entry capturing the user, node, client IP, and timestamp, so shell access is fully accountable. diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 3ea01f66..1f2318b6 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -3,7 +3,7 @@ title: Licensing & Billing description: How Sencho licensing works, including trials, activation, and subscription management. --- -Sencho uses an open-core model. The **Community** tier is free forever with unlimited nodes. Paid tiers unlock advanced features: **Skipper** for solo operators and **Admiral** for teams. +Sencho uses an open-core model. **Community** is the complete self-hosted control plane, free forever with unlimited nodes. **Admiral** adds governance, security, and fleet control for teams. *Our tier names are inspired by the meaning of Sencho (船長), because you're the captain of your container fleet.* @@ -11,64 +11,48 @@ Sencho uses an open-core model. The **Community** tier is free forever with unli ## Plans -| Tier | Annual (per mo, billed yearly) | Monthly | Founder Lifetime | Seats | -|------|--------------------------------|---------|------------------|-------| -| **Community** | Free | Free | Free | 1 admin | -| **Skipper** | $5.75 | $9.99 | $149 | 1 admin + 3 viewers | -| **Admiral** | $20.75 | $39.99 | $499 | Unlimited | +| Tier | Annual (per mo, billed yearly) | Monthly | Seats | +|------|--------------------------------|---------|-------| +| **Community** | Free | Free | Unlimited | +| **Admiral** | $8.25 | $12 | Unlimited | -For larger deployments, an **Enterprise** tier is available with custom pricing, including SLA, priority support, security questionnaires, and custom contracts. Contact [licensing@sencho.io](mailto:licensing@sencho.io) or see [the pricing page](https://sencho.io/pricing) for details. - -**Founder Lifetime** is an Early Access offer available for a limited time. Once the window closes, only the Monthly and Annual cycles remain. +See [the pricing page](https://sencho.io/pricing) for current pricing. ### Feature breakdown -**Community** includes: +**Community** is the complete self-hosted control plane. It includes: - Unlimited nodes, the Monaco compose editor, the full stack file explorer (browse, view, edit, upload, download, rename, chmod, delete; admin role for writes), and the App Store with 199+ one-click templates - Real-time container stats, global logs, the interactive network topology graph, and stack labels - Git sources for compose stacks - Multi-node management in both Proxy and Pilot Agent modes -- Manual fleet snapshots (create, browse, restore, delete) and Remote OTA node updates (per-node and **Update all**) -- Fleet Actions tab (stop stacks fleet-wide by label, bulk-assign labels to many stacks on a node, prune Docker resources fleet-wide; admin role required) -- Custom S3-compatible backup target (bring your own AWS S3, Cloudflare R2, MinIO, Backblaze B2, or Wasabi bucket) -- Vulnerability scanning: install, update, and uninstall Trivy, on-demand scans for vulnerabilities, secrets, and misconfigurations, plus scan comparison -- CVE suppressions -- Alert rules with Discord, Slack, and webhook targets -- Two-factor authentication (TOTP plus backup codes) -- Custom OIDC single sign-on (works with Authelia, Keycloak, Authentik, Zitadel, Pocket ID, or any spec-compliant OIDC provider) - -**Skipper** includes everything in Community, plus: - - Fleet View with search, sort, filter, and node-card drill-down -- Webhooks (incoming, to trigger deploys from CI/CD) -- Notification routing (per-stack and per-category rules to Discord, Slack, or any webhook) -- Atomic deployments with rollback -- Auto-update policies for stack images -- Auto-heal policies -- Scheduled operations across the full action catalog (lifecycle, updates, scans, snapshots, prune) -- Scan policies with `block_on_deploy` deploy enforcement, SBOM (SPDX, CycloneDX), and SARIF export -- Auto-update of the managed Trivy binary +- Manual and scheduled fleet snapshots (create, browse, restore, delete) and Remote OTA node updates (per-node and **Update all**) +- Fleet Actions tab (stop stacks fleet-wide by label, bulk-assign labels to many stacks on a node, prune Docker resources fleet-wide; admin role required), plus fleet-wide bulk Sencho restart - Bulk actions on a label (deploy, stop, or restart every stack tagged with it) -- Fleet-wide bulk Sencho restart -- Blueprints and Fleet Secrets -- Preset SSO for Google, GitHub, and Okta -- Viewer accounts (1 admin plus 3 viewers) +- Atomic deployments with automatic rollback, and one-click rollback to the previous deployment +- Auto-update policies for stack images and auto-heal policies for failed containers +- Scheduled operations across the full action catalog (lifecycle, updates, scans, snapshots, prune) +- Webhooks (incoming, to trigger deploys from CI/CD) and notification routing (per-stack and per-category rules to Discord, Slack, or any webhook) +- Custom S3-compatible backup target (bring your own AWS S3, Cloudflare R2, MinIO, Backblaze B2, or Wasabi bucket) +- Vulnerability scanning: install, update, and uninstall Trivy, on-demand scans for vulnerabilities, secrets, and misconfigurations, scan comparison, and CVE suppressions +- Alert rules with Discord, Slack, and webhook targets +- API tokens for CI/CD pipelines and scripts (admin role required) +- Unlimited accounts with the Admin and Viewer roles +- Two-factor authentication (TOTP plus backup codes) +- Single sign-on with Custom OIDC (Authelia, Keycloak, Authentik, Zitadel, Pocket ID, or any spec-compliant OIDC provider) and preset providers for Google, GitHub, and Okta -**Admiral** includes everything in Skipper, plus: +**Admiral** adds governance, security, and fleet control for teams. It includes everything in Community, plus: -- Unlimited admin and viewer accounts with the full role set (deployer, node-admin, auditor) -- LDAP / Active Directory authentication -- Audit log with CSV export -- Host Console (a browser-based terminal on the Sencho host) -- API tokens for CI/CD scripts -- Private and custom registry credentials -- Sencho Mesh (cross-node container networking) -- Sencho Cloud Backup +- **Governance:** advanced RBAC roles (Deployer, Node Admin, Auditor), scoped permissions per stack or node, and the audit log with CSV export +- **Security:** Fleet Secrets, private and custom registry credentials, deploy enforcement (scan policies with `block_on_deploy`), SBOM (SPDX, CycloneDX) and SARIF export, auto-update of the managed Trivy binary, and LDAP / Active Directory authentication +- **Fleet operations:** node labels, node cordon, Blueprints, and Sencho Mesh (cross-node container networking) +- **Managed continuity:** Sencho Cloud Backup (a managed, off-site snapshot allowance) +- **Operator access:** the Host Console (a browser-based terminal on the Sencho host) ## Free trial -Sencho offers a **14-day Admiral trial** so you can evaluate the flagship features (Host Console, Sencho Mesh, LDAP / Active Directory, audit log, unlimited accounts) with your real infrastructure before committing. The trial is offered on the monthly and annual Admiral plans; the Founder Lifetime plan does not include a trial. +Sencho offers a **14-day Admiral trial** so you can evaluate the flagship features (Host Console, Sencho Mesh, LDAP / Active Directory, audit log, advanced RBAC) with your real infrastructure before committing. The trial is offered on the monthly and annual Admiral plans. To start a trial: @@ -103,7 +87,7 @@ Sencho validates the key and unlocks your tier. If activation fails, the toast s When a license is active, **Settings → License** opens on the **Plan** section. The page masthead at the top exposes three stat pills, and the section below lists the metadata for the active license. - License page showing an Admiral lifetime plan with the masthead stat strip listing SCOPE, PLAN, and DURATION, then the Plan section with the Customer, Product, and masked License key fields and the Deactivate button + License page showing an active Sencho Admiral license, with the Plan section listing the Customer, Product, and masked License key fields and a Deactivate button The masthead pills are: @@ -111,19 +95,19 @@ The masthead pills are: | Pill | Meaning | |------|---------| | **SCOPE** | Reads `operator` when you are signed in as an admin. | -| **PLAN** | The current tier: `community`, `skipper`, or `admiral`. Trial licenses show the trial tier (typically `admiral`). | -| **DURATION** / **RENEWS** / **TRIAL** / **STATUS** | `DURATION: lifetime` for lifetime licenses, `RENEWS: ` for active subscriptions, `TRIAL: Xd left` for trials, and `STATUS: expired` for expired licenses. | +| **PLAN** | The current tier: `community` or `admiral`. Trial licenses show `admiral`. | +| **RENEWS** / **TRIAL** / **STATUS** | `RENEWS: ` for active subscriptions, `TRIAL: Xd left` for trials, and `STATUS: expired` for expired licenses. | The **Plan** card lists: - **Tier name** (e.g. `Sencho Admiral`) with a short status line, such as "Active license on this control plane", "Trial: X days remaining", or "Your license has expired." - **Customer**: the customer name on the purchase. -- **Product**: the purchased product variant (e.g. `Sencho Admiral`). +- **Product**: the purchased product (e.g. `Sencho Admiral`). - **License key**: the last four characters of your key, displayed as `****-****-****-XXXX`. The full key is never re-displayed after activation. ## Managing your subscription -When the license is an active subscription (not lifetime), the Plan section action row exposes **Manage subscription** alongside **Deactivate**. **Manage subscription** opens the billing portal in a new tab, where you can update your payment method, view invoices, cancel, or switch plans. +When the license is an active subscription, the Plan section action row exposes **Manage subscription** alongside **Deactivate**. **Manage subscription** opens the billing portal in a new tab, where you can update your payment method, view invoices, cancel, or switch plans. The same portal is reachable from the profile menu in the top-right corner of the app: @@ -131,11 +115,7 @@ The same portal is reachable from the profile menu in the top-right corner of th Profile dropdown popover showing an identity header with the admin role and Admiral tier badges, a navigation strip with Settings, Documentation, and Feedback entries, an Appearance theme picker, and a Log Out button -Click your initials in the top-right corner to open the popover. For active subscription licenses, a **Billing** row appears between **Settings** and **Documentation** that opens the same portal as **Manage subscription**. Lifetime licenses have no recurring subscription to manage, so the **Billing** row is hidden. - -### Lifetime licenses - -Lifetime licenses have no recurring subscription, so the **Manage subscription** button and the profile menu **Billing** row are both hidden, and the masthead shows `DURATION: lifetime` instead of a renewal date. Deactivation, multi-node enforcement, and periodic validation work the same as for subscription licenses. +Click your initials in the top-right corner to open the popover. For active subscription licenses, a **Billing** row appears between **Settings** and **Documentation** that opens the same portal as **Manage subscription**. ## License validation @@ -150,7 +130,6 @@ The License page renders differently depending on the license status: | **Community** | `Sencho Community` with "Free tier with the core experience." | Visible | Visible | | **Trial** | `Sencho Admiral (Trial)` with a countdown chip | Visible (so a paid key can replace the trial) | Hidden | | **Active subscription** | Tier name with Customer, Product, License key, plus `Manage subscription` and `Deactivate` | Hidden | Hidden | -| **Active lifetime** | Tier name with Customer, Product, License key, plus `Deactivate` only | Hidden | Hidden | | **Expired** | `Sencho Community` with "Your license has expired. Renew to restore paid features." and a destructive **Status: Expired** field | Visible | Visible | | **Disabled** | `Sencho Community` with "Your license has been disabled. Contact support for assistance." | Visible | Visible | diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index e79540de..c44a52de 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -169,7 +169,7 @@ The Nodes table surfaces routing, status, and per-node automation at a glance fo | **Mode** | `-` for the local node; `Proxy` or `Pilot Agent` badge for remotes, with an icon matching the mode. | | **Endpoint** | `docker.sock` for local; the full Sencho API URL for proxy nodes; `tunnel (seen X ago)` or `tunnel (waiting)` for pilot agents. | | **Status** | `Online`, `Offline`, or `Unknown` badge. | -| **Labels** | Per-node label palette. On Skipper and Admiral the cell shows the picker (an empty cell reads `No labels` with an Add label control); on Community the cell shows a single dash. | +| **Labels** | Per-node label palette. On Admiral the cell shows the picker (an empty cell reads `No labels` with an Add label control); on Community the cell shows a single dash. | | **Schedules** | Number of active scheduled tasks targeting this node, plus a `next X` countdown to the next run. Click the count or the calendar icon in the Actions column to filter the Schedules view to that node. | | **Updates** | `Auto` if at least one enabled `Auto-update Stack` or `Auto-update All Stacks` schedule targets the node; `Off` otherwise. A pulsing dot and count appear when stacks have pending image updates. | | **Actions** | **View Schedules**, **Test Connection**, **Edit Node**, and **Delete Node** icon buttons. The local row hides Delete because the local node cannot be removed. | @@ -198,7 +198,7 @@ Panels that manage control-plane concerns (Account, License, Users, SSO, API Tok ## License enforcement across nodes -When the control instance has a paid license (Skipper or Admiral), all remote nodes inherit that license tier for proxied requests. You do not activate a license on each remote node separately. +When the control instance has an Admiral license, all remote nodes inherit that license tier for proxied requests. You do not activate a license on each remote node separately. ### How it works @@ -206,8 +206,8 @@ The control instance asserts its license tier on every proxied request. Remote n This means: -- **Paid control plane → remote nodes**: Skipper and Admiral features work on every remote node, governed by the control instance's license. -- **Community control plane → remote nodes**: Paid features are blocked on remote nodes, even if a remote node has its own paid license. The control instance's tier is authoritative for proxied requests. +- **Admiral control plane → remote nodes**: Admiral features work on every remote node, governed by the control instance's license. +- **Community control plane → remote nodes**: Admiral features are blocked on remote nodes, even if a remote node has its own Admiral license. The control instance's tier is authoritative for proxied requests. - **Direct access to a node**: If you load a remote Sencho instance in your browser directly (not through the control plane), it uses its own local license tier. @@ -319,7 +319,7 @@ Sencho takes the opposite approach: infrastructure-level encryption (VPN, revers The bearer token saved for the row no longer matches what the remote will accept. This usually means somebody clicked **Generate Token** on the remote (which invalidates the previous token) or the row was saved with a typo. Generate a fresh token on the remote, click the pencil icon on the row in the control instance, paste the new token into the API Token field, and save. - The control instance's license tier is authoritative for proxied requests, so a Community control plane gates Skipper and Admiral features on every remote, even if the remote itself has its own paid license. Activate a paid license on the control instance to lift the gate fleet-wide. The reverse case (paid control plane, Community remote) works automatically because the control plane's tier is what the remote trusts. + The control instance's license tier is authoritative for proxied requests, so a Community control plane gates Admiral features on every remote, even if the remote itself has its own Admiral license. Activate an Admiral license on the control instance to lift the gate fleet-wide. The reverse case (Admiral control plane, Community remote) works automatically because the control plane's tier is what the remote trusts. That is intentional. Account, License, Users, SSO, API Tokens, Registries, Cloud Backup, Nodes, Routing, and Webhooks are control-plane concerns and are hidden while a remote node is selected. Switch back to **Local** from the node switcher to manage them. The full list of which panels are per-node, per-browser, and control-plane-only lives in the [What Settings apply per node](#what-settings-apply-per-node) table above. diff --git a/docs/features/node-compatibility.mdx b/docs/features/node-compatibility.mdx index 93d49a91..d3f42e64 100644 --- a/docs/features/node-compatibility.mdx +++ b/docs/features/node-compatibility.mdx @@ -94,7 +94,7 @@ If you expect a node to support a feature that is being gated, the fastest fix i ## Interaction with license tiers -Some features need both a license tier (Skipper or Admiral) **and** node capability support. The two gates evaluate in this order: +Some features need both an Admiral license **and** node capability support. The two gates evaluate in this order: 1. The license gate is checked first. On the wrong tier, the feature's entry point (sidebar item, top-nav button, settings section) is hidden entirely, so you never reach the panel. 2. If you are on the right tier but the active node does not advertise the capability, the entry point is visible but the panel is replaced by the capability lock card. diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index b9d6ebad..d27e6895 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -29,7 +29,7 @@ Full in-browser Monaco editor for `compose.yaml` and `.env` files with syntax hi ### Stack file explorer -Browse, edit, upload, and manage files inside a stack's directory from the dashboard. Read-only browsing and text-file viewing are available on every tier; upload, download, edit, folder creation, and file deletion are Skipper or Admiral features. [Learn more →](/features/stack-file-explorer) +Browse, edit, upload, and manage files inside a stack's directory from the dashboard. Read-only browsing and text-file viewing are open to every role; upload, download, edit, folder creation, and file deletion require the admin role. [Learn more →](/features/stack-file-explorer) ### Deploy progress @@ -45,15 +45,15 @@ Browse pre-configured application templates. Filter by category (Media, Automati ### Atomic deployments -Sencho snapshots your compose and environment files before applying changes. If containers crash after deploy, the previous configuration is restored automatically. Skipper or Admiral. [Learn more →](/features/atomic-deployments) +Sencho snapshots your compose and environment files before applying changes. If containers crash after deploy, the previous configuration is restored automatically. [Learn more →](/features/atomic-deployments) ### Deploy enforcement -Block deploys that violate a scan policy before `docker compose up` runs, with an admin bypass path and a full audit trail. The pre-flight gate enumerates images and rejects deploys when any image meets or exceeds the policy's severity threshold; drift detection continues post-deploy and on schedule. Skipper or Admiral. [Learn more →](/features/deploy-enforcement) +Block deploys that violate a scan policy before `docker compose up` runs, with an admin bypass path and a full audit trail. The pre-flight gate enumerates images and rejects deploys when any image meets or exceeds the policy's severity threshold; drift detection continues post-deploy and on schedule. Admiral. [Learn more →](/features/deploy-enforcement) ### Blueprints -Fleet-wide compose templates that Sencho keeps in sync across the nodes you choose. One declaration covers many nodes via label selectors, drift detection always runs, and stateful blueprints get confirmation prompts before first deploy and before eviction. Skipper or Admiral. [Learn more →](/features/blueprint-model) +Fleet-wide compose templates that Sencho keeps in sync across the nodes you choose. One declaration covers many nodes via label selectors, drift detection always runs, and stateful blueprints get confirmation prompts before first deploy and before eviction. Admiral. [Learn more →](/features/blueprint-model) ### Git sources @@ -61,7 +61,7 @@ Link a stack to a Git repository and keep `compose.yaml` in sync via manual pull ### Stack labels -Tag your stacks with custom colored labels like `production`, `staging`, or `media-server`. Filter the sidebar by label, identify stacks at a glance, and organize your infrastructure visually. Bulk-action a label (deploy, stop, or restart every stack tagged with it) on Skipper or Admiral. [Learn more →](/features/stack-labels) +Tag your stacks with custom colored labels like `production`, `staging`, or `media-server`. Filter the sidebar by label, identify stacks at a glance, and organize your infrastructure visually. Bulk-action a label to deploy, stop, or restart every stack tagged with it. [Learn more →](/features/stack-labels) ## Observability @@ -125,11 +125,11 @@ When several Sencho instances run as a fleet, the control instance is the source ### Fleet Secrets -Centralized, encrypted, versioned env-var bundles you can push to labeled nodes' stacks. Bundles are encrypted at rest with AES-256-GCM, every save bumps a version, and every push records a per-node diff in the audit log using overlay merge semantics. Skipper or Admiral. [Learn more →](/features/fleet-secrets) +Centralized, encrypted, versioned env-var bundles you can push to labeled nodes' stacks. Bundles are encrypted at rest with AES-256-GCM, every save bumps a version, and every push records a per-node diff in the audit log using overlay merge semantics. Admiral. [Learn more →](/features/fleet-secrets) ### Fleet-wide backups -Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy, even to remote nodes. Manual snapshots are available on every tier; scheduled fleet snapshots are Skipper or Admiral. [Learn more →](/features/fleet-backups) +Create point-in-time snapshots of every compose file and environment file across all nodes. Snapshots are stored centrally and can be browsed by node and stack. Restore individual stacks from any snapshot with optional one-click redeploy, even to remote nodes. Both manual and scheduled fleet snapshots are available on every tier. [Learn more →](/features/fleet-backups) ### Remote updates @@ -137,7 +137,7 @@ Check for outdated nodes and trigger over-the-air updates from the Fleet View. W ### Scheduled operations -Automate recurring maintenance tasks like stack restarts, fleet snapshots, and system prunes on a cron schedule. Every execution is logged with full history so you always know what ran and when. Scheduled scans, updates, and snapshots are available on Skipper and Admiral; other scheduled actions remain Admiral only. [Learn more →](/features/scheduled-operations) +Automate recurring maintenance tasks like stack restarts, fleet snapshots, system prunes, scans, and image updates on a cron schedule. Every execution is logged with full history so you always know what ran and when. [Learn more →](/features/scheduled-operations) ## Security & Identity @@ -147,11 +147,11 @@ Protect your Sencho account with a time-based one-time password (TOTP) from an a ### RBAC & user management -Create viewer accounts with read-only access to dashboards, logs, and file contents, while keeping deploy and edit permissions locked to admins. Skipper supports two roles (Admin and Viewer); Admiral adds three more (Deployer, Node Admin, Auditor) plus scoped permissions per stack or node. [Learn more →](/features/rbac) +Create unlimited accounts with read-only Viewer access to dashboards, logs, and file contents, while keeping deploy and edit permissions locked to admins. Community includes the Admin and Viewer roles; Admiral adds three more (Deployer, Node Admin, Auditor) plus scoped permissions per stack or node. [Learn more →](/features/rbac) ### SSO & LDAP authentication -Authenticate with your existing identity provider. Custom OIDC (Authelia, Keycloak, Authentik, any spec-compliant OIDC provider) is available on every tier. Skipper adds preset providers for Google, GitHub, and Okta. Admiral adds LDAP / Active Directory for enterprise directories. SSO works alongside password authentication and auto-provisions accounts on first login with configurable role mapping. [Learn more →](/features/sso) +Authenticate with your existing identity provider. Custom OIDC (Authelia, Keycloak, Authentik, any spec-compliant OIDC provider) and preset providers for Google, GitHub, and Okta are available on every tier. Admiral adds LDAP / Active Directory for enterprise directories. SSO works alongside password authentication and auto-provisions accounts on first login with configurable role mapping. [Learn more →](/features/sso) ### API tokens @@ -159,7 +159,7 @@ Generate scoped API tokens for CI/CD pipelines, scripts, and automation workflow ### Vulnerability scanning -Scan container images for known CVEs with [Trivy](https://trivy.dev). Install Trivy with one click from Settings → Security on first use; the [setup guide](/operations/trivy-setup) covers bind-mounted and air-gapped alternatives. Manual scanning, secret and misconfiguration detection, scan comparison, and CVE suppressions are available on every tier; scheduled scans, scan policies that gate deploys, SBOM generation, and SARIF export are available on Skipper and Admiral. Auto-update of the managed Trivy binary is Skipper. [Learn more →](/features/vulnerability-scanning) +Scan container images for known CVEs with [Trivy](https://trivy.dev). Install Trivy with one click from Settings → Security on first use; the [setup guide](/operations/trivy-setup) covers bind-mounted and air-gapped alternatives. Manual scanning, secret and misconfiguration detection, scan comparison, scheduled scans, and CVE suppressions are available on every tier; scan policies that gate deploys, SBOM generation, SARIF export, and auto-update of the managed Trivy binary are Admiral. [Learn more →](/features/vulnerability-scanning) ### CVE suppressions @@ -173,11 +173,11 @@ Store credentials for private Docker registries: Docker Hub organizations, GHCR, ### Auto-Update Policies -Review pending container updates across your fleet with risk badges (`Safe · patch`, `Review · minor`, `Blocked · major`, `Digest rebuild`) and one-line changelog previews on a single board. The hero counts pending updates and tells you how many are ready to apply without human review; stacks with a major version bump are surfaced as a separate count for review. Skipper or Admiral. [Learn more →](/features/auto-update-policies) +Review pending container updates across your fleet with risk badges (`Safe · patch`, `Review · minor`, `Blocked · major`, `Digest rebuild`) and one-line changelog previews on a single board. The hero counts pending updates and tells you how many are ready to apply without human review; stacks with a major version bump are surfaced as a separate count for review. [Learn more →](/features/auto-update-policies) ### Auto-Heal Policies -Automatically restart containers that fail Docker healthchecks for longer than a specified threshold. Each policy ships with safety rails: a cooldown period, hourly restart cap, recent-user-action suppression, and auto-disable on repeated restart failures. Skipper or Admiral. [Learn more →](/features/auto-heal-policies) +Automatically restart containers that fail Docker healthchecks for longer than a specified threshold. Each policy ships with safety rails: a cooldown period, hourly restart cap, recent-user-action suppression, and auto-disable on repeated restart failures. [Learn more →](/features/auto-heal-policies) ### Webhooks @@ -201,4 +201,4 @@ When you manage multiple nodes running different Sencho versions, the dashboard ### Licensing & billing -Sencho is free for personal use with the Community tier. Skipper and Admiral unlock RBAC, webhooks, fleet backups, atomic deployments, and advanced fleet features. Manage your license, view subscription details, and access the billing portal from Settings. [Learn more →](/features/licensing) +Community is the complete self-hosted control plane, free forever. Admiral adds governance, security, and fleet control for teams: advanced RBAC, the audit log, Fleet Secrets, deploy enforcement, Blueprints, Sencho Mesh, and more. Manage your license, view subscription details, and access the billing portal from Settings. [Learn more →](/features/licensing) diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index 761bac27..95c8739e 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -4,7 +4,7 @@ description: Role-based access control for Sencho. Manage admin, viewer, deploye --- - Multi-user support requires a Sencho **Skipper** or **Admiral** license. Community Edition runs as a single admin account. The **Deployer**, **Node Admin**, and **Auditor** roles, plus scoped permissions, require **Admiral**. + Community supports unlimited accounts with the **Admin** and **Viewer** roles. The **Deployer**, **Node Admin**, and **Auditor** roles, plus scoped permissions, require **Admiral**. @@ -17,8 +17,8 @@ Sencho ships with five built-in roles that map to the permissions most operators | Role | What it grants | Tier | |------|----------------|------| -| **Admin** | Full operator access: deploy, edit compose, manage users, configure nodes, view audit log, every system setting | Skipper+ | -| **Viewer** | Read-only access to stacks, logs, stats, file contents, and node listings | Skipper+ | +| **Admin** | Full operator access: deploy, edit compose, manage users, configure nodes, view audit log, every system setting | Community | +| **Viewer** | Read-only access to stacks, logs, stats, file contents, and node listings | Community | | **Deployer** | Deploy, restart, stop, and start stacks. Cannot edit compose files, create or delete stacks, or view nodes | Admiral | | **Node Admin** | Full stack and node management across the fleet. No access to system settings, users, or license | Admiral | | **Auditor** | Read-only access to stacks, nodes, and the audit log. No write access anywhere | Admiral | @@ -51,15 +51,14 @@ On Admiral, a user with a lower global role can still hold extra permissions on | Tier | Admin accounts | Non-admin accounts | Intermediate roles | Scoped permissions | |------|---------------|--------------------|--------------------|--------------------| -| **Community** | 1 | 0 | No | No | -| **Skipper** | 1 | 3 | No | No | +| **Community** | Unlimited | Unlimited | No | No | | **Admiral** | Unlimited | Unlimited | Yes | Yes | -Quotas are enforced when you click **Create user**. Hitting a cap returns a `403` with a clear message, and the form keeps your input so you can adjust the role. +Community accounts use the **Admin** and **Viewer** roles. Admiral adds the intermediate roles (Deployer, Node Admin, Auditor) and scoped permissions. ## Managing users -The Users panel lives at **Settings · Users**, under the **Identity** group of the settings sidebar. It is visible only to users with the Admin role on Skipper or Admiral, and is hidden when a remote node is the active selection. +The Users panel lives at **Settings · Users**, under the **Identity** group of the settings sidebar. It is visible only to users with the Admin role, and is hidden when a remote node is the active selection. Settings Users panel showing the Identity sidebar selection, a SCOPE operator chip, an OPERATORS 2 counter, an Add user button, and a four-column table (Username, Role, Created, Actions) with two rows: admin marked (you) with a disabled trash icon and viewer with active edit and trash icons. @@ -84,7 +83,7 @@ Click **Add user**. The form opens inline below the button (it is not a modal), | Field | Rules | |-------|-------| | **Username** | At least 3 characters. Letters, numbers, underscores, and hyphens only. Submitting with `.` or whitespace returns `Username can only contain letters, numbers, underscores, and hyphens.` | -| **Role** | Combobox. On Admiral you see all five roles; on Skipper you see Admin and Viewer only. | +| **Role** | Combobox. On Admiral you see all five roles; on Community you see Admin and Viewer only. | | **Password** | At least 8 characters. The placeholder reads `min. 8 characters`. | | **Confirm Password** | Must match the password field, validated on submit. | @@ -105,7 +104,7 @@ The password fields change subtly in edit mode: - The **Password** label becomes **New Password (optional)** with the placeholder `Leave blank to keep`. Submit without filling them in and the current password is preserved. - If the user was provisioned via SSO, the password fields are replaced with an inline line that reads `Password is managed by the identity provider ().` Sencho never stores or rotates passwords for SSO accounts. -Click **Update user** to save. Changing the role takes effect on the next API request from any of that user's active sessions; see [Session security](#session-security) below. Promoting a user to **Admin** consumes an admin seat, so it is subject to the same per-tier limit as creating an admin: at your cap, the change is rejected until you free a seat or upgrade. +Click **Update user** to save. Changing the role takes effect on the next API request from any of that user's active sessions; see [Session security](#session-security) below. ## Scoped permissions @@ -195,7 +194,7 @@ Two SSO-specific behaviors to keep in mind: The role assigned at provisioning is the role configured on the SSO provider (or, for LDAP, derived from group membership). After provisioning, an admin can adjust the role and add scoped permissions just like any local account. -To configure a provider, see [SSO Authentication](/features/sso). The tier split for provider configuration (Custom OIDC at Community, preset providers at Skipper, LDAP at Admiral) is enforced separately from the rest of the user-management surface. +To configure a provider, see [SSO Authentication](/features/sso). The tier split for provider configuration (Custom OIDC and preset providers at Community, LDAP at Admiral) is enforced separately from the rest of the user-management surface. ## API tokens for automation @@ -219,19 +218,16 @@ Entries include the acting user, IP address, HTTP method and path, response stat - The Users entry is hidden in three cases. **One,** the active license is Community: the Users panel is gated to Skipper+ and does not render on Community. Activate a Skipper or Admiral license under **Settings · License** to expose it. **Two,** you are signed in as a non-admin (Viewer, Deployer, Auditor): the entry is admin-only. **Three,** you have a remote node selected: the panel is hub-only and is hidden in the sidebar when any remote node is active. Switch back to the local node via the node switcher in the masthead. + The Users entry is hidden in two cases. **One,** you are signed in as a non-admin (Viewer, Deployer, Auditor): the entry is admin-only. **Two,** you have a remote node selected: the panel is hub-only and is hidden in the sidebar when any remote node is active. Switch back to the local node via the node switcher in the masthead. - The combobox only shows roles available on your tier. On Skipper, the combobox lists Admin and Viewer only. **Deployer**, **Node Admin**, and **Auditor** are Admiral-only roles and do not appear on Skipper. Upgrade to Admiral, or use scoped permissions equivalents once you do. - - - You have hit the seat limit for your tier. Skipper allows one admin and three non-admin users; Admiral has no cap. Promoting an existing user to Admin counts against the admin limit the same way creating one does. Either delete an unused account or upgrade. The exact remaining capacity is visible on the OPERATORS counter in the panel header. + The combobox only shows roles available on your tier. On Community, the combobox lists Admin and Viewer only. **Deployer**, **Node Admin**, and **Auditor** are Admiral roles. Upgrade to Admiral to use them, or the scoped-permission equivalents. Token-version bumps invalidate sessions. Two events do this: an admin changed the user's password, or an admin reset their 2FA. Both rotate the user's token version, so every JWT issued before the rotation is rejected on the next request. The user can sign in again with their (possibly new) password. Role changes do **not** sign the user out; they take effect on the next request without rotating the token version. - Two causes. **One,** the assignment was created on Admiral but the license has since dropped to Skipper. The permission resolver only consults scoped assignments when the effective tier is Admiral; on Skipper the scope is ignored and the user falls back to their global role. **Two,** the resource type or name on the assignment does not match the request's resource. Re-open the user in the edit form and check the existing-scope row matches the stack name (case-sensitive) exactly. + Two causes. **One,** the assignment was created on Admiral but the license has since dropped to Community. The permission resolver only consults scoped assignments when the effective tier is Admiral; on Community the scope is ignored and the user falls back to their global role. **Two,** the resource type or name on the assignment does not match the request's resource. Re-open the user in the edit form and check the existing-scope row matches the stack name (case-sensitive) exactly. The icon only appears for users with a finished TOTP enrollment. If the user started enrollment but never confirmed their first code, the enrollment is incomplete and the icon stays hidden. Ask the user to finish enrollment from their account settings, or, if they cannot, leave the row alone: there is nothing to reset. diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 7e1461e8..09cbea8a 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -6,7 +6,7 @@ description: Automate stack lifecycle, image updates, vulnerability scans, fleet Schedules is a unified surface for every recurring maintenance operation Sencho knows how to run: stack restarts, per-node and fleet-wide image updates, lifecycle events (stop, take down, start, backup), system prunes, and vulnerability scans. The default view is a next-24-hour Timeline of upcoming runs across five lanes; an All tasks table view lists every schedule regardless of when it next fires. - Available to admins on Skipper and Admiral. + Available to admins. diff --git a/docs/features/sencho-mesh.mdx b/docs/features/sencho-mesh.mdx index 70c7eebb..34d655ca 100644 --- a/docs/features/sencho-mesh.mdx +++ b/docs/features/sencho-mesh.mdx @@ -4,7 +4,7 @@ description: Cross-node container networking. Reach any meshed service on any no --- - Sencho Mesh requires an [Admiral license](/features/licensing). Community and Skipper do not include this feature. + Sencho Mesh requires an [Admiral license](/features/licensing). Sencho Mesh gives a multi-node fleet the network topology of a single machine. Once a stack opts in, every service it exposes becomes reachable from any other meshed stack on the fleet by a stable hostname. Cross-node traffic rides the same authenticated channel Sencho already uses to manage the fleet, so a node behind NAT or a residential firewall participates exactly like a public VPS. diff --git a/docs/features/sidebar.mdx b/docs/features/sidebar.mdx index ab32a810..f200788d 100644 --- a/docs/features/sidebar.mdx +++ b/docs/features/sidebar.mdx @@ -67,7 +67,7 @@ Each row gives you everything you need to read the stack at a glance, in a fixed Click the **Bulk mode** icon next to **Create Stack** (or press B) to enter selection mode. A checkbox appears at the left of every row, and a sticky toolbar slides in just above the list: - **Start**, **Stop**, **Restart** apply the action to every selected stack. -- **Update** pulls the latest images for every selected stack and requires a **Skipper** or **Admiral** license. +- **Update** pulls the latest images for every selected stack. Click rows to toggle their selection. The toolbar header shows a running count. Click the **×** in the corner of the toolbar to clear the selection, and click the icon again (or press B) to leave bulk mode. @@ -85,7 +85,7 @@ Right-click any stack (or open the kebab that appears on hover) for its context - **Destructive**: **Delete**. - **Auto-Heal** requires a **Skipper** or **Admiral** license; configuring policies (add, toggle, delete) also requires an **admin** role. **Schedule task** requires a **Skipper** or **Admiral** license and an **admin** role. + Configuring **Auto-Heal** policies (add, toggle, delete) requires an **admin** role. **Schedule task** also requires an **admin** role. @@ -113,7 +113,7 @@ On macOS, use Cmd in place of Ctrl. | Key | Action | |-----|--------| | A | Open the alerts sheet | -| H | Open the auto-heal sheet (Skipper or Admiral) | +| H | Open the auto-heal sheet | | U | Check for image updates | | P | Pin or unpin the stack | | B | Toggle bulk mode | @@ -144,9 +144,9 @@ The footer surfaces the most recent stack lifecycle event on the node. Each tick Click the **+** icon to the right of the search box to bring them back. The chip row collapses to a thin **−** / **+** toggle, and the state is remembered in your browser, so an earlier collapse persists across reloads until you expand it again. - Scheduling requires a **Skipper** or **Admiral** license and an **admin** role. Ask an admin on this node to schedule the task for you, or sign in with an admin account. + Scheduling requires an **admin** role. Ask an admin on this node to schedule the task for you, or sign in with an admin account. - Reading existing Auto-Heal policies only needs a **Skipper** or **Admiral** license, so the panel still opens for non-admin operators on paid tiers. Adding, enabling, disabling, or deleting policies also requires the **admin** role. Sign in with an admin account, or ask an admin on this node to make the change. + Reading existing Auto-Heal policies is open to every operator, so the panel still opens for non-admins. Adding, enabling, disabling, or deleting policies requires the **admin** role. Sign in with an admin account, or ask an admin on this node to make the change. diff --git a/docs/features/sso.mdx b/docs/features/sso.mdx index 7d0f18ec..fb917abf 100644 --- a/docs/features/sso.mdx +++ b/docs/features/sso.mdx @@ -5,16 +5,16 @@ description: Authenticate with your existing identity provider, including LDAP, Sencho lets your team sign in with the identity provider you already use instead of maintaining a second set of credentials. SSO works **alongside** password authentication; it does not replace it. -SSO is available on every Sencho tier. Higher tiers add preset providers for Google, GitHub, and Okta, plus enterprise directory support via LDAP and Active Directory. +SSO is available on every Sencho tier. Custom OIDC and the preset providers for Google, GitHub, and Okta work on every tier; LDAP and Active Directory require Sencho Admiral. ## Supported providers | Provider | Protocol | Tier | Notes | |----------|----------|------|-------| | **Custom OIDC** | OpenID Connect | Community | Any spec-compliant OIDC provider: Authelia, Keycloak, Authentik, Zitadel, KanIDM, Pocket ID, and more | -| **Google** | OpenID Connect | Skipper | Preset for Google Workspace or personal Google accounts, with branded login button | -| **GitHub** | OAuth 2.0 | Skipper | Preset for GitHub personal accounts and GitHub orgs, with branded login button | -| **Okta** | OpenID Connect | Skipper | Preset for any Okta org or Okta-compatible IdP, with branded login button | +| **Google** | OpenID Connect | Community | Preset for Google Workspace or personal Google accounts, with branded login button | +| **GitHub** | OAuth 2.0 | Community | Preset for GitHub personal accounts and GitHub orgs, with branded login button | +| **Okta** | OpenID Connect | Community | Preset for any Okta org or Okta-compatible IdP, with branded login button | | **LDAP / Active Directory** | LDAP bind + search | Admiral | Works with OpenLDAP, Active Directory, FreeIPA, and any LDAPv3 server | ## How it works @@ -42,9 +42,8 @@ When a user signs in via SSO for the first time, Sencho creates a local account: - **Username** is derived from the identity provider profile (display name, email prefix, or login handle). - **Role** is assigned from [role mapping](#role-mapping); defaults to Viewer if no mapping matches. - **Password** is set to an unusable placeholder. SSO users cannot sign in with the password form. -- **Seat limits** from your license apply. If admin seats are full, the user is downgraded to Viewer. If every seat is full, sign-in is denied with a clear error message. -On every subsequent sign-in, the existing account is reused and the user's **email** and **role** are synced from the identity provider. Adding someone to your admin group promotes them to Admin on their next sign-in; removing them demotes them to the default role. Promotions defer cleanly when admin seats are full and apply as soon as a seat opens up. +On every subsequent sign-in, the existing account is reused and the user's **email** and **role** are synced from the identity provider. Adding someone to your admin group promotes them to Admin on their next sign-in; removing them demotes them to the default role. ## Role mapping diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index c08645e5..3f0805b2 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -129,7 +129,7 @@ When a flag is not supported, paste the partial output into the **Empty** tab as ## The stack list -All discovered stacks appear in the left sidebar. The list groups stacks by label (Skipper+) and by pinned status, so the most relevant entries surface at the top. +All discovered stacks appear in the left sidebar. The list groups stacks by label and by pinned status, so the most relevant entries surface at the top. ### Status indicator @@ -163,7 +163,7 @@ Each chip carries a live count. Click **Hide filters** in the top-right of the r The list is grouped to keep frequently used stacks reachable. - **PINNED**: sits at the very top when at least one stack is pinned. Pin a stack with **Pin to top** in the kebab menu (`P` shortcut). -- **Label groups**: each label produces its own collapsible section, header rendered uppercase. Labels are a Skipper+ feature; see [Stack Labels](/features/stack-labels) for color and assignment options. +- **Label groups**: each label produces its own collapsible section, header rendered uppercase. See [Stack Labels](/features/stack-labels) for color and assignment options. - **UNLABELED**: bottom group with everything that has no label. Click any group header to collapse or expand it. The group's stack count appears in the header. @@ -279,8 +279,8 @@ The stack header groups actions by frequency of use. The most common action is t | Primary | **Restart** | `docker compose restart` | Restarts all containers in the stack. | | Secondary | **Stop** | `docker compose stop` | Stops containers without removing them. State is preserved. | | Secondary | **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. | -| Overflow | **Rollback** | Restores backup | Reverts compose and env files to the pre-deploy snapshot and redeploys. Only shown when a backup exists (Skipper+). | -| Overflow | **Scan config** | Trivy config scan | Scans the compose file for misconfigurations (Skipper+, admin). | +| Overflow | **Rollback** | Restores backup | Reverts compose and env files to the pre-deploy snapshot and redeploys. Only shown when a backup exists. | +| Overflow | **Scan config** | Trivy config scan | Scans the compose file for misconfigurations (admin role). | | Overflow | **Delete** | `down --volumes` + removes files | Stops and removes containers and volumes, then deletes the stack directory. | **When stopped:** @@ -316,7 +316,7 @@ Bulk mode lets you run a single lifecycle action across several stacks at once, - **Start**: deploys every selected stack that is currently down. - **Stop**: stops every selected stack that is currently running. - **Restart**: restarts every selected stack that is currently running. -- **Update**: pulls the latest image tags and redeploys (Skipper+). +- **Update**: pulls the latest image tags and redeploys. Press `B` again or toggle the bulk mode button to leave bulk mode. @@ -345,13 +345,13 @@ Click the kebab on a stack row in the sidebar to open its context menu. The menu **inspect** - **Alerts** (`A`): open the alert rule editor for this stack. See [Alerts and Notifications](/features/alerts-notifications). -- **Auto-Heal** (`H`): configure auto-recovery for this stack (Skipper+). See [Auto-Heal Policies](/features/auto-heal-policies). +- **Auto-Heal** (`H`): configure auto-recovery for this stack. See [Auto-Heal Policies](/features/auto-heal-policies). - **Check updates** (`U`): force an image update check now. - **Open App** (`↗`): open the stack's web interface in a new tab. Shown only when the stack is running and exposes a web port. **organize** -- **Labels** (`›`): assign or remove labels (Skipper+). See [Stack Labels](/features/stack-labels). +- **Labels** (`›`): assign or remove labels. See [Stack Labels](/features/stack-labels). - **Pin to top** (`P`): pin or unpin the stack from the **PINNED** group at the top of the list. **lifecycle** @@ -359,7 +359,7 @@ Click the kebab on a stack row in the sidebar to open its context menu. The menu - **Stop** (`⌘.`): shown when running. - **Restart** (`⌘R`): shown when running. - **Update** (`⌘↑`): pulls the latest image tags and redeploys. -- **Schedule task**: open the scheduler pre-filled for this stack (Skipper+); pick **Auto-update Stack** to set up unattended image updates on your own cadence. See [Scheduled Operations](/features/scheduled-operations) and [Auto-Update Policies](/features/auto-update-policies). +- **Schedule task**: open the scheduler pre-filled for this stack; pick **Auto-update Stack** to set up unattended image updates on your own cadence. See [Scheduled Operations](/features/scheduled-operations) and [Auto-Update Policies](/features/auto-update-policies). - **Deploy** (`⌘↵`): shown when stopped, in place of Stop and Restart. **destructive** @@ -391,7 +391,3 @@ Click the **Scan stacks folder** icon button to the right of **Create Stack**. S ### Service action returns a "service not found" error The service name used in the action must match the `services:` key in the stack's `compose.yaml`. This error occurs when no running containers match that service name, either because the service was never deployed or because the compose file defines a different name. Verify the service key in your compose file and ensure the stack has been deployed at least once so containers exist for that service. - -### Bulk **Update** is missing from the action bar - -The bulk **Update** action is a Skipper-tier feature; on Community, it does not appear in the action bar at all. **Start**, **Stop**, and **Restart** are available on every tier. diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index e2d97ba8..645aafa3 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -3,7 +3,7 @@ title: "Vulnerability Scanning" description: "Scan container images and stack compose files for CVEs, secrets, and misconfigurations. Surface severity badges in the Resources Hub, compare scans over time, and gate deploys on policy violations." --- -Sencho integrates with [Trivy](https://trivy.dev) to scan container images and Compose files for vulnerabilities (CVEs), hardcoded secrets, and misconfigurations. Findings surface as severity badges in the Resources Hub and as drillable reports in the scan drawer. Manual scanning, secret and misconfig detection, scan history, comparison, and CVE suppressions are available on every tier. Skipper and Admiral add scheduled fleet scans, policy enforcement, SBOM, and SARIF exports. +Sencho integrates with [Trivy](https://trivy.dev) to scan container images and Compose files for vulnerabilities (CVEs), hardcoded secrets, and misconfigurations. Findings surface as severity badges in the Resources Hub and as drillable reports in the scan drawer. Manual scanning, secret and misconfig detection, scan history, comparison, scheduled fleet scans, and CVE suppressions are available on every tier. Admiral adds policy enforcement, SBOM, and SARIF exports. Resources Hub Images table with severity badges (CRITICAL, HIGH, MEDIUM) on managed image rows alongside the Scan history button @@ -19,25 +19,25 @@ The Trivy CLI must be available on the machine running Sencho. Trivy is not bund ## Tier availability -| Feature | Community | Skipper | Admiral | -|---------|:---------:|:-------:|:-------:| -| Install / update / uninstall Trivy from Settings | ✓ | ✓ | ✓ | -| On-demand image vulnerability scanning | ✓ | ✓ | ✓ | -| Full scan (vulnerabilities + secrets) | ✓ | ✓ | ✓ | -| Compose file misconfiguration scanning | ✓ | ✓ | ✓ | -| Severity badges in the Resources Hub | ✓ | ✓ | ✓ | -| Scan results drawer with grouped tabs | ✓ | ✓ | ✓ | -| Post-deploy automated scanning | ✓ | ✓ | ✓ | -| Scan history sheet | ✓ | ✓ | ✓ | -| Scan comparison | ✓ | ✓ | ✓ | -| CVE suppressions | ✓ | ✓ | ✓ | -| Misconfig acknowledgements | ✓ | ✓ | ✓ | -| Scheduled fleet scans (all images on a node) | | ✓ | ✓ | -| Scan policies with `block_on_deploy` enforcement | | ✓ | ✓ | -| Suppression-aware deploy blocking (optional toggle) | | ✓ | ✓ | -| SBOM generation (SPDX, CycloneDX) | | ✓ | ✓ | -| SARIF export (code scanning integration) | | ✓ | ✓ | -| Auto-update of the managed Trivy binary | | ✓ | ✓ | +| Feature | Community | Admiral | +|---------|:---------:|:-------:| +| Install / update / uninstall Trivy from Settings | ✓ | ✓ | +| On-demand image vulnerability scanning | ✓ | ✓ | +| Full scan (vulnerabilities + secrets) | ✓ | ✓ | +| Compose file misconfiguration scanning | ✓ | ✓ | +| Severity badges in the Resources Hub | ✓ | ✓ | +| Scan results drawer with grouped tabs | ✓ | ✓ | +| Post-deploy automated scanning | ✓ | ✓ | +| Scan history sheet | ✓ | ✓ | +| Scan comparison | ✓ | ✓ | +| CVE suppressions | ✓ | ✓ | +| Misconfig acknowledgements | ✓ | ✓ | +| Scheduled fleet scans (all images on a node) | ✓ | ✓ | +| Scan policies with `block_on_deploy` enforcement | | ✓ | +| Suppression-aware deploy blocking (optional toggle) | | ✓ | +| SBOM generation (SPDX, CycloneDX) | | ✓ | +| SARIF export (code scanning integration) | | ✓ | +| Auto-update of the managed Trivy binary | | ✓ | ## On-demand scanning @@ -75,9 +75,9 @@ The drawer opens as a right-side sheet with the breadcrumb `Security › Scans - **Re-scan**: kick off a fresh scan, ignoring the digest cache. - **Compare**: pick a baseline scan from the dropdown to diff against this one. - **CSV**: export the full vulnerability list for offline review. -- **SARIF**: download the full scan (vulnerabilities, secrets, and misconfigs) as SARIF 2.1.0 for upload to GitHub code scanning or any SARIF-aware tool. Skipper or Admiral required. +- **SARIF**: download the full scan (vulnerabilities, secrets, and misconfigs) as SARIF 2.1.0 for upload to GitHub code scanning or any SARIF-aware tool. Admiral required. -The summary header below the actions reports the per-severity counts, the total, how many findings have a fix available, when the scan ran, and what triggered it. An **SBOM** button below the summary downloads a Software Bill of Materials in SPDX JSON or CycloneDX format (Skipper or Admiral). +The summary header below the actions reports the per-severity counts, the total, how many findings have a fix available, when the scan ran, and what triggered it. An **SBOM** button below the summary downloads a Software Bill of Materials in SPDX JSON or CycloneDX format (Admiral). ### Vulnerabilities tab @@ -120,10 +120,6 @@ The App Store deploy sheet has a **Security** section with a **Scan images for v ## Scheduled fleet scans - - Scheduled fleet scans require a **Skipper** or **Admiral** license. - - You can run recurring scans of every image on a node through the standard [Scheduled Operations](/features/scheduled-operations) system. Create a scheduled task with action **Scan** and a cron expression. The scheduler iterates every image on the target node with a short delay between scans and records the result in the task's run history. Use scheduled scans to keep badge counts fresh even for images that are rarely redeployed. A nightly cron like `0 3 * * *` is a sensible default for most fleets. @@ -140,7 +136,7 @@ Failures are usually transient (registry timeouts, missing credentials) and neve ## Scan policies - Scan policies require a **Skipper** or **Admiral** license. + Scan policies require an **Admiral** license. Policies define severity thresholds that govern whether a stack can deploy. A policy with **Block on deploy** enabled runs a pre-flight scan on every image in the stack before `docker compose up` executes; if any image meets or exceeds the threshold, the deploy is rejected with a dialog listing the offending images. Policies with **Block on deploy** disabled still evaluate every post-deploy and scheduled scan and dispatch warning alerts when the threshold is exceeded. @@ -297,7 +293,7 @@ Acknowledged misconfigs are emitted in the SARIF export with a `suppressions` en ## SBOM generation - SBOM generation requires a **Skipper** or **Admiral** license. + SBOM generation requires an **Admiral** license. A Software Bill of Materials (SBOM) is a machine-readable inventory of every package in a container image. SBOMs satisfy security frameworks (SLSA, Executive Order 14028, EU Cyber Resilience Act) and support offline supply-chain analysis. @@ -314,7 +310,7 @@ The download starts immediately and uses the image's digest (when available) in ## SARIF export - SARIF export requires a **Skipper** or **Admiral** license. + SARIF export requires an **Admiral** license. SARIF (Static Analysis Results Interchange Format) is the standard format supported by GitHub code scanning, Microsoft Defender for Cloud, and most security dashboards. Sencho generates SARIF 2.1.0 documents from the stored scan results, so the download matches what you see in the drawer (same findings, same suppression state) without re-running Trivy. @@ -395,7 +391,7 @@ Up to 1000 findings per scan are loaded for comparison. When a scan exceeds this Sencho forwards the same registry credentials configured under **Settings → Registries** to Trivy. If a pull works in Sencho but a scan fails, make sure the image has been pulled at least once so Trivy can work against the cached local image. - Post-deploy scanning only runs on deploy actions. For long-running images that aren't redeployed, schedule a recurring scan (Skipper or Admiral) or click the shield icon in the Resources Hub to re-scan on demand. + Post-deploy scanning only runs on deploy actions. For long-running images that aren't redeployed, schedule a recurring scan or click the shield icon in the Resources Hub to re-scan on demand. Scans have a 5-minute internal timeout. The scheduler sweeps every tick and marks any scan stuck `in_progress` for more than 15 minutes as failed, so the UI always recovers on its own. Wait for the sweep, then click the shield icon again to trigger a fresh scan. diff --git a/docs/features/webhooks.mdx b/docs/features/webhooks.mdx index c28a73d3..4b29866f 100644 --- a/docs/features/webhooks.mdx +++ b/docs/features/webhooks.mdx @@ -4,7 +4,7 @@ description: Trigger stack actions from CI/CD pipelines via HTTP webhooks with H --- - Webhooks require a **Skipper** or **Admiral** license. Managing webhooks is admin-only. + Managing webhooks is admin-only. Sencho webhooks let external systems trigger stack actions over HTTP. The typical use case: your CI pipeline builds a new image, then calls a Sencho webhook to deploy the updated stack, with no manual intervention required. @@ -163,7 +163,7 @@ Sencho retains the last 100 executions per webhook and surfaces the 20 most rece - The page requires a **Skipper** or **Admiral** license. If you are on a paid tier but the node switcher in the top-left shows a remote node, switch to **Local** to reveal the page. + Webhooks are managed on the local node. If the node switcher in the top-left shows a remote node, switch to **Local** to reveal the page. diff --git a/docs/getting-started/configuration.mdx b/docs/getting-started/configuration.mdx index 8024d6f0..fe0842ef 100644 --- a/docs/getting-started/configuration.mdx +++ b/docs/getting-started/configuration.mdx @@ -73,7 +73,7 @@ services: ## SSO environment variables -If you use SSO (Admiral), configure your identity providers via environment variables: +If you use SSO, configure your identity providers via environment variables: | Variable | Description | |----------|-------------| diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index 37e0e588..ba470f79 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -64,7 +64,7 @@ The **Fleet** view is the multi-node command center. The masthead summarizes onl The Fleet toolbar includes **Check Updates**, **Refresh**, and **Add node** for admins. The **Overview** tab supports search, sort, status filters, label filters, and a Grid or Topology view. Node cards show online state, resource use, container counts, version state, update actions, and direct drill-down into stacks on that node. -Fleet also provides dedicated tabs for snapshots, node status, blueprint deployments, traffic management, federation, fleet actions, and secrets. Some fleet features require Skipper or Admiral. See [Licensing](/features/licensing) for the full tier breakdown. +Fleet also provides dedicated tabs for snapshots, node status, blueprint deployments, traffic management, federation, fleet actions, and secrets. Some fleet features require Admiral. See [Licensing](/features/licensing) for the full tier breakdown. ## Resources, templates, and logs diff --git a/docs/getting-started/sso-quickstart.mdx b/docs/getting-started/sso-quickstart.mdx index b7ced487..9b3a5b42 100644 --- a/docs/getting-started/sso-quickstart.mdx +++ b/docs/getting-started/sso-quickstart.mdx @@ -6,7 +6,7 @@ description: Step-by-step instructions for connecting Sencho to your identity pr SSO can be configured from the Settings UI or seeded via environment variables (shown below). - **Tier availability.** Custom OIDC is available on every tier, including Community. The Google, GitHub, and Okta preset providers require Skipper or higher. LDAP / Active Directory requires Admiral. See [Licensing & Billing](/features/licensing#feature-breakdown) for the full breakdown. + **Tier availability.** Custom OIDC and the Google, GitHub, and Okta preset providers are available on every tier, including Community. LDAP / Active Directory requires Admiral. See [Licensing & Billing](/features/licensing#feature-breakdown) for the full breakdown. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b01bd4f6..daa21fdb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -11,7 +11,7 @@ info: ## Authentication All authenticated endpoints accept a Bearer token in the `Authorization` header. - Generate API tokens from **Settings > API Tokens** in the Sencho dashboard (requires Admiral license). + Generate API tokens from **Settings > API Tokens** in the Sencho dashboard (admin role required). ``` Authorization: Bearer YOUR_API_TOKEN @@ -25,8 +25,8 @@ info: ## License Tiers - Some endpoints require a Skipper or Admiral license. Requests to gated endpoints - on Community Edition return `403` with `code: "PAID_REQUIRED"` or `code: "ADMIRAL_REQUIRED"`. + Some endpoints require an Admiral license. Requests to gated endpoints + on Community return `403` with `code: "PAID_REQUIRED"`. contact: name: Sencho url: https://sencho.io @@ -57,15 +57,15 @@ tags: - name: Containers description: List and manage running containers - name: API Tokens - description: Manage scoped API tokens (Admiral license required) + description: Manage scoped API tokens (admin role required) - name: Webhooks - description: Configure and trigger deployment webhooks (Skipper or Admiral license required) + description: Configure and trigger deployment webhooks - name: Nodes description: Manage local and remote Sencho nodes - name: Fleet - description: Multi-node fleet overview and snapshots (Skipper or Admiral license required) + description: Multi-node fleet overview and snapshots - name: Scheduled Tasks - description: Configure recurring automated operations (Admiral license required) + description: Configure recurring automated operations (admin role required) - name: Registries description: Manage private container registry credentials (Admiral license required). These endpoints are only accessible via browser sessions — API tokens receive `SCOPE_DENIED`. - name: Image Updates @@ -132,7 +132,7 @@ components: code: type: string description: Machine-readable error code (e.g., `PAID_REQUIRED`, `SCOPE_DENIED`). - enum: [PAID_REQUIRED, ADMIRAL_REQUIRED, SCOPE_DENIED] + enum: [PAID_REQUIRED, SCOPE_DENIED] SuccessMessage: type: object @@ -506,7 +506,7 @@ components: schema: $ref: "#/components/schemas/Error" example: - error: "This feature requires a Skipper or Admiral license." + error: "This feature requires a Sencho Admiral license." code: "PAID_REQUIRED" NotFound: description: Resource not found. @@ -934,7 +934,7 @@ paths: tags: [Stacks] summary: Deploy stack description: | - Runs `docker compose up -d` for the stack. On Skipper/Admiral tier, uses atomic deployment + Runs `docker compose up -d` for the stack using atomic deployment with automatic rollback on failure. Requires `stack:deploy` permission. parameters: - $ref: "#/components/parameters/stackName" @@ -962,7 +962,7 @@ paths: type: string rolledBack: type: boolean - description: Whether the stack was automatically rolled back (Skipper/Admiral tier). + description: Whether the stack was automatically rolled back. /api/stacks/{stackName}/down: post: @@ -1077,8 +1077,8 @@ paths: tags: [Stacks] summary: Pull and recreate stack description: | - Pulls latest images and recreates containers (`docker compose pull && up -d`). - On Skipper/Admiral tier, uses atomic update with automatic rollback on failure. + Pulls latest images and recreates containers (`docker compose pull && up -d`) + using atomic update with automatic rollback on failure. Requires `stack:deploy` permission. parameters: - $ref: "#/components/parameters/stackName" @@ -1115,7 +1115,7 @@ paths: operationId: rollbackStack tags: [Stacks] summary: Rollback stack - description: Restores the stack to its previous deployment state. Requires Skipper or Admiral license and `stack:deploy` permission. + description: Restores the stack to its previous deployment state. Requires `stack:deploy` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" @@ -1301,7 +1301,7 @@ paths: summary: Create API token description: | Generates a new scoped API token. The full token is only returned in the creation response - and cannot be retrieved again. Requires Admiral license and admin role. + and cannot be retrieved again. Requires admin role. **Note:** API tokens cannot create other API tokens. responses: @@ -1366,7 +1366,7 @@ paths: summary: List API tokens description: | Returns all API tokens for the current user. Token hashes are never exposed. - Requires Admiral license and admin role. + Requires admin role. **Note:** API tokens cannot list other API tokens. responses: @@ -1390,7 +1390,7 @@ paths: summary: Revoke API token description: | Permanently revokes an API token. Users can only revoke their own tokens. - Requires Admiral license and admin role. + Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -1419,7 +1419,7 @@ paths: operationId: listWebhooks tags: [Webhooks] summary: List webhooks - description: Returns all configured webhooks with masked secrets. Requires Skipper or Admiral license. + description: Returns all configured webhooks with masked secrets. responses: "200": description: Array of webhook objects. @@ -1439,7 +1439,7 @@ paths: summary: Create webhook description: | Creates a new webhook for a stack. The webhook secret is auto-generated and only - returned in the creation response. Requires Skipper or Admiral license and admin role. + returned in the creation response. Requires admin role. requestBody: required: true content: @@ -1496,7 +1496,7 @@ paths: operationId: updateWebhook tags: [Webhooks] summary: Update webhook - description: Updates webhook configuration. Requires Skipper or Admiral license and admin role. + description: Updates webhook configuration. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" requestBody: @@ -1542,7 +1542,7 @@ paths: operationId: deleteWebhook tags: [Webhooks] summary: Delete webhook - description: Permanently deletes a webhook. Requires Skipper or Admiral license and admin role. + description: Permanently deletes a webhook. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -1564,7 +1564,7 @@ paths: operationId: getWebhookHistory tags: [Webhooks] summary: Get webhook execution history - description: Returns the execution log for a webhook. Requires Skipper or Admiral license. + description: Returns the execution log for a webhook. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -2100,7 +2100,7 @@ paths: operationId: createFleetSnapshot tags: [Fleet] summary: Create fleet snapshot - description: Creates a point-in-time backup of all compose files across all nodes. Requires Skipper or Admiral license and admin role. + description: Creates a point-in-time backup of all compose files across all nodes. Requires admin role. requestBody: required: false content: @@ -2127,7 +2127,7 @@ paths: operationId: listFleetSnapshots tags: [Fleet] summary: List fleet snapshots - description: Returns paginated fleet snapshots. Requires Skipper or Admiral license. + description: Returns paginated fleet snapshots. parameters: - name: limit in: query @@ -2165,7 +2165,7 @@ paths: operationId: getFleetSnapshot tags: [Fleet] summary: Get snapshot details - description: Returns full snapshot details including all captured files grouped by node and stack. Requires Skipper or Admiral license. + description: Returns full snapshot details including all captured files grouped by node and stack. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -2185,7 +2185,7 @@ paths: operationId: deleteFleetSnapshot tags: [Fleet] summary: Delete snapshot - description: Permanently deletes a fleet snapshot. Requires Skipper or Admiral license and admin role. + description: Permanently deletes a fleet snapshot. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -2207,7 +2207,7 @@ paths: operationId: restoreFleetSnapshot tags: [Fleet] summary: Restore from snapshot - description: Restores a specific stack on a specific node from the snapshot. Optionally redeploys after restore. Requires Skipper or Admiral license and admin role. + description: Restores a specific stack on a specific node from the snapshot. Optionally redeploys after restore. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" requestBody: @@ -2256,7 +2256,7 @@ paths: operationId: listScheduledTasks tags: [Scheduled Tasks] summary: List scheduled tasks - description: Returns all scheduled tasks. Requires Admiral license and admin role. + description: Returns all scheduled tasks. Requires admin role. responses: "200": description: Array of scheduled task objects. @@ -2280,7 +2280,7 @@ paths: - `snapshot` requires `target_type: fleet` - `prune` requires `target_type: system` - Requires Admiral license and admin role. + Requires admin role. requestBody: required: true content: @@ -2373,7 +2373,7 @@ paths: operationId: updateScheduledTask tags: [Scheduled Tasks] summary: Update scheduled task - description: Updates task configuration. Same validation rules as creation apply. Requires Admiral license and admin role. + description: Updates task configuration. Same validation rules as creation apply. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" requestBody: @@ -2432,7 +2432,7 @@ paths: operationId: deleteScheduledTask tags: [Scheduled Tasks] summary: Delete scheduled task - description: Permanently deletes a scheduled task. Requires Admiral license and admin role. + description: Permanently deletes a scheduled task. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -2460,7 +2460,7 @@ paths: operationId: toggleScheduledTask tags: [Scheduled Tasks] summary: Toggle task enabled/disabled - description: Flips the enabled state of a scheduled task. Requires Admiral license and admin role. + description: Flips the enabled state of a scheduled task. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -2488,7 +2488,7 @@ paths: operationId: runScheduledTask tags: [Scheduled Tasks] summary: Run task immediately - description: Executes the scheduled task immediately, regardless of its cron schedule. Requires Admiral license and admin role. + description: Executes the scheduled task immediately, regardless of its cron schedule. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -2516,7 +2516,7 @@ paths: operationId: listScheduledTaskRuns tags: [Scheduled Tasks] summary: List task execution history - description: Returns paginated execution history for a scheduled task. Requires Admiral license and admin role. + description: Returns paginated execution history for a scheduled task. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" - name: limit @@ -2556,7 +2556,7 @@ paths: operationId: exportScheduledTaskRuns tags: [Scheduled Tasks] summary: Export task history as CSV - description: Downloads the execution history for a scheduled task as a CSV file. Requires Admiral license and admin role. + description: Downloads the execution history for a scheduled task as a CSV file. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: diff --git a/docs/operations/backup.mdx b/docs/operations/backup.mdx index e1c37450..6ea38122 100644 --- a/docs/operations/backup.mdx +++ b/docs/operations/backup.mdx @@ -6,7 +6,7 @@ description: What to back up, how to restore it, and how to migrate Sencho to a Sencho stores all its state in two places: the **data directory** (SQLite database and encryption key) and your **compose directory** (your actual stack files). Both need to be backed up for a complete recovery. - Skipper and Admiral users also have access to [Fleet-Wide Backups](/features/fleet-backups), which snapshot compose files across all nodes directly from the dashboard. This page covers lower-level backup of the Sencho instance itself. + Every tier also has access to [Fleet-Wide Backups](/features/fleet-backups), which snapshot compose files across all nodes directly from the dashboard. This page covers lower-level backup of the Sencho instance itself. --- diff --git a/docs/operations/trivy-setup.mdx b/docs/operations/trivy-setup.mdx index 55d85dee..8ab38f42 100644 --- a/docs/operations/trivy-setup.mdx +++ b/docs/operations/trivy-setup.mdx @@ -44,7 +44,7 @@ When a newer Trivy release is available, Settings → Security shows an **Update To update automatically instead, toggle **Auto-update Trivy** on. Sencho checks for new releases once a day and installs them in the background. You'll get an in-app notification each time a new version is installed, or when an update is available and auto-update is off. -The install, update, and uninstall buttons are available to admins on every tier. The **Auto-update Trivy** toggle requires Skipper. +The install, update, and uninstall buttons are available to admins on every tier. The **Auto-update Trivy** toggle requires Admiral. ### Removing the managed install diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index 457eb955..6213c3e4 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -164,11 +164,11 @@ To re-test connectivity after making changes, open **Profile > Settings > Nodes* ## License tier shows the wrong name -**Symptom:** The license card in **Settings > License** shows the wrong tier name (e.g. "Sencho Skipper" when you purchased Admiral). +**Symptom:** The license card in **Settings > License** shows a tier that does not match what you purchased. -**Cause:** Older versions of Sencho could misidentify certain license variant names from the payment provider. +**Cause:** The tier is read from your license at validation time. A stale cached value can show after the key was activated but before the next validation cycle ran. -**Fix:** Update to the latest version of Sencho and restart. The correct tier name will appear automatically in **Settings > License**. +**Fix:** Deactivate and re-activate the key in **Settings > License**, then restart the Sencho container. The tier is re-read on the next validation and the correct name appears automatically. If it still does not match, email `licensing@sencho.io` with your order ID. --- @@ -192,7 +192,7 @@ To re-test connectivity after making changes, open **Profile > Settings > Nodes* **Checks in order:** 1. Open **Settings > License** and verify it shows your license as **active** with the correct tier name. -2. If the tier shows "Skipper" but you purchased Admiral, this is the same variant identification issue described above. Update Sencho to the latest version to resolve it. +2. If the tier name does not match what you purchased, see [License tier shows the wrong name](#license-tier-shows-the-wrong-name) above. 3. If the tier shows correctly but features are still locked, restart the Sencho container. --- @@ -203,18 +203,17 @@ To re-test connectivity after making changes, open **Profile > Settings > Nodes* **Possible causes:** -- **Lifetime license:** Lifetime licenses do not have a billing portal. This is expected behavior. In the latest version, the **Manage Subscription** button is hidden for lifetime licenses. - **Network issues:** If your Sencho instance cannot reach the internet, the billing portal URL cannot be fetched. Check your instance's outbound connectivity and try again. --- ## Paid features return 403 on remote nodes -**Symptom:** A Skipper or Admiral feature works on the local node but returns a 403 error when you switch to a remote node. +**Symptom:** An Admiral feature works on the local node but returns a 403 error when you switch to a remote node. **Checks in order:** -1. **Is your primary instance licensed?** Open **Profile > Settings > License** on the primary instance and verify it shows an active Skipper or Admiral license. Remote nodes inherit the primary's tier; if the primary is on Community, all remote nodes will be Community too. +1. **Is your primary instance licensed?** Open **Profile > Settings > License** on the primary instance and verify it shows an active Admiral license. Remote nodes inherit the primary's tier; if the primary is on Community, all remote nodes will be Community too. 2. **Is the remote node's token valid?** An expired or revoked token prevents the license tier from being transmitted. Regenerate the token on the remote instance and update the node config on the primary. 3. **Is the remote node running an up-to-date version of Sencho?** Distributed license enforcement requires both the primary and remote instances to be on a compatible version. Update the remote node if it's outdated. 4. **Are you accessing the remote node directly?** If you navigate directly to the remote Sencho instance's URL (bypassing the primary), it uses its own local license. License inheritance only works through the primary's proxy. @@ -301,10 +300,6 @@ If you need to clean up unused *user-created* networks, use the **Prune Dead Net **Symptom:** Switching to the **Topology** view mode on the Networks tab shows "No user-created networks found" even though you have running containers. - - Network Topology requires a Skipper or Admiral license. - - **Possible causes:** - **All containers are on system networks only.** The topology view excludes Docker's built-in `bridge`, `host`, and `none` networks. If your containers only use the default bridge, they won't appear. Create a custom network in your compose file to see them in the topology. @@ -472,9 +467,8 @@ docker compose pull && docker compose up -d **Checks:** -1. **License tier**: Labels require a Skipper or Admiral license. Verify your tier in **Profile > Settings > License**. -2. **At least one label must exist**: Labels are managed in **Profile > Settings > Labels**. Create at least one label before assigning it to stacks. -3. **Downgrade**: If you downgraded from a paid tier to Community, the label UI is hidden but your data is preserved. Upgrading again restores all labels and assignments. +1. **At least one label must exist**: Labels are managed in **Profile > Settings > Labels**. Create at least one label before assigning it to stacks. +2. **Assignments are saved**: Open the stack's context menu, choose **Labels**, and confirm the label is checked. Pills appear in the sidebar once at least one label is assigned. --- @@ -486,7 +480,6 @@ docker compose pull && docker compose up -d 1. **Stack name match**: The stack name in the routing rule must match exactly. Stack names are case-sensitive and correspond to the directory name in your compose folder. 2. **Rule is enabled**: Check that the route's toggle is turned on in **Profile > Settings > Routing**. -3. **License tier**: Notification Routing requires a Skipper or Admiral license. --- diff --git a/docs/operations/two-factor-admin.mdx b/docs/operations/two-factor-admin.mdx index 91d1b3b8..ea212848 100644 --- a/docs/operations/two-factor-admin.mdx +++ b/docs/operations/two-factor-admin.mdx @@ -60,7 +60,7 @@ Two separate toggles let you change this, and they are independent: | Toggle | Where it lives | Tier | What it does | |--------|----------------|------|--------------| | **Require 2FA on SSO sign-in** | Each user's own **Settings · Account · Two-factor authentication** section | All tiers | Per-user opt-in. When on, that one user's SSO sign-ins also require a TOTP after the SSO flow returns. Helper text: `By default, SSO logins skip the second factor. Enforce it here to require both.` | -| **Require MFA** (per provider) | The provider config under **Settings · SSO** | Admiral (LDAP), Skipper (preset OIDC), Community (Custom OIDC) | Admin-set, fleet-wide for that provider. When on, every SSO-provisioned user must enrol TOTP after their first successful sign-in before they can use the rest of the console. | +| **Require MFA** (per provider) | The provider config under **Settings · SSO** | Admiral (LDAP), Community (Custom OIDC and preset OIDC) | Admin-set, fleet-wide for that provider. When on, every SSO-provisioned user must enrol TOTP after their first successful sign-in before they can use the rest of the console. | The per-user toggle decides *whether a TOTP is asked for on every SSO sign-in*. The per-provider toggle decides *whether new SSO users are forced to enrol TOTP at all*. Both can be on at once: the per-provider toggle ensures enrolment, the per-user toggle then asks for the TOTP on every sign-in. diff --git a/docs/reference/contact.mdx b/docs/reference/contact.mdx index 55fed207..4de4e58f 100644 --- a/docs/reference/contact.mdx +++ b/docs/reference/contact.mdx @@ -8,7 +8,7 @@ Sencho provides dedicated email channels for different types of inquiries. Use t ## Support - Technical support, how-to questions, and troubleshooting help. Available to Skipper and Admiral license holders via **Settings > Help & Support** in the app. + Technical support, how-to questions, and troubleshooting help. Available to Admiral license holders via **Settings > Help & Support** in the app. Community users can get help through the [Documentation](https://docs.sencho.io) and [GitHub Issues](https://github.com/studio-saelix/sencho/issues). @@ -22,7 +22,7 @@ Community users can get help through the [Documentation](https://docs.sencho.io) ## Licensing - License activation issues, upgrade questions, enterprise pricing, refund requests, and alternative licensing arrangements. + License activation issues, upgrade questions, custom pricing, refund requests, and alternative licensing arrangements. This address appears in: @@ -60,9 +60,9 @@ All reports are reviewed promptly and handled with confidentiality. See the [Cod | Email | When to use | |-------|-------------| -| `support@sencho.io` | Technical help (paid tiers) | +| `support@sencho.io` | Technical help (Admiral) | | `contact@sencho.io` | General inquiries, partnerships, media | -| `licensing@sencho.io` | Upgrades, enterprise deals, refunds, license issues | +| `licensing@sencho.io` | Upgrades, custom deals, refunds, license issues | | `security@sencho.io` | Vulnerability reports, security incidents | | `privacy@sencho.io` | GDPR/CCPA requests, legal, DMCA | | `conduct@sencho.io` | Code of Conduct violations | diff --git a/docs/reference/security.mdx b/docs/reference/security.mdx index 6161e0f8..bbe93fac 100644 --- a/docs/reference/security.mdx +++ b/docs/reference/security.mdx @@ -73,30 +73,30 @@ Every self-hosted instance includes the full security stack, with advanced featu Every Sencho instance includes the foundational security stack. Advanced access-control and compliance features are available on paid tiers. -| Feature | Community | Skipper | Admiral | -|---------|:---------:|:-------:|:-------:| -| Password authentication | ✓ | ✓ | ✓ | -| Custom OIDC SSO (Authelia, Keycloak, Authentik, any provider) | ✓ | ✓ | ✓ | -| Preset Google / GitHub / Okta SSO | | ✓ | ✓ | -| LDAP / Active Directory | | | ✓ | -| Two-factor authentication (TOTP + backup codes) | ✓ | ✓ | ✓ | -| Session management (httpOnly, Secure, SameSite) | ✓ | ✓ | ✓ | -| Encryption at rest (AES-256-GCM) | ✓ | ✓ | ✓ | -| Rate limiting (auth + API) | ✓ | ✓ | ✓ | -| Node-to-node authentication | ✓ | ✓ | ✓ | -| Vulnerability scanning (on-demand + post-deploy) | ✓ | ✓ | ✓ | -| Multi-user with RBAC (Admin, Viewer) | | ✓ | ✓ | -| Scan policies, scheduled scans, SBOM generation | | ✓ | ✓ | -| Fleet Secrets (encrypted env-var bundles) | | ✓ | ✓ | -| Webhook signatures (HMAC-SHA256) | | ✓ | ✓ | -| API tokens (scoped, expiring) | ✓ | ✓ | ✓ | -| Advanced RBAC (Deployer, Node Admin, Auditor) | | | ✓ | -| Scoped permissions (per-stack, per-node) | | | ✓ | -| Audit log with export | | | ✓ | +| Feature | Community | Admiral | +|---------|:---------:|:-------:| +| Password authentication | ✓ | ✓ | +| Custom OIDC SSO (Authelia, Keycloak, Authentik, any provider) | ✓ | ✓ | +| Preset Google / GitHub / Okta SSO | ✓ | ✓ | +| LDAP / Active Directory | | ✓ | +| Two-factor authentication (TOTP + backup codes) | ✓ | ✓ | +| Session management (httpOnly, Secure, SameSite) | ✓ | ✓ | +| Encryption at rest (AES-256-GCM) | ✓ | ✓ | +| Rate limiting (auth + API) | ✓ | ✓ | +| Node-to-node authentication | ✓ | ✓ | +| Vulnerability scanning (on-demand + post-deploy + scheduled) | ✓ | ✓ | +| Multi-user with RBAC (Admin, Viewer) | ✓ | ✓ | +| Webhook signatures (HMAC-SHA256) | ✓ | ✓ | +| API tokens (scoped, expiring) | ✓ | ✓ | +| Scan policies and SBOM generation | | ✓ | +| Fleet Secrets (encrypted env-var bundles) | | ✓ | +| Advanced RBAC (Deployer, Node Admin, Auditor) | | ✓ | +| Scoped permissions (per-stack, per-node) | | ✓ | +| Audit log with export | | ✓ | ## Password authentication -Sencho enforces a minimum 8-character password policy aligned with [NIST SP 800-63B](https://pages.nist.gov/800-63-3/sp800-63b.html). Passwords are hashed with bcrypt (cost factor 10) before storage. On first launch, you create the initial admin account through a setup wizard; additional users can be created from **Settings · Users** on paid tiers. +Sencho enforces a minimum 8-character password policy aligned with [NIST SP 800-63B](https://pages.nist.gov/800-63-3/sp800-63b.html). Passwords are hashed with bcrypt (cost factor 10) before storage. On first launch, you create the initial admin account through a setup wizard; additional users can be created from **Settings · Users**. Changing your password immediately invalidates all other active sessions, so a compromised session cannot survive a password reset. @@ -104,8 +104,7 @@ Changing your password immediately invalidates all other active sessions, so a c Sencho supports five identity providers split across tiers by delivery model: -- **Community**: **Custom OIDC**, which connects to any spec-compliant OpenID Connect provider (Authelia, Keycloak, Authentik, Zitadel, KanIDM, Pocket ID, and others). -- **Skipper**: preset providers for **Google**, **GitHub**, and **Okta**. +- **Community**: **Custom OIDC**, which connects to any spec-compliant OpenID Connect provider (Authelia, Keycloak, Authentik, Zitadel, KanIDM, Pocket ID, and others), plus preset providers for **Google**, **GitHub**, and **Okta**. - **Admiral**: **LDAP / Active Directory** for on-premises directories. All OIDC flows use PKCE (Proof Key for Code Exchange) and a cryptographic state parameter to prevent authorization code interception and cross-site request forgery. SSO credentials (client secrets and LDAP bind passwords) are encrypted at rest with AES-256-GCM. @@ -174,11 +173,11 @@ For setup and usage, see [API Tokens](/features/api-tokens). Webhook triggers are authenticated with a per-webhook HMAC-SHA256 secret (32 random bytes). The secret is generated at create time and displayed exactly once in the success callout; the configured-webhooks list shows only a masked preview thereafter. Every inbound trigger must present a matching `X-Webhook-Signature` header computed over the raw request body. Sencho recomputes the HMAC server-side and compares it with a constant-time check to defeat timing attacks; mismatches return 401 before any deploy action runs. -Webhooks are available on Skipper and Admiral. For setup and verification recipes, see [Webhooks](/features/webhooks). +Webhooks are available on every tier. For setup and verification recipes, see [Webhooks](/features/webhooks). ## Role-based access control -Sencho defines five roles with increasing levels of access. Admin and Viewer are available on Skipper; Deployer, Node Admin, and Auditor require Admiral. +Sencho defines five roles with increasing levels of access. Admin and Viewer are available on every tier; Deployer, Node Admin, and Auditor require Admiral. ### Permission matrix @@ -200,7 +199,7 @@ Sencho defines five roles with increasing levels of access. Admin and Viewer are On Admiral, you can create **scoped assignments** that grant a user elevated permissions on specific stacks or nodes without giving them broad access. For example, a Viewer can be promoted to Deployer on a single production stack. -Account limits are enforced by tier: Community supports one admin, Skipper supports one admin and three viewers, and Admiral supports unlimited accounts. +Both tiers support unlimited accounts. Community uses the Admin and Viewer roles; Admiral adds the Deployer, Node Admin, and Auditor roles plus scoped permissions. For user management and scoped permissions, see [RBAC & User Management](/features/rbac). diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 9e21972c..15f9001f 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -139,12 +139,12 @@ Activate, view, or deactivate the license for this Sencho control plane. The mas | Element | Description | |---------|-------------| -| **Plan** | The active tier (Sencho Community, Sencho Skipper, Sencho Admiral, or Sencho Admiral (Trial)) with a tier badge. | +| **Plan** | The active tier (Sencho Community, Sencho Admiral, or Sencho Admiral (Trial)) with a tier badge. | | **Customer** | The customer name on file with Lemon Squeezy (paid plans only). | -| **Product** | The product variant (paid plans only). | +| **Product** | The product (paid plans only). | | **License key** | The active key, masked to the last four characters. | | **Trial countdown** | Days remaining. Visible only while a trial is active. | -| **Manage subscription** | Opens the Lemon Squeezy customer portal (subscription plans only; not shown for lifetime). | +| **Manage subscription** | Opens the Lemon Squeezy customer portal to update payment, view invoices, or cancel. | | **Deactivate** | Releases the key from this instance and reverts to Community features. | | **See pricing** | Direct link to the pricing page; visible on Community or when an existing license has expired. | @@ -155,7 +155,7 @@ See [Licensing & Billing](/features/licensing) for the full walkthrough includin ## Users - User management requires a Sencho Skipper or Admiral license and admin role. Community supports a single admin account only. + User management requires an admin role. Community supports unlimited accounts with the Admin and Viewer roles. **Scope:** Global @@ -173,8 +173,8 @@ Create and manage user accounts with role-based access. The masthead publishes a | Role | Tier | Description | |------|------|-------------| -| **Admin** | Skipper+ | Full access to all features | -| **Viewer** | Skipper+ | Read-only access to stacks and nodes | +| **Admin** | Community | Full access to all features | +| **Viewer** | Community | Read-only access to stacks and nodes | | **Deployer** | Admiral | Can view stacks and trigger deployments | | **Node Admin** | Admiral | Full stack and node management, no system settings | | **Auditor** | Admiral | Read-only plus audit log access | @@ -186,7 +186,7 @@ See [RBAC & User Management](/features/rbac) for details on what each role can a ## SSO - Custom OIDC is available on Community; preset providers (Google, GitHub, Okta) require Skipper; LDAP / Active Directory requires Admiral. + Custom OIDC and the preset providers (Google, GitHub, Okta) are available on Community; LDAP / Active Directory requires Admiral. **Scope:** Global, admin-only @@ -322,7 +322,7 @@ At least one agent must be enabled for stack alerts to deliver notifications. Se ## Routing - Notification Routing requires a Sencho Skipper or Admiral license. + Creating, editing, and deleting routes is admin-only. **Scope:** Global, admin-only @@ -350,7 +350,7 @@ See [Notification Routing](/features/alerts-notifications#notification-routing) ## Webhooks - Webhooks require a Sencho Skipper or Admiral license. + Managing webhooks is admin-only. **Scope:** Global @@ -374,7 +374,7 @@ See [Webhooks](/features/webhooks) for the full walkthrough including authentica ## Labels - Label organization (create, edit, assign, remove) is available on every tier. Bulk label actions (apply or remove a label across multiple stacks in one operation) require Skipper or Admiral. + Label organization (create, edit, assign, remove) and bulk label actions (apply or remove a label across multiple stacks in one operation) are available on every tier. **Scope:** Per-node @@ -388,7 +388,7 @@ See [Stack Labels](/features/stack-labels) for the full walkthrough. ## Security - Security is admin-only. The Trivy installer and CVE/misconfig suppressions are available on all tiers; scan policies and the **Auto-update Trivy** toggle require Sencho Skipper or Admiral (the toggle also requires a managed Trivy binary). + Security is admin-only. The Trivy installer and CVE/misconfig suppressions are available on all tiers; scan policies and the **Auto-update Trivy** toggle require an Admiral license (the toggle also requires a managed Trivy binary). **Scope:** Per-node @@ -406,7 +406,7 @@ Manage the Trivy scanner, scan policies, suppressions, and acknowledgements that | **Status** | `Installed (managed)` when Sencho manages the binary, `Installed (host)` when an existing host binary is being reused, or empty when nothing is detected. | | **Version** | The current Trivy version, when installed. | | **Install / Update / Uninstall** | Lifecycle actions for the managed binary. Uninstall asks for confirmation. | -| **Auto-update Trivy** toggle | When on, Sencho checks daily and installs newer Trivy releases automatically. Requires Skipper and a managed Trivy binary. | +| **Auto-update Trivy** toggle | When on, Sencho checks daily and installs newer Trivy releases automatically. Requires Admiral and a managed Trivy binary. | ### Scan policies @@ -472,7 +472,7 @@ See [App Store](/features/app-store#custom-template-registry) for more on custom **Scope:** Global -Links to help resources, with additional channels for Skipper and Admiral operators. +Links to help resources, with an additional channel for Admiral operators. ### Resources (all tiers) @@ -481,12 +481,11 @@ Links to help resources, with additional channels for Skipper and Admiral operat | **Documentation** | Opens docs.sencho.io. | | **GitHub Issues** | Report bugs and request features on GitHub. | -### Paid tier support +### Admiral support | Channel | Tier | Description | |---------|------|-------------| -| **Email Support** | Skipper | Direct email support. | -| **Priority Email Support** | Admiral | Responses within 24 hours. | +| **Priority Email Support** | Admiral | Direct email support with responses within 24 hours. | Community operators see an upgrade callout with a link to the pricing page in place of the support channels. @@ -501,7 +500,7 @@ Displays instance information at a glance. | Field | Description | |-------|-------------| | **Version** | Current Sencho version. | -| **Tier** | Community, Skipper, or Admiral badge. | +| **Tier** | Community or Admiral badge. | | **License Status** | active, trial, expired, or community. | | **Instance ID** | First eight characters of the unique identifier for this Sencho control plane (used by the license server to identify it). | diff --git a/e2e/auto-heal-policies.spec.ts b/e2e/auto-heal-policies.spec.ts index 9caa2629..99055f6e 100644 --- a/e2e/auto-heal-policies.spec.ts +++ b/e2e/auto-heal-policies.spec.ts @@ -4,8 +4,8 @@ * Opens the Auto-Heal sheet from the stack sidebar context menu, creates a * policy, verifies it appears in the list, then deletes it. * - * Requires a paid license (Skipper or Admiral) on the test instance. The test - * skips gracefully when the PaidGate upgrade prompt is detected instead. + * Auto-heal policies are available on every tier, so no paid license is + * required; the test skips gracefully when no stacks exist on the instance. */ import { test, expect } from '@playwright/test'; import { loginAs } from './helpers'; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b5425cb9..4c1c99d5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,8 +36,8 @@ function AppContent() { - {/* Portal lives inside LicenseProvider so DeployFeedbackModal can - call useLicense() (M-3 atomic-deploy notice depends on isPaid). + {/* Portal lives inside LicenseProvider so the editor surface and its + portalled overlays can read license state via useLicense(). Outer DeployFeedbackProvider is still an ancestor through App. */} diff --git a/frontend/src/components/AdmiralGate.tsx b/frontend/src/components/AdmiralGate.tsx deleted file mode 100644 index 0218ff9a..00000000 --- a/frontend/src/components/AdmiralGate.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ReactNode } from 'react'; -import { useLicense } from '@/context/LicenseContext'; - -/** - * Thin wrapper that renders its children only for licensees on the - * Admiral plan. All other tiers (Community, Skipper) see nothing in - * this slot. Backend tier guards (`requireAdmiral`) remain the - * authoritative enforcement; this component only controls UI - * visibility. - */ -export function AdmiralGate({ children }: { children: ReactNode }) { - const { isPaid, license } = useLicense(); - const isAdmiral = isPaid && license?.variant === 'admiral'; - return isAdmiral ? <>{children} : null; -} diff --git a/frontend/src/components/AutoUpdateReadinessView.tsx b/frontend/src/components/AutoUpdateReadinessView.tsx index 1a52b08a..2cc2c459 100644 --- a/frontend/src/components/AutoUpdateReadinessView.tsx +++ b/frontend/src/components/AutoUpdateReadinessView.tsx @@ -5,7 +5,6 @@ import { Badge } from '@/components/ui/badge'; import { RefreshCw, Shield, AlertTriangle, ShieldAlert, CircleSlash, Clock, Play, CalendarClock, Monitor, Globe } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; -import { PaidGate } from '@/components/PaidGate'; import { useNodes } from '@/context/NodeContext'; import type { ScheduledTask } from '@/types/scheduling'; @@ -657,9 +656,5 @@ function AutoUpdateReadinessContent() { } export default function AutoUpdateReadinessView() { - return ( - - - - ); + return ; } diff --git a/frontend/src/components/DeployFeedbackModal.tsx b/frontend/src/components/DeployFeedbackModal.tsx index 48b6c8f2..238ae981 100644 --- a/frontend/src/components/DeployFeedbackModal.tsx +++ b/frontend/src/components/DeployFeedbackModal.tsx @@ -13,7 +13,6 @@ import { Button } from '@/components/ui/button'; import { StructuredLogRow } from '@/components/log-rendering/StructuredLogRow'; import TerminalComponent from '@/components/Terminal'; import { useDeployFeedback, VERB_LABELS } from '@/context/DeployFeedbackContext'; -import { useLicense } from '@/context/LicenseContext'; const AUTO_CLOSE_SECONDS = 4; @@ -33,7 +32,6 @@ function formatElapsed(seconds: number): string { export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) { const { panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback(); - const { isPaid } = useLicense(); const [showRaw, setShowRaw] = useState(false); const [elapsedSeconds, setElapsedSeconds] = useState(0); @@ -216,16 +214,6 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM - {/* Atomic-deploy notice for Community: deploys without auto-rollback. */} - {!isPaid && (action === 'deploy' || action === 'update') && ( -
- Auto-rollback on failure is a Skipper feature. -
- )} - {/* Body */}
['can']; isAdmin: boolean; - isPaid: boolean; trivy: { available: boolean }; activeNode: Node | null; @@ -255,7 +254,6 @@ export function EditorView({ stackMisconfigScanning, can, isAdmin, - isPaid, trivy, activeNode, copiedDigestTimerRef, @@ -404,7 +402,7 @@ export function EditorView({ {(() => { const canDeploy = can('stack:deploy', 'stack', stackName); const canDelete = can('stack:delete', 'stack', stackName); - const canRollback = canDeploy && isPaid && backupInfo.exists; + const canRollback = canDeploy && backupInfo.exists; const canScan = trivy.available && isAdmin; const hasOverflowExtras = canRollback || canScan; const hasOverflow = hasOverflowExtras || canDelete; diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index 55ae5e68..2b395fbf 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -1,7 +1,7 @@ import { Suspense, lazy, type ReactNode } from 'react'; import { Skeleton } from '@/components/ui/skeleton'; import { useAuth } from '@/context/AuthContext'; -import { AdmiralGate } from '../AdmiralGate'; +import { PaidGate } from '../PaidGate'; import { CapabilityGate } from '../CapabilityGate'; import { HubOnlyGate } from '../HubOnlyGate'; import LazyBoundary from '../LazyBoundary'; @@ -15,7 +15,7 @@ import type { ScheduleTaskPrefill } from '../ScheduledOperationsView'; import type { ActiveView } from './hooks/useViewNavigationState'; // Paid-tier views and the security-history overlay are loaded on demand. -// Their internal PaidGate / AdmiralGate / CapabilityGate wrappers render +// Their internal PaidGate / CapabilityGate wrappers render // the upsell or capability-missing card with blurred children rather than // short-circuiting, so a tier-locked or capability-missing operator // opening one of these tabs still triggers the chunk fetch to render the @@ -127,13 +127,13 @@ export function ViewRouter({ // view another way from mounting a console that the server will 403. if (!can('system:console')) return null; return ( - + - + ); } // Fall-through: when activeView === 'editor' but selectedFile is diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx index e2516db0..bda6746b 100644 --- a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx @@ -23,18 +23,16 @@ function mockCommunityUser() { } as unknown as ReturnType); vi.mocked(LicenseContext.useLicense).mockReturnValue({ isPaid: false, - license: null, } as unknown as ReturnType); } -function mockAdmiralAdmin() { +function mockPaidAdmin() { vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true, can: (p: string) => p === 'system:audit', } as unknown as ReturnType); vi.mocked(LicenseContext.useLicense).mockReturnValue({ isPaid: true, - license: { variant: 'admiral' } as ReturnType['license'], } as unknown as ReturnType); } @@ -45,18 +43,6 @@ function mockCommunityAdmin() { } as unknown as ReturnType); vi.mocked(LicenseContext.useLicense).mockReturnValue({ isPaid: false, - license: null, - } as unknown as ReturnType); -} - -function mockSkipperAdmin() { - vi.mocked(AuthContext.useAuth).mockReturnValue({ - isAdmin: true, - can: () => false, - } as unknown as ReturnType); - vi.mocked(LicenseContext.useLicense).mockReturnValue({ - isPaid: true, - license: { variant: 'skipper' } as ReturnType['license'], } as unknown as ReturnType); } @@ -231,6 +217,16 @@ describe('useViewNavigationState', () => { expect(result.current.navItems.map(i => i.value)).toContain('global-observability'); }); + it('shows Auto-Update and Schedules for a community admin (now free) but hides paid Console and Audit', () => { + mockCommunityAdmin(); + const { result } = renderHook(() => useViewNavigationState()); + const values = result.current.navItems.map(i => i.value); + expect(values).toContain('auto-updates'); + expect(values).toContain('scheduled-ops'); + expect(values).not.toContain('host-console'); + expect(values).not.toContain('audit-log'); + }); + it('redirects a non-admin off the Logs view when reached via a deep-link event', () => { const onNavigateToDashboard = vi.fn(); // Community (non-admin) is the beforeEach default. @@ -244,10 +240,10 @@ describe('useViewNavigationState', () => { expect(onNavigateToDashboard).toHaveBeenCalled(); }); - // ── navItems: admiral admin ──────────────────────────────────────────────── + // ── navItems: paid admin ─────────────────────────────────────────────────── - it('navItems for admiral paid admin contains all items', () => { - mockAdmiralAdmin(); + it('navItems for a paid admin contains all items', () => { + mockPaidAdmin(); const { result } = renderHook(() => useViewNavigationState()); const values = result.current.navItems.map(i => i.value); expect(values).toContain('auto-updates'); @@ -256,22 +252,10 @@ describe('useViewNavigationState', () => { expect(values).toContain('scheduled-ops'); }); - // ── navItems: skipper admin ──────────────────────────────────────────────── - - it('navItems for skipper paid admin contains schedules and auto-updates but not admiral items', () => { - mockSkipperAdmin(); - const { result } = renderHook(() => useViewNavigationState()); - const values = result.current.navItems.map(i => i.value); - expect(values).toContain('auto-updates'); - expect(values).toContain('scheduled-ops'); - expect(values).not.toContain('host-console'); - expect(values).not.toContain('audit-log'); - }); - // ── navItems: hub-only gating on remote node ─────────────────────────────── it('hides hub-only views from the nav strip when active node is remote', () => { - mockAdmiralAdmin(); + mockPaidAdmin(); mockActiveNode('remote'); const { result } = renderHook(() => useViewNavigationState()); const values = result.current.navItems.map(i => i.value); @@ -288,7 +272,7 @@ describe('useViewNavigationState', () => { }); it('shows hub-only views again when active node switches back to local', () => { - mockAdmiralAdmin(); + mockPaidAdmin(); mockActiveNode('remote'); const { result, rerender } = renderHook(() => useViewNavigationState()); expect(result.current.navItems.map(i => i.value)).not.toContain('fleet'); @@ -305,7 +289,7 @@ describe('useViewNavigationState', () => { it('auto-redirects to dashboard when active view is hub-only and node becomes remote', () => { const onNavigateToDashboard = vi.fn(); - mockAdmiralAdmin(); + mockPaidAdmin(); mockActiveNode('local'); const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }), @@ -329,7 +313,7 @@ describe('useViewNavigationState', () => { it('does not redirect when a non-hub-only view is active and node becomes remote', () => { const onNavigateToDashboard = vi.fn(); - mockAdmiralAdmin(); + mockPaidAdmin(); mockActiveNode('local'); const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }), diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index c69da470..1d4e179b 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -19,8 +19,6 @@ interface UseSidebarContextMenuOptions { overlayState: OverlayState; stackActions: StackActionsHook; activeNode: Node | null | undefined; - isPaid: boolean; - isAdmiral: boolean; isAdmin: boolean; can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean; } @@ -31,8 +29,6 @@ export function useSidebarContextMenu({ overlayState, stackActions, activeNode, - isPaid, - isAdmiral, isAdmin, can, }: UseSidebarContextMenuOptions) { @@ -42,8 +38,6 @@ export function useSidebarContextMenu({ stackStatus: (stackListState.stackStatuses[file] ?? 'unknown') as 'running' | 'exited' | 'unknown', hasPort: Boolean(stackListState.stackPorts[file]), isBusy: stackListState.isStackBusy(file), - isPaid, - isAdmiral, isAdmin, canDelete: can('stack:delete', 'stack', sName), canEditLabels: can('stack:edit', 'stack', sName), @@ -130,7 +124,7 @@ export function useSidebarContextMenu({ // deps would force a rebuild on every parent render and defeat the memo. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - stackListState.stackStatuses, stackListState.stackPorts, isPaid, isAdmiral, isAdmin, + stackListState.stackStatuses, stackListState.stackPorts, isAdmin, stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap, stackListState.pin, stackListState.unpin, ]); diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index 4c9c5135..e06beba6 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -98,7 +98,6 @@ function setup(over: { editorState?: Partial; overlay?: Partial[0]['activeNode'], setActiveNode: vi.fn(), nodes: [], - isPaid: false, runWithLog, diffPreviewEnabled: false, }), @@ -144,7 +143,6 @@ describe('useStackActions.saveFile', () => { activeNode: { id: 1, type: 'local' } as Parameters[0]['activeNode'], setActiveNode: vi.fn(), nodes: [], - isPaid: false, runWithLog, diffPreviewEnabled: false, }), diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index e1753ac2..b36625f2 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -58,7 +58,6 @@ interface UseStackActionsOptions { activeNode: Node | null | undefined; setActiveNode: (node: Node) => void; nodes: Node[]; - isPaid: boolean; runWithLog: ( params: { stackName: string; action: ActionVerb }, run: (deployStarted: Promise, deploySessionId: string) => Promise, @@ -128,7 +127,6 @@ export function useStackActions(options: UseStackActionsOptions) { activeNode, setActiveNode, nodes, - isPaid, runWithLog, diffPreviewEnabled, } = options; @@ -295,7 +293,6 @@ export function useStackActions(options: UseStackActionsOptions) { }; const loadBackupState = async (filename: string, signal?: AbortSignal) => { - if (!isPaid) return; try { const backupRes = await apiFetch(`/stacks/${filename}/backup`, { signal }); if (signal?.aborted) return; @@ -575,13 +572,11 @@ export function useStackActions(options: UseStackActionsOptions) { const conts = await containersRes.json(); editorState.setContainers(Array.isArray(conts) ? conts : []); } - if (isPaid) { - try { - const backupRes = await apiFetch(`/stacks/${stackName}/backup`); - if (backupRes.ok) editorState.setBackupInfo(await backupRes.json()); - } catch { - /* ignore */ - } + try { + const backupRes = await apiFetch(`/stacks/${stackName}/backup`); + if (backupRes.ok) editorState.setBackupInfo(await backupRes.json()); + } catch { + /* ignore */ } return { ok: true }; } catch (error) { @@ -591,7 +586,7 @@ export function useStackActions(options: UseStackActionsOptions) { const deployError = error as StackActionError; const errorMessage = deployError.message || 'Failed to deploy stack'; toast.error( - isPaid && deployError.rolledBack === true + deployError.rolledBack === true ? `${errorMessage} - automatically rolled back to previous version.` : errorMessage, ); @@ -963,7 +958,7 @@ export function useStackActions(options: UseStackActionsOptions) { editorState.setContainers(Array.isArray(conts) ? conts : []); } if (action === 'update') stackListState.fetchImageUpdates(); - if (action === 'deploy' && isPaid) { + if (action === 'deploy') { try { const backupRes = await apiFetch(`/stacks/${stackName}/backup`); if (backupRes.ok) editorState.setBackupInfo(await backupRes.json()); @@ -976,7 +971,7 @@ export function useStackActions(options: UseStackActionsOptions) { const actionError = error as StackActionError; const msg = actionError.message || `Failed to ${action} stack`; toast.error( - action === 'deploy' && isPaid && actionError.rolledBack === true + action === 'deploy' && actionError.rolledBack === true ? `${msg} - automatically rolled back to previous version.` : msg, ); diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index 2242a967..6313811c 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -52,7 +52,7 @@ interface UseViewNavigationStateOptions { export function useViewNavigationState(options?: UseViewNavigationStateOptions) { const { onNavigateToDashboard } = options ?? {}; const { isAdmin, can } = useAuth(); - const { isPaid, license } = useLicense(); + const { isPaid } = useLicense(); const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; @@ -108,18 +108,18 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) // The aggregated Logs feed crosses every managed stack, so it is an // admin-only operator view (the backend gates the same routes on admin). if (isAdmin) items.push({ value: 'global-observability', label: 'Logs', icon: Activity }); - if (isPaid && isAdmin) { + if (isAdmin) { items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw }); items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock }); } - if (isPaid && license?.variant === 'admiral') { + if (isPaid) { if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal }); if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText }); } return isRemote ? items.filter(i => !HUB_ONLY_VIEWS.has(i.value)) : items; - }, [isAdmin, isPaid, license?.variant, can, isRemote]); + }, [isAdmin, isPaid, can, isRemote]); useEffect(() => { // Redirect off a view the active context can't reach: a hub-only view while diff --git a/frontend/src/components/FleetSnapshots.tsx b/frontend/src/components/FleetSnapshots.tsx index 0cbf33b0..a963416e 100644 --- a/frontend/src/components/FleetSnapshots.tsx +++ b/frontend/src/components/FleetSnapshots.tsx @@ -71,11 +71,10 @@ const PAGE_SIZE = 10; export default function FleetSnapshots() { const { isAdmin } = useAuth(); - const { license, isPaid } = useLicense(); - const isAdmiral = isPaid && license?.variant === 'admiral'; + const { isPaid } = useLicense(); // Cloud-upload affordance is reachable when the saved provider is custom - // (every tier) or sencho on an Admiral license. A downgraded admin whose + // (every tier) or sencho on a paid license. A downgraded admin whose // saved provider is still 'sencho' sees no upload button — they cannot // call POST /cloud-backup/upload/:id because gateForCurrentProvider would // 403 anyway, so the UI must not advertise an action that is gated away. @@ -133,11 +132,11 @@ export default function FleetSnapshots() { const res = await apiFetch('/cloud-backup/config', { localOnly: true }); if (!res.ok) return; const data = await res.json() as { provider: 'disabled' | 'sencho' | 'custom' }; - setCloudEnabled(data.provider === 'custom' || (data.provider === 'sencho' && isAdmiral)); + setCloudEnabled(data.provider === 'custom' || (data.provider === 'sencho' && isPaid)); } catch { // best-effort; cloud affordances stay hidden on failure } - }, [isAdmiral]); + }, [isPaid]); const fetchCloudSnapshots = useCallback(async () => { if (!cloudEnabled) return; diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 21e92b87..35dba1aa 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -18,7 +18,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI import { springs } from '@/lib/motion'; import { useLicense } from '@/context/LicenseContext'; import { useAuth } from '@/context/AuthContext'; -import { AdmiralGate } from './AdmiralGate'; +import { PaidGate } from './PaidGate'; import FleetSnapshots from './FleetSnapshots'; import { FleetConfiguration } from './fleet/FleetConfiguration'; import { RoutingTab } from './fleet/RoutingTab'; @@ -34,9 +34,8 @@ interface FleetViewProps { } export function FleetView({ onNavigateToNode }: FleetViewProps) { - const { isPaid, license } = useLicense(); + const { isPaid } = useLicense(); const { isAdmin } = useAuth(); - const isAdmiral = isPaid && license?.variant === 'admiral'; const { prefs, updatePrefs } = useFleetPreferences(); const updateStatus = useFleetUpdateStatus(); @@ -98,14 +97,14 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} - {isAdmiral && ( + {isPaid && ( Routing )} - {isAdmiral && ( + {isPaid && ( Federation @@ -207,18 +206,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} - {isAdmiral && ( + {isPaid && ( - + - + )} - {isAdmiral && ( + {isPaid && ( - + - + )} diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index 6d014c17..7ec1ec52 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -67,17 +67,16 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u const [cordonReason, setCordonReason] = useState(''); const [cordonSubmitting, setCordonSubmitting] = useState(false); - const { isPaid, license } = useLicense(); + const { isPaid } = useLicense(); const { isAdmin, can } = useAuth(); const { nodes: registryNodes } = useNodes(); - const isAdmiral = isPaid && license?.variant === 'admiral'; const registryNode = registryNodes.find(n => n.id === node.id); const canEdit = Boolean(isAdmin && onEdit && registryNode); const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default); - // Cordon is Admiral-tier AND requires node:manage, matching the backend guard - // (requirePermission('node:manage','node',id) + requireAdmiral). Gating on tier + // Cordon is a paid feature AND requires node:manage, matching the backend guard + // (requirePermission('node:manage','node',id) + requirePaid). Gating on tier // alone would surface the control to deployer/viewer/auditor users whose calls 403. - const canCordon = isAdmiral && can('node:manage', 'node', String(node.id)); + const canCordon = isPaid && can('node:manage', 'node', String(node.id)); const showMenu = canEdit || canDelete || canCordon; const isOnline = node.status === 'online'; diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx index c4bc67a1..8c526b7f 100644 --- a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx @@ -36,7 +36,7 @@ function baseProps(node: FleetNode) { beforeEach(() => { useNodesMock.mockReturnValue({ nodes: [] }); useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) }); - useLicenseMock.mockReturnValue({ isPaid: false, license: null }); + useLicenseMock.mockReturnValue({ isPaid: false }); }); afterEach(() => vi.clearAllMocks()); @@ -55,24 +55,24 @@ describe('NodeCard', () => { expect(screen.queryByText('Running')).not.toBeInTheDocument(); }); - it('hides the actions menu for a non-admiral user', () => { - useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'skipper' } }); + it('hides the actions menu for a free-tier user', () => { + useLicenseMock.mockReturnValue({ isPaid: false }); render(); expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument(); }); - it('exposes the actions menu (cordon entry point) for an admiral admin', () => { - useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } }); + it('exposes the actions menu (cordon entry point) for a paid admin', () => { + useLicenseMock.mockReturnValue({ isPaid: true }); render(); // With no edit/delete affordances wired, the menu renders iff cordon is - // allowed: isAdmiral && can('node:manage'). The admin's can() returns true. + // allowed: isPaid && can('node:manage'). The admin's can() returns true. expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument(); }); it('exposes the cordon control for a node-admin via the node:manage permission', async () => { const can = vi.fn((action: string) => action === 'node:manage'); useAuthMock.mockReturnValue({ isAdmin: false, can }); - useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } }); + useLicenseMock.mockReturnValue({ isPaid: true }); render(); await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); @@ -80,18 +80,18 @@ describe('NodeCard', () => { expect(can).toHaveBeenCalledWith('node:manage', 'node', '2'); }); - it('hides the cordon control from an admiral user lacking node:manage', () => { + it('hides the cordon control from a paid user lacking node:manage', () => { useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) }); - useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } }); + useLicenseMock.mockReturnValue({ isPaid: true }); render(); - // Admiral tier alone must not surface cordon to a deployer/viewer/auditor. + // The paid tier alone must not surface cordon to a deployer/viewer/auditor. expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument(); }); it('shows Uncordon when the node is already cordoned', async () => { const can = vi.fn((action: string) => action === 'node:manage'); useAuthMock.mockReturnValue({ isAdmin: false, can }); - useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } }); + useLicenseMock.mockReturnValue({ isPaid: true }); render(); await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); diff --git a/frontend/src/components/PaidGate.tsx b/frontend/src/components/PaidGate.tsx index 050be3cb..488609bf 100644 --- a/frontend/src/components/PaidGate.tsx +++ b/frontend/src/components/PaidGate.tsx @@ -2,10 +2,10 @@ import type { ReactNode } from 'react'; import { useLicense } from '@/context/LicenseContext'; /** - * Thin wrapper that renders its children only for licensees on a paid - * plan (Skipper or Admiral). Community-tier users see nothing in this - * slot. Backend tier guards (`requirePaid`) remain the authoritative - * enforcement; this component only controls UI visibility. + * Thin wrapper that renders its children only for licensees on the paid + * plan. Community-tier users see nothing in this slot. Backend tier + * guards (`requirePaid`) remain the authoritative enforcement; this + * component only controls UI visibility. * * Use only when wrapping a discrete fragment that has no neighboring * context for Community users. Where possible, prefer a parent-level diff --git a/frontend/src/components/RegistriesSection.tsx b/frontend/src/components/RegistriesSection.tsx index 2dca79d3..07bd03c4 100644 --- a/frontend/src/components/RegistriesSection.tsx +++ b/frontend/src/components/RegistriesSection.tsx @@ -8,7 +8,7 @@ import { Combobox } from '@/components/ui/combobox'; import { ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; -import { AdmiralGate } from './AdmiralGate'; +import { PaidGate } from './PaidGate'; import { CapabilityGate } from './CapabilityGate'; import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock, Zap } from 'lucide-react'; import { SettingsPrimaryButton } from './settings/SettingsActions'; @@ -283,7 +283,7 @@ export function RegistriesSection() { }; return ( - +
@@ -474,6 +474,6 @@ export function RegistriesSection() {
- + ); } diff --git a/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx index 81aba157..93d1a0ae 100644 --- a/frontend/src/components/SSOSection.tsx +++ b/frontend/src/components/SSOSection.tsx @@ -9,7 +9,6 @@ import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { CapabilityGate } from './CapabilityGate'; import { PaidGate } from './PaidGate'; -import { AdmiralGate } from './AdmiralGate'; import { Loader2, CheckCircle, XCircle } from 'lucide-react'; import { SettingsPrimaryButton } from './settings/SettingsActions'; import { useMastheadStats } from './settings/MastheadStatsContext'; @@ -46,7 +45,7 @@ interface SSOProviderConfig { oidcEmailClaim?: string; } -// Ordered by tier: Custom OIDC (Community) → preset OIDC (Skipper) → LDAP/AD (Admiral). +// Ordered by tier: free OIDC (Custom + presets) first, then LDAP/AD (paid). // The ordering reinforces the free → paid progression in the UI. const PROVIDERS = [ { id: 'oidc_custom', label: 'Custom OIDC', type: 'oidc' as const }, @@ -397,7 +396,7 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: { } // Mirrors the backend tier split in ssoConfig.ts requireTierForProvider: Custom OIDC -// is free, preset OIDC (Google/GitHub/Okta) requires Skipper+, LDAP requires Admiral. +// and preset OIDC (Google/GitHub/Okta) are free, LDAP/AD requires the paid plan. function ProviderCardWithGate(props: { providerId: string; type: 'ldap' | 'oidc'; @@ -406,11 +405,10 @@ function ProviderCardWithGate(props: { onSave: () => void; }) { const card = ; - if (props.providerId === 'oidc_custom') return card; if (props.providerId === 'ldap') { - return {card}; + return {card}; } - return {card}; + return card; } export function SSOSection() { diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx index b3f2a376..6d3032a3 100644 --- a/frontend/src/components/StackAlertSheet.tsx +++ b/frontend/src/components/StackAlertSheet.tsx @@ -21,7 +21,6 @@ import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; -import { useLicense } from '@/context/LicenseContext'; interface StackAlert { id?: number; @@ -129,22 +128,16 @@ function actionLabel(action: AutoHealHistoryEntry['action']): string { } export function StackAlertSheet({ open, onOpenChange, stackName, initialTab = 'alerts' }: StackAlertSheetProps) { - const { isPaid } = useLicense(); - // Per Sencho convention: paid features hide their trigger entirely. Community users - // never see the Auto-heal tab, and a stray initialTab='auto-heal' falls back to alerts. - const effectiveInitialTab: MonitorTab = !isPaid && initialTab === 'auto-heal' ? 'alerts' : initialTab; - const [activeTab, setActiveTab] = useState(effectiveInitialTab); + const [activeTab, setActiveTab] = useState(initialTab); useEffect(() => { - if (open) setActiveTab(effectiveInitialTab); - }, [open, effectiveInitialTab, stackName]); + if (open) setActiveTab(initialTab); + }, [open, initialTab, stackName]); - const tabs = isPaid - ? [ - { id: 'alerts', label: 'Alerts' }, - { id: 'auto-heal', label: 'Auto-heal' }, - ] - : [{ id: 'alerts', label: 'Alerts' }]; + const tabs = [ + { id: 'alerts', label: 'Alerts' }, + { id: 'auto-heal', label: 'Auto-heal' }, + ]; return ( } - {activeTab === 'auto-heal' && isPaid && } + {activeTab === 'auto-heal' && } ); } diff --git a/frontend/src/components/TierBadge.tsx b/frontend/src/components/TierBadge.tsx index a6cd654d..2938bf7e 100644 --- a/frontend/src/components/TierBadge.tsx +++ b/frontend/src/components/TierBadge.tsx @@ -1,33 +1,21 @@ -import { Compass, Globe, ShipWheel } from 'lucide-react'; +import { Globe, ShipWheel } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; -import { useLicense, type LicenseTier, type LicenseVariant, type LicenseStatus } from '@/context/LicenseContext'; +import { useLicense, type LicenseTier } from '@/context/LicenseContext'; interface TierBadgeProps { tier?: LicenseTier; - variant?: LicenseVariant; - status?: LicenseStatus; className?: string; } const tierConfig = { community: { icon: Globe, label: 'Community' }, - skipper: { icon: Compass, label: 'Skipper' }, - admiral: { icon: ShipWheel, label: 'Admiral' }, + paid: { icon: ShipWheel, label: 'Admiral' }, } as const; -function resolveTier(tier: LicenseTier, variant: LicenseVariant, status: LicenseStatus) { - // Only show Admiral badge for active admiral licenses (trials default to skipper) - if (tier === 'paid' && variant === 'admiral' && status === 'active') return tierConfig.admiral; - if (tier === 'paid') return tierConfig.skipper; - return tierConfig.community; -} - -export function TierBadge({ tier, variant, status, className }: TierBadgeProps) { +export function TierBadge({ tier, className }: TierBadgeProps) { const { license } = useLicense(); const resolvedTier = tier ?? license?.tier ?? 'community'; - const resolvedVariant = variant !== undefined ? variant : license?.variant ?? null; - const resolvedStatus = status ?? license?.status ?? 'community'; - const { icon: Icon, label } = resolveTier(resolvedTier, resolvedVariant, resolvedStatus); + const { icon: Icon, label } = resolvedTier === 'paid' ? tierConfig.paid : tierConfig.community; return ( diff --git a/frontend/src/components/__tests__/SSOSection.test.tsx b/frontend/src/components/__tests__/SSOSection.test.tsx index ecd2440b..763090bf 100644 --- a/frontend/src/components/__tests__/SSOSection.test.tsx +++ b/frontend/src/components/__tests__/SSOSection.test.tsx @@ -33,9 +33,6 @@ vi.mock('../CapabilityGate', () => ({ vi.mock('../PaidGate', () => ({ PaidGate: ({ children }: { children: React.ReactNode }) => <>{children}, })); -vi.mock('../AdmiralGate', () => ({ - AdmiralGate: ({ children }: { children: React.ReactNode }) => <>{children}, -})); vi.mock('../settings/MastheadStatsContext', () => ({ useMastheadStats: () => undefined, })); diff --git a/frontend/src/components/dashboard/ConfigurationStatus.tsx b/frontend/src/components/dashboard/ConfigurationStatus.tsx index 65d7a5f9..3a6c77d6 100644 --- a/frontend/src/components/dashboard/ConfigurationStatus.tsx +++ b/frontend/src/components/dashboard/ConfigurationStatus.tsx @@ -1,7 +1,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Bell, Zap, Shield, HardDrive, ChevronRight } from 'lucide-react'; import { formatCount } from '@/lib/utils'; -import { useLicense } from '@/context/LicenseContext'; import { useConfigurationStatus } from './useConfigurationStatus'; import type { SectionId } from '@/components/settings/types'; @@ -83,7 +82,6 @@ function SkeletonRow() { export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps = {}) { const { status, loading } = useConfigurationStatus(); - const { isPaid } = useLicense(); const open = (section: SectionId) => () => onOpenSection?.(section); @@ -156,34 +154,30 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps /> )} - {isPaid && ( - <> - - - - {!automation.webhooks.locked && ( - - )} - {!automation.scheduledTasks.locked && ( - - )} - + + + + {!automation.webhooks.locked && ( + + )} + {!automation.scheduledTasks.locked && ( + )} diff --git a/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx b/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx index 4d71973b..5f7c251f 100644 --- a/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx +++ b/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx @@ -6,18 +6,12 @@ vi.mock('../useConfigurationStatus', () => ({ useConfigurationStatus: () => useConfigurationStatusMock(), })); -const useLicenseMock = vi.fn(); -vi.mock('@/context/LicenseContext', () => ({ - useLicense: () => useLicenseMock(), -})); - import { ConfigurationStatus } from '../ConfigurationStatus'; import type { ConfigurationStatus as ConfigurationStatusPayload } from '../useConfigurationStatus'; function makePayload(overrides: Partial = {}): ConfigurationStatusPayload { return { tier: 'community', - variant: null, notifications: { agents: { discord: { configured: false, enabled: false }, @@ -25,19 +19,19 @@ function makePayload(overrides: Partial = {}): Confi webhook: { configured: false, enabled: false }, }, alertRules: 0, - routingRules: { count: 0, enabledCount: 0, locked: true, requiredTier: 'skipper' }, + routingRules: { count: 0, enabledCount: 0, locked: true }, }, automation: { autoHeal: { total: 0, enabled: 0 }, autoUpdate: { enabled: 0, total: 0 }, - scheduledTasks: { total: 0, enabled: 0, locked: true, requiredTier: 'admiral' }, - webhooks: { total: 0, enabled: 0, locked: true, requiredTier: 'skipper' }, + scheduledTasks: { total: 0, enabled: 0, locked: true }, + webhooks: { total: 0, enabled: 0, locked: true }, }, security: { mfaEnabled: null, ssoEnabled: false, ssoProvider: null, - scanPolicies: { total: 0, enabled: 0, locked: true, requiredTier: 'skipper' }, + scanPolicies: { total: 0, enabled: 0, locked: true }, }, thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false }, backup: { provider: 'disabled', autoUpload: false, locked: false }, @@ -47,39 +41,34 @@ function makePayload(overrides: Partial = {}): Confi beforeEach(() => { useConfigurationStatusMock.mockReset(); - useLicenseMock.mockReset(); }); -describe('ConfigurationStatus tier parity', () => { +describe('ConfigurationStatus row visibility', () => { it('renders a skeleton while loading', () => { useConfigurationStatusMock.mockReturnValue({ status: null, loading: true }); - useLicenseMock.mockReturnValue({ isPaid: false }); render(); expect(screen.getByText('Configuration Status')).toBeDefined(); - // Skeleton renders 8 placeholder rows; assert the load-error message - // is NOT shown. + // Skeleton renders placeholder rows; assert the load-error message is NOT shown. expect(screen.queryByText(/Unable to load configuration/i)).toBeNull(); }); it('renders an error state when the payload is null and not loading', () => { useConfigurationStatusMock.mockReturnValue({ status: null, loading: false }); - useLicenseMock.mockReturnValue({ isPaid: false }); render(); expect(screen.getByText(/Unable to load configuration/i)).toBeDefined(); }); - it('hides the Automation section, routing rules, vulnerability scanning, and webhooks for Community', () => { + it('always shows the Automation section and its free rows, hiding only the per-row locked entries', () => { useConfigurationStatusMock.mockReturnValue({ status: makePayload(), loading: false }); - useLicenseMock.mockReturnValue({ isPaid: false }); render(); - // Notifications section header always renders. - expect(screen.getByText('Notifications')).toBeDefined(); - // Locked rows should be absent for Community. + // Automation moved to free: the section and its auto-heal / auto-update + // rows render for every tier. + expect(screen.getByText('Automation')).toBeDefined(); + expect(screen.getByText('Auto-heal policies')).toBeDefined(); + expect(screen.getByText('Auto-update schedules')).toBeDefined(); + // Rows whose payload reports locked stay hidden. expect(screen.queryByText('Notification routing')).toBeNull(); - expect(screen.queryByText('Automation')).toBeNull(); - expect(screen.queryByText('Auto-heal policies')).toBeNull(); - expect(screen.queryByText('Auto-update schedules')).toBeNull(); expect(screen.queryByText('Webhooks')).toBeNull(); expect(screen.queryByText('Scheduled tasks')).toBeNull(); expect(screen.queryByText('Vulnerability scanning')).toBeNull(); @@ -87,11 +76,10 @@ describe('ConfigurationStatus tier parity', () => { expect(screen.getByText('Cloud Backup')).toBeDefined(); }); - it('shows Automation rows and Webhooks for Skipper but keeps Scheduled tasks hidden', () => { + it('shows every row when the payload reports nothing locked', () => { useConfigurationStatusMock.mockReturnValue({ status: makePayload({ tier: 'paid', - variant: 'skipper', notifications: { agents: { discord: { configured: false, enabled: false }, @@ -99,69 +87,28 @@ describe('ConfigurationStatus tier parity', () => { webhook: { configured: false, enabled: false }, }, alertRules: 2, - routingRules: { count: 1, enabledCount: 1, locked: false, requiredTier: 'skipper' }, + routingRules: { count: 1, enabledCount: 1, locked: false }, }, automation: { autoHeal: { total: 3, enabled: 2 }, autoUpdate: { enabled: 4, total: 5 }, - scheduledTasks: { total: 0, enabled: 0, locked: true, requiredTier: 'admiral' }, - webhooks: { total: 1, enabled: 1, locked: false, requiredTier: 'skipper' }, - }, - security: { - mfaEnabled: true, - ssoEnabled: false, - ssoProvider: null, - scanPolicies: { total: 2, enabled: 2, locked: false, requiredTier: 'skipper' }, - }, - }), - loading: false, - }); - useLicenseMock.mockReturnValue({ isPaid: true }); - render(); - - expect(screen.getByText('Automation')).toBeDefined(); - expect(screen.getByText('Auto-heal policies')).toBeDefined(); - expect(screen.getByText('Auto-update schedules')).toBeDefined(); - expect(screen.getByText('Webhooks')).toBeDefined(); - expect(screen.getByText('Notification routing')).toBeDefined(); - expect(screen.getByText('Vulnerability scanning')).toBeDefined(); - // Scheduled tasks is Admiral-only; the response.locked flag controls - // visibility independently of the outer isPaid block. - expect(screen.queryByText('Scheduled tasks')).toBeNull(); - }); - - it('shows every gated row for Admiral', () => { - useConfigurationStatusMock.mockReturnValue({ - status: makePayload({ - tier: 'paid', - variant: 'admiral', - notifications: { - agents: { - discord: { configured: false, enabled: false }, - slack: { configured: false, enabled: false }, - webhook: { configured: false, enabled: false }, - }, - alertRules: 0, - routingRules: { count: 0, enabledCount: 0, locked: false, requiredTier: 'skipper' }, - }, - automation: { - autoHeal: { total: 0, enabled: 0 }, - autoUpdate: { enabled: 0, total: 0 }, - scheduledTasks: { total: 1, enabled: 1, locked: false, requiredTier: 'admiral' }, - webhooks: { total: 0, enabled: 0, locked: false, requiredTier: 'skipper' }, + scheduledTasks: { total: 1, enabled: 1, locked: false }, + webhooks: { total: 1, enabled: 1, locked: false }, }, security: { mfaEnabled: true, ssoEnabled: true, ssoProvider: 'oidc_google', - scanPolicies: { total: 0, enabled: 0, locked: false, requiredTier: 'skipper' }, + scanPolicies: { total: 2, enabled: 2, locked: false }, }, }), loading: false, }); - useLicenseMock.mockReturnValue({ isPaid: true }); render(); + expect(screen.getByText('Automation')).toBeDefined(); + expect(screen.getByText('Auto-heal policies')).toBeDefined(); + expect(screen.getByText('Auto-update schedules')).toBeDefined(); expect(screen.getByText('Notification routing')).toBeDefined(); expect(screen.getByText('Webhooks')).toBeDefined(); expect(screen.getByText('Scheduled tasks')).toBeDefined(); diff --git a/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx b/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx index 75e2b712..8ebce50e 100644 --- a/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx +++ b/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx @@ -38,19 +38,18 @@ beforeEach(() => { apiFetchMock.mockReset(); apiFetchMock.mockImplementation(() => Promise.resolve(okJson({ tier: 'community', - variant: null, - notifications: { agents: {}, alertRules: 0, routingRules: { count: 0, enabledCount: 0, locked: true, requiredTier: 'skipper' } }, + notifications: { agents: {}, alertRules: 0, routingRules: { count: 0, enabledCount: 0, locked: true } }, automation: { autoHeal: { total: 0, enabled: 0 }, autoUpdate: { enabled: 0, total: 0 }, - scheduledTasks: { total: 0, enabled: 0, locked: true, requiredTier: 'admiral' }, - webhooks: { total: 0, enabled: 0, locked: true, requiredTier: 'skipper' }, + scheduledTasks: { total: 0, enabled: 0, locked: true }, + webhooks: { total: 0, enabled: 0, locked: true }, }, security: { mfaEnabled: null, ssoEnabled: false, ssoProvider: null, - scanPolicies: { total: 0, enabled: 0, locked: true, requiredTier: 'skipper' }, + scanPolicies: { total: 0, enabled: 0, locked: true }, }, thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false }, backup: { provider: 'disabled', autoUpload: false, locked: false }, diff --git a/frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx b/frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx index abf6bd6b..0b6c052b 100644 --- a/frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx +++ b/frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx @@ -7,9 +7,9 @@ vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetchMock(...args), })); -const useAuthMock = vi.fn(); -vi.mock('@/context/AuthContext', () => ({ - useAuth: () => useAuthMock(), +const useLicenseMock = vi.fn(); +vi.mock('@/context/LicenseContext', () => ({ + useLicense: () => useLicenseMock(), })); vi.mock('@/lib/utils', async () => { @@ -38,7 +38,7 @@ function statusJson(status: number, payload: unknown = {}): Response { beforeEach(() => { apiFetchMock.mockReset(); - useAuthMock.mockReset(); + useLicenseMock.mockReset(); }); afterEach(() => { @@ -46,8 +46,8 @@ afterEach(() => { }); describe('useMeshDataPlane', () => { - it('does not fetch /mesh/status when the session is non-Admiral', async () => { - useAuthMock.mockReturnValue({ permissions: { isAdmiral: false } }); + it('does not fetch /mesh/status when the session is on the free tier', async () => { + useLicenseMock.mockReturnValue({ isPaid: false }); const { result } = renderHook(() => useMeshDataPlane()); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); @@ -56,8 +56,8 @@ describe('useMeshDataPlane', () => { expect(result.current.loading).toBe(false); }); - it('fetches once on mount and surfaces the localDataPlane payload for Admiral', async () => { - useAuthMock.mockReturnValue({ permissions: { isAdmiral: true } }); + it('fetches once on mount and surfaces the localDataPlane payload for a paid tier', async () => { + useLicenseMock.mockReturnValue({ isPaid: true }); apiFetchMock.mockResolvedValue(okJson({ localDataPlane: { ok: true, reason: null, lastChecked: 1000 }, })); @@ -71,7 +71,7 @@ describe('useMeshDataPlane', () => { }); it('keeps status null on a 403 response without raising an error', async () => { - useAuthMock.mockReturnValue({ permissions: { isAdmiral: true } }); + useLicenseMock.mockReturnValue({ isPaid: true }); apiFetchMock.mockResolvedValue(statusJson(403, { error: 'forbidden' })); const { result } = renderHook(() => useMeshDataPlane()); @@ -82,7 +82,7 @@ describe('useMeshDataPlane', () => { }); it('falls back to null when the response omits localDataPlane', async () => { - useAuthMock.mockReturnValue({ permissions: { isAdmiral: true } }); + useLicenseMock.mockReturnValue({ isPaid: true }); apiFetchMock.mockResolvedValue(okJson({ nodes: [] })); const { result } = renderHook(() => useMeshDataPlane()); diff --git a/frontend/src/components/dashboard/useConfigurationStatus.ts b/frontend/src/components/dashboard/useConfigurationStatus.ts index 9cc86382..783a33fb 100644 --- a/frontend/src/components/dashboard/useConfigurationStatus.ts +++ b/frontend/src/components/dashboard/useConfigurationStatus.ts @@ -14,23 +14,22 @@ interface AgentStatus { export interface ConfigurationStatus { tier: 'community' | 'paid'; - variant: 'skipper' | 'admiral' | null; 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; diff --git a/frontend/src/components/dashboard/useMeshDataPlane.ts b/frontend/src/components/dashboard/useMeshDataPlane.ts index 3ea2f5d8..21020351 100644 --- a/frontend/src/components/dashboard/useMeshDataPlane.ts +++ b/frontend/src/components/dashboard/useMeshDataPlane.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { apiFetch } from '@/lib/api'; -import { useAuth } from '@/context/AuthContext'; +import { useLicense } from '@/context/LicenseContext'; import { visibilityInterval } from '@/lib/utils'; import type { MeshDataPlaneStatus } from '@/types/mesh'; @@ -12,15 +12,13 @@ export interface MeshDataPlaneResult { /** * Poll `/mesh/status` for the local data-plane health so dashboard surfaces * can flag a down mesh without opening the Routing tab. The endpoint is - * Admiral-gated, so the hook short-circuits on non-Admiral tiers (no - * request fired, no banner rendered). On the rare 403 from an Admiral - * tier (token race during downgrade) we leave `status` at null. 30 s - * cadence matches `useFleetHeartbeat` so the dashboard refresh feel is - * consistent. + * paid-gated, so the hook short-circuits on the free tier (no request + * fired, no banner rendered). On the rare 403 from a paid tier (token + * race during downgrade) we leave `status` at null. 30 s cadence matches + * `useFleetHeartbeat` so the dashboard refresh feel is consistent. */ export function useMeshDataPlane(): MeshDataPlaneResult { - const { permissions } = useAuth(); - const isAdmiral = permissions?.isAdmiral ?? false; + const { isPaid } = useLicense(); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); @@ -43,14 +41,14 @@ export function useMeshDataPlane(): MeshDataPlaneResult { }, []); useEffect(() => { - if (!isAdmiral) { + if (!isPaid) { setStatus(null); setLoading(false); return; } void fetchStatus(); return visibilityInterval(() => { void fetchStatus(); }, 30_000); - }, [isAdmiral, fetchStatus]); + }, [isPaid, fetchStatus]); return { status, loading }; } diff --git a/frontend/src/components/fleet/FleetConfiguration.tsx b/frontend/src/components/fleet/FleetConfiguration.tsx index 912fa10b..7d7bcc81 100644 --- a/frontend/src/components/fleet/FleetConfiguration.tsx +++ b/frontend/src/components/fleet/FleetConfiguration.tsx @@ -7,7 +7,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { Bell, Zap, Shield, HardDrive, WifiOff, CheckCircle2, RefreshCw, } from 'lucide-react'; -import { useLicense } from '@/context/LicenseContext'; import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus'; import { STICKY_CONTROL_IDENTITY_MISMATCH, type FleetSyncStatus } from '@/lib/fleetSyncApi'; import type { ConfigurationStatusPayload } from '@/components/dashboard'; @@ -92,9 +91,8 @@ function PolicySyncRow({ state }: { state: PolicySyncState }) { ); } -function NodeCard({ node, isPaid, policySyncState }: { +function NodeCard({ node, policySyncState }: { node: FleetNodeConfiguration; - isPaid: boolean; policySyncState: PolicySyncState | null; }) { const isRemote = node.type === 'remote'; @@ -143,12 +141,10 @@ function NodeCard({ node, isPaid, policySyncState }: { value={agentCount === 0 ? 'None' : `${agentCount} active`} /> - {isPaid && ( - - )} + {!automation.webhooks.locked && ( @@ -175,7 +171,6 @@ function NodeCard({ node, isPaid, policySyncState }: { } export function FleetConfiguration() { - const { isPaid } = useLicense(); const { statuses: syncStatuses } = useFleetSyncStatus(); const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(true); @@ -256,7 +251,6 @@ export function FleetConfiguration() { ))} diff --git a/frontend/src/components/settings/CloudBackupSection.tsx b/frontend/src/components/settings/CloudBackupSection.tsx index 6112b2d6..a2be4d42 100644 --- a/frontend/src/components/settings/CloudBackupSection.tsx +++ b/frontend/src/components/settings/CloudBackupSection.tsx @@ -68,9 +68,8 @@ const PANEL_CLASS = 'rounded-lg border border-card-border border-t-card-border-t const PAGE_SIZE = 10; export function CloudBackupSection() { - const { license, isPaid } = useLicense(); - const isAdmiral = isPaid && license?.variant === 'admiral'; - const providerOptions = isAdmiral + const { isPaid } = useLicense(); + const providerOptions = isPaid ? [BASE_PROVIDER_OPTIONS[0], SENCHO_PROVIDER_OPTION, BASE_PROVIDER_OPTIONS[1]] : BASE_PROVIDER_OPTIONS; const [loading, setLoading] = useState(true); @@ -312,7 +311,7 @@ export function CloudBackupSection() {

- {isAdmiral && provider === 'sencho' && !senchoProvisioned && ( + {isPaid && provider === 'sencho' && !senchoProvisioned && (
@@ -328,7 +327,7 @@ export function CloudBackupSection() {
)} - {isAdmiral && provider === 'sencho' && senchoProvisioned && ( + {isPaid && provider === 'sencho' && senchoProvisioned && (
diff --git a/frontend/src/components/settings/DeveloperSection.tsx b/frontend/src/components/settings/DeveloperSection.tsx index 8d199746..9ba65a1b 100644 --- a/frontend/src/components/settings/DeveloperSection.tsx +++ b/frontend/src/components/settings/DeveloperSection.tsx @@ -3,6 +3,7 @@ import { Input } from '@/components/ui/input'; import { TogglePill } from '@/components/ui/toggle-pill'; import { Skeleton } from '@/components/ui/skeleton'; import { useAuth } from '@/context/AuthContext'; +import { useLicense } from '@/context/LicenseContext'; import { RefreshCw } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; @@ -41,10 +42,10 @@ const DEFAULT_DEVELOPER: DeveloperFields = { }; export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) { - const { isAdmin, permissions } = useAuth(); + const { isAdmin } = useAuth(); + const { isPaid } = useLicense(); const { activeNode } = useNodes(); const readOnly = !isAdmin; - const isAdmiral = permissions?.isAdmiral ?? false; const [settings, setSettings] = useState({ ...DEFAULT_DEVELOPER }); const serverSettingsRef = useRef({ ...DEFAULT_DEVELOPER }); const [isLoading, setIsLoading] = useState(false); @@ -202,7 +203,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
- {isAdmiral && ( + {isPaid && ( ({ isRemote, isAdmin, isPaid, isAdmiral }), - [isRemote, isAdmin, isPaid, isAdmiral], + () => ({ isRemote, isAdmin, isPaid }), + [isRemote, isAdmin, isPaid], ); // Resolve the rendered section: must be a registry id and must be visible to the @@ -195,7 +194,7 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp case 'system': return handleDirtyChange('system', d)} />; case 'notifications': return ; case 'notification-routing': return ; - case 'webhooks': return ; + case 'webhooks': return ; case 'security': return ; case 'cloud-backup': return ; case 'developer': return handleDirtyChange('developer', d)} />; diff --git a/frontend/src/components/settings/SettingsSidebar.tsx b/frontend/src/components/settings/SettingsSidebar.tsx index 4ffbd1ce..2bf94c96 100644 --- a/frontend/src/components/settings/SettingsSidebar.tsx +++ b/frontend/src/components/settings/SettingsSidebar.tsx @@ -16,17 +16,15 @@ interface SettingsSidebarProps { } export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, onOpenPalette }: SettingsSidebarProps) { - const { isAdmin, permissions } = useAuth(); + const { isAdmin } = useAuth(); const { isPaid } = useLicense(); const { activeNode } = useNodes(); - const isAdmiral = permissions?.isAdmiral ?? false; const isRemote = activeNode?.type === 'remote'; const visibility: VisibilityContext = { isAdmin, isPaid, - isAdmiral, isRemote, }; diff --git a/frontend/src/components/settings/SupportSection.tsx b/frontend/src/components/settings/SupportSection.tsx index 039b1a8e..f422e6c7 100644 --- a/frontend/src/components/settings/SupportSection.tsx +++ b/frontend/src/components/settings/SupportSection.tsx @@ -34,7 +34,7 @@ function ResourceLink({ icon, title, blurb, href, external = true }: ResourceLin } export function SupportSection() { - const { isPaid, license } = useLicense(); + const { isPaid } = useLicense(); return (
@@ -63,13 +63,9 @@ export function SupportSection() {
} - title={license?.variant === 'admiral' ? 'Priority email support' : 'Email support'} - blurb={ - license?.variant === 'admiral' - ? 'Direct support with responses within 24 hours' - : 'Reach our support team directly' - } - href={license?.variant === 'admiral' ? 'mailto:support@sencho.io' : 'mailto:licensing@sencho.io'} + title="Priority email support" + blurb="Direct support with responses within 24 hours" + href="mailto:support@sencho.io" external={false} />
@@ -80,7 +76,7 @@ export function SupportSection() { } title="Need faster support?" - subtitle="Skipper and Admiral tiers include direct email support and priority issue handling." + subtitle="Admiral includes direct email support and priority issue handling." action={ ([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); @@ -260,8 +259,7 @@ export function UsersSection() { }; return ( - - +
{!showForm && (
@@ -290,7 +288,7 @@ export function UsersSection() { options={[ { value: 'admin', label: 'Admin' }, { value: 'viewer', label: 'Viewer' }, - ...(isPaid && license?.variant === 'admiral' ? [ + ...(isPaid ? [ { value: 'deployer', label: 'Deployer' }, { value: 'node-admin', label: 'Node Admin' }, { value: 'auditor', label: 'Auditor' }, @@ -336,8 +334,8 @@ export function UsersSection() {
- {/* Scoped Permissions (Admiral, editing only) */} - {editingUser && isPaid && license?.variant === 'admiral' && ( + {/* Scoped Permissions (paid, editing only) */} + {editingUser && isPaid && (

Scoped Permissions

@@ -522,7 +520,6 @@ export function UsersSection() {

- - + ); } diff --git a/frontend/src/components/settings/WebhooksSection.tsx b/frontend/src/components/settings/WebhooksSection.tsx index 2617a23c..f6148812 100644 --- a/frontend/src/components/settings/WebhooksSection.tsx +++ b/frontend/src/components/settings/WebhooksSection.tsx @@ -42,7 +42,7 @@ interface WebhookExecution { executed_at: number; } -export function WebhooksSection({ isPaid }: { isPaid: boolean }) { +export function WebhooksSection() { const { isAdmin } = useAuth(); const { activeNode, nodes } = useNodes(); const [webhooks, setWebhooks] = useState([]); @@ -158,8 +158,6 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) { } }; - if (!isPaid) return null; - return (
{isAdmin && ( diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index d58bd912..85a9c4f1 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -16,7 +16,7 @@ export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [ { id: 'advanced', label: 'Advanced', glyph: '\u25C7' }, ]; -export type TierGate = 'skipper' | 'admiral' | null; +export type TierGate = 'paid' | null; export type Scope = 'global' | 'node'; export interface SettingsItemMeta { @@ -67,7 +67,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ label: 'Users', description: 'Operators, role assignments, and access scopes.', keywords: ['operators', 'team', 'rbac', 'roles', 'permissions'], - tier: 'skipper', + tier: null, scope: 'global', adminOnly: true, hiddenOnRemote: true, @@ -109,7 +109,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ label: 'Registries', description: 'Private Docker registries and pull credentials.', keywords: ['docker', 'ghcr', 'ecr', 'private', 'pull', 'auth'], - tier: 'admiral', + tier: 'paid', scope: 'global', adminOnly: true, hiddenOnRemote: true, @@ -150,7 +150,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ label: 'Routing', description: 'Rules that steer alerts to the right channel based on severity or label.', keywords: ['rules', 'routing', 'channels', 'severity', 'labels'], - tier: 'skipper', + tier: null, scope: 'global', adminOnly: true, hiddenOnRemote: true, @@ -161,7 +161,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ label: 'Webhooks', description: 'Incoming HMAC-signed HTTP triggers that run stack actions from CI/CD pipelines.', keywords: ['webhook', 'incoming', 'trigger', 'ci', 'cd', 'pipeline', 'deploy', 'hmac', 'signature', 'action'], - tier: 'skipper', + tier: null, scope: 'global', hiddenOnRemote: true, }, @@ -245,7 +245,6 @@ export interface VisibilityContext { isRemote: boolean; isAdmin: boolean; isPaid: boolean; - isAdmiral: boolean; } export function isItemVisible(item: SettingsItemMeta, ctx: VisibilityContext): boolean { @@ -255,7 +254,5 @@ export function isItemVisible(item: SettingsItemMeta, ctx: VisibilityContext): b } export function isItemLocked(item: SettingsItemMeta, ctx: VisibilityContext): boolean { - if (item.tier === 'skipper') return !ctx.isPaid; - if (item.tier === 'admiral') return !ctx.isAdmiral; - return false; + return item.tier === 'paid' ? !ctx.isPaid : false; } diff --git a/frontend/src/components/sidebar/SidebarBulkBar.tsx b/frontend/src/components/sidebar/SidebarBulkBar.tsx index 25097bb7..f080b2eb 100644 --- a/frontend/src/components/sidebar/SidebarBulkBar.tsx +++ b/frontend/src/components/sidebar/SidebarBulkBar.tsx @@ -4,12 +4,11 @@ import type { BulkAction } from '@/hooks/useBulkStackActions'; interface SidebarBulkBarProps { selectedCount: number; - isPaid: boolean; onAction: (action: BulkAction) => void; onClear: () => void; } -export function SidebarBulkBar({ selectedCount, isPaid, onAction, onClear }: SidebarBulkBarProps) { +export function SidebarBulkBar({ selectedCount, onAction, onClear }: SidebarBulkBarProps) { return (
@@ -27,9 +26,7 @@ export function SidebarBulkBar({ selectedCount, isPaid, onAction, onClear }: Sid - {isPaid && ( - - )} +
); diff --git a/frontend/src/components/sidebar/StackList.tsx b/frontend/src/components/sidebar/StackList.tsx index 2614e65f..9b481bc7 100644 --- a/frontend/src/components/sidebar/StackList.tsx +++ b/frontend/src/components/sidebar/StackList.tsx @@ -28,7 +28,6 @@ interface RemoteSearchFailure { export interface StackListProps { files: string[]; isLoading: boolean; - isPaid: boolean; selectedFile: string | null; searchQuery: string; stackLabelMap: Record; @@ -118,7 +117,7 @@ interface StackListBulkProps { export function StackList(props: StackListProps & StackListBulkProps) { const { - files, isLoading, isPaid, selectedFile, searchQuery, stackLabelMap, stackStatuses, + files, isLoading, selectedFile, searchQuery, stackLabelMap, stackStatuses, stackUpdates, gitSourcePendingMap, pinnedFiles, isCollapsed, toggleCollapse, isBusy, getDisplayName, onSelectFile, buildMenuCtx, bulkMode, selectedFiles, onToggleSelect, @@ -179,7 +178,6 @@ export function StackList(props: StackListProps & StackListBulkProps) { status={stackStatuses[file] ?? 'unknown'} isBusy={isBusy(file)} isActive={selectedFile === file} - isPaid={isPaid} labels={stackLabelMap[file] ?? []} hasUpdate={!!stackUpdates[file]} hasGitPending={!!gitSourcePendingMap[file]} diff --git a/frontend/src/components/sidebar/StackRow.tsx b/frontend/src/components/sidebar/StackRow.tsx index 90e8e930..136e56a3 100644 --- a/frontend/src/components/sidebar/StackRow.tsx +++ b/frontend/src/components/sidebar/StackRow.tsx @@ -15,7 +15,6 @@ interface StackRowProps { status: StackRowStatus; isBusy: boolean; isActive: boolean; - isPaid: boolean; labels: Label[]; hasUpdate: boolean; hasGitPending: boolean; diff --git a/frontend/src/components/sidebar/StackSidebar.tsx b/frontend/src/components/sidebar/StackSidebar.tsx index 4af8857f..ea73a320 100644 --- a/frontend/src/components/sidebar/StackSidebar.tsx +++ b/frontend/src/components/sidebar/StackSidebar.tsx @@ -29,7 +29,6 @@ export interface StackSidebarProps { onActivityAction: (action: SidebarActivityAction) => void; bulkMode: boolean; selectedFiles: Set; - isPaid: boolean; onToggleBulkMode: () => void; onToggleSelect: (file: string) => void; onClearSelection: () => void; @@ -41,7 +40,7 @@ export function StackSidebar(props: StackSidebarProps) { isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate, searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange, list, activitySummary, onActivityAction, - bulkMode, selectedFiles, isPaid, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction, + bulkMode, selectedFiles, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction, } = props; const [filtersVisible, setFiltersVisible] = useState(() => { @@ -84,7 +83,6 @@ export function StackSidebar(props: StackSidebarProps) { {selectedFiles.size > 0 && ( diff --git a/frontend/src/components/sidebar/__tests__/StackRow.test.tsx b/frontend/src/components/sidebar/__tests__/StackRow.test.tsx index 976b1efc..6c239334 100644 --- a/frontend/src/components/sidebar/__tests__/StackRow.test.tsx +++ b/frontend/src/components/sidebar/__tests__/StackRow.test.tsx @@ -11,7 +11,6 @@ function base(overrides: Partial> = {}) { status: 'running' as const, isBusy: false, isActive: false, - isPaid: true, labels: [] as Label[], hasUpdate: false, hasGitPending: false, @@ -74,12 +73,12 @@ describe('StackRow', () => { expect(screen.queryByText('UP')).not.toBeInTheDocument(); }); - it('renders label indicators on community tier', () => { + it('renders label indicators', () => { const labels: Label[] = [ { id: 1, node_id: 0, name: 'prod', color: 'teal' }, { id: 2, node_id: 0, name: 'media', color: 'blue' }, ]; - const { container } = render(); + const { container } = render(); expect(container.querySelectorAll('[style*="--label-"]')).toHaveLength(2); }); }); diff --git a/frontend/src/components/sidebar/sidebar-types.ts b/frontend/src/components/sidebar/sidebar-types.ts index d9d2c3db..8420d2c3 100644 --- a/frontend/src/components/sidebar/sidebar-types.ts +++ b/frontend/src/components/sidebar/sidebar-types.ts @@ -25,8 +25,6 @@ export interface StackMenuCtx { stackStatus: StackLifecycleStatus; hasPort: boolean; isBusy: boolean; - isPaid: boolean; - isAdmiral: boolean; isAdmin: boolean; canDelete: boolean; canEditLabels: boolean; diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 0185c21f..c3cbb345 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -19,7 +19,6 @@ interface PermissionsData { globalRole: UserRole; globalPermissions: PermissionAction[]; scopedPermissions: Record; - isAdmiral: boolean; } interface AuthContextType { diff --git a/frontend/src/context/LicenseContext.tsx b/frontend/src/context/LicenseContext.tsx index d436303e..4cd134a5 100644 --- a/frontend/src/context/LicenseContext.tsx +++ b/frontend/src/context/LicenseContext.tsx @@ -4,12 +4,9 @@ import { apiFetch } from '@/lib/api'; export type LicenseTier = 'community' | 'paid'; export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled'; -export type LicenseVariant = 'skipper' | 'admiral' | null; - export interface LicenseInfo { tier: LicenseTier; status: LicenseStatus; - variant: LicenseVariant; customerName: string | null; productName: string | null; maskedKey: string | null; diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx index d89c0c85..7f23687b 100644 --- a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx +++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx @@ -9,8 +9,6 @@ function makeCtx(overrides: Partial = {}): StackMenuCtx { stackStatus: 'running', hasPort: true, isBusy: false, - isPaid: true, - isAdmiral: false, isAdmin: true, canDelete: true, canEditLabels: true, @@ -50,10 +48,10 @@ describe('useStackMenuItems', () => { expect(inspect.items.some(i => i.icon === BellRing)).toBe(true); }); - it('hides Auto-Heal when !isPaid', () => { - const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: false }))); + it('always includes Auto-Heal in Inspect', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx())); const inspect = result.current.find(g => g.id === 'inspect')!; - expect(inspect.items.find(i => i.id === 'auto-heal')).toBeUndefined(); + expect(inspect.items.find(i => i.id === 'auto-heal')).toBeDefined(); }); it('hides Open App unless running + hasPort', () => { @@ -85,25 +83,24 @@ describe('useStackMenuItems', () => { }); it('does not show an auto-update entry; Schedule task is the auto-update path', () => { - const paid = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: true }))); - const community = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: false }))); - for (const r of [paid, community]) { + const admin = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isAdmin: true }))); + const viewer = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isAdmin: false }))); + for (const r of [admin, viewer]) { const groups = r.result.current; expect(groups.some(g => g.items.some(i => i.id === 'auto-update'))).toBe(false); } - const lifecycle = paid.result.current.find(g => g.id === 'lifecycle')!; + const lifecycle = admin.result.current.find(g => g.id === 'lifecycle')!; expect(lifecycle.items.some(i => i.id === 'schedule')).toBe(true); }); - it('hides Schedule task when paid but not admin', () => { - const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: true, isAdmin: false }))); + it('hides Schedule task when not admin', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isAdmin: false }))); const lifecycle = result.current.find(g => g.id === 'lifecycle'); expect(lifecycle?.items.some(i => i.id === 'schedule')).toBeFalsy(); }); - it('keeps label assignment available when !isPaid', () => { + it('keeps label assignment available for any tier', () => { const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ - isPaid: false, labels: [{ id: 1, node_id: 0, name: 'prod', color: 'teal' }], }))); const organize = result.current.find(g => g.id === 'organize')!; diff --git a/frontend/src/hooks/useBulkStackActions.ts b/frontend/src/hooks/useBulkStackActions.ts index 42d11f8e..324b91f8 100644 --- a/frontend/src/hooks/useBulkStackActions.ts +++ b/frontend/src/hooks/useBulkStackActions.ts @@ -1,7 +1,6 @@ import { useCallback } from 'react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; -import { useLicense } from '@/context/LicenseContext'; export type BulkAction = 'start' | 'stop' | 'restart' | 'update'; @@ -30,18 +29,12 @@ interface BulkResponse { } export function useBulkStackActions() { - const { isPaid } = useLicense(); - const runBulk = useCallback(async ( action: BulkAction, files: string[], cbs?: BulkCallbacks, ) => { if (files.length === 0) return; - if (action === 'update' && !isPaid) { - toast.error('Bulk update requires a Skipper license.'); - return; - } cbs?.onBefore?.(files); @@ -83,7 +76,7 @@ export function useBulkStackActions() { console.error('Bulk action failed:', err); toast.error(`Bulk ${action} failed: ${(err as Error).message}`); } - }, [isPaid]); + }, []); - return { runBulk, isPaid }; + return { runBulk }; } diff --git a/frontend/src/hooks/useStackKeyboardShortcuts.ts b/frontend/src/hooks/useStackKeyboardShortcuts.ts index 21c95109..c93ba2dc 100644 --- a/frontend/src/hooks/useStackKeyboardShortcuts.ts +++ b/frontend/src/hooks/useStackKeyboardShortcuts.ts @@ -52,7 +52,7 @@ export function useStackKeyboardShortcuts( if (key === 'a') { e.preventDefault(); ctx.openAlertSheet(); - } else if (key === 'h' && ctx.isPaid) { + } else if (key === 'h') { e.preventDefault(); ctx.openAutoHeal(); } else if (key === 'u') { @@ -67,6 +67,5 @@ export function useStackKeyboardShortcuts( window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); } diff --git a/frontend/src/hooks/useStackMenuItems.tsx b/frontend/src/hooks/useStackMenuItems.tsx index 96446f89..6dc7b39f 100644 --- a/frontend/src/hooks/useStackMenuItems.tsx +++ b/frontend/src/hooks/useStackMenuItems.tsx @@ -18,7 +18,7 @@ import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sid export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] { const { - stackStatus, hasPort, isBusy, isPaid, isAdmin, canDelete, canEditLabels, isPinned, labels, + stackStatus, hasPort, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels, openAlertSheet, openAutoHeal, checkUpdates, openStackApp, deploy, stop, restart, update, remove, pin, unpin, toggleLabel, menuVisibility, openScheduleTask, @@ -30,10 +30,8 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] const inspect: MenuItem[] = [ { id: 'alerts', label: 'Alerts', icon: BellRing, shortcut: 'A', onSelect: openAlertSheet }, + { id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal }, ]; - if (isPaid) { - inspect.push({ id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal }); - } inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates }); if (stackStatus === 'running' && hasPort) { inspect.push({ id: 'open-app', label: 'Open App', icon: ArrowUpRight, shortcut: '↗', onSelect: openStackApp }); @@ -68,7 +66,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] if (showStop) lifecycle.push({ id: 'stop', label: 'Stop', icon: Square, shortcut: '⌘.', onSelect: stop, disabled: isBusy }); if (showRestart) lifecycle.push({ id: 'restart', label: 'Restart', icon: RotateCw, shortcut: '⌘R', onSelect: restart, disabled: isBusy }); if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘↑', onSelect: update, disabled: isBusy }); - if (isPaid && isAdmin) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask }); + if (isAdmin) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask }); if (lifecycle.length > 0) groups.push({ id: 'lifecycle', items: lifecycle }); if (canDelete) { @@ -80,7 +78,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] return groups; }, [ - stackStatus, hasPort, isBusy, isPaid, isAdmin, canDelete, canEditLabels, isPinned, labels, + stackStatus, hasPort, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels, showDeploy, showStop, showRestart, showUpdate, openAlertSheet, openAutoHeal, checkUpdates, openStackApp, deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,