//! 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. use std::sync::Arc; use anyhow::Result; use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; use tracing::{debug, warn}; use core_api::inbox::InboxApi; use skald_relay_client::{ClientState, RelayClient, RelayEvent}; use crate::PLUGIN_ID; use crate::payloads::{self, ClientPayload}; /// Glue between the relay transport ([`RelayClient`]) and Skald's Inbox. pub struct RelayApp { client: Arc, inbox: Arc, /// When true, a freshly paired device stays Pending until a human confirms; /// when false, the app auto-authorizes on `ClientPaired`. require_device_confirmation: bool, } impl RelayApp { pub fn new( client: Arc, inbox: Arc, require_device_confirmation: bool, ) -> Self { Self { client, inbox, require_device_confirmation } } /// The underlying transport client (used by the `RelayAgent` impl + router). pub fn client(&self) -> &Arc { &self.client } // ── Inbox → clients ─────────────────────────────────────────────────────── /// 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; let plaintext = serde_json::to_vec(&payloads::build_inbox_update(&snapshot))?; self.broadcast_plaintext(&plaintext).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 .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"); } } } /// 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; let plaintext = serde_json::to_vec(&payloads::build_inbox_update(&snapshot))?; self.client.send(client_ed25519_pub, &plaintext, true).await } // ── Clients → Inbox ─────────────────────────────────────────────────────── /// Apply a decoded client payload to the Inbox. `payload` is the clean inner /// JSON the client already decrypted + de-framed. 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; } 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"); } } ClientPayload::Logout => { if let Err(e) = self.client.revoke(from).await { warn!(plugin = PLUGIN_ID, error = %e, "logout revoke failed"); } } ClientPayload::Unknown => { debug!(plugin = PLUGIN_ID, "unknown/ignored client payload"); } } } // ── 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, mut rx: broadcast::Receiver, cancel: CancellationToken, ) { loop { tokio::select! { _ = cancel.cancelled() => break, ev = rx.recv() => match ev { Ok(RelayEvent::Message { from, payload, .. }) => { 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( "Nuovo device", "Un nuovo device è in attesa di conferma", ) .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; } } Ok(RelayEvent::ClientRevoked { .. }) | Ok(RelayEvent::Connected) | Ok(RelayEvent::Disconnected) => {} Err(broadcast::error::RecvError::Lagged(n)) => { warn!(plugin = PLUGIN_ID, skipped = n, "relay event stream lagged"); } Err(broadcast::error::RecvError::Closed) => break, } } } } }