# ADR-009: Frontend plugin gating - **Status:** ACCEPTED - **Date:** 2026-07-10 - **Deciders:** cproudlock - **Supersedes:** none ## Context The backend plugin system (ADR-002, ADR-003) lets an operator disable a plugin. Disabling it unregisters the plugin's API blueprint, drops its rows from global search (see `test_search_disabled`), and removes its entry from `get_navigation_items()`, so the disabled feature's sidebar link disappears. The frontend told a different story. Every plugin's Vue routes and views ship inside the core bundle. `frontend/src/router/index.js` auto-discovers them with `import.meta.glob('./routes/*.js')`, so a route like `/usb` or `/printers/3` is registered regardless of whether the owning backend plugin is enabled. A user who typed the URL, followed a stale bookmark, or clicked a cross-link reached a page whose API calls all 404, landing on a broken shell instead of a clean redirect. The navigation was already data-driven; direct-URL reachability was the gap. There is no frontend plugin system yet. The product is packaged as one core Flask app plus backend-only plugins; the Vue app is monolithic and build-time static. Any gating has to live in core frontend code because that is the only place the plugin routes exist. ## Decision ### Step 1 (this ADR, implemented): route-level gating Plugin-owned frontend routes are gated against the backend's enabled-plugin list. This is the whole of what ships now. 1. **Enabled-list endpoint.** A new `GET /api/plugins/enabled` (`jwt_required(optional=True)`) returns a flat JSON array of enabled plugin name strings and nothing else. It is a cheap registry read (`registry.get_enabled_plugins()`), no database access. Exposing it to anonymous callers is safe because `GET /api/dashboard/navigation` already leaks the same enabled/disabled signal, and unauthenticated kiosk routes (`/tv`) need the answer too. It carries no metadata, so it reveals strictly less than the equally-anonymous but metadata-carrying `GET /api/plugins` (also `jwt_required(optional=True)`; only the `PUT /api/plugins/` toggle is admin-gated). 2. **Route tagging.** Every plugin-owned route carries `meta.plugin = ''`. This covers the per-plugin route modules (`routes/computers.js`, `routes/equipment.js`, ...) and the plugin-owned routes that physically live in core files: the PC-relationships and toner reports, the slide manager and `/tv` dashboard (slides), the printer-QR and USB-label print pages, and the employee-detail page. Genuinely core routes (dashboard, search, map, applications, reference-data settings) stay untagged and are never gated. 3. **Cached fetch, fail-open.** A composable (`composables/enabledPlugins.js`) fetches the list exactly once behind a cached promise. If the fetch fails or returns a non-array, the code fails **open**: every plugin is treated as enabled. A transient API error must never brick navigation. The cost is that a disabled plugin's page is briefly reachable during an outage, which is acceptable because its API calls would fail anyway and the next successful fetch closes the gap. 4. **Router guard.** `router.beforeEach` awaits the cached fetch when the target route has `meta.plugin`. If that plugin is not enabled it redirects to `/` and raises an info toast. The endpoint is jwt-optional, so the guard works for both authenticated pages and the unauthenticated `/tv` kiosk route. This is intentionally a thin layer. It does not change the plugin contract surface, so `__contract_version__` does not move: adding a core HTTP endpoint and tagging core-shipped routes are not plugin-contract changes. Plugins still ship no frontend code of their own. ### Non-goals for step 1 - No build-time or runtime loading of plugin-authored Vue code. - No per-permission or per-role route gating (that stays with the existing `requiresAuth` / `requiresAdmin` meta flags). - No removal of disabled routes from the route table; they remain registered and are intercepted by the guard. Keeping them registered avoids a rebuild when a plugin is toggled and keeps the redirect path simple. ## Future direction (PROPOSED, not implemented) Step 1 gates routes that core already owns. The longer-term goal is a real frontend-plugin contract where a plugin ships its own frontend and core discovers it, mirroring the backend model. Sketch: 1. **Plugin-owned frontend tree.** Each plugin gains a `plugins//frontend/` directory holding its route module, views, and any plugin-specific components. Core stops carrying `views/usb`, `views/printers`, and so on. 2. **Build-time discovery.** The Vite build discovers plugin frontends with a glob over `plugins/*/frontend/routes.js` (analogous to today's `import.meta.glob('./routes/*.js')`), so a plugin's presence in the tree is what puts its routes in the bundle. Combined with the step-1 enabled-list gate, a plugin that is absent from the build ships no code and a plugin that is present-but-disabled is route-gated at runtime. 3. **Shared component + registration contract.** Plugins register into named extension points instead of editing core files: an `iconMap` registration for nav/asset icons, asset-detail panels, map-marker renderers, and search-result renderers (the "Frontend hook contract" already listed as deferred in docs/ROADMAP.md). Core exposes a stable set of shared components (form controls, detail-page shells, table primitives) as the plugin frontend's only allowed core imports, the frontend analogue of the `shopdb.api` namespace. 4. **Versioned frontend contract.** The shared-component and registration surface would be versioned the same way the backend contract is (ADR-002), so a plugin frontend can declare the core frontend range it needs. ### Tradeoffs of the future direction - **Pro:** true plugin self-containment; a site can drop in or remove a plugin (frontend and backend together) without patching core; smaller core; clearer ownership. - **Con:** significant build-system work (per-plugin Vite entry discovery, code-splitting, dev-server HMR across the plugin tree); a new versioned frontend contract to maintain and document; a migration that moves ten plugins' worth of views out of core; risk of a leaky shared-component surface becoming an accidental contract. The payoff only matters once external (out-of-tree) plugins with their own frontends are a real requirement. Until then, step 1's route gating delivers the user-visible correctness (no reachable dead pages) at a fraction of the cost. ## Consequences - Disabling a backend plugin now makes its frontend routes redirect to the dashboard instead of loading a broken shell. Behavior matches the already-dynamic navigation. - One extra lightweight request at app boot (`GET /api/plugins/enabled`), cached for the session. - Fail-open means gating is a UX guardrail, not a security control. It is not a substitute for backend authorization: the API still enforces auth and the disabled plugin's endpoints are simply unregistered. Never rely on route gating to protect data. - The future frontend-plugin contract remains open work; this ADR records the direction and its cost so a later decision can pick it up.