Files
shopdb-flask/shopdb/core/api/plugins.py
cproudlock 1a6ac67cab
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled
Say what a plugin settings tab can actually do, and catch a declaration that cannot work
Finding F12 of the architectural review, checked against the code and found
worse than reported: the dynamic component import cannot be rewritten by the
bundler, so the built file ships the unresolved path and a plugin tab answers
404 in every production build, staged or not. It works only under the dev
server. Nothing shipping is affected, because the one plugin that declares a
tab also registers the same page as an ordinary route, and that is the one
sites reach.

- The ADR draft now carries a "Constraints as implemented" section: only the
  model catalog hub registers plugin tabs, registration happens when that hub
  mounts so a tab is not deep-linkable and does not survive a refresh, and the
  build finding above with the bundler's own warning. The two ways out are
  stated as the open decision rather than taken.
- The hook's docstring reads as instructions to a plugin author, and its
  example no longer names a path that does not exist at build time.
- A route declared for a hub that cannot render it is dropped with an error
  naming the plugin, the hub and the hubs that do work, and the same is logged
  at startup. Enabling the plugin still succeeds: a tab nobody can reach is no
  reason to take a working backend offline.
- Tests hold the two halves together: no bundled plugin may declare a hub the
  frontend does not implement, and the list in the server must equal the hubs
  that really register routes, so the two cannot drift apart again.
2026-09-16 09:57:51 -04:00

129 lines
5.2 KiB
Python

"""Plugin introspection + enable/disable API."""
from flask import Blueprint, current_app, request
from flask_jwt_extended import jwt_required
from shopdb.plugins.base import renderable_settings_routes
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
from shopdb.utils.authz import require_permission
plugins_bp = Blueprint('plugins', __name__)
@plugins_bp.route('', methods=['GET'])
@require_permission('settings.view')
def list_plugins():
"""List all discovered plugins (enabled or not) with their metadata and
the framework contract version."""
from shopdb import __contract_version__
pm = current_app.extensions.get('plugin_manager')
plugins = pm.discover_available() if pm else []
plugins.sort(key=lambda p: p['name'])
return success_response({
'contract_version': __contract_version__,
'count': len(plugins),
'plugins': plugins,
})
@plugins_bp.route('/enabled', methods=['GET'])
@jwt_required(optional=True)
def list_enabled_plugins():
"""Return just the names of enabled plugins as a flat array.
Cheap registry read (no DB). Exposed to anonymous callers on purpose:
the navigation endpoint already leaks the same enabled/disabled signal,
and the frontend needs it (including unauthenticated kiosk routes like
/tv) to gate plugin-owned routes. No metadata beyond the names.
"""
pm = current_app.extensions.get('plugin_manager')
names = pm.registry.get_enabled_plugins() if pm else []
return success_response(sorted(names))
@plugins_bp.route('/settings-routes', methods=['GET'])
@require_permission('settings.view')
def get_plugin_settings_routes():
"""Return settings routes contributed by all enabled plugins.
Plugins can register routes to add tabs/pages to settings hubs like
Model Catalog, Asset Config, etc. Frontend router merges these dynamically.
Gated on settings.view, the same permission list_plugins carries. It was
optional-auth and listed as a public read, on the stated reasoning that
unauthenticated pages (kiosks, TV displays) register plugin tabs before
login. Nothing does: the only caller in the frontend is
views/settings/ModelCatalogHub.vue, which sits behind requiresAuth and
requiresAdmin, and no kiosk route touches it. Open, it handed an anonymous
caller the enabled plugin list, the settings hub layout and the internal Vue
component paths - free reconnaissance about which modules a site runs. The
login page never calls this, so gating it costs nothing pre-login.
A route naming a hub that has no code to draw plugin tabs is DROPPED here,
loudly. Returning it would put an entry in a response that nothing reads:
the plugin author sees a correct declaration and an API that accepts it, and
the tab never appears anywhere. Dropping it costs the same missing tab and
leaves a line in the log that says which plugin, which hub, and what the
supported set is. See SETTINGS_HUBS_WITH_PLUGIN_TABS in shopdb/plugins/base.
Returns: { routes: [{hub, path, name, tab, component}, ...] }
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return success_response({'routes': []})
routes = []
for plugin_name, plugin in pm.get_all_plugins().items():
# Only include routes from enabled plugins
if not pm.registry.is_enabled(plugin_name):
continue
if not hasattr(plugin, 'get_settings_routes'):
continue
# One broken plugin must not take every settings hub down with it.
try:
plugin_routes = plugin.get_settings_routes()
except Exception:
current_app.logger.exception(
'get_settings_routes failed for plugin %s; skipping it',
plugin_name)
continue
routes.extend(renderable_settings_routes(plugin_name, plugin_routes,
current_app.logger))
return success_response({'routes': routes})
@plugins_bp.route('/<name>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def set_plugin_enabled(name: str):
"""Enable or disable a plugin. Takes effect on the next app restart for
route/navigation changes."""
data = request.get_json() or {}
if 'enabled' not in data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'enabled is required')
pm = current_app.extensions.get('plugin_manager')
if not pm:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Plugin manager unavailable', http_code=500)
want = bool(data['enabled'])
ok = pm.enable_plugin(name) if want else pm.disable_plugin(name)
if not ok:
# enable/disable refused (unknown plugin, or a dependency conflict)
return error_response(
ErrorCodes.CONFLICT,
f"Could not {'enable' if want else 'disable'} '{name}' "
f"(check it exists and dependencies allow it)",
http_code=409
)
return success_response(
{'name': name, 'enabled': want},
message=f"Plugin {'enabled' if want else 'disabled'} "
f"(restart to apply route changes)"
)