# 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 admin-gated `GET /api/plugins`.

2. **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 `/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/<name>/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 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 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.
