Framework: - Per-plugin Alembic migration chains (ADR-008): every bundled plugin carries its own chain with a stamp-only anchor at the ownership cutover; new plugin schema lands in plugins/<name>/migrations/, never the core chain. Deploys add flask plugin upgrade-all. Fixed a latent bug in the shared alembic template (engine URL resolution) and taught the metadata filter to include FK-referenced core tables. - Frontend plugin route gating (ADR-009): plugin routes carry meta.plugin; a disabled plugin's pages redirect to the dashboard via a cached, fail-open check against the new public GET /api/plugins/enabled. - get_reports() plugin hook (contract 0.5.0 -> 0.6.0): plugins contribute report cards; warranty and toner cards moved off the hardcoded list. Reports: - Hub grouped by category with search; inline reports render at the top, are URL-backed (?report=id, back-button and deep links work), expose their server-side filter params as controls, and export CSV. Warranty and Toner pages gained CSV export. - Deleted the dead legacy Warranty Status report (always-zero buckets from a retired column). Theming and fonts: - Inter (variable) bundled locally via @fontsource, replacing the Google Fonts Roboto import - air-gapped installs now render correctly; tables use tabular numerals. - Optional brand_primary_dark_color, brand_accent_color, brand_sidebar_color settings applied to CSS vars at bootstrap. USB frontend repair (views were reading a dead legacy shape): - List/detail/form and the employee profile USB panels remapped to the real API shape (device_id/device_desc/checkinoutlog); employee panels now use /usb/checkouts endpoints; external-mode /usb/checkouts/active honors the badge filter; dead client methods pruned. Also: warranties list page no longer requires login (matches app convention); collector doc rewritten with a GE-Enforce integration guide and paste-ready PowerShell reporter; ADR index and CHANGELOG updated. Verified: 323 tests pass, naming/style green, frontend builds, plugin migration dry-run green on scratch MySQL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7.1 KiB
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.
-
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 becauseGET /api/dashboard/navigationalready 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 admin-gatedGET /api/plugins. -
Route tagging. Every plugin-owned route carries
meta.plugin = '<pluginname>'. 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/tvdashboard (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. -
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. -
Router guard.
router.beforeEachawaits the cached fetch when the target route hasmeta.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/tvkiosk 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/requiresAdminmeta 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:
-
Plugin-owned frontend tree. Each plugin gains a
plugins/<name>/frontend/directory holding its route module, views, and any plugin-specific components. Core stops carryingviews/usb,views/printers, and so on. -
Build-time discovery. The Vite build discovers plugin frontends with a glob over
plugins/*/frontend/routes.js(analogous to today'simport.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. -
Shared component + registration contract. Plugins register into named extension points instead of editing core files: an
iconMapregistration for nav/asset icons, asset-detail panels, map-marker renderers, and search-result renderers (the "Frontend hook contract" already listed as deferred in the project CLAUDE.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 theshopdb.apinamespace. -
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.