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:
@@ -35,7 +35,7 @@ Domain words are allowed only in seed data, preset labels, UI copy and positioni
|
|||||||
|
|
||||||
### Current state
|
### Current state
|
||||||
|
|
||||||
`UserManager` (§11) exists and works — `crates/skald-core/src/users/mod.rs`, with real per-user SQLCipher encryption (§4). It is **not consumed yet**: there is no login, and `Runtime` still hands every call site the one shared `Arc<SqlitePool>` on `system.db`, so chats still land in that file's owner tables. The next step is migrating those call sites to `pool_of`, and only then deciding where the owner-without-a-user lives (see blueprint §19).
|
`UserManager` (§11) is now **consumed**. Login exists (`crates/skald-core/src/auth/`: `SessionStore` + the `guard.rs` deny-by-default middleware; first admin created by `skald-setup`), and the per-user owner-bound runtime is `UserContext` (`crates/skald-core/src/skald/user_context.rs`) — resolved by `Skald::user_context` / the frontend's `require_context`, keyed off `UserManager::pool_of`. The frontend owner call-sites (WS, sessions, inbox, approval-pending, projects, uploads, run-context, **cron**) route through the per-user pool; dev/stats read `llm_requests` — a *registry* table — from `system.db`, which is correct. The "owner-without-a-user" question resolved to **there isn't one**: every owner content belongs to a logged-in user (the admin included). The global owner-bound bundles (`Conversation`/`Tasks`: the "ownerless" `ChatSessionManager`, `ChatHub`, cron `TaskManager`, `TicManager`) are still constructed but **inert** — their loops never spawn and nothing consumes their accessors; removing them is pending follow-on work (kept for now because `RunContextManager` shares the `Conversation` bundle and *is* used, being registry-backed). See blueprint §19.
|
||||||
|
|
||||||
Direction of travel, decided but not yet executed: strip the **power-user surface** (self-rewriting, arbitrary shell, dev-agent suite, ticket system) and move to a **binary-first** layout — the app is built once and run from a compiled binary, not executed from its own source tree.
|
Direction of travel, decided but not yet executed: strip the **power-user surface** (self-rewriting, arbitrary shell, dev-agent suite, ticket system) and move to a **binary-first** layout — the app is built once and run from a compiled binary, not executed from its own source tree.
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ The schema is split into two buckets (§5.1), and the split is the point:
|
|||||||
|
|
||||||
**Memory injection into the prompt**: `MessageBuilder::load_inject_memory` routes each `meta.inject_memory` entry — `user-memory/…` → owner pool, `shared-memory/…` → the shared (`system.db`) pool, both via `memory_docs::get`; anything else (`data/…`, `$WD/…`) is a disk read. The shared pool is threaded `ChatSessionManager` → handler → `MessageBuilder`. `main` and `project-coordinator` inject `user-memory/index.md` + `shared-memory/index.md`.
|
**Memory injection into the prompt**: `MessageBuilder::load_inject_memory` routes each `meta.inject_memory` entry — `user-memory/…` → owner pool, `shared-memory/…` → the shared (`system.db`) pool, both via `memory_docs::get`; anything else (`data/…`, `$WD/…`) is a disk read. The shared pool is threaded `ChatSessionManager` → handler → `MessageBuilder`. `main` and `project-coordinator` inject `user-memory/index.md` + `shared-memory/index.md`.
|
||||||
|
|
||||||
`system.db` currently gets **both** bucket functions, because nothing has migrated to per-user pools yet. That is transitional.
|
`system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` and `mcp_servers`/`mcp_events` (`SecretsStore` and `McpManager` are built on the system pool and shared by reference into every `UserContext`). Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4/§7/§14 scope decisions for secrets and MCP, not on call-site migration.
|
||||||
|
|
||||||
`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` has no foreign key yet: sqlx enables `PRAGMA foreign_keys`, so referencing the not-yet-existing `roles` table would fail every insert.
|
`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` has no foreign key yet: sqlx enables `PRAGMA foreign_keys`, so referencing the not-yet-existing `roles` table would fail every insert.
|
||||||
|
|
||||||
|
|||||||
Generated
+67
@@ -432,6 +432,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -2563,6 +2569,19 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png 0.18.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "include_dir"
|
name = "include_dir"
|
||||||
version = "0.7.4"
|
version = "0.7.4"
|
||||||
@@ -3162,6 +3181,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.19.3"
|
version = "0.19.3"
|
||||||
@@ -3986,6 +4015,28 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "plugin-mobile-connector"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
|
"axum",
|
||||||
|
"chrono",
|
||||||
|
"core-api",
|
||||||
|
"hex",
|
||||||
|
"image",
|
||||||
|
"qrcode",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"skald-relay-client",
|
||||||
|
"skald-relay-common",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "plugin-tailscale-remote"
|
name = "plugin-tailscale-remote"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4392,6 +4443,21 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||||
|
dependencies = [
|
||||||
|
"image",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.41.0"
|
version = "0.41.0"
|
||||||
@@ -5407,6 +5473,7 @@ dependencies = [
|
|||||||
"notify",
|
"notify",
|
||||||
"plugin-comfyui",
|
"plugin-comfyui",
|
||||||
"plugin-elevenlabs",
|
"plugin-elevenlabs",
|
||||||
|
"plugin-mobile-connector",
|
||||||
"plugin-tailscale-remote",
|
"plugin-tailscale-remote",
|
||||||
"plugin-telegram-bot",
|
"plugin-telegram-bot",
|
||||||
"plugin-transcribe-whisper-local",
|
"plugin-transcribe-whisper-local",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ members = [
|
|||||||
"crates/mcp-client",
|
"crates/mcp-client",
|
||||||
"crates/plugin-tailscale-remote",
|
"crates/plugin-tailscale-remote",
|
||||||
"crates/plugin-telegram-bot",
|
"crates/plugin-telegram-bot",
|
||||||
|
"crates/plugin-mobile-connector",
|
||||||
"crates/plugin-transcribe-whisper-local",
|
"crates/plugin-transcribe-whisper-local",
|
||||||
"crates/plugin-comfyui",
|
"crates/plugin-comfyui",
|
||||||
"crates/plugin-tts-orpheus-3b",
|
"crates/plugin-tts-orpheus-3b",
|
||||||
@@ -83,6 +84,7 @@ core-api = { path = "crates/core-api" }
|
|||||||
mcp-client = { path = "crates/mcp-client" }
|
mcp-client = { path = "crates/mcp-client" }
|
||||||
plugin-tailscale-remote = { path = "crates/plugin-tailscale-remote" }
|
plugin-tailscale-remote = { path = "crates/plugin-tailscale-remote" }
|
||||||
plugin-telegram-bot = { path = "crates/plugin-telegram-bot" }
|
plugin-telegram-bot = { path = "crates/plugin-telegram-bot" }
|
||||||
|
plugin-mobile-connector = { path = "crates/plugin-mobile-connector" }
|
||||||
plugin-transcribe-whisper-local = { path = "crates/plugin-transcribe-whisper-local", optional = true }
|
plugin-transcribe-whisper-local = { path = "crates/plugin-transcribe-whisper-local", optional = true }
|
||||||
plugin-comfyui = { path = "crates/plugin-comfyui" }
|
plugin-comfyui = { path = "crates/plugin-comfyui" }
|
||||||
plugin-tts-orpheus-3b = { path = "crates/plugin-tts-orpheus-3b" }
|
plugin-tts-orpheus-3b = { path = "crates/plugin-tts-orpheus-3b" }
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use tokio::sync::broadcast;
|
|||||||
use crate::approval::ApprovalApi;
|
use crate::approval::ApprovalApi;
|
||||||
use crate::chat_hub::ChatHubApi;
|
use crate::chat_hub::ChatHubApi;
|
||||||
use crate::events::GlobalEvent;
|
use crate::events::GlobalEvent;
|
||||||
|
use crate::inbox::InboxApi;
|
||||||
|
|
||||||
/// Resolves an unlocked user's channel handle.
|
/// 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.
|
/// The user's approval manager — resolve pending tool-call approvals.
|
||||||
fn approval(&self) -> Arc<dyn ApprovalApi>;
|
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.
|
/// Subscribe to the user's server→client event stream.
|
||||||
/// Events are scoped to this user; no cross-user leakage.
|
/// Events are scoped to this user; no cross-user leakage.
|
||||||
fn subscribe(&self) -> broadcast::Receiver<GlobalEvent>;
|
fn subscribe(&self) -> broadcast::Receiver<GlobalEvent>;
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ pub struct ClientInfo {
|
|||||||
pub platform: Option<String>,
|
pub platform: Option<String>,
|
||||||
/// Unix ms of last activity, if any.
|
/// Unix ms of last activity, if any.
|
||||||
pub last_seen: Option<i64>,
|
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
|
/// The control API exposed by the plugin. Reachable via
|
||||||
@@ -50,18 +52,18 @@ pub trait RelayAgent: Send + Sync {
|
|||||||
/// Derived namespace id (hex).
|
/// Derived namespace id (hex).
|
||||||
fn namespace_id(&self) -> String;
|
fn namespace_id(&self) -> String;
|
||||||
|
|
||||||
/// Send the current Inbox snapshot to all authorized clients.
|
/// List all known devices, each tagged with its bound user (if any).
|
||||||
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.
|
|
||||||
async fn list_clients(&self) -> Vec<ClientInfo>;
|
async fn list_clients(&self) -> Vec<ClientInfo>;
|
||||||
|
|
||||||
/// Authorize a Pending device by its ed25519 pubkey.
|
/// Bind a paired device to a Skald user and authorize it (blueprint §13,
|
||||||
async fn authorize_client(&self, ed25519_pub: [u8; 32]) -> anyhow::Result<()>;
|
/// 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<()>;
|
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`].
|
//! The application layer on top of the payload-agnostic [`RelayClient`].
|
||||||
//!
|
//!
|
||||||
//! `RelayApp` owns the Skald-specific semantics that the transport crate
|
//! `RelayApp` is the plugin's central shared hub (the mobile analogue of the
|
||||||
//! deliberately knows nothing about: the E2E JSON payload schemas (`payloads`),
|
//! Telegram `TgShared`): it owns the E2E payload semantics, the device→user
|
||||||
//! the `InboxApi` dispatch, and the authorization policy. It consumes
|
//! bindings cache, and the per-user forwarder/notifier registries. It knows
|
||||||
//! `client.events()` and calls `client.send(...)`; the client handles the wire,
|
//! nothing about the wire — the client handles transport, crypto, counters, and
|
||||||
//! crypto, counters, and device registry.
|
//! 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::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::{broadcast, Mutex, RwLock};
|
||||||
use tokio_util::sync::CancellationToken;
|
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 skald_relay_client::{ClientState, RelayClient, RelayEvent};
|
||||||
|
|
||||||
use crate::PLUGIN_ID;
|
use crate::PLUGIN_ID;
|
||||||
|
use crate::auth::{self, MobileConfig};
|
||||||
|
use crate::notifier::DelayedNotifier;
|
||||||
use crate::payloads::{self, ClientPayload};
|
use crate::payloads::{self, ClientPayload};
|
||||||
|
|
||||||
/// Glue between the relay transport ([`RelayClient`]) and Skald's Inbox.
|
/// The plugin's shared application state.
|
||||||
pub struct RelayApp {
|
pub struct RelayApp {
|
||||||
client: Arc<RelayClient>,
|
client: Arc<RelayClient>,
|
||||||
inbox: Arc<dyn InboxApi>,
|
/// Per-user runtime resolver (blueprint §13). `None` = user locked (§9).
|
||||||
/// When true, a freshly paired device stays Pending until a human confirms;
|
pub(crate) user_channel: Arc<dyn UserChannelApi>,
|
||||||
/// when false, the app auto-authorizes on `ClientPaired`.
|
/// 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,
|
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 {
|
impl RelayApp {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
client: Arc<RelayClient>,
|
client: Arc<RelayClient>,
|
||||||
inbox: Arc<dyn InboxApi>,
|
user_channel: Arc<dyn UserChannelApi>,
|
||||||
|
config: Arc<dyn ConfigApi>,
|
||||||
|
bindings: MobileConfig,
|
||||||
require_device_confirmation: bool,
|
require_device_confirmation: bool,
|
||||||
) -> Self {
|
notify_delay: Duration,
|
||||||
Self { client, inbox, require_device_confirmation }
|
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).
|
/// The underlying transport client (used by the `RelayAgent` impl + router).
|
||||||
@@ -42,128 +82,219 @@ impl RelayApp {
|
|||||||
&self.client
|
&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
|
/// Write guard on the bindings cache (used by the config listener).
|
||||||
/// client. `live=false` so the relay stores-and-forwards + pushes to offline
|
pub(crate) async fn bindings_mut(&self) -> tokio::sync::RwLockWriteGuard<'_, MobileConfig> {
|
||||||
/// phones.
|
self.bindings.write().await
|
||||||
pub async fn broadcast_inbox(&self) -> Result<()> {
|
}
|
||||||
let snapshot = self.inbox.list_pending().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))?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build and send a generic notification to all Authorized clients.
|
/// Send an opaque plaintext to every Authorized device bound to `user_id`.
|
||||||
pub async fn broadcast_notification(&self, title: &str, body: &str) -> Result<()> {
|
async fn send_to_user_devices(&self, user_id: &str, plaintext: &[u8], live: bool) {
|
||||||
let plaintext = serde_json::to_vec(&payloads::build_notification(title, body))?;
|
let bound = self.bindings.read().await.pubkeys_for_user(user_id);
|
||||||
self.broadcast_plaintext(&plaintext).await;
|
if bound.is_empty() {
|
||||||
Ok(())
|
return;
|
||||||
}
|
}
|
||||||
|
let authorized: HashSet<[u8; 32]> = self
|
||||||
/// Send an opaque plaintext to every Authorized device (`live=false`).
|
|
||||||
async fn broadcast_plaintext(&self, plaintext: &[u8]) {
|
|
||||||
for c in self
|
|
||||||
.client
|
.client
|
||||||
.list_clients()
|
.list_clients()
|
||||||
.await
|
.await
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|c| c.state == ClientState::Authorized)
|
.filter(|c| c.state == ClientState::Authorized)
|
||||||
{
|
.map(|c| c.ed25519_pub)
|
||||||
if let Err(e) = self.client.send(&c.ed25519_pub, plaintext, false).await {
|
.collect();
|
||||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to send to client");
|
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
|
/// Send the current Inbox snapshot to a single requesting device (`live=true`:
|
||||||
/// `inbox_request`). `live=true`: the requester is online by construction.
|
/// the requester is online by construction).
|
||||||
async fn send_inbox_to(&self, client_ed25519_pub: &[u8; 32]) -> Result<()> {
|
async fn send_inbox_to_device(&self, user_id: &str, device: &[u8; 32]) -> Result<()> {
|
||||||
let snapshot = self.inbox.list_pending().await;
|
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))?;
|
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
|
/// Apply a decoded client payload to the sending device's *user's* Inbox.
|
||||||
/// JSON the client already decrypted + de-framed.
|
/// Unbound device or locked user → the request is ignored (no cross-user leak).
|
||||||
async fn apply_client_payload(&self, from: &[u8; 32], payload: &[u8]) {
|
async fn apply_client_payload(&self, from: &[u8; 32], payload: &[u8]) {
|
||||||
match payloads::parse_client_payload(payload) {
|
let parsed = payloads::parse_client_payload(payload);
|
||||||
ClientPayload::ApprovalResponse { request_id, approved, reason } => {
|
|
||||||
if approved {
|
// Hello / Logout are device-registry ops that need no user resolution.
|
||||||
self.inbox.approve(request_id).await;
|
match &parsed {
|
||||||
} 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 } => {
|
ClientPayload::Hello { device_info } => {
|
||||||
if let Err(e) = self.client.set_device_info(from, &device_info.to_string()).await {
|
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");
|
warn!(plugin = PLUGIN_ID, error = %e, "failed to persist device_info");
|
||||||
}
|
}
|
||||||
}
|
return;
|
||||||
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 => {
|
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");
|
warn!(plugin = PLUGIN_ID, error = %e, "logout revoke failed");
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
ClientPayload::Unknown => {
|
ClientPayload::Unknown => {
|
||||||
debug!(plugin = PLUGIN_ID, "unknown/ignored client payload");
|
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 ────────────────────────────────────────────────────────────
|
// ── Event loop ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Consume the client's [`RelayEvent`] stream until `cancel` fires. This is
|
/// Consume the client's [`RelayEvent`] stream until cancelled. Applies inbound
|
||||||
/// where the authorization policy and Inbox application live.
|
/// payloads and the pairing authorization policy, and lazily spawns a per-user
|
||||||
pub async fn run_event_loop(
|
/// forwarder when a bound device becomes active.
|
||||||
self: Arc<Self>,
|
pub async fn run_event_loop(self: Arc<Self>, mut rx: broadcast::Receiver<RelayEvent>) {
|
||||||
mut rx: broadcast::Receiver<RelayEvent>,
|
let cancel = self.cancel.clone();
|
||||||
cancel: CancellationToken,
|
|
||||||
) {
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancel.cancelled() => break,
|
_ = cancel.cancelled() => break,
|
||||||
ev = rx.recv() => match ev {
|
ev = rx.recv() => match ev {
|
||||||
Ok(RelayEvent::Message { from, payload, .. }) => {
|
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;
|
self.apply_client_payload(&from, &payload).await;
|
||||||
}
|
}
|
||||||
Ok(RelayEvent::ClientPaired { ed25519_pub, .. }) => {
|
Ok(RelayEvent::ClientPaired { ed25519_pub, .. }) => {
|
||||||
if self.require_device_confirmation {
|
// The device is not bound to any user yet, so there is no
|
||||||
debug!(
|
// one to push to. An admin binds it with `mobile_bind_device`
|
||||||
plugin = PLUGIN_ID,
|
// (which authorizes it). We only optionally pre-authorize.
|
||||||
device = %hex::encode(ed25519_pub),
|
if !self.require_device_confirmation {
|
||||||
"new device paired (pending manual confirmation)"
|
if let Err(e) = self.client.authorize(&ed25519_pub).await {
|
||||||
);
|
warn!(plugin = PLUGIN_ID, error = %e, "auto-authorize failed");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
info!(
|
||||||
|
plugin = PLUGIN_ID,
|
||||||
|
device = %hex::encode(ed25519_pub),
|
||||||
|
"new device paired — awaiting admin binding (mobile_bind_device)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(RelayEvent::ClientRevoked { .. })
|
Ok(RelayEvent::ClientRevoked { .. })
|
||||||
| Ok(RelayEvent::Connected)
|
| 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`).
|
//! Mobile connector plugin (plugin id `mobile-connector`).
|
||||||
//!
|
//!
|
||||||
//! Bridges Skald's Inbox (approvals + clarifications) to mobile apps over the
|
//! Bridges each Skald user's Inbox (approvals + clarifications + elicitations) to
|
||||||
//! relay. The **networking** (v2 WS transport, E2E crypto, anti-replay counters,
|
//! their mobile devices over the relay, end-to-end encrypted. The **networking**
|
||||||
//! pairing, device authorization, SQLite persistence) lives in the standalone
|
//! (v2 WS transport, E2E crypto, anti-replay counters, pairing, device
|
||||||
//! `skald-relay-client` crate; this plugin is the thin **application** layer on
|
//! authorization, SQLite persistence) lives in the standalone `skald-relay-client`
|
||||||
//! top of it. See `data/iOS-app/v2/relay-protocol.md` for the wire contract and
|
//! crate; this plugin is the thin **application** layer on top of it. See
|
||||||
//! `docs/relay/` for the client/server split.
|
//! `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:
|
//! Module map:
|
||||||
//! - `payloads` — E2E JSON payload schemas (inbox_update, responses, …)
|
//! - `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
|
//! - `router` — the QR-code HTTP endpoint
|
||||||
//! - `agent` — the `RelayAgent` control trait
|
//! - `agent` — the `RelayAgent` control trait
|
||||||
//! - `tools` — `Tool` impls callable by the host (registered in the main crate)
|
//! - `tools` — `Tool` impls callable by the host (registered in the main crate)
|
||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
mod app;
|
mod app;
|
||||||
|
mod auth;
|
||||||
|
mod events;
|
||||||
mod notifier;
|
mod notifier;
|
||||||
mod payloads;
|
mod payloads;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
@@ -40,7 +55,6 @@ pub use agent::{ClientInfo, ClientState, PairingHandle, RelayAgent};
|
|||||||
pub use tools::mobile_tools;
|
pub use tools::mobile_tools;
|
||||||
|
|
||||||
use app::RelayApp;
|
use app::RelayApp;
|
||||||
use notifier::{DelayedNotifier, Kind};
|
|
||||||
|
|
||||||
pub(crate) const PLUGIN_ID: &str = "mobile-connector";
|
pub(crate) const PLUGIN_ID: &str = "mobile-connector";
|
||||||
const DEFAULT_TTL: u32 = 300;
|
const DEFAULT_TTL: u32 = 300;
|
||||||
@@ -60,8 +74,6 @@ pub struct MobileConnectorPlugin {
|
|||||||
inner: Arc<Mutex<Option<Arc<RelayApp>>>>,
|
inner: Arc<Mutex<Option<Arc<RelayApp>>>>,
|
||||||
cancel: Mutex<Option<CancellationToken>>,
|
cancel: Mutex<Option<CancellationToken>>,
|
||||||
handles: Mutex<Vec<JoinHandle<()>>>,
|
handles: Mutex<Vec<JoinHandle<()>>>,
|
||||||
/// Debounces Inbox pushes to the phone; present only while running.
|
|
||||||
notifier: Mutex<Option<Arc<DelayedNotifier>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MobileConnectorPlugin {
|
impl MobileConnectorPlugin {
|
||||||
@@ -71,7 +83,6 @@ impl MobileConnectorPlugin {
|
|||||||
inner: Arc::new(Mutex::new(None)),
|
inner: Arc::new(Mutex::new(None)),
|
||||||
cancel: Mutex::new(None),
|
cancel: Mutex::new(None),
|
||||||
handles: Mutex::new(Vec::new()),
|
handles: Mutex::new(Vec::new()),
|
||||||
notifier: Mutex::new(None),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,69 +130,49 @@ impl MobileConnectorPlugin {
|
|||||||
);
|
);
|
||||||
client.start().await?;
|
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();
|
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();
|
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 app2 = Arc::clone(&app);
|
||||||
let rx = client.events();
|
let rx = client.events();
|
||||||
let c = cancel.clone();
|
|
||||||
handles.push(tokio::spawn(async move {
|
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.
|
// Config listener: reloads bindings when the "mobile-connector" config key
|
||||||
// `*Requested` arms a delayed push; `*Resolved` cancels it (or refreshes
|
// changes (e.g. the bind tool writes a new binding) and (re)spawns forwarders.
|
||||||
// the phone if the push already went out).
|
|
||||||
{
|
{
|
||||||
let notifier = Arc::clone(¬ifier);
|
let app3 = Arc::clone(&app);
|
||||||
let c = cancel.clone();
|
let bus_rx = ctx.system_bus.subscribe();
|
||||||
let mut rx = ctx.chat_hub.events(PLUGIN_ID);
|
handles.push(tokio::spawn(auth::config_listener(app3, bus_rx)));
|
||||||
handles.push(tokio::spawn(async move {
|
}
|
||||||
use core_api::events::ServerEvent::*;
|
|
||||||
loop {
|
// Reconcile loop: (re)spawns forwarders for bound users as they unlock. Its
|
||||||
tokio::select! {
|
// first tick fires immediately (covering already-unlocked users at start),
|
||||||
_ = c.cancelled() => break,
|
// then it periodically catches users who log in later — there is no "user
|
||||||
ev = rx.recv() => match ev {
|
// unlocked" event to hook, and at boot every pool is locked (§9).
|
||||||
Ok(ge) => match ge.event {
|
{
|
||||||
ApprovalRequested { request_id, .. } => {
|
let app4 = Arc::clone(&app);
|
||||||
notifier.on_requested((Kind::Approval, request_id)).await;
|
handles.push(tokio::spawn(events::reconcile_loop(app4)));
|
||||||
}
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP reverse proxy: bridge `http-local-proxy` pipes to the local web
|
// 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.inner.lock().await = Some(app);
|
||||||
*self.cancel.lock().await = Some(cancel);
|
*self.cancel.lock().await = Some(cancel);
|
||||||
*self.handles.lock().await = handles;
|
*self.handles.lock().await = handles;
|
||||||
@@ -210,13 +200,12 @@ impl MobileConnectorPlugin {
|
|||||||
if let Some(c) = self.cancel.lock().await.take() {
|
if let Some(c) = self.cancel.lock().await.take() {
|
||||||
c.cancel();
|
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
|
// 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() {
|
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;
|
app.client().shutdown().await;
|
||||||
}
|
}
|
||||||
for h in self.handles.lock().await.drain(..) {
|
for h in self.handles.lock().await.drain(..) {
|
||||||
@@ -363,47 +352,41 @@ impl RelayAgent for MobileConnectorPlugin {
|
|||||||
.unwrap_or_default()
|
.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> {
|
async fn list_clients(&self) -> Vec<ClientInfo> {
|
||||||
let Some(app) = self.app().await else { return Vec::new() };
|
let Some(app) = self.app().await else { return Vec::new() };
|
||||||
app.client()
|
let rows = app.client().list_clients().await;
|
||||||
.list_clients()
|
let bindings = app.bindings.read().await;
|
||||||
.await
|
rows.into_iter()
|
||||||
.into_iter()
|
.map(|r| {
|
||||||
.map(|r| ClientInfo {
|
let bound_user = bindings.user_for_pubkey(&hex::encode(r.ed25519_pub));
|
||||||
ed25519_pub: r.ed25519_pub,
|
ClientInfo {
|
||||||
x25519_pub: r.x25519_pub,
|
ed25519_pub: r.ed25519_pub,
|
||||||
state: match r.state {
|
x25519_pub: r.x25519_pub,
|
||||||
RelayClientState::Authorized => ClientState::Authorized,
|
state: match r.state {
|
||||||
RelayClientState::Pending => ClientState::Pending,
|
RelayClientState::Authorized => ClientState::Authorized,
|
||||||
},
|
RelayClientState::Pending => ClientState::Pending,
|
||||||
device_info: r.device_info,
|
},
|
||||||
platform: r.platform,
|
device_info: r.device_info,
|
||||||
last_seen: r.last_seen,
|
platform: r.platform,
|
||||||
|
last_seen: r.last_seen,
|
||||||
|
bound_user,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.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"))?;
|
let app = self.app().await.ok_or_else(|| anyhow::anyhow!("plugin not running"))?;
|
||||||
app.client().authorize(&ed25519_pub).await?;
|
app.bind_device(ed25519_pub, user_id, display).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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn revoke_client(&self, ed25519_pub: [u8; 32]) -> Result<()> {
|
async fn revoke_client(&self, ed25519_pub: [u8; 32]) -> Result<()> {
|
||||||
let app = self.app().await.ok_or_else(|| anyhow::anyhow!("plugin not running"))?;
|
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
|
//! 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
|
//! the chat), so there is no computer-side answer to debounce against and they
|
||||||
//! are pushed immediately regardless of `delay`.
|
//! 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::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Weak};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -47,16 +55,27 @@ struct State {
|
|||||||
notified: HashSet<Key>,
|
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 {
|
pub struct DelayedNotifier {
|
||||||
app: Arc<RelayApp>,
|
/// Weak to break the `RelayApp` → notifiers → `RelayApp` cycle.
|
||||||
|
app: Weak<RelayApp>,
|
||||||
|
user_id: String,
|
||||||
delay: Duration,
|
delay: Duration,
|
||||||
state: Mutex<State>,
|
state: Mutex<State>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DelayedNotifier {
|
impl DelayedNotifier {
|
||||||
pub fn new(app: Arc<RelayApp>, delay: Duration) -> Arc<Self> {
|
pub fn new(app: Weak<RelayApp>, user_id: String, delay: Duration) -> Arc<Self> {
|
||||||
Arc::new(Self { app, delay, state: Mutex::new(State::default()) })
|
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
|
/// A request entered the Inbox: arm a timer. If `delay` elapses before a
|
||||||
@@ -78,9 +97,7 @@ impl DelayedNotifier {
|
|||||||
st.notified.insert(key)
|
st.notified.insert(key)
|
||||||
};
|
};
|
||||||
if newly_notified {
|
if newly_notified {
|
||||||
if let Err(e) = self.app.broadcast_inbox().await {
|
self.push().await;
|
||||||
warn!(plugin = PLUGIN_ID, error = %e, "immediate elicitation push failed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -111,9 +128,7 @@ impl DelayedNotifier {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if still_armed {
|
if still_armed {
|
||||||
if let Err(e) = this.app.broadcast_inbox().await {
|
this.push().await;
|
||||||
warn!(plugin = PLUGIN_ID, error = %e, "delayed inbox push failed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,9 +150,7 @@ impl DelayedNotifier {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if broadcast {
|
if broadcast {
|
||||||
if let Err(e) = self.app.broadcast_inbox().await {
|
self.push().await;
|
||||||
warn!(plugin = PLUGIN_ID, error = %e, "inbox broadcast failed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
pub fn build_notification(title: &str, body: &str) -> Value {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"v": 1,
|
"v": 1,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use crate::agent::{ClientState, RelayAgent};
|
|||||||
/// Tool name constants (also the patterns the host uses for approval rules).
|
/// Tool name constants (also the patterns the host uses for approval rules).
|
||||||
pub const TOOL_START_PAIRING: &str = "mobile_start_pairing";
|
pub const TOOL_START_PAIRING: &str = "mobile_start_pairing";
|
||||||
pub const TOOL_LIST_DEVICES: &str = "mobile_list_devices";
|
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";
|
pub const TOOL_REVOKE_DEVICE: &str = "mobile_revoke_device";
|
||||||
|
|
||||||
/// Build the plugin's LLM tools, bound to a `RelayAgent`. The host calls this
|
/// 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![
|
vec![
|
||||||
Arc::new(StartPairingTool { agent: Arc::clone(&agent) }),
|
Arc::new(StartPairingTool { agent: Arc::clone(&agent) }),
|
||||||
Arc::new(ListDevicesTool { agent: Arc::clone(&agent) }),
|
Arc::new(ListDevicesTool { agent: Arc::clone(&agent) }),
|
||||||
|
Arc::new(BindDeviceTool { agent: Arc::clone(&agent) }),
|
||||||
Arc::new(RevokeDeviceTool { agent }),
|
Arc::new(RevokeDeviceTool { agent }),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -82,7 +84,8 @@ struct ListDevicesTool {
|
|||||||
impl Tool for ListDevicesTool {
|
impl Tool for ListDevicesTool {
|
||||||
fn name(&self) -> &str { TOOL_LIST_DEVICES }
|
fn name(&self) -> &str { TOOL_LIST_DEVICES }
|
||||||
fn description(&self) -> &str {
|
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 {
|
fn parameters_schema(&self) -> Value {
|
||||||
json!({ "type": "object", "properties": {} })
|
json!({ "type": "object", "properties": {} })
|
||||||
@@ -109,6 +112,7 @@ impl Tool for ListDevicesTool {
|
|||||||
ClientState::Authorized => "authorized",
|
ClientState::Authorized => "authorized",
|
||||||
ClientState::Pending => "pending",
|
ClientState::Pending => "pending",
|
||||||
},
|
},
|
||||||
|
"bound_user": c.bound_user,
|
||||||
"platform": c.platform,
|
"platform": c.platform,
|
||||||
"device_info": device_info,
|
"device_info": device_info,
|
||||||
"last_seen": c.last_seen,
|
"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 ──────────────────────────────────────────────────────
|
// ── mobile_revoke_device ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
struct RevokeDeviceTool {
|
struct RevokeDeviceTool {
|
||||||
@@ -129,7 +190,8 @@ struct RevokeDeviceTool {
|
|||||||
impl Tool for RevokeDeviceTool {
|
impl Tool for RevokeDeviceTool {
|
||||||
fn name(&self) -> &str { TOOL_REVOKE_DEVICE }
|
fn name(&self) -> &str { TOOL_REVOKE_DEVICE }
|
||||||
fn description(&self) -> &str {
|
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 {
|
fn parameters_schema(&self) -> Value {
|
||||||
json!({
|
json!({
|
||||||
|
|||||||
@@ -235,6 +235,10 @@ impl ApprovalManager {
|
|||||||
// Opening a mobile pairing window emits a secret (the QR) into chat:
|
// 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).
|
// it must be a deliberate human action, not LLM-triggerable (plugin.md §11).
|
||||||
("mobile_start_pairing", "require"),
|
("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.
|
// NOTE: file-write tools are NOT seeded here as per-tool `require` rules.
|
||||||
// Filesystem gating is owned by the "File System" category — path-scoped
|
// 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::approval::ApprovalApi;
|
||||||
use core_api::chat_hub::ChatHubApi;
|
use core_api::chat_hub::ChatHubApi;
|
||||||
use core_api::events::GlobalEvent;
|
use core_api::events::GlobalEvent;
|
||||||
|
use core_api::inbox::InboxApi;
|
||||||
use core_api::system_bus::SystemEventBus;
|
use core_api::system_bus::SystemEventBus;
|
||||||
use core_api::user_channel::UserChannelHandle;
|
use core_api::user_channel::UserChannelHandle;
|
||||||
|
|
||||||
@@ -292,6 +293,12 @@ impl UserChannelHandle for UserContextHandle {
|
|||||||
Arc::clone(&self.ctx.approval) as Arc<dyn ApprovalApi>
|
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> {
|
fn subscribe(&self) -> broadcast::Receiver<GlobalEvent> {
|
||||||
self.ctx.global_tx.subscribe()
|
self.ctx.global_tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ fn build_plugins() -> Vec<Arc<dyn Plugin>> {
|
|||||||
let mut plugins: Vec<Arc<dyn Plugin>> = vec![
|
let mut plugins: Vec<Arc<dyn Plugin>> = vec![
|
||||||
Arc::new(plugin_tailscale_remote::RemotePlugin::new()),
|
Arc::new(plugin_tailscale_remote::RemotePlugin::new()),
|
||||||
Arc::new(plugin_telegram_bot::TelegramPlugin::new()),
|
Arc::new(plugin_telegram_bot::TelegramPlugin::new()),
|
||||||
|
Arc::new(plugin_mobile_connector::MobileConnectorPlugin::new()),
|
||||||
Arc::new(plugin_comfyui::ComfyUIPlugin::new()),
|
Arc::new(plugin_comfyui::ComfyUIPlugin::new()),
|
||||||
Arc::new(plugin_tts_orpheus_3b::OrpheusTtsPlugin::new()),
|
Arc::new(plugin_tts_orpheus_3b::OrpheusTtsPlugin::new()),
|
||||||
Arc::new(plugin_tts_kokoro::KokoroTtsPlugin::new()),
|
Arc::new(plugin_tts_kokoro::KokoroTtsPlugin::new()),
|
||||||
|
|||||||
Reference in New Issue
Block a user