diff --git a/backend/src/__tests__/mesh-route-gating.test.ts b/backend/src/__tests__/mesh-route-gating.test.ts
new file mode 100644
index 00000000..3a363a16
--- /dev/null
+++ b/backend/src/__tests__/mesh-route-gating.test.ts
@@ -0,0 +1,155 @@
+/**
+ * Gate coverage for the mesh router.
+ *
+ * Every /api/mesh route is tier-gated (requireAdmiral). 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
+ * 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
+ * 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).
+ */
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import request from 'supertest';
+import jwt from 'jsonwebtoken';
+import bcrypt from 'bcrypt';
+import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb';
+
+let tmpDir: string;
+let app: import('express').Express;
+let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
+let LicenseService: typeof import('../services/LicenseService').LicenseService;
+let defaultNodeId: number;
+
+function userToken(username: string): string {
+ const user = DatabaseService.getInstance().getUserByUsername(username);
+ if (!user) throw new Error(`missing test user ${username}`);
+ 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 {
+ vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
+ vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(variant);
+}
+
+beforeAll(async () => {
+ tmpDir = await setupTestDb();
+ ({ DatabaseService } = await import('../services/DatabaseService'));
+ ({ LicenseService } = await import('../services/LicenseService'));
+
+ const viewerHash = await bcrypt.hash('password123', 1);
+ DatabaseService.getInstance().addUser({ username: 'mesh-viewer', password_hash: viewerHash, role: 'viewer' });
+ defaultNodeId = DatabaseService.getInstance().getDefaultNode()?.id ?? 1;
+
+ ({ app } = await import('../index'));
+});
+
+beforeEach(() => {
+ // Default every test to a fully entitled Admiral instance; tier-rejection
+ // tests override this locally.
+ setTier('paid', 'admiral');
+});
+
+afterAll(() => {
+ vi.restoreAllMocks();
+ cleanupTestDb(tmpDir);
+});
+
+describe('mesh tier gate (requireAdmiral)', () => {
+ it('rejects Community tier with PAID_REQUIRED', async () => {
+ setTier('community', null);
+ 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('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);
+ const res = await request(app)
+ .post('/api/mesh/regen-overrides')
+ .set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
+ expect(res.status).toBe(403);
+ expect(res.body.code).toBe('PAID_REQUIRED');
+ });
+});
+
+describe('mesh read routes are visible to a non-admin Admiral user', () => {
+ it('returns aliases to a viewer', async () => {
+ const res = await request(app)
+ .get('/api/mesh/aliases')
+ .set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
+ expect(res.status).toBe(200);
+ expect(Array.isArray(res.body.aliases)).toBe(true);
+ });
+
+ it('returns activity to a viewer', async () => {
+ const res = await request(app)
+ .get('/api/mesh/activity')
+ .set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
+ expect(res.status).toBe(200);
+ expect(Array.isArray(res.body.events)).toBe(true);
+ });
+
+ it('returns status to a viewer', async () => {
+ const res = await request(app)
+ .get('/api/mesh/status')
+ .set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
+ expect(res.status).toBe(200);
+ expect(Array.isArray(res.body.nodes)).toBe(true);
+ });
+});
+
+describe('mesh mutation routes require the admin role (requireAdmin)', () => {
+ const mutationRoutes: { name: string; path: () => string }[] = [
+ { name: 'POST /regen-overrides', path: () => '/api/mesh/regen-overrides' },
+ { name: 'POST /nodes/:id/enable', path: () => `/api/mesh/nodes/${defaultNodeId}/enable` },
+ { name: 'POST /nodes/:id/disable', path: () => `/api/mesh/nodes/${defaultNodeId}/disable` },
+ { name: 'POST /nodes/:id/stacks/:stack/opt-in', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-in` },
+ { name: 'POST /nodes/:id/stacks/:stack/opt-out', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-out` },
+ ];
+
+ for (const route of mutationRoutes) {
+ it(`${route.name} rejects a non-admin Admiral user with ADMIN_REQUIRED`, async () => {
+ const res = await request(app)
+ .post(route.path())
+ .set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
+ expect(res.status).toBe(403);
+ expect(res.body.code).toBe('ADMIN_REQUIRED');
+ });
+ }
+
+ it('lets an Admiral admin pass both gates on regen-overrides', async () => {
+ const res = await request(app)
+ .post('/api/mesh/regen-overrides')
+ .set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveProperty('regenerated');
+ });
+
+ it('lets an Admiral 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;
+ // only the gate codes are asserted absent.
+ const res = await request(app)
+ .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/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx
index 8c8d42b4..21e92b87 100644
--- a/frontend/src/components/FleetView.tsx
+++ b/frontend/src/components/FleetView.tsx
@@ -210,7 +210,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{isAdmiral && (
-
+
)}
diff --git a/frontend/src/components/fleet/MeshOptInSheet.test.tsx b/frontend/src/components/fleet/MeshOptInSheet.test.tsx
new file mode 100644
index 00000000..2b856171
--- /dev/null
+++ b/frontend/src/components/fleet/MeshOptInSheet.test.tsx
@@ -0,0 +1,73 @@
+/**
+ * Render-gate coverage for MeshOptInSheet's opt-in/out controls.
+ *
+ * Opting a stack in or out is admin-only on the backend
+ * (POST /api/mesh/nodes/:id/stacks/:stack/opt-in|opt-out require admin). This
+ * test locks the matching UI gate: a manager sees Add/Remove buttons, a
+ * non-manager sees the membership read-only with a hint and still gets the
+ * read-only topology affordance. The read-only branch must never issue the
+ * admin-only mutation.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import type { MeshStackEntry } from '@/types/mesh';
+
+vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
+vi.mock('@/components/ui/toast-store', () => ({
+ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
+}));
+
+import { apiFetch } from '@/lib/api';
+import { MeshOptInSheet } from './MeshOptInSheet';
+
+const STACKS: MeshStackEntry[] = [
+ { name: 'web', optedIn: true },
+ { name: 'db', optedIn: false },
+];
+
+beforeEach(() => {
+ vi.mocked(apiFetch).mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ stacks: STACKS }),
+ } as unknown as Response);
+});
+
+function renderSheet(canManage: boolean) {
+ return render(
+ {}}
+ nodeId={1}
+ nodeName="node-alpha"
+ onChanged={() => {}}
+ onViewTopology={() => {}}
+ canManage={canManage}
+ />,
+ );
+}
+
+describe('MeshOptInSheet canManage gate', () => {
+ it('shows opt-in/out controls for a manager', async () => {
+ renderSheet(true);
+ expect(await screen.findByText('web')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Remove from mesh/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Add to mesh/i })).toBeInTheDocument();
+ expect(screen.queryByText(/Changing mesh membership requires an administrator/i)).not.toBeInTheDocument();
+ });
+
+ it('renders the membership read-only for a non-manager', async () => {
+ renderSheet(false);
+ expect(await screen.findByText('web')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Remove from mesh/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Add to mesh/i })).not.toBeInTheDocument();
+ expect(screen.getByText(/Changing mesh membership requires an administrator/i)).toBeInTheDocument();
+ // Topology is read-only and stays available for opted-in stacks.
+ expect(screen.getByRole('button', { name: /View topology for web/i })).toBeInTheDocument();
+ // The read-only branch must never issue an opt-in/opt-out request.
+ expect(vi.mocked(apiFetch)).not.toHaveBeenCalledWith(
+ expect.stringContaining('/opt-'),
+ expect.anything(),
+ );
+ });
+});
diff --git a/frontend/src/components/fleet/MeshOptInSheet.tsx b/frontend/src/components/fleet/MeshOptInSheet.tsx
index babf01f6..ba3a6d8c 100644
--- a/frontend/src/components/fleet/MeshOptInSheet.tsx
+++ b/frontend/src/components/fleet/MeshOptInSheet.tsx
@@ -14,9 +14,11 @@ interface Props {
nodeName: string;
onChanged: () => void;
onViewTopology?: (stack: string) => void;
+ /** Opt-in/out is admin-only on the backend; non-admins see the list read-only. */
+ canManage: boolean;
}
-export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged, onViewTopology }: Props) {
+export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged, onViewTopology, canManage }: Props) {
const [stacks, setStacks] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -93,6 +95,7 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
Adding a stack lets its services be reached from other meshed stacks by hostname.
Toggling a stack triggers a redeploy on its node so the routing override applies.
+ {!canManage && ' Changing mesh membership requires an administrator.'}
{loading && (
@@ -132,13 +135,15 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
Topology
)}
-
+ {canManage && (
+
+ )}
)}
diff --git a/frontend/src/components/fleet/RoutingNodeCard.tsx b/frontend/src/components/fleet/RoutingNodeCard.tsx
index 59785636..9e4bdda6 100644
--- a/frontend/src/components/fleet/RoutingNodeCard.tsx
+++ b/frontend/src/components/fleet/RoutingNodeCard.tsx
@@ -18,6 +18,7 @@ interface Props {
onShowAlias: (alias: string) => void;
onTestUpstream: (alias: string) => Promise;
onChanged: () => void;
+ canManage: boolean;
}
const REVERSE_BRIDGE: Record = {
@@ -62,7 +63,7 @@ function buildFooterContext(
}
export function RoutingNodeCard({
- status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged,
+ status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, canManage,
}: Props) {
const [toggling, setToggling] = useState(false);
const [testingAlias, setTestingAlias] = useState(null);
@@ -175,6 +176,7 @@ export function RoutingNodeCard({
onRetry={onChanged}
footerContext={footerContext}
offlineReason={status.reachableReason}
+ canManage={canManage}
/>
);
}
diff --git a/frontend/src/components/fleet/RoutingTab.tsx b/frontend/src/components/fleet/RoutingTab.tsx
index 117890fc..f04a5660 100644
--- a/frontend/src/components/fleet/RoutingTab.tsx
+++ b/frontend/src/components/fleet/RoutingTab.tsx
@@ -45,7 +45,7 @@ function readStoredEdgeMode(): MeshGraphEdgeMode {
}
}
-export function RoutingTab() {
+export function RoutingTab({ canManage }: { canManage: boolean }) {
const [status, setStatus] = useState([]);
const [localDataPlane, setLocalDataPlane] = useState(null);
const [aliases, setAliases] = useState([]);
@@ -178,11 +178,13 @@ export function RoutingTab() {
onShowAlias={(alias) => setRouteDetailAlias(alias)}
onTestUpstream={testUpstream}
onChanged={() => { void refresh(); }}
+ canManage={canManage}
/>
))}
setRouteDetailAlias(alias)}
onTestUpstream={testUpstream}
onChanged={() => { void refresh(); }}
+ canManage={canManage}
/>
))}
@@ -247,6 +250,7 @@ export function RoutingTab() {
/>
)}
void;
diagnosticsNode: { id: number; name: string } | null;
@@ -311,6 +316,7 @@ function SheetsRoot(props: {
onOpenChange={(open) => { if (!open) props.setOptInNode(null); }}
nodeId={optInNode.id}
nodeName={optInNode.name}
+ canManage={props.canManage}
onChanged={props.onChanged}
onViewTopology={(stack) => {
props.setTopologyStack({ nodeId: optInNode.id, nodeName: optInNode.name, stack });
diff --git a/frontend/src/components/ui/routing-node-card.test.tsx b/frontend/src/components/ui/routing-node-card.test.tsx
new file mode 100644
index 00000000..86dfbfff
--- /dev/null
+++ b/frontend/src/components/ui/routing-node-card.test.tsx
@@ -0,0 +1,105 @@
+/**
+ * Render-gate coverage for the routing-node-card `canManage` prop.
+ *
+ * Enabling/disabling mesh on a node and opting a stack in are admin-only on the
+ * backend. This locks the matching UI gate: a manager sees the enable/disable
+ * toggle and the enable/add CTAs, a non-manager sees neither (just a hint) while
+ * the read-only affordances, diagnostics in particular, stay available. Without
+ * this the card can drift back to rendering a control the API answers with 403.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { RoutingNodeCard, type RoutingNodeCardProps } from '@/components/ui/routing-node-card';
+
+function renderCard(overrides: Partial = {}) {
+ const props: RoutingNodeCardProps = {
+ crumb: ['Routing', 'Node', 'node-alpha'],
+ name: 'node-alpha',
+ nodeState: 'idle',
+ meta: { pilotConnected: true, reverseBridge: 'na', stacks: 0, aliases: 0 },
+ aliases: [],
+ onToggleEnabled: vi.fn(),
+ onShowDiagnostics: vi.fn(),
+ onAddStack: vi.fn(),
+ onRetry: vi.fn(),
+ footerContext: 'Mesh off',
+ // canManage is required on the primitive; tests override it per case.
+ canManage: true,
+ ...overrides,
+ };
+ return render();
+}
+
+describe('routing-node-card canManage gate', () => {
+ it('shows the enable toggle and the enable CTA for a manager', () => {
+ renderCard({ nodeState: 'idle', canManage: true });
+ expect(screen.getByRole('switch')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Enable mesh on node-alpha/i })).toBeInTheDocument();
+ expect(screen.queryByText(/Managing the mesh requires an administrator/i)).not.toBeInTheDocument();
+ });
+
+ it('hides the toggle and enable CTA for a non-manager but keeps diagnostics', () => {
+ renderCard({ nodeState: 'idle', canManage: false });
+ expect(screen.queryByRole('switch')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Enable mesh on/i })).not.toBeInTheDocument();
+ expect(screen.getByText(/Managing the mesh requires an administrator/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Diagnostics/i })).toBeInTheDocument();
+ });
+
+ it('hides the add-stack CTA for a non-manager on a meshed node', () => {
+ renderCard({
+ nodeState: 'meshed',
+ meta: { pilotConnected: true, reverseBridge: 'up', stacks: 0, aliases: 0 },
+ canManage: false,
+ });
+ expect(screen.queryByRole('switch')).not.toBeInTheDocument();
+ expect(screen.queryByText(/Add stack to mesh/i)).not.toBeInTheDocument();
+ expect(screen.getByText(/Managing the mesh requires an administrator/i)).toBeInTheDocument();
+ });
+
+ it('keeps the retry CTA for a non-manager on a degraded node', () => {
+ // Retry is a read-only refresh, not a management action, so it must
+ // survive the gate. A regression dropping the management-state check
+ // would hide it for non-admins.
+ renderCard({ nodeState: 'degraded', canManage: false });
+ expect(screen.getByRole('button', { name: /Retry now/i })).toBeInTheDocument();
+ expect(screen.queryByText(/Managing the mesh requires an administrator/i)).not.toBeInTheDocument();
+ });
+
+ it('keeps the retry CTA for a non-manager on an offline node', () => {
+ renderCard({ nodeState: 'offline', canManage: false });
+ expect(screen.getByRole('button', { name: /Retry now/i })).toBeInTheDocument();
+ });
+});
+
+describe('routing-node-card canManage gate (compact density)', () => {
+ beforeEach(() => {
+ window.localStorage.setItem('sencho.appearance.density', 'compact');
+ });
+ afterEach(() => {
+ window.localStorage.removeItem('sencho.appearance.density');
+ });
+
+ it('hides the toggle for a non-manager on a meshed node', () => {
+ renderCard({
+ nodeState: 'meshed',
+ meta: { pilotConnected: true, reverseBridge: 'up', stacks: 0, aliases: 0 },
+ canManage: false,
+ });
+ expect(screen.queryByRole('switch')).not.toBeInTheDocument();
+ });
+
+ it('shows the toggle for a manager on a meshed node', () => {
+ renderCard({
+ nodeState: 'meshed',
+ meta: { pilotConnected: true, reverseBridge: 'up', stacks: 0, aliases: 0 },
+ canManage: true,
+ });
+ expect(screen.getByRole('switch')).toBeInTheDocument();
+ });
+
+ it('keeps the retry CTA for a non-manager on an offline node', () => {
+ renderCard({ nodeState: 'offline', canManage: false });
+ expect(screen.getByRole('button', { name: /Retry now/i })).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/ui/routing-node-card.tsx b/frontend/src/components/ui/routing-node-card.tsx
index 2a18dac5..3b799fe0 100644
--- a/frontend/src/components/ui/routing-node-card.tsx
+++ b/frontend/src/components/ui/routing-node-card.tsx
@@ -40,6 +40,15 @@ export interface RoutingNodeCardProps {
footerContext: string;
/** Offline-state reason copy; falls back to a generic line. */
offlineReason?: string | null;
+ /**
+ * When false, the management affordances (enable/disable toggle and the
+ * enable-mesh / add-stack empty-state CTAs) are hidden so a viewer without
+ * the admin role gets a read-only card. Read-only affordances (diagnostics,
+ * alias probe, retry) stay available. Required, with no default: a caller
+ * must state the gate explicitly so a new call site cannot fall open to a
+ * management view it did not intend.
+ */
+ canManage: boolean;
}
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
@@ -76,6 +85,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
crumb, name, isLocal, nodeState, meta, aliases,
onToggleEnabled, onShowDiagnostics, onShowAlias, onTestAlias,
onAddStack, onRetry, footerContext, offlineReason,
+ canManage,
} = props;
const [density] = useDensity();
const compact = density === 'compact';
@@ -107,6 +117,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
diagnosticsDisabled={diagnosticsDisabled}
onToggleEnabled={onToggleEnabled}
onShowDiagnostics={onShowDiagnostics}
+ canManage={canManage}
footerContext={footerContext}
aliasesEmpty={aliases.length === 0}
onAddStack={onAddStack}
@@ -124,6 +135,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
diagnosticsDisabled={diagnosticsDisabled}
onToggleEnabled={onToggleEnabled}
onShowDiagnostics={onShowDiagnostics}
+ canManage={canManage}
aliases={aliases}
onShowAlias={onShowAlias}
onTestAlias={onTestAlias}
@@ -147,6 +159,7 @@ interface BodyChrome {
diagnosticsDisabled: boolean;
onToggleEnabled: (next: boolean) => void;
onShowDiagnostics: () => void;
+ canManage: boolean;
footerContext: string;
onAddStack?: () => void;
onRetry?: () => void;
@@ -169,6 +182,7 @@ function ComfortableBody(props: ComfortableProps) {
crumb, name, isLocal, chip, meta, nodeState, isEnabled,
toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics,
aliases, onShowAlias, onTestAlias, onAddStack, onRetry, footerContext, offlineReason,
+ canManage,
} = props;
const published = aliases.filter((a) => a.kind === 'alias').length;
const showAliases = aliases.length > 0;
@@ -207,6 +221,7 @@ function ComfortableBody(props: ComfortableProps) {
diagnosticsDisabled={diagnosticsDisabled}
onToggleEnabled={onToggleEnabled}
onShowDiagnostics={onShowDiagnostics}
+ canManage={canManage}
/>