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)
|
||||
|
||||
Reference in New Issue
Block a user