feat(plugins): plugin pages, per-user config, capabilities gate, mobile/telegram refactors
- Plugin HTTP routes + web pages (plugin-page-host, plugin-catalog, plugin-detail) - Plugin access grants + per-user config (DB tables + API + frontend forms) - Capabilities-based guard (caps.rs) replacing role-id checks - Mobile connector: message routing, payload types, router refactor - Telegram bot: auth flow, event handling improvements - Honcho plugin: substantial rework - Sidebar: plugin pages integration, role-driven visibility - i18n: new strings for plugins, connectors, capabilities - Remove unused mascot asset
This commit is contained in:
@@ -69,6 +69,10 @@ pub struct ToolCallEvent {
|
||||
pub struct ChatEvent {
|
||||
pub session_id: i64,
|
||||
pub stack_id: i64,
|
||||
/// The session owner's user id. Ids in this event (`session_id`, …) are local
|
||||
/// to that user's pool, so consumers scoping per-user (e.g. the Honcho memory
|
||||
/// sink) must key on `user_id` to avoid cross-user collisions.
|
||||
pub user_id: String,
|
||||
/// `chat_history.id` for this message.
|
||||
pub message_id: i64,
|
||||
pub role: ChatEventRole,
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod remote;
|
||||
pub mod tool;
|
||||
pub mod user_channel;
|
||||
pub mod user_fs;
|
||||
pub mod user_plugin_config;
|
||||
pub mod secrets;
|
||||
pub mod transcribe;
|
||||
pub mod tts;
|
||||
|
||||
@@ -18,7 +18,11 @@ pub trait Memory: Send + Sync {
|
||||
|
||||
/// Retrieves context for the upcoming turn to inject into the system prompt.
|
||||
/// Returns `None` on cold start, backend down, or nothing useful available.
|
||||
async fn query_context(&self, session_id: i64, user_message: &str) -> Option<String>;
|
||||
///
|
||||
/// `user_id` is the session owner: multi-user backends scope retrieval to that
|
||||
/// user's own memory (e.g. their Honcho peer), and `session_id` is local to
|
||||
/// that user's pool so it must be namespaced by `user_id` to stay unique.
|
||||
async fn query_context(&self, user_id: &str, session_id: i64, user_message: &str) -> Option<String>;
|
||||
|
||||
/// Optional LLM-callable tools exposed by this backend (e.g. `memory_query`).
|
||||
/// Called per turn — added to the live tool list and dispatched before the
|
||||
|
||||
@@ -17,10 +17,46 @@ use crate::secrets::SecretsApi;
|
||||
use crate::transcribe::{TranscribeProvider, TranscribeRegistry};
|
||||
use crate::tts::{TtsProvider, TtsRegistry};
|
||||
use crate::user_channel::UserChannelApi;
|
||||
use crate::user_plugin_config::PluginUserConfigApi;
|
||||
|
||||
/// Closure that builds a fresh Axum router (e.g. for the mesh-facing server).
|
||||
pub type RouterFactory = Arc<dyn Fn() -> axum::Router + Send + Sync>;
|
||||
|
||||
/// The authenticated caller behind a plugin-router request.
|
||||
///
|
||||
/// The frontend's auth layer injects this into request extensions for every
|
||||
/// gated request (alongside its own richer, bin-private `AuthUser`). A plugin
|
||||
/// router cannot name bin-crate types, so this is how a plugin handler learns
|
||||
/// *who* is calling — e.g. to bind a freshly paired device to the admin who
|
||||
/// opened the pairing window. Gate admin-only actions with
|
||||
/// [`crate::user_channel::UserChannelApi::plugin_access`] (which returns `true`
|
||||
/// only for admins when the plugin `manages_own_access`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Caller {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
/// A web UI page contributed by a plugin — see [`Plugin::web_pages`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginPage {
|
||||
/// Stable id, unique within the plugin — used in the route
|
||||
/// (`#plugin/<plugin_id>/<page_id>`). e.g. "pairing", "devices".
|
||||
pub page_id: &'static str,
|
||||
/// Menu label. Shown as-is (the plugin owns its UI strings).
|
||||
pub title: String,
|
||||
/// Bootstrap Icons name (e.g. "qr-code", "phone"), rendered as `bi-<icon>`.
|
||||
pub icon: &'static str,
|
||||
/// Path of the page's ES module **inside this plugin's router**, e.g.
|
||||
/// "web/pairing.js" — served at `/api/plugin/<id>/web/pairing.js`.
|
||||
pub entry: String,
|
||||
/// `true` = only the built-in admin role sees the menu entry (e.g. a
|
||||
/// pairing/devices console). `false` = any user with `plugin_access`.
|
||||
pub admin_only: bool,
|
||||
/// Menu ordering — ascending; the native menu will adopt the same field
|
||||
/// when it is reworked. Use round numbers (10, 20, …) to leave room.
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
/// All deps a plugin may need — passed to [`Plugin::start`] and [`Plugin::reload`].
|
||||
///
|
||||
/// Fields are `Arc<dyn Trait>` sourced from `core-api`. Plugins use only the
|
||||
@@ -51,6 +87,9 @@ pub struct PluginContext {
|
||||
/// (Telegram, mobile, …) look up an unlocked user's chat hub, approval
|
||||
/// manager and event stream by user id.
|
||||
pub user_channel: Arc<dyn UserChannelApi>,
|
||||
/// Per-user plugin configuration store (`plugin_user_configs` table).
|
||||
/// Admin-readable — never secrets.
|
||||
pub user_config: Arc<dyn PluginUserConfigApi>,
|
||||
pub web_port: u16,
|
||||
pub remote_slot: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
|
||||
pub router_factory: RouterFactory,
|
||||
@@ -70,6 +109,30 @@ pub trait Plugin: Send + Sync {
|
||||
/// JSON Schema describing the plugin's config fields.
|
||||
fn config_schema(&self) -> Value { serde_json::json!({}) }
|
||||
|
||||
/// JSON Schema describing the plugin's *per-user* config fields (e.g.
|
||||
/// Telegram's pairing code). Empty schema (the default) = the plugin has
|
||||
/// no per-user settings and does not appear as configurable in the user
|
||||
/// UI. Values are stored admin-readable in `system.db` — never secrets.
|
||||
fn user_config_schema(&self) -> Value { serde_json::json!({}) }
|
||||
|
||||
/// Applies a per-user config submission. The default just stores the blob
|
||||
/// in the generic store; plugins that need validation or a side effect
|
||||
/// (e.g. Telegram turning a pairing code into a chat binding) override it
|
||||
/// and may store a sanitized status blob for the UI via `ctx.user_config`.
|
||||
async fn update_user_config(&self, user_id: &str, config: Value, ctx: &PluginContext) -> Result<()> {
|
||||
ctx.user_config.set(self.id(), user_id, config).await
|
||||
}
|
||||
|
||||
/// Whether the plugin decides *who may use it* through its own binding /
|
||||
/// pairing lifecycle rather than the generic `plugin_access` grants — e.g.
|
||||
/// the mobile connector, whose access is the admin-mediated device→user
|
||||
/// binding (§13). When `true`, the admin Plugins UI suppresses the "User
|
||||
/// access" checklist (it would control nothing) and the plugin never appears
|
||||
/// in a user's "My plugins" view. Default `false`: access is the admin's
|
||||
/// per-user `plugin_access` grant (as Telegram uses — its grant gates the
|
||||
/// bot at runtime even though pairing is self-service).
|
||||
fn manages_own_access(&self) -> bool { false }
|
||||
|
||||
/// Called whenever the enabled flag or config changes — including at startup.
|
||||
/// The plugin is responsible for diffing state and restarting only what changed.
|
||||
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()>;
|
||||
@@ -82,11 +145,40 @@ pub trait Plugin: Send + Sync {
|
||||
|
||||
/// Optional Axum router contributed by the plugin. When `Some`, the main
|
||||
/// `WebFrontend` nests it under `/api/plugin/<id>/` behind Skald's normal
|
||||
/// auth (plugin.md §12.3). The router must close over the plugin's own state
|
||||
/// (it receives no `State`). Default: no routes — existing plugins are
|
||||
/// unaffected.
|
||||
/// auth plus a runtime enabled-gate: **every** plugin router is mounted at
|
||||
/// boot, and a disabled plugin's routes answer 404 until it is enabled
|
||||
/// (no restart needed).
|
||||
///
|
||||
/// Contract:
|
||||
/// - Building the router must be cheap and safe even if the plugin never
|
||||
/// starts — it is called at boot regardless of the enabled flag. Handlers
|
||||
/// must tolerate the not-running state (an enabled-but-crashed plugin can
|
||||
/// still receive requests). Resolve runtime state per request through a
|
||||
/// shared cell (e.g. `Arc<Mutex<Option<State>>>`) rather than capturing it.
|
||||
/// - The router closes over the plugin's own state (it receives no `State`).
|
||||
/// - Page fragments and other assets are served from here too; responses
|
||||
/// automatically get `Cache-Control: no-cache` from the shell.
|
||||
///
|
||||
/// Default: no routes — existing plugins are unaffected.
|
||||
fn http_router(&self) -> Option<axum::Router> { None }
|
||||
|
||||
/// Web UI pages this plugin contributes to the frontend, surfaced as menu
|
||||
/// entries and served to the browser by `GET /api/plugins/pages`.
|
||||
///
|
||||
/// Each page is a self-contained ES module served by this plugin's own
|
||||
/// [`Plugin::http_router`] at `entry` (e.g. `web/pairing.js` →
|
||||
/// `/api/plugin/<id>/web/pairing.js`). Fragment contract:
|
||||
/// - default-export an `HTMLElement` class (a Lit element works); the host
|
||||
/// registers it as a custom element and sets the `plugin-id` attribute;
|
||||
/// - the fragment talks to its own backend only through
|
||||
/// `/api/plugin/<id>/…` — no host APIs are injected;
|
||||
/// - it runs with the full privileges of the logged-in session (plugins are
|
||||
/// trusted — they ship in the binary);
|
||||
/// - it carries its own UI strings (reads the locale from `/api/auth/me`).
|
||||
///
|
||||
/// Default: no pages.
|
||||
fn web_pages(&self) -> Vec<PluginPage> { Vec::new() }
|
||||
|
||||
/// Tools this plugin contributes to the registry — the sibling of
|
||||
/// [`Plugin::http_router`].
|
||||
///
|
||||
|
||||
@@ -47,6 +47,10 @@ pub enum ToolCategory {
|
||||
pub struct ToolContext {
|
||||
/// The session that issued this tool call. Ids are local to `pool`.
|
||||
pub session_id: i64,
|
||||
/// The owner (caller) user id. Tools that address a per-user external store
|
||||
/// (e.g. the Honcho memory peer) key on this so they act on the caller's own
|
||||
/// data, never a shared/global peer.
|
||||
pub user_id: String,
|
||||
/// The owner's unlocked database pool (per-user in multi-user mode; the shared
|
||||
/// `system.db` in the transitional single-pool state).
|
||||
pub pool: Arc<sqlx::SqlitePool>,
|
||||
|
||||
@@ -34,6 +34,22 @@ pub trait UserChannelApi: Send + Sync {
|
||||
/// (§9: from first login until restart). `None` = locked — the caller
|
||||
/// should prompt the user to log in.
|
||||
async fn resolve_user(&self, user_id: &str) -> Option<Arc<dyn UserChannelHandle>>;
|
||||
|
||||
/// Whether `user_id` may currently use the plugin `plugin_id` — granted in
|
||||
/// `plugin_access`, or holding the admin role (implicit, mirroring the web
|
||||
/// `/plugins/mine` view). Channel adapters enforce this on every inbound
|
||||
/// message so an admin revoking access takes effect immediately, without
|
||||
/// having to touch existing pairing/binding rows. **Fail-closed**: an
|
||||
/// unknown user or a lookup error returns `false`.
|
||||
async fn plugin_access(&self, plugin_id: &str, user_id: &str) -> bool;
|
||||
|
||||
/// Resolves a web **session token** to its user id, or `None` if the token
|
||||
/// is unknown / expired. Lets a channel adapter turn a token the client
|
||||
/// obtained from `POST /api/auth/login` into an authenticated identity — the
|
||||
/// seam behind the mobile self-service device binding (a device proves *who*
|
||||
/// it is by presenting the session it just logged in with). Sessions are
|
||||
/// in-memory, so a token stops resolving after a restart.
|
||||
async fn user_for_session(&self, token: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
/// Handle to one unlocked user's owner-bound runtime.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Per-user plugin configuration store (`plugin_user_configs` table in
|
||||
/// `system.db`).
|
||||
///
|
||||
/// Values are deliberately admin-readable — the table lives in the registry
|
||||
/// database — so `user_config_schema`s must never collect secrets. A plugin
|
||||
/// that needs per-user secrets should keep them elsewhere.
|
||||
#[async_trait]
|
||||
pub trait PluginUserConfigApi: Send + Sync {
|
||||
async fn get(&self, plugin_id: &str, user_id: &str) -> Result<Option<Value>>;
|
||||
async fn set(&self, plugin_id: &str, user_id: &str, config: Value) -> Result<()>;
|
||||
async fn delete(&self, plugin_id: &str, user_id: &str) -> Result<()>;
|
||||
}
|
||||
Reference in New Issue
Block a user