Files
ServerKit/backend/app/plugins_sdk/__init__.py
T
Juan DenisandClaude Fable 5 4ca07fb71c feat(extensions): per-plugin config store + Configure form (#49)
Completes the deferred half of #30. InstalledPlugin gains config_json
(migration 045); admin-gated GET/PUT /api/v1/plugins/<id>/config
persists values; plugins read them via the new plugins_sdk.config(slug)
accessor. The Marketplace Installed tab shows a Configure button for
plugins declaring a config_schema and renders a real form
(string/number/integer/boolean/enum/secret fields, defaults, hints).
Values may hold secrets so they are excluded from to_dict() and only
served by the admin endpoint. Documented in docs/EXTENSIONS.md.

2 new tests (config round-trip + SDK accessor + secret non-leak;
non-object rejection); pipeline suite 18 green; lint 0 errors; build
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:09:39 -04:00

143 lines
3.9 KiB
Python

"""
ServerKit Backend Plugin SDK.
Plugins should depend on names re-exported here rather than reaching into
host internals directly. As long as this surface stays stable, host-side
refactors don't break installed plugins.
Typical plugin module:
from flask import Blueprint, request, jsonify
from app.plugins_sdk import (
db, jwt_required, current_user, audit, logger,
)
my_bp = Blueprint('my_plugin', __name__)
log = logger(__name__)
@my_bp.route('/things', methods=['GET'])
@jwt_required()
def list_things():
user = current_user()
# ... do work, use db.session, etc ...
return jsonify({'ok': True})
The lifecycle hook contract (called by plugin_service when installing /
uninstalling): a single positional arg — the InstalledPlugin row.
# In plugins/<slug>/lifecycle.py
from app.plugins_sdk import db, logger
log = logger(__name__)
def on_install(plugin):
log.info(f'Setting up {plugin.slug}')
def on_uninstall(plugin):
log.info(f'Tearing down {plugin.slug}')
"""
import logging
from app import db, jwt
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
def current_user():
"""Return the current authenticated user, or None if no JWT context.
Resolves the JWT identity (a stringified user id, per the host's
@jwt.user_identity_loader) into a User row.
"""
try:
uid = get_jwt_identity()
except Exception:
return None
if uid is None:
return None
from app.models.user import User
try:
return User.query.get(int(uid))
except (TypeError, ValueError):
return None
def logger(name):
"""Return a logger scoped to a plugin module name."""
return logging.getLogger(name)
def audit(action, target_type, target_id=None, details=None, user_id=None):
"""Write an audit log entry from a plugin.
Thin wrapper around AuditService so plugins don't have to know its
import path. `user_id` defaults to the current authenticated user.
"""
if user_id is None:
u = current_user()
user_id = u.id if u else None
from app.services.audit_service import AuditService
return AuditService.log(
action=action,
user_id=user_id,
target_type=target_type,
target_id=target_id,
details=details or {},
)
# AI extension SDK — plugins do `from app.plugins_sdk import ai` then register
# tools/context against the core assistant (see plugins_sdk/ai.py). Imported
# lazily-safe: ai.py only depends on the AI tool registry, not on a running app.
from app.plugins_sdk import ai
from app.plugins_sdk import permissions
from app.plugins_sdk import sockets
from app.queue_bus.sdk import QueueBusSdk
from app.notifications.sdk import NotifySdk
from app.jobs.sdk import JobsSdk
queue = QueueBusSdk()
notify = NotifySdk()
jobs = JobsSdk()
def panel_version():
"""The panel's version string (for compat checks inside a plugin)."""
from app.utils.version import get_panel_version
return get_panel_version()
def config(slug):
"""Saved config values for an installed plugin (empty dict if none).
The manifest's ``config_schema`` documents the fields; admins edit the
values from Marketplace → Installed → Configure. Read-only here — the
panel owns writes (PUT /api/v1/plugins/<id>/config).
"""
from app.models.plugin import InstalledPlugin
p = InstalledPlugin.query.filter_by(slug=slug).first()
return dict(p.config or {}) if p else {}
# Capability gate: `require(slug, 'docker')` raises unless the plugin declared
# that permission in its manifest. See app/plugins_sdk/permissions.py.
require_permission = permissions.require
__all__ = [
'db',
'jwt',
'jwt_required',
'get_jwt_identity',
'get_jwt',
'current_user',
'logger',
'audit',
'ai',
'queue',
'notify',
'jobs',
'permissions',
'require_permission',
'panel_version',
'config',
'sockets',
]