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:
@@ -22,6 +22,7 @@ use tokio::sync::broadcast;
|
||||
use crate::approval::ApprovalApi;
|
||||
use crate::chat_hub::ChatHubApi;
|
||||
use crate::events::GlobalEvent;
|
||||
use crate::inbox::InboxApi;
|
||||
|
||||
/// Resolves an unlocked user's channel handle.
|
||||
///
|
||||
@@ -51,6 +52,12 @@ pub trait UserChannelHandle: Send + Sync {
|
||||
/// The user's approval manager — resolve pending tool-call approvals.
|
||||
fn approval(&self) -> Arc<dyn ApprovalApi>;
|
||||
|
||||
/// The user's Inbox — the unified view over pending approvals,
|
||||
/// clarifications and MCP elicitations. Channel adapters that bridge the
|
||||
/// whole Inbox (e.g. the mobile connector) use this instead of wiring
|
||||
/// `approval()`/clarification/elicitation separately.
|
||||
fn inbox(&self) -> Arc<dyn InboxApi>;
|
||||
|
||||
/// Subscribe to the user's server→client event stream.
|
||||
/// Events are scoped to this user; no cross-user leakage.
|
||||
fn subscribe(&self) -> broadcast::Receiver<GlobalEvent>;
|
||||
|
||||
@@ -31,6 +31,8 @@ pub struct ClientInfo {
|
||||
pub platform: Option<String>,
|
||||
/// Unix ms of last activity, if any.
|
||||
pub last_seen: Option<i64>,
|
||||
/// The Skald user this device is bound to, if any (blueprint §13).
|
||||
pub bound_user: Option<String>,
|
||||
}
|
||||
|
||||
/// The control API exposed by the plugin. Reachable via
|
||||
@@ -50,18 +52,18 @@ pub trait RelayAgent: Send + Sync {
|
||||
/// Derived namespace id (hex).
|
||||
fn namespace_id(&self) -> String;
|
||||
|
||||
/// Send the current Inbox snapshot to all authorized clients.
|
||||
async fn broadcast_inbox(&self) -> anyhow::Result<()>;
|
||||
|
||||
/// Generic push notification to all authorized clients.
|
||||
async fn broadcast_notification(&self, title: &str, body: &str) -> anyhow::Result<()>;
|
||||
|
||||
/// List all known devices.
|
||||
/// List all known devices, each tagged with its bound user (if any).
|
||||
async fn list_clients(&self) -> Vec<ClientInfo>;
|
||||
|
||||
/// Authorize a Pending device by its ed25519 pubkey.
|
||||
async fn authorize_client(&self, ed25519_pub: [u8; 32]) -> anyhow::Result<()>;
|
||||
/// Bind a paired device to a Skald user and authorize it (blueprint §13,
|
||||
/// admin-mediated — the mobile analogue of `telegram_pairing`).
|
||||
async fn bind_device(
|
||||
&self,
|
||||
ed25519_pub: [u8; 32],
|
||||
user_id: String,
|
||||
display: Option<String>,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
/// Revoke a device (lost/stolen) by its ed25519 pubkey.
|
||||
/// Revoke a device (lost/stolen) by its ed25519 pubkey and drop its binding.
|
||||
async fn revoke_client(&self, ed25519_pub: [u8; 32]) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,80 @@
|
||||
//! The application layer on top of the payload-agnostic [`RelayClient`].
|
||||
//!
|
||||
//! `RelayApp` owns the Skald-specific semantics that the transport crate
|
||||
//! deliberately knows nothing about: the E2E JSON payload schemas (`payloads`),
|
||||
//! the `InboxApi` dispatch, and the authorization policy. It consumes
|
||||
//! `client.events()` and calls `client.send(...)`; the client handles the wire,
|
||||
//! crypto, counters, and device registry.
|
||||
//! `RelayApp` is the plugin's central shared hub (the mobile analogue of the
|
||||
//! Telegram `TgShared`): it owns the E2E payload semantics, the device→user
|
||||
//! bindings cache, and the per-user forwarder/notifier registries. It knows
|
||||
//! nothing about the wire — the client handles transport, crypto, counters, and
|
||||
//! the device registry.
|
||||
//!
|
||||
//! # Multi-user (blueprint §13)
|
||||
//!
|
||||
//! Every device is bound to one Skald user (`auth::Binding`). Inbound payloads
|
||||
//! resolve the sending device's user, then apply to **that user's** Inbox via the
|
||||
//! [`UserChannelApi`] seam. Outbound Inbox pushes go only to the authorized
|
||||
//! devices bound to the target user — never a global broadcast. A per-user
|
||||
//! forwarder (`events`) drives pushes from the user's event stream.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::{broadcast, Mutex, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use core_api::inbox::InboxApi;
|
||||
use core_api::config_api::ConfigApi;
|
||||
use core_api::user_channel::UserChannelApi;
|
||||
use skald_relay_client::{ClientState, RelayClient, RelayEvent};
|
||||
|
||||
use crate::PLUGIN_ID;
|
||||
use crate::auth::{self, MobileConfig};
|
||||
use crate::notifier::DelayedNotifier;
|
||||
use crate::payloads::{self, ClientPayload};
|
||||
|
||||
/// Glue between the relay transport ([`RelayClient`]) and Skald's Inbox.
|
||||
/// The plugin's shared application state.
|
||||
pub struct RelayApp {
|
||||
client: Arc<RelayClient>,
|
||||
inbox: Arc<dyn InboxApi>,
|
||||
/// When true, a freshly paired device stays Pending until a human confirms;
|
||||
/// when false, the app auto-authorizes on `ClientPaired`.
|
||||
/// Per-user runtime resolver (blueprint §13). `None` = user locked (§9).
|
||||
pub(crate) user_channel: Arc<dyn UserChannelApi>,
|
||||
/// Config store — used to persist binding removals (logout/revoke).
|
||||
config: Arc<dyn ConfigApi>,
|
||||
/// Device→user bindings, cached in memory; kept in sync by `auth::config_listener`.
|
||||
pub(crate) bindings: RwLock<MobileConfig>,
|
||||
/// When true, a freshly paired device stays Pending until an admin binds it
|
||||
/// (`mobile_bind_device`, which authorizes it). Binding *is* the confirmation.
|
||||
require_device_confirmation: bool,
|
||||
/// Debounce before an unresolved Inbox item is pushed to the phone.
|
||||
pub(crate) notify_delay: Duration,
|
||||
/// Cancellation for every task spawned by this run (forwarders, listeners).
|
||||
cancel: CancellationToken,
|
||||
/// user_ids with an active per-user forwarder task.
|
||||
pub(crate) forwarders: Mutex<HashSet<String>>,
|
||||
/// Per-user debounced notifiers, created on demand by the forwarders.
|
||||
pub(crate) notifiers: Mutex<HashMap<String, Arc<DelayedNotifier>>>,
|
||||
}
|
||||
|
||||
impl RelayApp {
|
||||
pub fn new(
|
||||
client: Arc<RelayClient>,
|
||||
inbox: Arc<dyn InboxApi>,
|
||||
user_channel: Arc<dyn UserChannelApi>,
|
||||
config: Arc<dyn ConfigApi>,
|
||||
bindings: MobileConfig,
|
||||
require_device_confirmation: bool,
|
||||
) -> Self {
|
||||
Self { client, inbox, require_device_confirmation }
|
||||
notify_delay: Duration,
|
||||
cancel: CancellationToken,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
client,
|
||||
user_channel,
|
||||
config,
|
||||
bindings: RwLock::new(bindings),
|
||||
require_device_confirmation,
|
||||
notify_delay,
|
||||
cancel,
|
||||
forwarders: Mutex::new(HashSet::new()),
|
||||
notifiers: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// The underlying transport client (used by the `RelayAgent` impl + router).
|
||||
@@ -42,128 +82,219 @@ impl RelayApp {
|
||||
&self.client
|
||||
}
|
||||
|
||||
// ── Inbox → clients ───────────────────────────────────────────────────────
|
||||
/// Cancellation token for this run's spawned tasks.
|
||||
pub(crate) fn cancel(&self) -> CancellationToken {
|
||||
self.cancel.clone()
|
||||
}
|
||||
|
||||
/// Build the Inbox snapshot and send it (encrypted) to every Authorized
|
||||
/// client. `live=false` so the relay stores-and-forwards + pushes to offline
|
||||
/// phones.
|
||||
pub async fn broadcast_inbox(&self) -> Result<()> {
|
||||
let snapshot = self.inbox.list_pending().await;
|
||||
/// Write guard on the bindings cache (used by the config listener).
|
||||
pub(crate) async fn bindings_mut(&self) -> tokio::sync::RwLockWriteGuard<'_, MobileConfig> {
|
||||
self.bindings.write().await
|
||||
}
|
||||
|
||||
/// The user bound to a device pubkey, if any.
|
||||
pub(crate) async fn user_for_device(&self, pubkey: &[u8; 32]) -> Option<String> {
|
||||
self.bindings.read().await.user_for_pubkey(&hex::encode(pubkey))
|
||||
}
|
||||
|
||||
// ── Bind / unbind (admin-mediated, via the control tools) ─────────────────
|
||||
|
||||
/// Bind a paired device to a user and authorize it, then push that user's
|
||||
/// current Inbox to it. Persists the binding (fires `ConfigKeyUpdated`, which
|
||||
/// refreshes the cache and spawns the user's forwarder).
|
||||
pub async fn bind_device(
|
||||
&self,
|
||||
pubkey: [u8; 32],
|
||||
user_id: String,
|
||||
display: Option<String>,
|
||||
) -> Result<()> {
|
||||
// Update the persisted config + local cache up front (avoids a race with
|
||||
// the listener before its event arrives).
|
||||
let snapshot = {
|
||||
let mut cfg = self.bindings.write().await;
|
||||
cfg.upsert(auth::Binding { pubkey_hex: hex::encode(pubkey), user_id: user_id.clone(), display });
|
||||
cfg.clone()
|
||||
};
|
||||
auth::save_config(&*self.config, &snapshot).await?;
|
||||
|
||||
// Authorize at the relay level so pushes/sends reach the device.
|
||||
self.client.authorize(&pubkey).await?;
|
||||
info!(plugin = PLUGIN_ID, user_id = %user_id, device = %hex::encode(pubkey), "device bound + authorized");
|
||||
|
||||
// Send the user's current Inbox to the freshly bound device.
|
||||
if let Err(e) = self.push_inbox_to_user(&user_id).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to push inbox after bind");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke a device and drop its binding.
|
||||
pub async fn revoke_device(&self, pubkey: [u8; 32]) -> Result<()> {
|
||||
self.client.revoke(&pubkey).await?;
|
||||
let snapshot = {
|
||||
let mut cfg = self.bindings.write().await;
|
||||
cfg.remove(&hex::encode(pubkey));
|
||||
cfg.clone()
|
||||
};
|
||||
auth::save_config(&*self.config, &snapshot).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Inbox → user's devices ────────────────────────────────────────────────
|
||||
|
||||
/// Build the Inbox snapshot for `user_id` and send it to every Authorized
|
||||
/// device bound to that user. `live=false` so the relay stores-and-forwards +
|
||||
/// pushes to offline phones. No-op if the user is locked (§9).
|
||||
pub async fn push_inbox_to_user(&self, user_id: &str) -> Result<()> {
|
||||
let Some(handle) = self.user_channel.resolve_user(user_id).await else {
|
||||
debug!(plugin = PLUGIN_ID, user_id, "inbox push skipped — user locked");
|
||||
return Ok(());
|
||||
};
|
||||
let snapshot = handle.inbox().list_pending().await;
|
||||
let plaintext = serde_json::to_vec(&payloads::build_inbox_update(&snapshot))?;
|
||||
self.broadcast_plaintext(&plaintext).await;
|
||||
self.send_to_user_devices(user_id, &plaintext, false).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build and send a generic notification to all Authorized clients.
|
||||
pub async fn broadcast_notification(&self, title: &str, body: &str) -> Result<()> {
|
||||
let plaintext = serde_json::to_vec(&payloads::build_notification(title, body))?;
|
||||
self.broadcast_plaintext(&plaintext).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send an opaque plaintext to every Authorized device (`live=false`).
|
||||
async fn broadcast_plaintext(&self, plaintext: &[u8]) {
|
||||
for c in self
|
||||
/// Send an opaque plaintext to every Authorized device bound to `user_id`.
|
||||
async fn send_to_user_devices(&self, user_id: &str, plaintext: &[u8], live: bool) {
|
||||
let bound = self.bindings.read().await.pubkeys_for_user(user_id);
|
||||
if bound.is_empty() {
|
||||
return;
|
||||
}
|
||||
let authorized: HashSet<[u8; 32]> = self
|
||||
.client
|
||||
.list_clients()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|c| c.state == ClientState::Authorized)
|
||||
{
|
||||
if let Err(e) = self.client.send(&c.ed25519_pub, plaintext, false).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to send to client");
|
||||
.map(|c| c.ed25519_pub)
|
||||
.collect();
|
||||
for pk_hex in bound {
|
||||
let Some(pk) = skald_relay_common::crypto::decode_hex::<32>(&pk_hex) else { continue };
|
||||
if !authorized.contains(&pk) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = self.client.send(&pk, plaintext, live).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to send to device");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the current Inbox snapshot to a single client (the targeted reply to
|
||||
/// `inbox_request`). `live=true`: the requester is online by construction.
|
||||
async fn send_inbox_to(&self, client_ed25519_pub: &[u8; 32]) -> Result<()> {
|
||||
let snapshot = self.inbox.list_pending().await;
|
||||
/// Send the current Inbox snapshot to a single requesting device (`live=true`:
|
||||
/// the requester is online by construction).
|
||||
async fn send_inbox_to_device(&self, user_id: &str, device: &[u8; 32]) -> Result<()> {
|
||||
let Some(handle) = self.user_channel.resolve_user(user_id).await else {
|
||||
return Ok(());
|
||||
};
|
||||
let snapshot = handle.inbox().list_pending().await;
|
||||
let plaintext = serde_json::to_vec(&payloads::build_inbox_update(&snapshot))?;
|
||||
self.client.send(client_ed25519_pub, &plaintext, true).await
|
||||
self.client.send(device, &plaintext, true).await
|
||||
}
|
||||
|
||||
// ── Clients → Inbox ───────────────────────────────────────────────────────
|
||||
// ── Devices → Inbox ───────────────────────────────────────────────────────
|
||||
|
||||
/// Apply a decoded client payload to the Inbox. `payload` is the clean inner
|
||||
/// JSON the client already decrypted + de-framed.
|
||||
/// 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]) {
|
||||
match payloads::parse_client_payload(payload) {
|
||||
ClientPayload::ApprovalResponse { request_id, approved, reason } => {
|
||||
if approved {
|
||||
self.inbox.approve(request_id).await;
|
||||
} else {
|
||||
self.inbox.reject(request_id, reason.unwrap_or_default()).await;
|
||||
}
|
||||
let _ = self.broadcast_inbox().await;
|
||||
}
|
||||
ClientPayload::ClarificationResponse { request_id, answer } => {
|
||||
self.inbox.answer(request_id, answer).await;
|
||||
let _ = self.broadcast_inbox().await;
|
||||
}
|
||||
ClientPayload::ElicitationResponse { request_id, action, content } => {
|
||||
// `content` may hold a secret (SSH/sudo password): hand it straight
|
||||
// to the Inbox; never log/persist it in clear (payloads.md §3.1).
|
||||
self.inbox.resolve_elicitation(request_id, action, content).await;
|
||||
let _ = self.broadcast_inbox().await;
|
||||
}
|
||||
let parsed = payloads::parse_client_payload(payload);
|
||||
|
||||
// Hello / Logout are device-registry ops that need no user resolution.
|
||||
match &parsed {
|
||||
ClientPayload::Hello { device_info } => {
|
||||
if let Err(e) = self.client.set_device_info(from, &device_info.to_string()).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to persist device_info");
|
||||
}
|
||||
}
|
||||
ClientPayload::InboxRequest => {
|
||||
if let Err(e) = self.send_inbox_to(from).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to send targeted inbox snapshot");
|
||||
}
|
||||
return;
|
||||
}
|
||||
ClientPayload::Logout => {
|
||||
if let Err(e) = self.client.revoke(from).await {
|
||||
if let Err(e) = self.revoke_device(*from).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "logout revoke failed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
ClientPayload::Unknown => {
|
||||
debug!(plugin = PLUGIN_ID, "unknown/ignored client payload");
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Everything else acts on a user's Inbox: resolve the device's user.
|
||||
let Some(user_id) = self.user_for_device(from).await else {
|
||||
warn!(plugin = PLUGIN_ID, device = %hex::encode(from), "payload from unbound device — ignored");
|
||||
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");
|
||||
return;
|
||||
};
|
||||
let inbox = handle.inbox();
|
||||
|
||||
match parsed {
|
||||
ClientPayload::ApprovalResponse { request_id, approved, reason } => {
|
||||
if approved {
|
||||
inbox.approve(request_id).await;
|
||||
} else {
|
||||
inbox.reject(request_id, reason.unwrap_or_default()).await;
|
||||
}
|
||||
let _ = self.push_inbox_to_user(&user_id).await;
|
||||
}
|
||||
ClientPayload::ClarificationResponse { request_id, answer } => {
|
||||
inbox.answer(request_id, answer).await;
|
||||
let _ = self.push_inbox_to_user(&user_id).await;
|
||||
}
|
||||
ClientPayload::ElicitationResponse { request_id, action, content } => {
|
||||
// `content` may hold a secret (SSH/sudo password): hand it straight
|
||||
// to the Inbox; never log/persist it in clear (payloads.md §3.1).
|
||||
inbox.resolve_elicitation(request_id, action, content).await;
|
||||
let _ = self.push_inbox_to_user(&user_id).await;
|
||||
}
|
||||
ClientPayload::InboxRequest => {
|
||||
if let Err(e) = self.send_inbox_to_device(&user_id, from).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to send targeted inbox snapshot");
|
||||
}
|
||||
}
|
||||
// Handled above.
|
||||
ClientPayload::Hello { .. } | ClientPayload::Logout | ClientPayload::Unknown => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event loop ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Consume the client's [`RelayEvent`] stream until `cancel` fires. This is
|
||||
/// where the authorization policy and Inbox application live.
|
||||
pub async fn run_event_loop(
|
||||
self: Arc<Self>,
|
||||
mut rx: broadcast::Receiver<RelayEvent>,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
/// Consume the client's [`RelayEvent`] stream until cancelled. Applies inbound
|
||||
/// payloads and the pairing authorization policy, and lazily spawns a per-user
|
||||
/// forwarder when a bound device becomes active.
|
||||
pub async fn run_event_loop(self: Arc<Self>, mut rx: broadcast::Receiver<RelayEvent>) {
|
||||
let cancel = self.cancel.clone();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
ev = rx.recv() => match ev {
|
||||
Ok(RelayEvent::Message { from, payload, .. }) => {
|
||||
// A bound + unlocked device becoming active ⇒ ensure its
|
||||
// user's forwarder is running so Inbox events reach the phone.
|
||||
if let Some(user_id) = self.user_for_device(&from).await {
|
||||
if let Some(handle) = self.user_channel.resolve_user(&user_id).await {
|
||||
crate::events::ensure_forwarder(Arc::clone(&self), user_id, handle).await;
|
||||
}
|
||||
}
|
||||
self.apply_client_payload(&from, &payload).await;
|
||||
}
|
||||
Ok(RelayEvent::ClientPaired { ed25519_pub, .. }) => {
|
||||
if self.require_device_confirmation {
|
||||
debug!(
|
||||
plugin = PLUGIN_ID,
|
||||
device = %hex::encode(ed25519_pub),
|
||||
"new device paired (pending manual confirmation)"
|
||||
);
|
||||
let _ = self
|
||||
.broadcast_notification(
|
||||
"New device",
|
||||
"A new device is pending confirmation",
|
||||
)
|
||||
.await;
|
||||
} else if let Err(e) = self.client.authorize(&ed25519_pub).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "auto-authorize failed");
|
||||
} else {
|
||||
// Send the newly-authorized device the current snapshot.
|
||||
let _ = self.broadcast_inbox().await;
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
info!(
|
||||
plugin = PLUGIN_ID,
|
||||
device = %hex::encode(ed25519_pub),
|
||||
"new device paired — awaiting admin binding (mobile_bind_device)"
|
||||
);
|
||||
}
|
||||
Ok(RelayEvent::ClientRevoked { .. })
|
||||
| Ok(RelayEvent::Connected)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Device→user binding (blueprint §13), the mobile analogue of the Telegram
|
||||
//! `chat_id ↔ user_id` pairing.
|
||||
//!
|
||||
//! One relay identity serves many devices; each **authorized device** (identified
|
||||
//! by its ed25519 public key) is bound to exactly one Skald user. The binding
|
||||
//! lives in the `config` table (key `"mobile-connector"`) as JSON, so it survives
|
||||
//! restarts and is admin-editable. Writing it emits a `ConfigKeyUpdated` event,
|
||||
//! which [`config_listener`] uses to refresh the in-memory cache and (re)spawn
|
||||
//! per-user forwarders — no polling, no restart.
|
||||
//!
|
||||
//! Unlike Telegram there is no "pairing code" concept here: the QR pairing and the
|
||||
//! Pending/Authorized device lifecycle are handled by `skald-relay-client`. A
|
||||
//! binding simply ties an already-paired pubkey to a user (admin-mediated, via the
|
||||
//! `mobile_bind_device` tool — the mobile analogue of `telegram_pairing`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use core_api::config_api::ConfigApi;
|
||||
use core_api::system_bus::SystemEvent;
|
||||
|
||||
use crate::PLUGIN_ID;
|
||||
use crate::app::RelayApp;
|
||||
|
||||
/// Config-table key under which all mobile bindings are stored as JSON.
|
||||
pub(crate) const CONFIG_KEY: &str = "mobile-connector";
|
||||
|
||||
// ── Bindings schema (stored as JSON in the `config` table) ────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
pub struct MobileConfig {
|
||||
#[serde(default)]
|
||||
pub bindings: Vec<Binding>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Binding {
|
||||
/// The device's ed25519 public key, hex-encoded (64 chars) — the stable,
|
||||
/// opaque device identity the relay registry keys on.
|
||||
pub pubkey_hex: String,
|
||||
pub user_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display: Option<String>,
|
||||
}
|
||||
|
||||
impl MobileConfig {
|
||||
/// The user bound to `pubkey_hex`, if any.
|
||||
pub fn user_for_pubkey(&self, pubkey_hex: &str) -> Option<String> {
|
||||
self.bindings
|
||||
.iter()
|
||||
.find(|b| b.pubkey_hex.eq_ignore_ascii_case(pubkey_hex))
|
||||
.map(|b| b.user_id.clone())
|
||||
}
|
||||
|
||||
/// Hex pubkeys of every device bound to `user_id`.
|
||||
pub fn pubkeys_for_user(&self, user_id: &str) -> Vec<String> {
|
||||
self.bindings
|
||||
.iter()
|
||||
.filter(|b| b.user_id == user_id)
|
||||
.map(|b| b.pubkey_hex.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The distinct user ids that have at least one bound device.
|
||||
pub fn bound_user_ids(&self) -> Vec<String> {
|
||||
let mut ids: Vec<String> = self.bindings.iter().map(|b| b.user_id.clone()).collect();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Insert or replace the binding for a pubkey (a device belongs to one user).
|
||||
pub fn upsert(&mut self, binding: Binding) {
|
||||
self.bindings
|
||||
.retain(|b| !b.pubkey_hex.eq_ignore_ascii_case(&binding.pubkey_hex));
|
||||
self.bindings.push(binding);
|
||||
}
|
||||
|
||||
/// Remove the binding for `pubkey_hex`. Returns true if one was removed.
|
||||
pub fn remove(&mut self, pubkey_hex: &str) -> bool {
|
||||
let before = self.bindings.len();
|
||||
self.bindings
|
||||
.retain(|b| !b.pubkey_hex.eq_ignore_ascii_case(pubkey_hex));
|
||||
self.bindings.len() != before
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config-table read/write ────────────────────────────────────────────────────
|
||||
|
||||
/// Reads the mobile config from the `config` table. Returns `Default` when the key
|
||||
/// is absent or unparseable (never fails the caller).
|
||||
pub(crate) async fn load_config(config: &dyn ConfigApi) -> anyhow::Result<MobileConfig> {
|
||||
match config.get(CONFIG_KEY).await? {
|
||||
Some(json) => Ok(serde_json::from_str(&json).unwrap_or_default()),
|
||||
None => Ok(MobileConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the mobile config to the `config` table. `ConfigApi::set` emits a
|
||||
/// `ConfigKeyUpdated` event when the value changes, so [`config_listener`] and the
|
||||
/// in-memory cache pick it up automatically.
|
||||
pub(crate) async fn save_config(config: &dyn ConfigApi, cfg: &MobileConfig) -> anyhow::Result<()> {
|
||||
config
|
||||
.set(CONFIG_KEY, &serde_json::to_string_pretty(cfg)?)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Config listener ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Subscribes to the system bus and reloads the in-memory bindings whenever the
|
||||
/// `"mobile-connector"` config key changes, then (re)spawns forwarders for every
|
||||
/// bound + unlocked user. The mobile analogue of Telegram's `config_listener`.
|
||||
pub(crate) async fn config_listener(
|
||||
app: Arc<RelayApp>,
|
||||
mut rx: broadcast::Receiver<SystemEvent>,
|
||||
) {
|
||||
let cancel = app.cancel();
|
||||
info!(plugin = PLUGIN_ID, "config listener started");
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
info!(plugin = PLUGIN_ID, "config listener stopped");
|
||||
return;
|
||||
}
|
||||
result = rx.recv() => match result {
|
||||
Ok(SystemEvent::ConfigKeyUpdated { key, new_value, .. }) if key == CONFIG_KEY => {
|
||||
match serde_json::from_str::<MobileConfig>(&new_value) {
|
||||
Ok(cfg) => {
|
||||
let n = cfg.bindings.len();
|
||||
*app.bindings_mut().await = cfg;
|
||||
info!(plugin = PLUGIN_ID, bindings = n, "bindings reloaded from config event");
|
||||
crate::events::spawn_forwarders_for_bound_users(&app).await;
|
||||
}
|
||||
Err(e) => warn!(plugin = PLUGIN_ID, error = %e, "failed to parse config from event"),
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(plugin = PLUGIN_ID, skipped = n, "config listener lagged");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn binding(pubkey_hex: &str, user_id: &str) -> Binding {
|
||||
Binding { pubkey_hex: pubkey_hex.to_string(), user_id: user_id.to_string(), display: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_replaces_by_pubkey_case_insensitive() {
|
||||
let mut cfg = MobileConfig::default();
|
||||
cfg.upsert(binding("AABB", "user-a"));
|
||||
// Re-binding the same device (different hex case) to another user replaces,
|
||||
// never duplicates — a device belongs to exactly one user.
|
||||
cfg.upsert(binding("aabb", "user-b"));
|
||||
assert_eq!(cfg.bindings.len(), 1);
|
||||
assert_eq!(cfg.user_for_pubkey("aabb").as_deref(), Some("user-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_for_pubkey_is_case_insensitive() {
|
||||
let mut cfg = MobileConfig::default();
|
||||
cfg.upsert(binding("DEADbeef", "alice"));
|
||||
assert_eq!(cfg.user_for_pubkey("deadBEEF").as_deref(), Some("alice"));
|
||||
assert_eq!(cfg.user_for_pubkey("0000"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pubkeys_for_user_scopes_to_owner() {
|
||||
let mut cfg = MobileConfig::default();
|
||||
cfg.upsert(binding("aa", "alice"));
|
||||
cfg.upsert(binding("bb", "alice"));
|
||||
cfg.upsert(binding("cc", "bob"));
|
||||
let mut alice = cfg.pubkeys_for_user("alice");
|
||||
alice.sort();
|
||||
assert_eq!(alice, vec!["aa".to_string(), "bb".to_string()]);
|
||||
assert_eq!(cfg.pubkeys_for_user("bob"), vec!["cc".to_string()]);
|
||||
assert!(cfg.pubkeys_for_user("carol").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_user_ids_dedup_sorted() {
|
||||
let mut cfg = MobileConfig::default();
|
||||
cfg.upsert(binding("aa", "bob"));
|
||||
cfg.upsert(binding("bb", "alice"));
|
||||
cfg.upsert(binding("cc", "bob"));
|
||||
assert_eq!(cfg.bound_user_ids(), vec!["alice".to_string(), "bob".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_reports_and_is_case_insensitive() {
|
||||
let mut cfg = MobileConfig::default();
|
||||
cfg.upsert(binding("AbCd", "alice"));
|
||||
assert!(cfg.remove("abcd"));
|
||||
assert!(!cfg.remove("abcd"));
|
||||
assert!(cfg.bindings.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Per-user event forwarders (blueprint §13), the mobile analogue of the Telegram
|
||||
//! `ensure_forwarder`.
|
||||
//!
|
||||
//! One forwarder per unlocked user with bound devices subscribes to that user's
|
||||
//! event stream ([`UserChannelHandle::subscribe`]) and routes the six Inbox
|
||||
//! lifecycle events through the user's [`DelayedNotifier`], which decides whether
|
||||
//! and when to push the Inbox to their phones.
|
||||
//!
|
||||
//! Unlike the Telegram forwarder there is **no `source` filter**: an approval
|
||||
//! raised in the user's *web* session must still reach their phone. The relevant
|
||||
//! events (`{Approval,Clarification,Elicitation}{Requested,Resolved}`) are
|
||||
//! Inbox-scoped and carry request ids from the user's own pool, so no cross-user
|
||||
//! collision is possible.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use core_api::events::ServerEvent;
|
||||
use core_api::user_channel::UserChannelHandle;
|
||||
|
||||
use crate::PLUGIN_ID;
|
||||
use crate::app::RelayApp;
|
||||
use crate::notifier::{DelayedNotifier, Kind};
|
||||
|
||||
/// How often the reconcile loop picks up bound users who have unlocked since the
|
||||
/// last pass. Pushes are best-effort, so a coarse cadence is fine.
|
||||
const RECONCILE_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Periodically (re)spawns forwarders for bound + unlocked users.
|
||||
///
|
||||
/// This is load-bearing, not a nicety: at boot every pool is locked (§9), so the
|
||||
/// eager start-time pass spawns nothing. Users unlock later via web/phone login,
|
||||
/// and there is no "user unlocked" system event to hook. Without this loop a user
|
||||
/// whose phone stays backgrounded would never get a forwarder — so no Inbox push
|
||||
/// would ever be armed for them. `ensure_forwarder` dedups, so this is idempotent
|
||||
/// and cheap (locked users resolve to `None` and are skipped without a build).
|
||||
pub(crate) async fn reconcile_loop(app: Arc<RelayApp>) {
|
||||
let cancel = app.cancel();
|
||||
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => return,
|
||||
_ = ticker.tick() => spawn_forwarders_for_bound_users(&app).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns forwarders for every bound user whose context is already unlocked.
|
||||
/// Called at plugin start, on binding changes, and by the reconcile loop. Users
|
||||
/// who unlock later also get their forwarder spawned lazily on device activity.
|
||||
pub(crate) async fn spawn_forwarders_for_bound_users(app: &Arc<RelayApp>) {
|
||||
let user_ids = app.bindings.read().await.bound_user_ids();
|
||||
for user_id in user_ids {
|
||||
if let Some(handle) = app.user_channel.resolve_user(&user_id).await {
|
||||
ensure_forwarder(Arc::clone(app), user_id, handle).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a per-user forwarder if one is not already running for `user_id`.
|
||||
pub(crate) async fn ensure_forwarder(
|
||||
app: Arc<RelayApp>,
|
||||
user_id: String,
|
||||
handle: Arc<dyn UserChannelHandle>,
|
||||
) {
|
||||
{
|
||||
let mut forwarders = app.forwarders.lock().await;
|
||||
if !forwarders.insert(user_id.clone()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let cancel = app.cancel();
|
||||
info!(plugin = PLUGIN_ID, user_id = %user_id, "spawning per-user forwarder");
|
||||
tokio::spawn(user_forwarder(app, user_id, handle, cancel));
|
||||
}
|
||||
|
||||
/// One forwarder per unlocked user. Subscribes to the user's event stream and
|
||||
/// drives their `DelayedNotifier`. Exits when the stream closes (user context
|
||||
/// dropped at restart / lock) or the plugin is cancelled — self-cleaning.
|
||||
async fn user_forwarder(
|
||||
app: Arc<RelayApp>,
|
||||
user_id: String,
|
||||
handle: Arc<dyn UserChannelHandle>,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
// Get or create this user's debounced notifier.
|
||||
let notifier: Arc<DelayedNotifier> = {
|
||||
let mut notifiers = app.notifiers.lock().await;
|
||||
notifiers
|
||||
.entry(user_id.clone())
|
||||
.or_insert_with(|| DelayedNotifier::new(Arc::downgrade(&app), user_id.clone(), app.notify_delay))
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut rx = handle.subscribe();
|
||||
loop {
|
||||
let event: ServerEvent = tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
result = rx.recv() => match result {
|
||||
Ok(ge) => ge.event,
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(plugin = PLUGIN_ID, user_id = %user_id, skipped = n, "forwarder lagged");
|
||||
continue;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
info!(plugin = PLUGIN_ID, user_id = %user_id, "forwarder — user context closed, exiting");
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// `*Requested` arms a delayed push; `*Resolved` cancels it (or refreshes
|
||||
// the phone if the push already went out). Any other event is ignored.
|
||||
match event {
|
||||
ServerEvent::ApprovalRequested { request_id, .. } => {
|
||||
notifier.on_requested((Kind::Approval, request_id)).await;
|
||||
}
|
||||
ServerEvent::ApprovalResolved { request_id, .. } => {
|
||||
notifier.on_resolved((Kind::Approval, request_id)).await;
|
||||
}
|
||||
ServerEvent::ClarificationRequested { request_id, .. } => {
|
||||
notifier.on_requested((Kind::Clarification, request_id)).await;
|
||||
}
|
||||
ServerEvent::ClarificationResolved { request_id } => {
|
||||
notifier.on_resolved((Kind::Clarification, request_id)).await;
|
||||
}
|
||||
ServerEvent::ElicitationRequested { request_id, .. } => {
|
||||
notifier.on_requested((Kind::Elicitation, request_id)).await;
|
||||
}
|
||||
ServerEvent::ElicitationResolved { request_id } => {
|
||||
notifier.on_resolved((Kind::Elicitation, request_id)).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up so a later reconnect can respawn.
|
||||
app.forwarders.lock().await.remove(&user_id);
|
||||
app.notifiers.lock().await.remove(&user_id);
|
||||
info!(plugin = PLUGIN_ID, user_id = %user_id, "forwarder exited");
|
||||
}
|
||||
@@ -1,21 +1,36 @@
|
||||
//! Mobile connector plugin (plugin id `mobile-connector`).
|
||||
//!
|
||||
//! Bridges Skald's Inbox (approvals + clarifications) to mobile apps over the
|
||||
//! relay. The **networking** (v2 WS transport, E2E crypto, anti-replay counters,
|
||||
//! pairing, device authorization, SQLite persistence) lives in the standalone
|
||||
//! `skald-relay-client` crate; this plugin is the thin **application** layer on
|
||||
//! top of it. See `data/iOS-app/v2/relay-protocol.md` for the wire contract and
|
||||
//! `docs/relay/` for the client/server split.
|
||||
//! Bridges each Skald user's Inbox (approvals + clarifications + elicitations) to
|
||||
//! their mobile devices over the relay, end-to-end encrypted. The **networking**
|
||||
//! (v2 WS transport, E2E crypto, anti-replay counters, pairing, device
|
||||
//! authorization, SQLite persistence) lives in the standalone `skald-relay-client`
|
||||
//! crate; this plugin is the thin **application** layer on top of it. See
|
||||
//! `data/iOS-app/v2/relay-protocol.md` for the wire contract.
|
||||
//!
|
||||
//! # Multi-user (blueprint §13)
|
||||
//!
|
||||
//! One relay identity serves many devices; each device is bound to one Skald user
|
||||
//! (`auth`, admin-mediated via `mobile_bind_device`). Inbound payloads apply to
|
||||
//! that user's Inbox via the [`UserChannelApi`] seam; per-user forwarders
|
||||
//! (`events`) push Inbox changes only to that user's devices — never a global
|
||||
//! broadcast. The HTTP reverse proxy (`proxy`) is user-agnostic: the phone renders
|
||||
//! the authenticated web UI over the tunnel, which handles per-user auth itself.
|
||||
//!
|
||||
//! Module map:
|
||||
//! - `payloads` — E2E JSON payload schemas (inbox_update, responses, …)
|
||||
//! - `app` — `RelayApp`: Inbox dispatch, auth policy, the events() loop
|
||||
//! - `auth` — device→user bindings (config-table-backed) + config listener
|
||||
//! - `app` — `RelayApp`: per-user Inbox dispatch, bindings, the events() loop
|
||||
//! - `events` — per-user event forwarders (drive the notifiers)
|
||||
//! - `notifier` — per-user debounced Inbox pushes
|
||||
//! - `proxy` — HTTP reverse proxy to the local web UI (user-agnostic)
|
||||
//! - `router` — the QR-code HTTP endpoint
|
||||
//! - `agent` — the `RelayAgent` control trait
|
||||
//! - `tools` — `Tool` impls callable by the host (registered in the main crate)
|
||||
|
||||
mod agent;
|
||||
mod app;
|
||||
mod auth;
|
||||
mod events;
|
||||
mod notifier;
|
||||
mod payloads;
|
||||
mod proxy;
|
||||
@@ -40,7 +55,6 @@ pub use agent::{ClientInfo, ClientState, PairingHandle, RelayAgent};
|
||||
pub use tools::mobile_tools;
|
||||
|
||||
use app::RelayApp;
|
||||
use notifier::{DelayedNotifier, Kind};
|
||||
|
||||
pub(crate) const PLUGIN_ID: &str = "mobile-connector";
|
||||
const DEFAULT_TTL: u32 = 300;
|
||||
@@ -60,8 +74,6 @@ pub struct MobileConnectorPlugin {
|
||||
inner: Arc<Mutex<Option<Arc<RelayApp>>>>,
|
||||
cancel: Mutex<Option<CancellationToken>>,
|
||||
handles: Mutex<Vec<JoinHandle<()>>>,
|
||||
/// Debounces Inbox pushes to the phone; present only while running.
|
||||
notifier: Mutex<Option<Arc<DelayedNotifier>>>,
|
||||
}
|
||||
|
||||
impl MobileConnectorPlugin {
|
||||
@@ -71,7 +83,6 @@ impl MobileConnectorPlugin {
|
||||
inner: Arc::new(Mutex::new(None)),
|
||||
cancel: Mutex::new(None),
|
||||
handles: Mutex::new(Vec::new()),
|
||||
notifier: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,69 +130,49 @@ impl MobileConnectorPlugin {
|
||||
);
|
||||
client.start().await?;
|
||||
|
||||
let app = Arc::new(RelayApp::new(
|
||||
Arc::clone(&client),
|
||||
Arc::clone(&ctx.inbox),
|
||||
require_device_confirmation,
|
||||
));
|
||||
|
||||
let notifier = DelayedNotifier::new(Arc::clone(&app), notify_delay);
|
||||
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
// Load device→user bindings from the config table (or default if absent).
|
||||
let bindings = auth::load_config(&*ctx.config).await.unwrap_or_default();
|
||||
info!(plugin = PLUGIN_ID, bindings = bindings.bindings.len(), "bindings loaded");
|
||||
|
||||
let app = RelayApp::new(
|
||||
Arc::clone(&client),
|
||||
Arc::clone(&ctx.user_channel),
|
||||
Arc::clone(&ctx.config),
|
||||
bindings,
|
||||
require_device_confirmation,
|
||||
notify_delay,
|
||||
cancel.clone(),
|
||||
);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// Event loop: apply inbound payloads + authorization policy.
|
||||
// Event loop: apply inbound payloads + pairing authorization policy, and
|
||||
// lazily spawn per-user forwarders when a bound device becomes active.
|
||||
{
|
||||
let app2 = Arc::clone(&app);
|
||||
let rx = client.events();
|
||||
let c = cancel.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
app2.run_event_loop(rx, c).await;
|
||||
app2.run_event_loop(rx).await;
|
||||
}));
|
||||
}
|
||||
|
||||
// Bus subscriber: route the four Inbox events through the debouncer.
|
||||
// `*Requested` arms a delayed push; `*Resolved` cancels it (or refreshes
|
||||
// the phone if the push already went out).
|
||||
// Config listener: reloads bindings when the "mobile-connector" config key
|
||||
// changes (e.g. the bind tool writes a new binding) and (re)spawns forwarders.
|
||||
{
|
||||
let notifier = Arc::clone(¬ifier);
|
||||
let c = cancel.clone();
|
||||
let mut rx = ctx.chat_hub.events(PLUGIN_ID);
|
||||
handles.push(tokio::spawn(async move {
|
||||
use core_api::events::ServerEvent::*;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = c.cancelled() => break,
|
||||
ev = rx.recv() => match ev {
|
||||
Ok(ge) => match ge.event {
|
||||
ApprovalRequested { request_id, .. } => {
|
||||
notifier.on_requested((Kind::Approval, request_id)).await;
|
||||
}
|
||||
ApprovalResolved { request_id, .. } => {
|
||||
notifier.on_resolved((Kind::Approval, request_id)).await;
|
||||
}
|
||||
ClarificationRequested { request_id, .. } => {
|
||||
notifier.on_requested((Kind::Clarification, request_id)).await;
|
||||
}
|
||||
ClarificationResolved { request_id } => {
|
||||
notifier.on_resolved((Kind::Clarification, request_id)).await;
|
||||
}
|
||||
ElicitationRequested { request_id, .. } => {
|
||||
notifier.on_requested((Kind::Elicitation, request_id)).await;
|
||||
}
|
||||
ElicitationResolved { request_id } => {
|
||||
notifier.on_resolved((Kind::Elicitation, request_id)).await;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(plugin = PLUGIN_ID, skipped = n, "event bus lagged");
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
let app3 = Arc::clone(&app);
|
||||
let bus_rx = ctx.system_bus.subscribe();
|
||||
handles.push(tokio::spawn(auth::config_listener(app3, bus_rx)));
|
||||
}
|
||||
|
||||
// Reconcile loop: (re)spawns forwarders for bound users as they unlock. Its
|
||||
// first tick fires immediately (covering already-unlocked users at start),
|
||||
// then it periodically catches users who log in later — there is no "user
|
||||
// unlocked" event to hook, and at boot every pool is locked (§9).
|
||||
{
|
||||
let app4 = Arc::clone(&app);
|
||||
handles.push(tokio::spawn(events::reconcile_loop(app4)));
|
||||
}
|
||||
|
||||
// HTTP reverse proxy: bridge `http-local-proxy` pipes to the local web
|
||||
@@ -197,7 +188,6 @@ impl MobileConnectorPlugin {
|
||||
}));
|
||||
}
|
||||
|
||||
*self.notifier.lock().await = Some(notifier);
|
||||
*self.inner.lock().await = Some(app);
|
||||
*self.cancel.lock().await = Some(cancel);
|
||||
*self.handles.lock().await = handles;
|
||||
@@ -210,13 +200,12 @@ impl MobileConnectorPlugin {
|
||||
if let Some(c) = self.cancel.lock().await.take() {
|
||||
c.cancel();
|
||||
}
|
||||
// Cancel any armed (not-yet-fired) push timers.
|
||||
if let Some(notifier) = self.notifier.lock().await.take() {
|
||||
notifier.cancel_all().await;
|
||||
}
|
||||
// Shut down the transport (cancels + joins the WS loop) before dropping
|
||||
// the app.
|
||||
// the app, cancelling any armed (not-yet-fired) per-user push timers first.
|
||||
if let Some(app) = self.inner.lock().await.take() {
|
||||
for notifier in app.notifiers.lock().await.values() {
|
||||
notifier.cancel_all().await;
|
||||
}
|
||||
app.client().shutdown().await;
|
||||
}
|
||||
for h in self.handles.lock().await.drain(..) {
|
||||
@@ -363,47 +352,41 @@ impl RelayAgent for MobileConnectorPlugin {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn broadcast_inbox(&self) -> Result<()> {
|
||||
let app = self.app().await.ok_or_else(|| anyhow::anyhow!("plugin not running"))?;
|
||||
app.broadcast_inbox().await
|
||||
}
|
||||
|
||||
async fn broadcast_notification(&self, title: &str, body: &str) -> Result<()> {
|
||||
let app = self.app().await.ok_or_else(|| anyhow::anyhow!("plugin not running"))?;
|
||||
app.broadcast_notification(title, body).await
|
||||
}
|
||||
|
||||
async fn list_clients(&self) -> Vec<ClientInfo> {
|
||||
let Some(app) = self.app().await else { return Vec::new() };
|
||||
app.client()
|
||||
.list_clients()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|r| ClientInfo {
|
||||
ed25519_pub: r.ed25519_pub,
|
||||
x25519_pub: r.x25519_pub,
|
||||
state: match r.state {
|
||||
RelayClientState::Authorized => ClientState::Authorized,
|
||||
RelayClientState::Pending => ClientState::Pending,
|
||||
},
|
||||
device_info: r.device_info,
|
||||
platform: r.platform,
|
||||
last_seen: r.last_seen,
|
||||
let rows = app.client().list_clients().await;
|
||||
let bindings = app.bindings.read().await;
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
let bound_user = bindings.user_for_pubkey(&hex::encode(r.ed25519_pub));
|
||||
ClientInfo {
|
||||
ed25519_pub: r.ed25519_pub,
|
||||
x25519_pub: r.x25519_pub,
|
||||
state: match r.state {
|
||||
RelayClientState::Authorized => ClientState::Authorized,
|
||||
RelayClientState::Pending => ClientState::Pending,
|
||||
},
|
||||
device_info: r.device_info,
|
||||
platform: r.platform,
|
||||
last_seen: r.last_seen,
|
||||
bound_user,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn authorize_client(&self, ed25519_pub: [u8; 32]) -> Result<()> {
|
||||
async fn bind_device(
|
||||
&self,
|
||||
ed25519_pub: [u8; 32],
|
||||
user_id: String,
|
||||
display: Option<String>,
|
||||
) -> Result<()> {
|
||||
let app = self.app().await.ok_or_else(|| anyhow::anyhow!("plugin not running"))?;
|
||||
app.client().authorize(&ed25519_pub).await?;
|
||||
// Send the current Inbox snapshot to the newly-authorized device
|
||||
// (payload-agnostic client doesn't do this itself).
|
||||
let _ = app.broadcast_inbox().await;
|
||||
Ok(())
|
||||
app.bind_device(ed25519_pub, user_id, display).await
|
||||
}
|
||||
|
||||
async fn revoke_client(&self, ed25519_pub: [u8; 32]) -> Result<()> {
|
||||
let app = self.app().await.ok_or_else(|| anyhow::anyhow!("plugin not running"))?;
|
||||
app.client().revoke(&ed25519_pub).await
|
||||
app.revoke_device(ed25519_pub).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,17 @@
|
||||
//! Elicitations are the exception: they live only in the Inbox (never inline in
|
||||
//! the chat), so there is no computer-side answer to debounce against and they
|
||||
//! are pushed immediately regardless of `delay`.
|
||||
//!
|
||||
//! # Per-user (blueprint §13)
|
||||
//!
|
||||
//! There is one `DelayedNotifier` **per user** (owned by that user's forwarder),
|
||||
//! so the `(kind, request_id)` keyspace is naturally scoped — request ids drawn
|
||||
//! from different user pools never collide. On fire the notifier pushes only that
|
||||
//! user's Inbox, via a `Weak` back-reference to the shared [`RelayApp`] (weak to
|
||||
//! avoid an `Arc` cycle: `RelayApp` holds the notifiers).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
@@ -47,16 +55,27 @@ struct State {
|
||||
notified: HashSet<Key>,
|
||||
}
|
||||
|
||||
/// Debounces Inbox pushes to the phone. Cheap to clone (`Arc` inside).
|
||||
/// Debounces Inbox pushes to one user's phones.
|
||||
pub struct DelayedNotifier {
|
||||
app: Arc<RelayApp>,
|
||||
/// Weak to break the `RelayApp` → notifiers → `RelayApp` cycle.
|
||||
app: Weak<RelayApp>,
|
||||
user_id: String,
|
||||
delay: Duration,
|
||||
state: Mutex<State>,
|
||||
}
|
||||
|
||||
impl DelayedNotifier {
|
||||
pub fn new(app: Arc<RelayApp>, delay: Duration) -> Arc<Self> {
|
||||
Arc::new(Self { app, delay, state: Mutex::new(State::default()) })
|
||||
pub fn new(app: Weak<RelayApp>, user_id: String, delay: Duration) -> Arc<Self> {
|
||||
Arc::new(Self { app, user_id, delay, state: Mutex::new(State::default()) })
|
||||
}
|
||||
|
||||
/// Push this user's Inbox to their phones, if the app is still alive.
|
||||
async fn push(&self) {
|
||||
if let Some(app) = self.app.upgrade() {
|
||||
if let Err(e) = app.push_inbox_to_user(&self.user_id).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "inbox push failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A request entered the Inbox: arm a timer. If `delay` elapses before a
|
||||
@@ -78,9 +97,7 @@ impl DelayedNotifier {
|
||||
st.notified.insert(key)
|
||||
};
|
||||
if newly_notified {
|
||||
if let Err(e) = self.app.broadcast_inbox().await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "immediate elicitation push failed");
|
||||
}
|
||||
self.push().await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -111,9 +128,7 @@ impl DelayedNotifier {
|
||||
}
|
||||
};
|
||||
if still_armed {
|
||||
if let Err(e) = this.app.broadcast_inbox().await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "delayed inbox push failed");
|
||||
}
|
||||
this.push().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,9 +150,7 @@ impl DelayedNotifier {
|
||||
}
|
||||
};
|
||||
if broadcast {
|
||||
if let Err(e) = self.app.broadcast_inbox().await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "inbox broadcast failed");
|
||||
}
|
||||
self.push().await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,10 @@ pub fn build_inbox_update(snapshot: &InboxSnapshot) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a generic `notification` payload (payloads.md §3.2).
|
||||
/// Build a generic `notification` payload (payloads.md §3.2). Part of the wire
|
||||
/// contract; retained for the protocol surface even though the current per-user
|
||||
/// flow pushes Inbox updates rather than free-form notifications.
|
||||
#[allow(dead_code)]
|
||||
pub fn build_notification(title: &str, body: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"v": 1,
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -235,6 +235,10 @@ impl ApprovalManager {
|
||||
// Opening a mobile pairing window emits a secret (the QR) into chat:
|
||||
// it must be a deliberate human action, not LLM-triggerable (plugin.md §11).
|
||||
("mobile_start_pairing", "require"),
|
||||
// Binding/revoking a device assigns a phone to a user — a security
|
||||
// decision (who receives whose Inbox), so it must be human-gated too.
|
||||
("mobile_bind_device", "require"),
|
||||
("mobile_revoke_device", "require"),
|
||||
];
|
||||
// NOTE: file-write tools are NOT seeded here as per-tool `require` rules.
|
||||
// Filesystem gating is owned by the "File System" category — path-scoped
|
||||
|
||||
@@ -32,6 +32,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use core_api::approval::ApprovalApi;
|
||||
use core_api::chat_hub::ChatHubApi;
|
||||
use core_api::events::GlobalEvent;
|
||||
use core_api::inbox::InboxApi;
|
||||
use core_api::system_bus::SystemEventBus;
|
||||
use core_api::user_channel::UserChannelHandle;
|
||||
|
||||
@@ -292,6 +293,12 @@ impl UserChannelHandle for UserContextHandle {
|
||||
Arc::clone(&self.ctx.approval) as Arc<dyn ApprovalApi>
|
||||
}
|
||||
|
||||
fn inbox(&self) -> Arc<dyn InboxApi> {
|
||||
// `Inbox` is a cheap facade over the interaction-stack `Arc`s (Clone);
|
||||
// the clone shares the same pending state as the context's own inbox.
|
||||
Arc::new(self.ctx.inbox.clone()) as Arc<dyn InboxApi>
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> broadcast::Receiver<GlobalEvent> {
|
||||
self.ctx.global_tx.subscribe()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user