feat(mobile): per-user device bindings, multi-user Inbox routing, and admin-mediated authorization

- Device→user bindings persisted in config table (auth.rs), loaded at plugin start
- RelayApp now routes Inbox responses per-user via UserChannelApi, never globally
- New mobile_bind_device LLM tool for admin-mediated device→user assignment
- Per-user event forwarders (events.rs) with per-user debounced notifiers
- Config listener (auth::config_listener) refreshes bindings cache reactively
- Reconcile loop catches users who unlock after boot
- Hello/Logout treated as device-registry ops (no user resolution needed)
- Unbound device payloads are silently dropped
- RelayAgent::authorize_client → bind_device (atomic bind + authorize)
- Approval rules seed mobile_bind_device/revoke_device as require
This commit is contained in:
2026-07-11 11:18:35 +01:00
parent a847dda88f
commit 2c54778116
15 changed files with 856 additions and 221 deletions
+64 -2
View File
@@ -19,6 +19,7 @@ use crate::agent::{ClientState, RelayAgent};
/// Tool name constants (also the patterns the host uses for approval rules).
pub const TOOL_START_PAIRING: &str = "mobile_start_pairing";
pub const TOOL_LIST_DEVICES: &str = "mobile_list_devices";
pub const TOOL_BIND_DEVICE: &str = "mobile_bind_device";
pub const TOOL_REVOKE_DEVICE: &str = "mobile_revoke_device";
/// Build the plugin's LLM tools, bound to a `RelayAgent`. The host calls this
@@ -27,6 +28,7 @@ pub fn mobile_tools(agent: Arc<dyn RelayAgent>) -> Vec<Arc<dyn Tool>> {
vec![
Arc::new(StartPairingTool { agent: Arc::clone(&agent) }),
Arc::new(ListDevicesTool { agent: Arc::clone(&agent) }),
Arc::new(BindDeviceTool { agent: Arc::clone(&agent) }),
Arc::new(RevokeDeviceTool { agent }),
]
}
@@ -82,7 +84,8 @@ struct ListDevicesTool {
impl Tool for ListDevicesTool {
fn name(&self) -> &str { TOOL_LIST_DEVICES }
fn description(&self) -> &str {
"List paired mobile devices: state (pending/authorized), platform, device info, last seen."
"List paired mobile devices: state (pending/authorized), bound user, platform, device info, last seen. \
Use the `ed25519_pub` of a pending device with mobile_bind_device to assign it to a user."
}
fn parameters_schema(&self) -> Value {
json!({ "type": "object", "properties": {} })
@@ -109,6 +112,7 @@ impl Tool for ListDevicesTool {
ClientState::Authorized => "authorized",
ClientState::Pending => "pending",
},
"bound_user": c.bound_user,
"platform": c.platform,
"device_info": device_info,
"last_seen": c.last_seen,
@@ -120,6 +124,63 @@ impl Tool for ListDevicesTool {
}
}
// ── mobile_bind_device ────────────────────────────────────────────────────────
struct BindDeviceTool {
agent: Arc<dyn RelayAgent>,
}
impl Tool for BindDeviceTool {
fn name(&self) -> &str { TOOL_BIND_DEVICE }
fn description(&self) -> &str {
"Bind a paired mobile device to a Skald user and authorize it. The device then \
receives that user's Inbox (approvals/clarifications/elicitations) and push \
notifications — and only that user's. Requires user approval."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pubkey": {
"type": "string",
"description": "The device's ed25519 public key, hex-encoded (64 chars), from mobile_list_devices."
},
"user_id": {
"type": "string",
"description": "The Skald user id to bind this device to."
},
"display": {
"type": "string",
"description": "Optional human-friendly label for the binding."
}
},
"required": ["pubkey", "user_id"]
})
}
fn category(&self) -> ToolCategory { ToolCategory::Config }
fn execute_async<'a>(
&'a self,
args: Value,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
Box::pin(async move {
let pubkey = args
.get("pubkey")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("mobile_bind_device: missing `pubkey`"))?;
let user_id = args
.get("user_id")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("mobile_bind_device: missing `user_id`"))?;
let display = args.get("display").and_then(Value::as_str).map(str::to_string);
let ed = skald_relay_common::crypto::decode_hex::<32>(pubkey)
.ok_or_else(|| anyhow::anyhow!("mobile_bind_device: `pubkey` is not 32-byte hex"))?;
self.agent.bind_device(ed, user_id.to_string(), display).await?;
Ok(format!("Device {pubkey} bound to user {user_id} and authorized."))
})
}
}
// ── mobile_revoke_device ──────────────────────────────────────────────────────
struct RevokeDeviceTool {
@@ -129,7 +190,8 @@ struct RevokeDeviceTool {
impl Tool for RevokeDeviceTool {
fn name(&self) -> &str { TOOL_REVOKE_DEVICE }
fn description(&self) -> &str {
"Revoke a paired mobile device by its ed25519 public key (hex). The device loses access immediately."
"Revoke a paired mobile device by its ed25519 public key (hex) and drop its user binding. \
The device loses access immediately."
}
fn parameters_schema(&self) -> Value {
json!({