feat(pro): generalize pro tier to multi-surface extension points

Move the pro mount decnet/services/pro/ -> decnet/pro/ so the Professional tier
can contribute to more than honeypots. The core wires each surface only when
decnet/pro/ is present (absence stays the entitlement gate):

* services  — registry scans decnet/pro/services/ (was decnet/services/pro/)
* API routes — decnet/pro/routes.py exposes ROUTERS, mounted under /api/v1
* web pages  — Vite aliases @pro to the pro frontend (community -> empty stub),
               App.tsx maps proRoutes into <Route>s, Layout renders a
               PROFESSIONAL nav group; both tree-shake out of the community build

Frontend gate mirrors the existing VITE_DECNET_DEVELOPER tree-shake pattern.
Tests: registry + router seams (backend), empty-stub contract (frontend).
This commit is contained in:
2026-06-17 15:02:28 -04:00
parent 80c92a6f80
commit a47f99c449
13 changed files with 151 additions and 57 deletions

View File

@@ -10,6 +10,7 @@ import { ToastProvider } from './components/Toasts/ToastProvider';
import { useToast } from './components/Toasts/useToast';
import { useGlobalHotkeys } from './hooks/useGlobalHotkeys';
import { isDeveloperMode } from './lib/devGate';
import { proRoutes } from '@pro';
// Page components are code-split per route. Each lands as its own
// chunk and only downloads when the user navigates to that path —
@@ -148,6 +149,10 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
{isDeveloperMode() && (
<Route path="/theme-lab" element={<ThemeLab />} />
)}
{/* Professional-tier pages. Empty in the community build (@pro -> stub). */}
{proRoutes.map((r) => (
<Route key={r.path} path={r.path} element={r.element} />
))}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>

View File

@@ -9,6 +9,7 @@ import {
} from '../icons';
import { prefetchRoute } from '../routePrefetch';
import { useThemeToggle } from '../lib/useThemeToggle';
import { proRoutes } from '@pro';
import './Layout.css';
type ThreatLevel = 'nominal' | 'elevated' | 'critical';
@@ -160,6 +161,13 @@ const Layout: React.FC<LayoutProps> = ({
<NavItem to="/swarm/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent />
<NavItem to="/swarm-updates" icon={<Package size={18} />} label="Remote Updates" open={sidebarOpen} indent />
</NavGroup>
{proRoutes.length > 0 && (
<NavGroup label="PROFESSIONAL" icon={<Zap size={20} />} open={sidebarOpen}>
{proRoutes.map((r) => (
<NavItem key={r.path} to={r.path} icon={r.icon} label={r.label} open={sidebarOpen} indent />
))}
</NavGroup>
)}
<NavItem to="/config" icon={<Settings size={20} />} label="Config" open={sidebarOpen} />
</nav>

View File

@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { proRoutes } from '@pro';
// In the community build, `@pro` resolves to the stub: no Professional pages,
// so App's route map and Layout's nav group both tree-shake to nothing.
describe('pro tier — community build', () => {
it('ships no pro routes', () => {
expect(proRoutes).toEqual([]);
});
});

View File

@@ -0,0 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Community build: no Professional pages. `@pro` resolves here unless the build
// sets VITE_DECNET_PRO=1 with decnet/pro/web/ present, in which case Vite
// aliases `@pro` to the real registry. proRoutes being empty lets the router
// and nav tree-shake the pro surface out of the community bundle.
import type { ProRoute } from './types';
export const proRoutes: ProRoute[] = [];

View File

@@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Contract for Professional-tier UI pages. The pro build aliases `@pro` to the
// real registry in decnet/pro/web/; the community build resolves it to ./stub.
import type { ReactElement, ReactNode } from 'react';
export interface ProRoute {
/** Router path, e.g. "/pro/intel". Convention: prefix pro routes with /pro. */
path: string;
/** Sidebar label. */
label: string;
/** Sidebar icon (lucide-react element), optional. */
icon?: ReactNode;
/** Page element rendered at `path`. May be a lazy component (App wraps Suspense). */
element: ReactElement;
}