From a47f99c44992077aa92929642a43021ac67f759f Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 17 Jun 2026 15:02:28 -0400 Subject: [PATCH] feat(pro): generalize pro tier to multi-surface extension points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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). --- .gitignore | 7 +- LICENSING.md | 2 +- decnet/services/registry.py | 17 ++--- decnet/web/router/__init__.py | 10 +++ decnet_web/src/App.tsx | 5 ++ decnet_web/src/components/Layout.tsx | 8 +++ decnet_web/src/pro/pro.test.ts | 10 +++ decnet_web/src/pro/stub.ts | 8 +++ decnet_web/src/pro/types.ts | 15 +++++ decnet_web/tsconfig.app.json | 6 ++ decnet_web/vite.config.ts | 16 +++++ pyproject.toml | 9 ++- tests/services/test_pro_tier.py | 95 ++++++++++++++++------------ 13 files changed, 151 insertions(+), 57 deletions(-) create mode 100644 decnet_web/src/pro/pro.test.ts create mode 100644 decnet_web/src/pro/stub.ts create mode 100644 decnet_web/src/pro/types.ts diff --git a/.gitignore b/.gitignore index c5201a3b..947e9546 100644 --- a/.gitignore +++ b/.gitignore @@ -76,5 +76,8 @@ testfail .phaseloop/ # Professional tier: proprietary, lives in a separate private repo -# (github.com/DECNET-Foundation/decnet-professional). Must NEVER be tracked by the open-core repo. -/decnet/services/pro/ +# (github.com/DECNET-Foundation/decnet-professional), mounted at decnet/pro/. +# Must NEVER be tracked by the open-core repo. src/pro-impl/ is where the pro +# build copies the pro frontend so the JS toolchain resolves it. +/decnet/pro/ +/decnet_web/src/pro-impl/ diff --git a/LICENSING.md b/LICENSING.md index c78e0c51..652dc0ee 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -27,7 +27,7 @@ depends on it. | Tier | Code | License | |--------------|----------------------------------------|----------------------------| | Community | this repo | AGPL-3.0-or-later | -| Professional | `decnet/services/pro/` (private repo) | DECNET Commercial EULA | +| Professional | `decnet/pro/` (private repo) | DECNET Commercial EULA | To use DECNET core under terms other than the AGPL, or to obtain DECNET Professional, contact **licensing@decnet.cl**. diff --git a/decnet/services/registry.py b/decnet/services/registry.py index b440cd57..9c80e45d 100644 --- a/decnet/services/registry.py +++ b/decnet/services/registry.py @@ -6,11 +6,11 @@ Auto-discovers all BaseService subclasses by importing every module in the services package. Adding a new service requires nothing beyond dropping a new .py file here that subclasses BaseService. -Professional-tier honeypots live in the optional ``decnet.services.pro`` -subpackage, which ships only in the Professional build (a private tree merged -in at packaging time) and is absent from the open-core Community build. The -registry scans it when present, so absence of the directory IS the entitlement -gate — no licence check, no feature flag. +Professional-tier honeypots live in the optional ``decnet.pro.services`` +subpackage (a clone of the private decnet-professional repo, mounted at +``decnet/pro/`` and absent from the open-core Community build). The registry +scans it when present, so absence of the directory IS the entitlement gate — +no licence check, no feature flag. """ import importlib @@ -40,12 +40,13 @@ def _load_plugins() -> None: continue importlib.import_module(f"decnet.services.{module_info.name}") # Professional build only: present == entitled. Community build has no pro/. - pro_dir = package_dir / "pro" + pro_dir = package_dir.parent / "pro" / "services" if pro_dir.is_dir(): for mi in pkgutil.iter_modules([str(pro_dir)]): - importlib.import_module(f"decnet.services.pro.{mi.name}") + importlib.import_module(f"decnet.pro.services.{mi.name}") for cls in _all_subclasses(BaseService): - if not cls.__module__.startswith("decnet.services."): + mod = cls.__module__ + if not (mod.startswith("decnet.services.") or mod.startswith("decnet.pro.")): continue instance = cls() # type: ignore[abstract] _registry[instance.name] = instance diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py index 2ae228b9..5b868446 100644 --- a/decnet/web/router/__init__.py +++ b/decnet/web/router/__init__.py @@ -218,3 +218,13 @@ api_router.include_router(ttp_rules_router) api_router.include_router(ttp_tag_details_router) api_router.include_router(ttp_navigator_router) api_router.include_router(ttp_groups_for_technique_router) + +# Professional tier (optional): mount pro routers under /api/v1 when the +# decnet/pro/ package is present. Absence is the entitlement gate — community +# builds have no decnet.pro, so this is a no-op there. +try: + from decnet.pro.routes import ROUTERS as _pro_routers +except ModuleNotFoundError: + _pro_routers = [] +for _pro_router in _pro_routers: + api_router.include_router(_pro_router) diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index e1158442..5d052959 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -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 = ({ onLogout, onSearch, searchQue {isDeveloperMode() && ( } /> )} + {/* Professional-tier pages. Empty in the community build (@pro -> stub). */} + {proRoutes.map((r) => ( + + ))} } /> diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index 19a8123e..aa578736 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -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 = ({ } label="SWARM Hosts" open={sidebarOpen} indent /> } label="Remote Updates" open={sidebarOpen} indent /> + {proRoutes.length > 0 && ( + } open={sidebarOpen}> + {proRoutes.map((r) => ( + + ))} + + )} } label="Config" open={sidebarOpen} /> diff --git a/decnet_web/src/pro/pro.test.ts b/decnet_web/src/pro/pro.test.ts new file mode 100644 index 00000000..d2f970f6 --- /dev/null +++ b/decnet_web/src/pro/pro.test.ts @@ -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([]); + }); +}); diff --git a/decnet_web/src/pro/stub.ts b/decnet_web/src/pro/stub.ts new file mode 100644 index 00000000..a2e03e0a --- /dev/null +++ b/decnet_web/src/pro/stub.ts @@ -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[] = []; diff --git a/decnet_web/src/pro/types.ts b/decnet_web/src/pro/types.ts new file mode 100644 index 00000000..42b1cafc --- /dev/null +++ b/decnet_web/src/pro/types.ts @@ -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; +} diff --git a/decnet_web/tsconfig.app.json b/decnet_web/tsconfig.app.json index 000ae696..902cb527 100644 --- a/decnet_web/tsconfig.app.json +++ b/decnet_web/tsconfig.app.json @@ -10,6 +10,12 @@ /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, + + /* `@pro` resolves to the empty community stub for type-checking. The + Professional build overrides this to decnet/pro/web/index.tsx. Must mirror + the Vite alias in vite.config.ts. (bundler resolution => no baseUrl.) */ + "paths": { "@pro": ["./src/pro/stub.ts"] }, + "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, diff --git a/decnet_web/vite.config.ts b/decnet_web/vite.config.ts index 014c89cd..08907e0d 100644 --- a/decnet_web/vite.config.ts +++ b/decnet_web/vite.config.ts @@ -1,10 +1,26 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' +import { existsSync } from 'node:fs' + +const here = dirname(fileURLToPath(import.meta.url)) +// `@pro` resolves to the real Professional registry only for an explicit pro +// build (VITE_DECNET_PRO=1) once the pro frontend is mounted at src/pro-impl/ +// (git-ignored; the pro build copies decnet/pro/web there so react/lucide and +// tsc resolve normally). Otherwise the empty community stub, which tree-shakes +// the pro surface out of the bundle. +const proRealEntry = resolve(here, 'src/pro-impl/index.tsx') +const proEntry = + process.env.VITE_DECNET_PRO === '1' && existsSync(proRealEntry) + ? proRealEntry + : resolve(here, 'src/pro/stub.ts') // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + resolve: { alias: { '@pro': proEntry } }, test: { environment: 'jsdom', globals: true, diff --git a/pyproject.toml b/pyproject.toml index 69f12879..5aab343a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,11 +173,10 @@ where = ["."] # "decnet*" also globs decnet_web/ and pulls in stray node_modules .py files; # pin to the actual package so the wheel/sdist stay clean. include = ["decnet", "decnet.*"] -# Community build is open-core: never ship the Professional-tier honeypots even -# if a dev tree has the private decnet/services/pro/ submodule mounted. The -# Professional build overrides this. templates/pro/ is absent from the public -# tree, so package-data's templates/**/* glob picks up nothing extra here. -exclude = ["decnet.services.pro", "decnet.services.pro.*"] +# Community build is open-core: never ship the Professional tier even if a dev +# tree has the private decnet/pro/ repo mounted. The Professional build overrides +# this. +exclude = ["decnet.pro", "decnet.pro.*"] [tool.setuptools.package-data] # Ship docker build contexts + syslog_bridge.py as package data so they land diff --git a/tests/services/test_pro_tier.py b/tests/services/test_pro_tier.py index 9dc3d5a3..22f3a11b 100644 --- a/tests/services/test_pro_tier.py +++ b/tests/services/test_pro_tier.py @@ -1,22 +1,21 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """ -Open-core tier split: the Professional build supplies advanced honeypots via the -optional decnet/services/pro/ subpackage (a separate private repo cloned into -this path; git-ignored here so it never enters the open-core tree). The -Community build simply omits it. +Open-core tier seams: the Professional tier is a separate private repo mounted at +decnet/pro/ (git-ignored here) that contributes to several core surfaces, each +wired only when the package is present: -The registry must auto-discover pro honeypots when present — including ones that -EXTEND a community service rather than subclassing BaseService directly (the -recursive-subclass walk). Absence of a pro module is the entitlement gate; there -is no licence check. +* decnet/pro/services/ — advanced honeypots, discovered by the service registry. +* decnet/pro/routes.py — ROUTERS, mounted under /api/v1 by the web router. -One test on purpose: it mutates decnet/services/pro/ and the process-global -registry, so it cannot race a sibling test under xdist. It tolerates a pro/ dir -that already exists (developer tree) and leaves the registry pristine. +Absence of decnet/pro/ is the entitlement gate; there is no licence check. + +One test function on purpose: it mutates decnet/pro/services/, the process-global +service registry, and reloads decnet.web.router — doing that sequentially in one +worker avoids the xdist races that separate tests on shared state would hit. """ import gc -import shutil +import importlib import sys from pathlib import Path @@ -26,50 +25,64 @@ _DEMO_MOD = "_demo_pro_tier_test" _DEMO_NAME = "demo-pro-honeypot" -def _reload_clean(): +def _reload_registry(): reg._loaded = False reg._registry.clear() reg._load_plugins() -def test_pro_tier_packaging_gate(): - pkg_dir = Path(reg.__file__).parent - pro_dir = pkg_dir / "pro" - init = pro_dir / "__init__.py" - demo = pro_dir / f"{_DEMO_MOD}.py" - - created_dir = not pro_dir.exists() +def test_pro_tier_seams(): + # --- service-discovery seam ------------------------------------------- + services_dir = Path(reg.__file__).parent.parent / "pro" / "services" + demo = services_dir / f"{_DEMO_MOD}.py" + created_dir = not services_dir.exists() if created_dir: - pro_dir.mkdir() - created_init = not init.exists() - if created_init: - init.write_text("") + services_dir.mkdir(parents=True) + (services_dir / "__init__.py").write_text("") try: - # Gate closed: our pro honeypot absent, community services present. - _reload_clean() - assert _DEMO_NAME not in reg.all_services() - assert "ssh" in reg.all_services() + _reload_registry() + assert _DEMO_NAME not in reg.all_services() # gate closed + assert "ssh" in reg.all_services() # community untouched - # Professional build: drop in a pro honeypot that EXTENDS a community - # service (only reachable via the recursive subclass walk). + # A pro honeypot that EXTENDS a community service — only reachable via + # the registry's recursive subclass walk. demo.write_text( "from decnet.services.ssh import SSHService\n" "class DemoProHoneypot(SSHService):\n" f" name = {_DEMO_NAME!r}\n" ) - _reload_clean() - svcs = reg.all_services() - assert _DEMO_NAME in svcs # pro discovered - assert "ssh" in svcs # community untouched + _reload_registry() + assert _DEMO_NAME in reg.all_services() finally: demo.unlink(missing_ok=True) - if created_init: - init.unlink(missing_ok=True) if created_dir: - shutil.rmtree(pro_dir) - # Drop the dynamically-imported class so it can't pollute the registry - # for sibling tests sharing this worker. - sys.modules.pop(f"decnet.services.pro.{_DEMO_MOD}", None) + import shutil + shutil.rmtree(services_dir) + sys.modules.pop(f"decnet.pro.services.{_DEMO_MOD}", None) gc.collect() - _reload_clean() + _reload_registry() + + # --- API-router seam -------------------------------------------------- + import decnet.pro.routes as pro_routes + import decnet.web.router as web_router + from fastapi import APIRouter, FastAPI + + saved = pro_routes.ROUTERS + probe = APIRouter() + + @probe.get("/pro/_probe_test") + async def _probe(): # pragma: no cover - never called, just registered + return {} + + try: + pro_routes.ROUTERS = [probe] + importlib.reload(web_router) + # This FastAPI version defers route flattening (_IncludedRouter), so go + # through openapi() — it forces resolution and lists effective paths. + app = FastAPI() + app.include_router(web_router.api_router, prefix="/api/v1") + assert "/api/v1/pro/_probe_test" in app.openapi()["paths"] + finally: + pro_routes.ROUTERS = saved + importlib.reload(web_router) # rebuild a pro-free api_router for others