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:
@@ -52,6 +52,11 @@ pub struct RelayApp {
|
||||
pub(crate) forwarders: Mutex<HashSet<String>>,
|
||||
/// Per-user debounced notifiers, created on demand by the forwarders.
|
||||
pub(crate) notifiers: Mutex<HashMap<String, Arc<DelayedNotifier>>>,
|
||||
/// The user a device paired *during the current window* auto-binds to — set
|
||||
/// by the web pairing console (the admin who opened the window). `None` for
|
||||
/// the agent-tool flow (`mobile_start_pairing`), which leaves the device
|
||||
/// Pending for an explicit `mobile_bind_device`. Cleared on stop-pairing.
|
||||
pending_owner: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl RelayApp {
|
||||
@@ -74,9 +79,22 @@ impl RelayApp {
|
||||
cancel,
|
||||
forwarders: Mutex::new(HashSet::new()),
|
||||
notifiers: Mutex::new(HashMap::new()),
|
||||
pending_owner: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Set (or clear) the user that devices paired during the current window
|
||||
/// auto-bind to. Called by the web pairing endpoint with the admin's id.
|
||||
pub(crate) async fn set_pending_owner(&self, user_id: Option<String>) {
|
||||
*self.pending_owner.lock().await = user_id;
|
||||
}
|
||||
|
||||
/// The user devices should auto-bind to while a web-console pairing window
|
||||
/// is open, if any.
|
||||
pub(crate) async fn pending_owner(&self) -> Option<String> {
|
||||
self.pending_owner.lock().await.clone()
|
||||
}
|
||||
|
||||
/// The underlying transport client (used by the `RelayAgent` impl + router).
|
||||
pub fn client(&self) -> &Arc<RelayClient> {
|
||||
&self.client
|
||||
@@ -194,6 +212,44 @@ impl RelayApp {
|
||||
|
||||
// ── Devices → Inbox ───────────────────────────────────────────────────────
|
||||
|
||||
/// Seal and send a single payload to one device (best-effort; a send failure
|
||||
/// is logged, never propagated).
|
||||
async fn send_to_device(&self, device: &[u8; 32], payload: &serde_json::Value) {
|
||||
match serde_json::to_vec(payload) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = self.client.send(device, &bytes, true).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to send payload to device");
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(plugin = PLUGIN_ID, error = %e, "failed to serialize device payload"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-service device binding (blueprint §13): resolve the presented web
|
||||
/// session token to a user and bind this device to them, then reply with a
|
||||
/// `bind_result`. An invalid/expired token yields `ok=false` so the app
|
||||
/// prompts the user to sign in again. The token is a bearer credential —
|
||||
/// never logged.
|
||||
async fn handle_bind_request(&self, from: &[u8; 32], session_token: &str) {
|
||||
match self.user_channel.user_for_session(session_token).await {
|
||||
Some(user_id) => match self.bind_device(*from, user_id.clone(), None).await {
|
||||
Ok(()) => {
|
||||
info!(plugin = PLUGIN_ID, user_id = %user_id, device = %hex::encode(from),
|
||||
"device self-bound via session token");
|
||||
self.send_to_device(from, &payloads::build_bind_result(true, Some(&user_id), None)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "self-bind failed");
|
||||
self.send_to_device(from, &payloads::build_bind_result(false, None, Some(&e.to_string()))).await;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
debug!(plugin = PLUGIN_ID, device = %hex::encode(from), "bind_request with invalid/expired session");
|
||||
self.send_to_device(from, &payloads::build_bind_result(false, None, Some("invalid or expired session"))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a decoded client payload to the sending device's *user's* Inbox.
|
||||
/// Unbound device or locked user → the request is ignored (no cross-user leak).
|
||||
async fn apply_client_payload(&self, from: &[u8; 32], payload: &[u8]) {
|
||||
@@ -213,6 +269,12 @@ impl RelayApp {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Self-service binding resolves its own user from the token — it must
|
||||
// NOT go through `user_for_device` (the device is not bound yet).
|
||||
ClientPayload::BindRequest { session_token } => {
|
||||
self.handle_bind_request(from, session_token).await;
|
||||
return;
|
||||
}
|
||||
ClientPayload::Unknown => {
|
||||
debug!(plugin = PLUGIN_ID, "unknown/ignored client payload");
|
||||
return;
|
||||
@@ -226,7 +288,10 @@ impl RelayApp {
|
||||
return;
|
||||
};
|
||||
let Some(handle) = self.user_channel.resolve_user(&user_id).await else {
|
||||
debug!(plugin = PLUGIN_ID, user_id = %user_id, "payload dropped — user locked");
|
||||
// Locked (§9): tell the app to run the login/unlock handshake rather
|
||||
// than silently dropping — the request is lost, but the app knows why.
|
||||
debug!(plugin = PLUGIN_ID, user_id = %user_id, "user locked — signalling needs_unlock");
|
||||
self.send_to_device(from, &payloads::build_needs_unlock()).await;
|
||||
return;
|
||||
};
|
||||
let inbox = handle.inbox();
|
||||
@@ -255,8 +320,11 @@ impl RelayApp {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to send targeted inbox snapshot");
|
||||
}
|
||||
}
|
||||
// Handled above.
|
||||
ClientPayload::Hello { .. } | ClientPayload::Logout | ClientPayload::Unknown => {}
|
||||
// Handled above (device-registry ops that return before this match).
|
||||
ClientPayload::Hello { .. }
|
||||
| ClientPayload::Logout
|
||||
| ClientPayload::BindRequest { .. }
|
||||
| ClientPayload::Unknown => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,19 +350,34 @@ impl RelayApp {
|
||||
self.apply_client_payload(&from, &payload).await;
|
||||
}
|
||||
Ok(RelayEvent::ClientPaired { ed25519_pub, .. }) => {
|
||||
// The device is not bound to any user yet, so there is no
|
||||
// one to push to. An admin binds it with `mobile_bind_device`
|
||||
// (which authorizes it). We only optionally pre-authorize.
|
||||
if !self.require_device_confirmation {
|
||||
if let Err(e) = self.client.authorize(&ed25519_pub).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "auto-authorize failed");
|
||||
// Web-console pairing: the admin who opened the window is
|
||||
// the pending owner, so bind (and thereby authorize) the
|
||||
// device to them straight away — usable on the phone at
|
||||
// once, reassignable later from the Devices page.
|
||||
if let Some(owner) = self.pending_owner().await {
|
||||
match self.bind_device(ed25519_pub, owner.clone(), None).await {
|
||||
Ok(()) => info!(
|
||||
plugin = PLUGIN_ID, user_id = %owner,
|
||||
device = %hex::encode(ed25519_pub),
|
||||
"new device paired — auto-bound to pairing admin"
|
||||
),
|
||||
Err(e) => warn!(plugin = PLUGIN_ID, error = %e, "auto-bind on pair failed"),
|
||||
}
|
||||
} else {
|
||||
// Agent-tool flow: no owner set. The device stays
|
||||
// Pending for an explicit `mobile_bind_device`; only
|
||||
// optionally pre-authorize per config.
|
||||
if !self.require_device_confirmation {
|
||||
if let Err(e) = self.client.authorize(&ed25519_pub).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "auto-authorize failed");
|
||||
}
|
||||
}
|
||||
info!(
|
||||
plugin = PLUGIN_ID,
|
||||
device = %hex::encode(ed25519_pub),
|
||||
"new device paired — awaiting admin binding (mobile_bind_device)"
|
||||
);
|
||||
}
|
||||
info!(
|
||||
plugin = PLUGIN_ID,
|
||||
device = %hex::encode(ed25519_pub),
|
||||
"new device paired — awaiting admin binding (mobile_bind_device)"
|
||||
);
|
||||
}
|
||||
Ok(RelayEvent::ClientRevoked { .. })
|
||||
| Ok(RelayEvent::Connected)
|
||||
|
||||
@@ -48,7 +48,7 @@ use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use core_api::plugin::{Plugin, PluginContext};
|
||||
use core_api::plugin::{Plugin, PluginContext, PluginPage};
|
||||
use skald_relay_client::{ClientState as RelayClientState, RelayClient, RelayClientConfig, SeedSource};
|
||||
|
||||
pub use agent::{ClientInfo, ClientState, PairingHandle, RelayAgent};
|
||||
@@ -232,6 +232,10 @@ impl Plugin for MobileConnectorPlugin {
|
||||
}
|
||||
fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) }
|
||||
|
||||
/// Access is the device→user binding (§13), not a `plugin_access` grant, so
|
||||
/// the admin Plugins UI hides the "User access" checklist for this plugin.
|
||||
fn manages_own_access(&self) -> bool { true }
|
||||
|
||||
fn config_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
@@ -305,6 +309,29 @@ impl Plugin for MobileConnectorPlugin {
|
||||
Some(router::build(Arc::clone(&self.inner)))
|
||||
}
|
||||
|
||||
/// Two admin-only console pages served from this plugin's own router
|
||||
/// (`web/*.js`). `manages_own_access` already hides them from non-admins.
|
||||
fn web_pages(&self) -> Vec<PluginPage> {
|
||||
vec![
|
||||
PluginPage {
|
||||
page_id: "pairing",
|
||||
title: "Pair a device".into(),
|
||||
icon: "qr-code",
|
||||
entry: "web/pairing.js".into(),
|
||||
admin_only: true,
|
||||
priority: 10,
|
||||
},
|
||||
PluginPage {
|
||||
page_id: "devices",
|
||||
title: "Mobile devices".into(),
|
||||
icon: "phone",
|
||||
entry: "web/devices.js".into(),
|
||||
admin_only: true,
|
||||
priority: 20,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Control tools (plugin.md §11). They close over the plugin itself as a
|
||||
/// `RelayAgent` and call into it lazily, so building them before the runloop
|
||||
/// starts is fine — they fail gracefully while it is stopped.
|
||||
|
||||
@@ -118,6 +118,35 @@ pub fn build_notification(title: &str, body: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a `bind_result` payload — the agent's reply to a `bind_request`
|
||||
/// (self-service device binding). `ok=true` carries the bound `user`; `ok=false`
|
||||
/// carries an `error` string (invalid/expired session, bind failure). The device
|
||||
/// uses it to confirm the pairing or to prompt the user to sign in again.
|
||||
pub fn build_bind_result(ok: bool, user: Option<&str>, error: Option<&str>) -> Value {
|
||||
serde_json::json!({
|
||||
"v": 1,
|
||||
"kind": "bind_result",
|
||||
"id": new_id(),
|
||||
"ts": Utc::now().timestamp_millis(),
|
||||
"ok": ok,
|
||||
"user": user, // Option<&str> → null when absent
|
||||
"error": error,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a `needs_unlock` payload — sent when a device acts for a user whose
|
||||
/// database is locked (§9). It tells the app to run the login/unlock handshake
|
||||
/// (`POST /api/auth/login` through the loopback proxy) rather than treating the
|
||||
/// dropped request as a hard failure.
|
||||
pub fn build_needs_unlock() -> Value {
|
||||
serde_json::json!({
|
||||
"v": 1,
|
||||
"kind": "needs_unlock",
|
||||
"id": new_id(),
|
||||
"ts": Utc::now().timestamp_millis(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Client → Agent ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A decoded client→agent payload (payloads.md §4). Only the fields the agent
|
||||
@@ -138,6 +167,11 @@ pub enum ClientPayload {
|
||||
/// §4.6). Sent after every `auth_ok`; the agent replies with a targeted
|
||||
/// `inbox_update`. No fields beyond the common envelope.
|
||||
InboxRequest,
|
||||
/// `bind_request`: self-service device binding. The device presents the
|
||||
/// `session_token` it obtained from `POST /api/auth/login`; the agent
|
||||
/// resolves it to a user and binds this device's pubkey to them (no admin
|
||||
/// step). The token is a bearer credential — never log it.
|
||||
BindRequest { session_token: String },
|
||||
/// `logout`: device removes itself.
|
||||
Logout,
|
||||
/// Anything else (ack, unknown kind, malformed request_id) — ignored.
|
||||
@@ -191,6 +225,10 @@ pub fn parse_client_payload(plaintext: &[u8]) -> ClientPayload {
|
||||
ClientPayload::ElicitationResponse { request_id: rid, action, content }
|
||||
}
|
||||
"inbox_request" => ClientPayload::InboxRequest,
|
||||
"bind_request" => match v.get("session_token").and_then(Value::as_str) {
|
||||
Some(tok) if !tok.is_empty() => ClientPayload::BindRequest { session_token: tok.to_string() },
|
||||
_ => ClientPayload::Unknown,
|
||||
},
|
||||
"logout" => ClientPayload::Logout,
|
||||
_ => ClientPayload::Unknown,
|
||||
}
|
||||
@@ -273,4 +311,49 @@ mod tests {
|
||||
}"#;
|
||||
assert!(matches!(parse_client_payload(raw), ClientPayload::Unknown));
|
||||
}
|
||||
|
||||
/// `bind_request` carries the session token the device logged in with.
|
||||
#[test]
|
||||
fn bind_request_parses_session_token() {
|
||||
let raw = br#"{
|
||||
"v": 1, "kind": "bind_request", "id": "abc", "ts": 1750000000000,
|
||||
"session_token": "tok-123"
|
||||
}"#;
|
||||
match parse_client_payload(raw) {
|
||||
ClientPayload::BindRequest { session_token } => assert_eq!(session_token, "tok-123"),
|
||||
other => panic!("expected BindRequest, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A missing or empty `session_token` is rejected as `Unknown` (never binds).
|
||||
#[test]
|
||||
fn bind_request_missing_or_empty_token_is_unknown() {
|
||||
let missing = br#"{ "v": 1, "kind": "bind_request", "id": "a", "ts": 1 }"#;
|
||||
let empty = br#"{ "v": 1, "kind": "bind_request", "id": "a", "ts": 1, "session_token": "" }"#;
|
||||
assert!(matches!(parse_client_payload(missing), ClientPayload::Unknown));
|
||||
assert!(matches!(parse_client_payload(empty), ClientPayload::Unknown));
|
||||
}
|
||||
|
||||
/// `bind_result` shape: ok carries the user; failure carries the error.
|
||||
#[test]
|
||||
fn bind_result_shape() {
|
||||
let ok = build_bind_result(true, Some("u1"), None);
|
||||
assert_eq!(ok["kind"], "bind_result");
|
||||
assert_eq!(ok["ok"], true);
|
||||
assert_eq!(ok["user"], "u1");
|
||||
assert!(ok["error"].is_null());
|
||||
|
||||
let err = build_bind_result(false, None, Some("invalid or expired session"));
|
||||
assert_eq!(err["ok"], false);
|
||||
assert!(err["user"].is_null());
|
||||
assert_eq!(err["error"], "invalid or expired session");
|
||||
}
|
||||
|
||||
/// `needs_unlock` is a bare envelope the app reacts to by (re)logging in.
|
||||
#[test]
|
||||
fn needs_unlock_shape() {
|
||||
let p = build_needs_unlock();
|
||||
assert_eq!(p["kind"], "needs_unlock");
|
||||
assert_eq!(p["v"], 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,243 @@
|
||||
//! The single HTTP route the plugin contributes: the runtime QR-code endpoint
|
||||
//! (plugin.md §5). Mounted by the main `WebFrontend` under
|
||||
//! `/api/plugin/mobile-connector/` behind Skald's normal auth. No QR is ever
|
||||
//! written to disk — the PNG is rendered on demand from the in-memory session.
|
||||
//! The plugin's HTTP surface, mounted by the main `WebFrontend` under
|
||||
//! `/api/plugin/mobile-connector/` behind Skald's normal auth + enabled-gate.
|
||||
//!
|
||||
//! The router receives the plugin's shared state cell
|
||||
//! (`Arc<Mutex<Option<Arc<RelayState>>>>`) so that every request resolves the
|
||||
//! **current** `RelayState` — the same one the LLM tools use. This avoids the
|
||||
//! classic stale-Arc bug when the plugin is reconfigured (reload stops the old
|
||||
//! runloop + creates a fresh `RelayState`, but the router is only built once).
|
||||
//! Two audiences on one router:
|
||||
//! - the **QR endpoint** (`/pairingqrcode`) — renders the pairing QR PNG on
|
||||
//! demand from the in-memory session (no QR ever touches disk);
|
||||
//! - the **admin pairing console** — the JSON API + the two page fragments
|
||||
//! (`web/pairing.js`, `web/devices.js`) that let an admin pair, list, bind and
|
||||
//! revoke devices from the browser instead of driving the LLM control tools.
|
||||
//!
|
||||
//! Every request resolves the *current* [`RelayApp`] through the shared state
|
||||
//! cell (`Arc<Mutex<Option<Arc<RelayApp>>>>`), so a reconfigure (reload → fresh
|
||||
//! `RelayApp`) is transparent. Management endpoints are admin-only: the router
|
||||
//! runs inside `require_auth` (which injects [`Caller`]) and gates on
|
||||
//! [`UserChannelApi::plugin_access`], which — because the connector
|
||||
//! `manages_own_access` — returns `true` only for admins.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use skald_relay_client::SessionState;
|
||||
use core_api::plugin::Caller;
|
||||
use skald_relay_client::{ClientState, SessionState};
|
||||
|
||||
use crate::app::RelayApp;
|
||||
use crate::PLUGIN_ID;
|
||||
|
||||
/// Shared cell type: an `Arc` to a `Mutex` holding the (optional) live app.
|
||||
/// Cloned cheaply and safely shared between the plugin and the router.
|
||||
type StateCell = Arc<Mutex<Option<Arc<RelayApp>>>>;
|
||||
|
||||
/// Build the plugin's router. Takes the shared state cell so each request
|
||||
/// resolves the *current* `RelayApp` — not a snapshot from startup.
|
||||
pub fn build(state_cell: StateCell) -> Router {
|
||||
Router::new()
|
||||
.route("/pairingqrcode", get(pairing_qr))
|
||||
// Page fragments (served as ES modules to the browser).
|
||||
.route("/web/pairing.js", get(|| async { serve_js(include_str!("../web/pairing.js")) }))
|
||||
.route("/web/devices.js", get(|| async { serve_js(include_str!("../web/devices.js")) }))
|
||||
.route("/web/common.js", get(|| async { serve_js(include_str!("../web/common.js")) }))
|
||||
// Admin pairing console API.
|
||||
.route("/pairing", post(start_pairing).delete(stop_pairing))
|
||||
.route("/devices", get(list_devices))
|
||||
.route("/devices/bind", post(bind_device))
|
||||
.route("/devices/revoke", post(revoke_device))
|
||||
.with_state(state_cell)
|
||||
}
|
||||
|
||||
// ── Admin console: shared plumbing ──────────────────────────────────────────────
|
||||
|
||||
/// Resolve the live app, or `503` when the plugin is enabled but its runloop is
|
||||
/// not up (e.g. no `relay_url` configured).
|
||||
async fn app_or_503(cell: &StateCell) -> Result<Arc<RelayApp>, Response> {
|
||||
cell.lock().await.as_ref().map(Arc::clone).ok_or_else(|| {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "mobile connector is not running").into_response()
|
||||
})
|
||||
}
|
||||
|
||||
/// Fail-closed admin gate. For a `manages_own_access` connector nobody holds a
|
||||
/// `plugin_access` grant, so this is `true` only for the built-in admin role.
|
||||
async fn require_admin(app: &RelayApp, caller: &Caller) -> Result<(), Response> {
|
||||
if app.user_channel.plugin_access(PLUGIN_ID, &caller.user_id).await {
|
||||
Ok(())
|
||||
} else {
|
||||
Err((StatusCode::FORBIDDEN, "admin only").into_response())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the app and check admin in one step (the common prelude).
|
||||
async fn admin_app(cell: &StateCell, caller: &Caller) -> Result<Arc<RelayApp>, Response> {
|
||||
let app = app_or_503(cell).await?;
|
||||
require_admin(&app, caller).await?;
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
fn bad_request(msg: impl Into<String>) -> Response {
|
||||
(StatusCode::BAD_REQUEST, msg.into()).into_response()
|
||||
}
|
||||
|
||||
fn decode_pubkey(hex: &str) -> Result<[u8; 32], Response> {
|
||||
skald_relay_common::crypto::decode_hex::<32>(hex)
|
||||
.ok_or_else(|| bad_request("`pubkey` must be 32-byte hex"))
|
||||
}
|
||||
|
||||
// ── POST/DELETE /pairing ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StartPairingBody {
|
||||
/// Window lifetime in seconds; `0`/absent = the configured default, capped at 600.
|
||||
#[serde(default)]
|
||||
ttl: Option<u32>,
|
||||
}
|
||||
|
||||
/// Open a pairing window and return the QR URL. The caller (an admin) becomes
|
||||
/// the pending owner, so a device that pairs in this window auto-binds to them.
|
||||
async fn start_pairing(
|
||||
State(cell): State<StateCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
Json(body): Json<StartPairingBody>,
|
||||
) -> Response {
|
||||
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
|
||||
// Pairing brokers through the relay: without a live WS there is no channel to
|
||||
// send `pairing_start` on ("WS outbound channel closed"). Fail with an
|
||||
// actionable message instead of the transport-level one.
|
||||
if !app.client().is_connected() {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Relay not connected. Set the connector's relay_url and make sure the relay is reachable, then try again.",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let ttl = body.ttl.unwrap_or(0).min(600);
|
||||
app.set_pending_owner(Some(caller.user_id.clone())).await;
|
||||
match app.client().start_pairing(ttl).await {
|
||||
Ok(started) => Json(json!({
|
||||
"url": format!("/api/plugin/{PLUGIN_ID}/pairingqrcode?code={}", started.code),
|
||||
"code": started.code,
|
||||
"expires_at": started.expires_at,
|
||||
}))
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
app.set_pending_owner(None).await;
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the pairing window and disarm auto-binding.
|
||||
async fn stop_pairing(
|
||||
State(cell): State<StateCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
) -> Response {
|
||||
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
|
||||
app.set_pending_owner(None).await;
|
||||
match app.client().stop_pairing().await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /devices ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// List every known device, each tagged with its bound user, state and metadata.
|
||||
async fn list_devices(
|
||||
State(cell): State<StateCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
) -> Response {
|
||||
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
|
||||
let rows = app.client().list_clients().await;
|
||||
let bindings = app.bindings.read().await;
|
||||
let devices: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let pk_hex = hex::encode(r.ed25519_pub);
|
||||
let bound_user = bindings.user_for_pubkey(&pk_hex);
|
||||
let device_info: Option<Value> =
|
||||
r.device_info.as_deref().and_then(|s| serde_json::from_str(s).ok());
|
||||
json!({
|
||||
"pubkey": pk_hex,
|
||||
"state": if r.state == ClientState::Authorized { "authorized" } else { "pending" },
|
||||
"bound_user": bound_user,
|
||||
"platform": r.platform,
|
||||
"device_info": device_info,
|
||||
"last_seen": r.last_seen,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Json(json!({ "devices": devices })).into_response()
|
||||
}
|
||||
|
||||
// ── POST /devices/bind + /devices/revoke ────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BindBody {
|
||||
pubkey: String,
|
||||
user_id: String,
|
||||
#[serde(default)]
|
||||
display: Option<String>,
|
||||
}
|
||||
|
||||
/// Bind (or reassign) a device to a user and authorize it.
|
||||
async fn bind_device(
|
||||
State(cell): State<StateCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
Json(body): Json<BindBody>,
|
||||
) -> Response {
|
||||
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
|
||||
let pk = match decode_pubkey(&body.pubkey) { Ok(p) => p, Err(r) => return r };
|
||||
if body.user_id.trim().is_empty() {
|
||||
return bad_request("`user_id` must not be empty");
|
||||
}
|
||||
match app.bind_device(pk, body.user_id, body.display).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RevokeBody {
|
||||
pubkey: String,
|
||||
}
|
||||
|
||||
/// Revoke a device and drop its binding.
|
||||
async fn revoke_device(
|
||||
State(cell): State<StateCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
Json(body): Json<RevokeBody>,
|
||||
) -> Response {
|
||||
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
|
||||
let pk = match decode_pubkey(&body.pubkey) { Ok(p) => p, Err(r) => return r };
|
||||
match app.revoke_device(pk).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Static fragment serving ─────────────────────────────────────────────────────
|
||||
|
||||
/// Serve an embedded ES module as `text/javascript`. The shell already adds
|
||||
/// `Cache-Control: no-cache`, so a rebuilt fragment is never served stale.
|
||||
fn serve_js(body: &'static str) -> Response {
|
||||
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], body).into_response()
|
||||
}
|
||||
|
||||
// ── QR endpoint (unchanged) ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QrQuery {
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the plugin's router. Takes the shared state cell so each request
|
||||
/// resolves the *current* `RelayState` — not a snapshot from startup.
|
||||
pub fn build(state_cell: StateCell) -> Router {
|
||||
Router::new()
|
||||
.route("/pairingqrcode", get(pairing_qr))
|
||||
.with_state(state_cell)
|
||||
}
|
||||
|
||||
/// `GET /pairingqrcode?code=<random>` → PNG of the QR while active, else a
|
||||
/// placeholder PNG (plugin.md §5 table).
|
||||
/// placeholder PNG.
|
||||
async fn pairing_qr(
|
||||
State(cell): State<StateCell>,
|
||||
Query(q): Query<QrQuery>,
|
||||
@@ -50,23 +246,19 @@ async fn pairing_qr(
|
||||
return png_response(render_placeholder("QR non valido"));
|
||||
};
|
||||
|
||||
// Dynamically resolve the *current* RelayApp (same one tools use).
|
||||
let app = match cell.lock().await.as_ref() {
|
||||
Some(s) => Arc::clone(s),
|
||||
None => return png_response(render_placeholder("Plugin non attivo")),
|
||||
};
|
||||
|
||||
match app.client().lookup_pairing(&code) {
|
||||
Some((qr, SessionState::Active)) => {
|
||||
// Encode the normative QrCodeData JSON into the QR.
|
||||
match serde_json::to_string(&qr) {
|
||||
Ok(json) => match render_qr(&json) {
|
||||
Ok(png) => png_response(png),
|
||||
Err(_) => png_response(render_placeholder("QR error")),
|
||||
},
|
||||
Some((qr, SessionState::Active)) => match serde_json::to_string(&qr) {
|
||||
Ok(json) => match render_qr(&json) {
|
||||
Ok(png) => png_response(png),
|
||||
Err(_) => png_response(render_placeholder("QR error")),
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(_) => png_response(render_placeholder("QR error")),
|
||||
},
|
||||
Some((_, SessionState::Consumed)) => png_response(render_placeholder("QR already used")),
|
||||
Some((_, SessionState::Superseded)) => png_response(render_placeholder("QR expired")),
|
||||
None => png_response(render_placeholder("QR expired")),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Shared helpers for the mobile-connector console fragments.
|
||||
//
|
||||
// Served at `/api/plugin/mobile-connector/web/common.js` and imported by the
|
||||
// two page fragments via a relative `./common.js` specifier. Everything the
|
||||
// fragments need is self-contained here — the host injects no APIs (see
|
||||
// `Plugin::web_pages` contract): they talk only to `/api/plugin/<id>/…` and,
|
||||
// for the user directory used by the reassign dropdown, the host `/api/users`
|
||||
// (the fragment runs with the logged-in admin's full session privileges).
|
||||
import { LitElement } from 'lit';
|
||||
|
||||
/// JSON fetch that throws the server's error text on non-2xx and tolerates an
|
||||
/// empty (204) body.
|
||||
export async function jf(url, opts = {}) {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||
...opts,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '');
|
||||
throw new Error(txt || `HTTP ${res.status}`);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
return ct.includes('application/json') ? res.json() : res.text();
|
||||
}
|
||||
|
||||
/// Base for the console fragments: renders into light DOM (so Bootstrap classes
|
||||
/// and the app's theme CSS variables apply) and exposes the plugin's API root
|
||||
/// from the host-set `plugin-id` attribute.
|
||||
export class MobileBase extends LitElement {
|
||||
createRenderRoot() { return this; }
|
||||
get api() { return `/api/plugin/${this.getAttribute('plugin-id') || 'mobile-connector'}`; }
|
||||
}
|
||||
|
||||
/// Human-friendly "time ago" for a Unix-ms timestamp (or "—" when absent).
|
||||
export function ago(ms) {
|
||||
if (!ms) return '—';
|
||||
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
/// Best-effort device label from the `device_info` JSON a phone sends on hello.
|
||||
export function deviceLabel(d) {
|
||||
const info = d.device_info || {};
|
||||
return info.name || info.model || info.device || d.platform || 'Unknown device';
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Mobile-connector "Mobile devices" console (page_id `devices`).
|
||||
//
|
||||
// Lists every paired device with its state and bound user, and lets an admin
|
||||
// reassign a device to another user (`POST /devices/bind`) or revoke it
|
||||
// (`POST /devices/revoke`). The user directory for the reassign dropdown comes
|
||||
// from the host `/api/users` (the fragment runs with the admin's session).
|
||||
// Default-exports the element class; the host registers it.
|
||||
import { html, nothing } from 'lit';
|
||||
import { MobileBase, jf, ago, deviceLabel } from './common.js';
|
||||
|
||||
export default class MobileDevicesPage extends MobileBase {
|
||||
static get properties() {
|
||||
return {
|
||||
_devices: { state: true }, // [] | null (loading)
|
||||
_users: { state: true }, // [{id, username, display_name}]
|
||||
_error: { state: true },
|
||||
_pick: { state: true }, // { [pubkey]: user_id } reassign selections
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._devices = null;
|
||||
this._users = [];
|
||||
this._error = null;
|
||||
this._pick = {};
|
||||
this._poll = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._load();
|
||||
this._poll = setInterval(() => this._load(true), 5000);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._poll) { clearInterval(this._poll); this._poll = null; }
|
||||
}
|
||||
|
||||
async _load(quiet = false) {
|
||||
if (!quiet) this._error = null;
|
||||
try {
|
||||
const [d, u] = await Promise.all([
|
||||
jf(`${this.api}/devices`),
|
||||
this._users.length ? Promise.resolve({ list: this._users }) : jf('/api/users').then(list => ({ list })),
|
||||
]);
|
||||
this._devices = d.devices || [];
|
||||
if (u.list) this._users = u.list;
|
||||
} catch (e) {
|
||||
if (!quiet) this._error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_userName(id) {
|
||||
const u = this._users.find(x => x.id === id);
|
||||
return u ? (u.display_name || u.username) : id;
|
||||
}
|
||||
|
||||
async _bind(pubkey) {
|
||||
const user_id = this._pick[pubkey];
|
||||
if (!user_id) return;
|
||||
try {
|
||||
await jf(`${this.api}/devices/bind`, { method: 'POST', body: JSON.stringify({ pubkey, user_id }) });
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
async _revoke(pubkey) {
|
||||
if (!confirm('Revoke this device? It loses access immediately.')) return;
|
||||
try {
|
||||
await jf(`${this.api}/devices/revoke`, { method: 'POST', body: JSON.stringify({ pubkey }) });
|
||||
await this._load();
|
||||
} catch (e) { this._error = e.message; }
|
||||
}
|
||||
|
||||
render() {
|
||||
const loading = this._devices === null && !this._error;
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<div class="um-header d-flex justify-content-between align-items-center">
|
||||
<h2 class="um-title"><i class="bi bi-phone me-2"></i>Mobile devices</h2>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._load()}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Refresh</button>
|
||||
</div>
|
||||
<div style="padding:0 1.25rem 1.5rem">
|
||||
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> Loading…</div>` : this._renderList()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderList() {
|
||||
const rows = this._devices || [];
|
||||
if (!rows.length) {
|
||||
return html`<div class="um-empty" style="padding:1rem">
|
||||
<i class="bi bi-phone"></i><p>No paired devices yet.</p>
|
||||
<p style="font-size:.8rem;opacity:.7">Use the <em>Pair a device</em> page to add one.</p>
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle" style="font-size:.88rem">
|
||||
<thead><tr>
|
||||
<th>Device</th><th>State</th><th>Bound to</th><th>Last seen</th><th class="text-end">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.map(d => this._renderRow(d))}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderRow(d) {
|
||||
const authorized = d.state === 'authorized';
|
||||
return html`
|
||||
<tr>
|
||||
<td>
|
||||
<div>${deviceLabel(d)}</div>
|
||||
<div class="text-body-secondary" style="font-size:.72rem; font-family:var(--font-mono,monospace)">
|
||||
${d.pubkey.slice(0, 16)}…</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${authorized ? 'text-bg-success' : 'text-bg-secondary'}">${d.state}</span>
|
||||
</td>
|
||||
<td>${d.bound_user ? this._userName(d.bound_user) : html`<span class="text-body-secondary">—</span>`}</td>
|
||||
<td class="text-body-secondary">${ago(d.last_seen)}</td>
|
||||
<td class="text-end">
|
||||
<div class="d-inline-flex gap-1 align-items-center">
|
||||
<select class="form-select form-select-sm" style="width:auto"
|
||||
.value=${this._pick[d.pubkey] || d.bound_user || ''}
|
||||
@change=${(e) => { this._pick = { ...this._pick, [d.pubkey]: e.target.value }; }}>
|
||||
<option value="">Assign to…</option>
|
||||
${this._users.map(u => html`<option value=${u.id}>${u.display_name || u.username}</option>`)}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary"
|
||||
?disabled=${!this._pick[d.pubkey] || this._pick[d.pubkey] === d.bound_user}
|
||||
@click=${() => this._bind(d.pubkey)}>Bind</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @click=${() => this._revoke(d.pubkey)}>
|
||||
<i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Mobile-connector "Pair a device" console (page_id `pairing`).
|
||||
//
|
||||
// Opens a pairing window on the plugin (`POST /pairing`), shows the QR the phone
|
||||
// scans, and counts down to expiry. A device that pairs in this window is
|
||||
// auto-bound to the admin who opened it (server-side, on `ClientPaired`) — so it
|
||||
// is usable on the phone immediately and can be reassigned later from the
|
||||
// Devices page. Default-exports the element class; the host registers it.
|
||||
import { html, nothing } from 'lit';
|
||||
import { MobileBase, jf } from './common.js';
|
||||
|
||||
export default class MobilePairingPage extends MobileBase {
|
||||
static get properties() {
|
||||
return {
|
||||
_session: { state: true }, // { url, code, expires_at } | null
|
||||
_remain: { state: true }, // seconds until expiry
|
||||
_busy: { state: true },
|
||||
_error: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._session = null;
|
||||
this._remain = 0;
|
||||
this._busy = false;
|
||||
this._error = null;
|
||||
this._timer = null;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopTimer();
|
||||
// Best-effort close so a forgotten window does not linger.
|
||||
if (this._session) jf(`${this.api}/pairing`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
|
||||
_stopTimer() { if (this._timer) { clearInterval(this._timer); this._timer = null; } }
|
||||
|
||||
_startTimer() {
|
||||
this._stopTimer();
|
||||
const tick = () => {
|
||||
const remain = Math.max(0, Math.round((this._session.expires_at - Date.now()) / 1000));
|
||||
this._remain = remain;
|
||||
if (remain <= 0) { this._stopTimer(); }
|
||||
};
|
||||
tick();
|
||||
this._timer = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
async _open() {
|
||||
this._busy = true;
|
||||
this._error = null;
|
||||
try {
|
||||
this._session = await jf(`${this.api}/pairing`, { method: 'POST', body: JSON.stringify({}) });
|
||||
this._startTimer();
|
||||
} catch (e) {
|
||||
this._error = e.message;
|
||||
this._session = null;
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _stop() {
|
||||
this._stopTimer();
|
||||
const had = this._session;
|
||||
this._session = null;
|
||||
if (had) { try { await jf(`${this.api}/pairing`, { method: 'DELETE' }); } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
render() {
|
||||
const expired = this._session && this._remain <= 0;
|
||||
return html`
|
||||
<div class="um-page">
|
||||
<div class="um-header">
|
||||
<h2 class="um-title"><i class="bi bi-qr-code me-2"></i>Pair a device</h2>
|
||||
</div>
|
||||
<div style="padding:0 1.25rem 1.5rem; max-width:640px">
|
||||
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
|
||||
|
||||
${!this._session ? html`
|
||||
<p class="text-body-secondary" style="font-size:.9rem">
|
||||
Open a pairing window, then scan the QR code with the Skald mobile app.
|
||||
The device is linked to <strong>you</strong> and works immediately — you can
|
||||
reassign it to another user from the <em>Mobile devices</em> page.
|
||||
</p>
|
||||
<button class="btn btn-primary" ?disabled=${this._busy} @click=${() => this._open()}>
|
||||
<i class="bi bi-qr-code-scan me-1"></i>${this._busy ? 'Opening…' : 'Open pairing window'}
|
||||
</button>
|
||||
` : html`
|
||||
<div class="d-flex flex-column align-items-center gap-3 p-3"
|
||||
style="border:1px solid var(--border-color,#ddd); border-radius:var(--radius-md,12px)">
|
||||
<img src=${this._session.url} alt="Pairing QR" width="256" height="256"
|
||||
style="image-rendering:pixelated; ${expired ? 'opacity:.25' : ''}" />
|
||||
${expired
|
||||
? html`<div class="text-danger" style="font-size:.9rem"><i class="bi bi-clock-history me-1"></i>Window expired</div>`
|
||||
: html`<div class="text-body-secondary" style="font-size:.9rem">
|
||||
Scan within <strong>${this._remain}s</strong>
|
||||
</div>`}
|
||||
<div class="d-flex gap-2">
|
||||
${expired
|
||||
? html`<button class="btn btn-primary btn-sm" @click=${() => this._open()}>
|
||||
<i class="bi bi-arrow-repeat me-1"></i>New code</button>`
|
||||
: html`<button class="btn btn-outline-secondary btn-sm" @click=${() => this._stop()}>
|
||||
<i class="bi bi-x-lg me-1"></i>Close</button>`}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user