Import-surface docs and docstrings describe the automation as a migration script; status-doc references in CHANGELOG/ADR-009/ROADMAP point at repo files. Screenshot/verify tools write to /tmp/shopdb-shots (created on import) instead of a machine-specific directory.
147 lines
7.1 KiB
Markdown
147 lines
7.1 KiB
Markdown
# 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 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.
|