From 485472a9ada37b4b13425eb9361600fe355518fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 00:21:27 +0000 Subject: [PATCH 1/4] Add CEC service client, contract, mock backend, and agent The CEC (Critical Error Computing) service is the hosted backend behind the two advertised paid services. This lands the client half in three new workspace crates, plus a runnable reference backend: - allmystuff-cec: the typed HTTP contract (CONTRACT.md is the source of truth), a transport-generic CecClient, the real reqwest transport, the shared conventions (cec-customer- networks, cec- help rooms), and an in-memory MockBackend reference implementation + socket-free MockTransport. - allmystuff-cec-mock: a tiny dependency-light HTTP server around MockBackend so the real app/agent can be exercised end to end (--demo, prints codes). - allmystuff-agent: the headless CEC technician tool (sign in, go online, watch/accept help sessions), sharing the client crate. Covers optional email-code accounts bound to the device's mesh identity, entitlements, per-customer CEC mesh provisioning (a remote venue + a single CEC Service node), Private Line rental, the Ask-for-Help queue, and agent presence. 14 tests pass and the server is verified over real HTTP. --- Cargo.lock | 33 ++ Cargo.toml | 4 + crates/allmystuff-agent/Cargo.toml | 29 + crates/allmystuff-agent/src/lib.rs | 123 ++++ crates/allmystuff-agent/src/main.rs | 277 +++++++++ crates/allmystuff-agent/tests/agent.rs | 94 ++++ crates/allmystuff-cec-mock/Cargo.toml | 23 + crates/allmystuff-cec-mock/src/main.rs | 347 ++++++++++++ crates/allmystuff-cec/CONTRACT.md | 114 ++++ crates/allmystuff-cec/Cargo.toml | 30 + crates/allmystuff-cec/src/client.rs | 304 ++++++++++ crates/allmystuff-cec/src/convention.rs | 122 ++++ crates/allmystuff-cec/src/lib.rs | 96 ++++ crates/allmystuff-cec/src/mock.rs | 713 ++++++++++++++++++++++++ crates/allmystuff-cec/src/model.rs | 493 ++++++++++++++++ crates/allmystuff-cec/src/transport.rs | 166 ++++++ crates/allmystuff-cec/tests/flows.rs | 243 ++++++++ 17 files changed, 3211 insertions(+) create mode 100644 crates/allmystuff-agent/Cargo.toml create mode 100644 crates/allmystuff-agent/src/lib.rs create mode 100644 crates/allmystuff-agent/src/main.rs create mode 100644 crates/allmystuff-agent/tests/agent.rs create mode 100644 crates/allmystuff-cec-mock/Cargo.toml create mode 100644 crates/allmystuff-cec-mock/src/main.rs create mode 100644 crates/allmystuff-cec/CONTRACT.md create mode 100644 crates/allmystuff-cec/Cargo.toml create mode 100644 crates/allmystuff-cec/src/client.rs create mode 100644 crates/allmystuff-cec/src/convention.rs create mode 100644 crates/allmystuff-cec/src/lib.rs create mode 100644 crates/allmystuff-cec/src/mock.rs create mode 100644 crates/allmystuff-cec/src/model.rs create mode 100644 crates/allmystuff-cec/src/transport.rs create mode 100644 crates/allmystuff-cec/tests/flows.rs diff --git a/Cargo.lock b/Cargo.lock index b56732c9..5e9cbf6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "allmystuff-agent" +version = "0.1.12" +dependencies = [ + "allmystuff-cec", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "allmystuff-bridge" version = "0.1.12" @@ -17,6 +28,28 @@ dependencies = [ "allmystuff-protocol", ] +[[package]] +name = "allmystuff-cec" +version = "0.1.12" +dependencies = [ + "hex", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "allmystuff-cec-mock" +version = "0.1.12" +dependencies = [ + "allmystuff-cec", + "serde_json", + "tokio", +] + [[package]] name = "allmystuff-cli" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index c954c6a8..4ad9a5d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,9 @@ members = [ "crates/allmystuff-session", "crates/allmystuff-updater", "crates/allmystuff-cli", + "crates/allmystuff-cec", + "crates/allmystuff-cec-mock", + "crates/allmystuff-agent", ] # The Tauri desktop app lives in its own Cargo workspace (`gui/src-tauri`) # so a `cargo build --workspace` at the root stays fast and webview-free — @@ -32,6 +35,7 @@ allmystuff-protocol = { path = "crates/allmystuff-protocol", version = "0.1.12" allmystuff-bridge = { path = "crates/allmystuff-bridge", version = "0.1.12" } allmystuff-session = { path = "crates/allmystuff-session", version = "0.1.12" } allmystuff-updater = { path = "crates/allmystuff-updater", version = "0.1.12" } +allmystuff-cec = { path = "crates/allmystuff-cec", version = "0.1.12", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/allmystuff-agent/Cargo.toml b/crates/allmystuff-agent/Cargo.toml new file mode 100644 index 00000000..a77eb5e2 --- /dev/null +++ b/crates/allmystuff-agent/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "allmystuff-agent" +description = "The CEC agent — a headless tool a Critical Error Computing technician uses to sign in, go online, and pick up Ask-for-Help sessions. Shares the allmystuff-cec contract; its live connection to a customer is brokered by the backend through the single CEC Service node." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[lib] +name = "allmystuff_agent" +path = "src/lib.rs" + +[[bin]] +name = "allmystuff-agent" +path = "src/main.rs" + +[lints.rust] +unsafe_code = "forbid" + +[dependencies] +allmystuff-cec = { workspace = true, features = ["reqwest"] } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/allmystuff-agent/src/lib.rs b/crates/allmystuff-agent/src/lib.rs new file mode 100644 index 00000000..c82de0aa --- /dev/null +++ b/crates/allmystuff-agent/src/lib.rs @@ -0,0 +1,123 @@ +//! The reusable core of the CEC agent tool: persisted config plus the +//! transport-generic "watch" operation, kept out of `main.rs` so it can be +//! tested against the in-memory backend without a socket. + +use std::path::{Path, PathBuf}; + +use allmystuff_cec::model::{AgentAssignment, HelpSession}; +use allmystuff_cec::{CecClient, Error, Transport}; +use serde::{Deserialize, Serialize}; + +/// Where the app family advertises its reference backend. Override with +/// `--backend` (e.g. point at a local `allmystuff-cec-mock`). +pub const DEFAULT_BACKEND: &str = "https://api.allmystuff.works"; + +/// The agent's persisted state: which backend, the session token, and a note +/// of who's signed in. Lives next to the mesh identity under `~/.myownmesh`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default = "default_backend")] + pub backend_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, +} + +fn default_backend() -> String { + DEFAULT_BACKEND.to_string() +} + +impl Default for Config { + fn default() -> Self { + Config { + backend_url: default_backend(), + token: None, + email: None, + } + } +} + +impl Config { + /// `~/.myownmesh/allmystuff-agent.json`, honouring `MYOWNMESH_HOME` exactly + /// like the daemon control socket does. + pub fn default_path() -> Option { + let home = std::env::var_os("MYOWNMESH_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(PathBuf::from))?; + Some(home.join(".myownmesh").join("allmystuff-agent.json")) + } + + /// Load config from `path`, or a default if it doesn't exist. + pub fn load(path: &Path) -> Self { + std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + /// Persist to `path`, creating the parent directory. + pub fn save(&self, path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let body = serde_json::to_string_pretty(self).unwrap_or_default(); + std::fs::write(path, body) + } + + pub fn is_signed_in(&self) -> bool { + self.token.is_some() + } +} + +/// What one pass of `watch` found (and possibly took). +#[derive(Debug, Clone)] +pub struct WatchReport { + pub queued: Vec, + pub accepted: Option, +} + +/// One pass of the agent loop: read the queue, and — when `accept_all` — take +/// the oldest waiting session, returning the assignment to act on. +/// +/// The actual live link to the customer is the backend's job: it operates the +/// single CEC Service node on the customer's mesh and bridges this agent to +/// it. The agent tool's responsibility ends at "I've got it" — exactly the +/// "connections are managed by the backend provider, not the mesh engine" +/// split the product describes. +pub async fn watch_once( + client: &CecClient, + accept_all: bool, +) -> Result { + let queued = client.agent_queue().await?; + let mut accepted = None; + if accept_all { + if let Some(first) = queued.iter().min_by_key(|s| s.created_at) { + accepted = Some(client.accept_help(&first.id).await?); + } + } + Ok(WatchReport { queued, accepted }) +} + +/// A one-line summary of a help session for the console. +pub fn fmt_session(s: &HelpSession) -> String { + let topic = s.topic.as_deref().unwrap_or("(no topic)"); + format!( + " {} · {} · {} · {}", + s.id, + s.customer_label, + status_word(s), + topic + ) +} + +fn status_word(s: &HelpSession) -> &'static str { + use allmystuff_cec::model::HelpStatus::*; + match s.status { + Queued => "waiting", + Assigned => "assigned", + Connected => "connected", + Ended => "ended", + Cancelled => "cancelled", + } +} diff --git a/crates/allmystuff-agent/src/main.rs b/crates/allmystuff-agent/src/main.rs new file mode 100644 index 00000000..4319781f --- /dev/null +++ b/crates/allmystuff-agent/src/main.rs @@ -0,0 +1,277 @@ +//! `allmystuff-agent` — the CEC technician's command-line tool. +//! +//! ```text +//! allmystuff-agent start --email sam@cec # email a sign-in code +//! allmystuff-agent verify --email sam@cec --code 123456 +//! allmystuff-agent whoami +//! allmystuff-agent online # available for requests +//! allmystuff-agent queue # waiting help sessions +//! allmystuff-agent watch --accept # loop: pick up the next one +//! allmystuff-agent accept +//! allmystuff-agent end +//! allmystuff-agent offline +//! allmystuff-agent logout +//! ``` +//! +//! Point it at a local mock with `--backend http://127.0.0.1:8787` (the flag +//! is remembered). + +use std::process::ExitCode; +use std::time::Duration; + +use allmystuff_agent::{fmt_session, watch_once, Config}; +use allmystuff_cec::{CecClient, Error, ReqwestTransport}; + +#[tokio::main] +async fn main() -> ExitCode { + let mut args: Vec = std::env::args().skip(1).collect(); + let Some(cmd) = take_positional(&mut args) else { + print_help(); + return ExitCode::SUCCESS; + }; + if matches!(cmd.as_str(), "-h" | "--help" | "help") { + print_help(); + return ExitCode::SUCCESS; + } + + let path = match Config::default_path() { + Some(p) => p, + None => { + eprintln!("can't locate a home directory (set MYOWNMESH_HOME or HOME)"); + return ExitCode::FAILURE; + } + }; + let mut config = Config::load(&path); + + // `--backend ` overrides and is remembered. + if let Some(backend) = take_flag(&mut args, "--backend") { + config.backend_url = backend.trim_end_matches('/').to_string(); + let _ = config.save(&path); + } + + match run(&cmd, &mut args, &mut config, &path).await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {}", describe(&e)); + ExitCode::FAILURE + } + } +} + +async fn run( + cmd: &str, + args: &mut Vec, + config: &mut Config, + path: &std::path::Path, +) -> Result<(), Error> { + let transport = ReqwestTransport::new(&config.backend_url)?; + let mut client = CecClient::with_token(transport, config.token.clone()); + + match cmd { + "start" => { + let email = require_flag(args, "--email")?; + let resp = client.start_sign_in(&email).await?; + config.email = Some(email.clone()); + let _ = config.save(path); + let masked = resp.masked_email.unwrap_or(email); + println!("Sign-in code sent to {masked}."); + println!("Then run: allmystuff-agent verify --email {masked} --code "); + Ok(()) + } + "verify" => { + let email = require_flag(args, "--email")?; + let code = require_flag(args, "--code")?; + let session = client.verify_sign_in(&email, &code, None, None).await?; + config.token = Some(session.token); + config.email = Some(session.account.email.clone()); + config.save(path).map_err(io)?; + let role = if session.account.is_agent() { + "agent" + } else { + "customer only (this account can't take help sessions yet)" + }; + println!("Signed in as {} — {role}.", session.account.email); + Ok(()) + } + "whoami" => { + let me = client.me().await?; + println!("{} ({})", me.account.display_name, me.account.email); + println!( + "roles: {}", + me.account + .roles + .iter() + .map(|r| format!("{r:?}")) + .collect::>() + .join(", ") + ); + Ok(()) + } + "online" => { + let p = client.set_presence(true).await?; + println!("You're online. {}", if p.online { "Available." } else { "" }); + Ok(()) + } + "offline" => { + client.set_presence(false).await?; + println!("You're offline."); + Ok(()) + } + "queue" => { + let q = client.agent_queue().await?; + if q.is_empty() { + println!("No one's waiting."); + } else { + println!("{} waiting:", q.len()); + for s in &q { + println!("{}", fmt_session(s)); + } + } + Ok(()) + } + "accept" => { + let id = require_positional(args, "")?; + let a = client.accept_help(&id).await?; + println!( + "Accepted {} for {}. Joining as the CEC Service node on {}.", + a.session.id, a.session.customer_label, a.session.network_id + ); + println!(" room: {}", a.session.room_id); + println!(" venue: {} signaling server(s)", a.venue.signaling.len()); + Ok(()) + } + "decline" => { + let id = require_positional(args, "")?; + client.decline_help(&id).await?; + println!("Declined {id}; it stays in the queue for another agent."); + Ok(()) + } + "end" => { + let id = require_positional(args, "")?; + client.end_help(&id).await?; + println!("Ended {id}."); + Ok(()) + } + "watch" => { + let accept = has_flag(args, "--accept"); + let interval = take_flag(args, "--interval") + .and_then(|v| v.parse::().ok()) + .unwrap_or(5) + .max(1); + client.set_presence(true).await?; + println!( + "Online and watching the queue every {interval}s{}. Ctrl-C to stop.", + if accept { ", auto-accepting" } else { "" } + ); + loop { + match watch_once(&client, accept).await { + Ok(report) => { + if let Some(a) = report.accepted { + println!( + "→ picked up {} for {} (room {})", + a.session.id, a.session.customer_label, a.session.room_id + ); + } else if report.queued.is_empty() { + // quiet + } else { + println!("{} waiting:", report.queued.len()); + for s in &report.queued { + println!("{}", fmt_session(s)); + } + } + } + Err(e) => eprintln!(" (queue error: {})", describe(&e)), + } + tokio::time::sleep(Duration::from_secs(interval)).await; + } + } + "logout" => { + let _ = client.sign_out().await; + config.token = None; + config.save(path).map_err(io)?; + println!("Signed out."); + Ok(()) + } + "config" => { + println!("backend: {}", config.backend_url); + println!( + "signed in: {}", + if config.is_signed_in() { + config.email.clone().unwrap_or_else(|| "yes".into()) + } else { + "no".into() + } + ); + Ok(()) + } + other => { + eprintln!("unknown command: {other}\n"); + print_help(); + Err(Error::Transport("unknown command".into())) + } + } +} + +// --- tiny arg helpers (no clap dependency) --------------------------------- + +fn take_positional(args: &mut Vec) -> Option { + let idx = args.iter().position(|a| !a.starts_with('-'))?; + Some(args.remove(idx)) +} + +fn require_positional(args: &mut Vec, name: &str) -> Result { + take_positional(args).ok_or_else(|| Error::Transport(format!("missing {name}"))) +} + +fn take_flag(args: &mut Vec, flag: &str) -> Option { + let idx = args.iter().position(|a| a == flag)?; + args.remove(idx); // the flag + if idx < args.len() { + Some(args.remove(idx)) // its value + } else { + None + } +} + +fn require_flag(args: &mut Vec, flag: &str) -> Result { + take_flag(args, flag).ok_or_else(|| Error::Transport(format!("missing {flag} "))) +} + +fn has_flag(args: &mut Vec, flag: &str) -> bool { + if let Some(idx) = args.iter().position(|a| a == flag) { + args.remove(idx); + true + } else { + false + } +} + +fn io(e: std::io::Error) -> Error { + Error::Transport(format!("local config: {e}")) +} + +fn describe(e: &Error) -> String { + match e { + Error::Api { code: Some(c), message, .. } => format!("{message} ({c})"), + _ => e.to_string(), + } +} + +fn print_help() { + eprintln!( + "allmystuff-agent — the CEC technician's tool\n\n\ + USAGE:\n allmystuff-agent [--backend ] [args]\n\n\ + COMMANDS:\n \ + start --email email a one-time sign-in code\n \ + verify --email --code complete sign-in\n \ + whoami show the signed-in account\n \ + online | offline set availability\n \ + queue list waiting help sessions\n \ + watch [--accept] [--interval ] loop the queue (optionally take the next)\n \ + accept take a session\n \ + decline pass on a session\n \ + end end a session you're handling\n \ + logout forget the session\n \ + config show current backend + sign-in state\n" + ); +} diff --git a/crates/allmystuff-agent/tests/agent.rs b/crates/allmystuff-agent/tests/agent.rs new file mode 100644 index 00000000..dfa39fc0 --- /dev/null +++ b/crates/allmystuff-agent/tests/agent.rs @@ -0,0 +1,94 @@ +//! The agent's reusable core, exercised against the in-memory backend. + +use allmystuff_agent::{watch_once, Config}; +use allmystuff_cec::convention; +use allmystuff_cec::mock::{MockBackend, MockTransport}; +use allmystuff_cec::model::*; +use allmystuff_cec::CecClient; + +#[test] +fn config_round_trips_and_defaults() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("allmystuff-agent.json"); + + // Missing file → defaults. + let fresh = Config::load(&path); + assert!(!fresh.is_signed_in()); + assert_eq!(fresh.backend_url, allmystuff_agent::DEFAULT_BACKEND); + + // Save then reload. + let mut c = Config::default(); + c.backend_url = "http://127.0.0.1:8787".into(); + c.token = Some("tok_abc".into()); + c.email = Some("sam@cec.example".into()); + c.save(&path).unwrap(); + + let back = Config::load(&path); + assert!(back.is_signed_in()); + assert_eq!(back.backend_url, "http://127.0.0.1:8787"); + assert_eq!(back.email.as_deref(), Some("sam@cec.example")); +} + +#[tokio::test] +async fn watch_once_picks_up_the_oldest_queued_session() { + let backend = MockBackend::shared(); + + // A customer queues a request. + let mut customer = CecClient::new(MockTransport::new(backend.clone())); + customer + .dev_grant(&DevGrant { + email: "casey@example.com".into(), + entitlements: Some(Entitlements { + concierge: Some(ConciergeTier::PayAsYouGo), + ..Default::default() + }), + agent: false, + }) + .await + .unwrap(); + customer.start_sign_in("casey@example.com").await.unwrap(); + let code = backend.last_code("casey@example.com").unwrap(); + customer + .verify_sign_in("casey@example.com", &code, Some("dev-casey"), None) + .await + .unwrap(); + let prov = customer.provision_mesh("dev-casey").await.unwrap(); + let session = customer + .ask_for_help(&AskForHelp { + network_id: prov.network_id, + room_id: convention::help_room_id("dev-casey"), + device_id: "dev-casey".into(), + topic: Some("can't hear sound".into()), + }) + .await + .unwrap(); + + // An agent goes online and watches with auto-accept. + let mut agent = CecClient::new(MockTransport::new(backend.clone())); + agent + .dev_grant(&DevGrant { + email: "sam@cec.example".into(), + agent: true, + ..Default::default() + }) + .await + .unwrap(); + agent.start_sign_in("sam@cec.example").await.unwrap(); + let acode = backend.last_code("sam@cec.example").unwrap(); + agent + .verify_sign_in("sam@cec.example", &acode, None, None) + .await + .unwrap(); + agent.set_presence(true).await.unwrap(); + + let report = watch_once(&agent, true).await.unwrap(); + assert_eq!(report.queued.len(), 1); + let accepted = report.accepted.expect("auto-accept took the session"); + assert_eq!(accepted.session.id, session.id); + assert_eq!(accepted.session.status, HelpStatus::Assigned); + + // Next pass: nothing left waiting. + let again = watch_once(&agent, true).await.unwrap(); + assert!(again.queued.is_empty()); + assert!(again.accepted.is_none()); +} diff --git a/crates/allmystuff-cec-mock/Cargo.toml b/crates/allmystuff-cec-mock/Cargo.toml new file mode 100644 index 00000000..bc46574e --- /dev/null +++ b/crates/allmystuff-cec-mock/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "allmystuff-cec-mock" +description = "A local reference/mock CEC backend — runs the in-memory allmystuff-cec MockBackend behind a tiny HTTP server so the real app and agent can be exercised end to end without the hosted service." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[[bin]] +name = "allmystuff-cec-mock" +path = "src/main.rs" + +[lints.rust] +unsafe_code = "forbid" + +[dependencies] +# Pure-logic build: we only need the contract + mock, never the HTTP client. +allmystuff-cec = { workspace = true } +serde_json = { workspace = true } +# `net` for the listener; the rest come from the workspace tokio features. +tokio = { workspace = true, features = ["net"] } diff --git a/crates/allmystuff-cec-mock/src/main.rs b/crates/allmystuff-cec-mock/src/main.rs new file mode 100644 index 00000000..9d70b057 --- /dev/null +++ b/crates/allmystuff-cec-mock/src/main.rs @@ -0,0 +1,347 @@ +//! `allmystuff-cec-mock` — a local, in-memory CEC backend you can actually +//! run, so the full app and agent flows work without the hosted service. +//! +//! ```text +//! allmystuff-cec-mock # listen on 127.0.0.1:8787 +//! allmystuff-cec-mock --port 9000 +//! allmystuff-cec-mock --demo # every account gets Concierge + is an agent +//! ``` +//! +//! It is the exact same [`MockBackend`] the tests use — this binary is just a +//! tiny HTTP/1.1 socket around `MockBackend::handle`, with permissive CORS so +//! the app's webview can fetch venue files, and it prints the sign-in codes it +//! "emails" so you can complete a login locally. + +use std::sync::Arc; + +use allmystuff_cec::mock::{MockBackend, MockConfig}; +use allmystuff_cec::model::{ConciergeTier, Entitlements}; +use allmystuff_cec::transport::{ApiRequest, ApiResponse, Method}; +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +#[tokio::main] +async fn main() { + let mut port: u16 = 8787; + let mut demo = false; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--port" | "-p" => { + port = args + .next() + .and_then(|v| v.parse().ok()) + .unwrap_or_else(|| die("--port wants a number")); + } + "--demo" => demo = true, + "-h" | "--help" => { + print_help(); + return; + } + other => die(&format!("unknown argument: {other}")), + } + } + + let cfg = if demo { + MockConfig { + default_entitlements: Entitlements { + concierge: Some(ConciergeTier::PayAsYouGo), + private_line: false, + hardware: false, + }, + everyone_is_agent: true, + } + } else { + MockConfig::default() + }; + let backend = Arc::new(MockBackend::with_config(cfg)); + + let addr = format!("127.0.0.1:{port}"); + let listener = TcpListener::bind(&addr) + .await + .unwrap_or_else(|e| die(&format!("bind {addr}: {e}"))); + eprintln!("allmystuff-cec-mock listening on http://{addr}"); + if demo { + eprintln!(" --demo: every account gets Concierge (Pay as you go) and is an agent"); + } + eprintln!(" point the app at: http://{addr}"); + eprintln!(" sign-in codes are printed here (this mock prints what it would email)\n"); + + loop { + match listener.accept().await { + Ok((stream, _)) => { + let backend = backend.clone(); + tokio::spawn(async move { + if let Err(e) = serve_one(stream, backend).await { + eprintln!("connection error: {e}"); + } + }); + } + Err(e) => eprintln!("accept error: {e}"), + } + } +} + +/// Handle a single connection: read one HTTP request, dispatch, reply, close. +/// A mock doesn't need keep-alive, so `Connection: close` keeps the parser +/// honest and simple. +async fn serve_one(mut stream: TcpStream, backend: Arc) -> std::io::Result<()> { + let Some(raw) = read_request(&mut stream).await? else { + return Ok(()); // empty/broken connection + }; + + let (head, body_bytes) = split_head(&raw); + let head = String::from_utf8_lossy(head); + let mut lines = head.split("\r\n"); + let request_line = lines.next().unwrap_or(""); + let mut parts = request_line.split_whitespace(); + let method_str = parts.next().unwrap_or(""); + let target = parts.next().unwrap_or("/"); + + // CORS preflight — let the webview fetch venue files cross-origin. + if method_str == "OPTIONS" { + return write_response(&mut stream, 204, &Value::Null, true).await; + } + + let method = match method_str { + "GET" => Method::Get, + "POST" => Method::Post, + "DELETE" => Method::Delete, + _ => return write_response(&mut stream, 405, &err_body("method_not_allowed"), true).await, + }; + + let mut bearer = None; + for line in lines { + if let Some(v) = header_value(line, "authorization") { + if let Some(tok) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) { + bearer = Some(tok.trim().to_string()); + } + } + } + + let (path, query) = split_target(target); + let body = if body_bytes.is_empty() { + None + } else { + match serde_json::from_slice::(body_bytes) { + Ok(v) => Some(v), + Err(_) => return write_response(&mut stream, 400, &err_body("bad_json"), true).await, + } + }; + + let req = ApiRequest { + method, + path: path.clone(), + query, + body: body.clone(), + bearer, + }; + let ApiResponse { status, body: out } = backend.handle(req); + + // Surface the sign-in code on the console so a human can complete login. + if path == "/v1/auth/start" && status == 200 { + let email = body + .as_ref() + .and_then(|b| b.get("email")) + .and_then(|v| v.as_str()) + .unwrap_or("?"); + if let Some(code) = out.get("dev_code").and_then(|v| v.as_str()) { + eprintln!(" → sign-in code for {email}: {code}"); + } + } + + write_response(&mut stream, status, &out, true).await +} + +/// Read until the end of headers, then read the declared body. Caps the read +/// so a mock can't be wedged by a giant request. +async fn read_request(stream: &mut TcpStream) -> std::io::Result>> { + const MAX: usize = 1 << 20; // 1 MiB is plenty for this contract + let mut buf = Vec::with_capacity(2048); + let mut chunk = [0u8; 2048]; + + // Read until we have the full header block. + loop { + if let Some(idx) = find_double_crlf(&buf) { + // Headers complete. Do we have the whole body yet? + let head = &buf[..idx]; + let need = content_length(head); + let have = buf.len() - (idx + 4); + while buf.len() - (idx + 4) < need && buf.len() < MAX { + let n = stream.read(&mut chunk).await?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + } + let _ = have; + return Ok(Some(buf)); + } + let n = stream.read(&mut chunk).await?; + if n == 0 { + return Ok(if buf.is_empty() { None } else { Some(buf) }); + } + buf.extend_from_slice(&chunk[..n]); + if buf.len() > MAX { + return Ok(Some(buf)); + } + } +} + +fn find_double_crlf(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n") +} + +fn split_head(raw: &[u8]) -> (&[u8], &[u8]) { + match find_double_crlf(raw) { + Some(idx) => (&raw[..idx], &raw[idx + 4..]), + None => (raw, &[]), + } +} + +fn content_length(head: &[u8]) -> usize { + let head = String::from_utf8_lossy(head); + for line in head.split("\r\n") { + if let Some(v) = header_value(line, "content-length") { + if let Ok(n) = v.trim().parse::() { + return n; + } + } + } + 0 +} + +/// Case-insensitive `Header: value` extraction. +fn header_value(line: &str, name_lower: &str) -> Option { + let (name, value) = line.split_once(':')?; + if name.trim().eq_ignore_ascii_case(name_lower) { + Some(value.trim().to_string()) + } else { + None + } +} + +/// Split a request target into a path and decoded query pairs. +fn split_target(target: &str) -> (String, Vec<(String, String)>) { + match target.split_once('?') { + Some((path, qs)) => { + let query = qs + .split('&') + .filter(|p| !p.is_empty()) + .map(|pair| match pair.split_once('=') { + Some((k, v)) => (url_decode(k), url_decode(v)), + None => (url_decode(pair), String::new()), + }) + .collect(); + (path.to_string(), query) + } + None => (target.to_string(), Vec::new()), + } +} + +/// Minimal percent-decoding (and `+` → space) for query values. +fn url_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'%' if i + 2 < bytes.len() => { + let hi = hex_val(bytes[i + 1]); + let lo = hex_val(bytes[i + 2]); + if let (Some(hi), Some(lo)) = (hi, lo) { + out.push(hi << 4 | lo); + i += 3; + continue; + } + out.push(bytes[i]); + i += 1; + } + b'+' => { + out.push(b' '); + i += 1; + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +async fn write_response( + stream: &mut TcpStream, + status: u16, + body: &Value, + cors: bool, +) -> std::io::Result<()> { + let payload = if body.is_null() { + Vec::new() + } else { + serde_json::to_vec(body).unwrap_or_default() + }; + let reason = reason_phrase(status); + let mut head = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n", + payload.len() + ); + if cors { + head.push_str( + "Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, DELETE, OPTIONS\r\nAccess-Control-Allow-Headers: authorization, content-type\r\n", + ); + } + head.push_str("\r\n"); + stream.write_all(head.as_bytes()).await?; + if !payload.is_empty() { + stream.write_all(&payload).await?; + } + stream.flush().await?; + let _ = stream.shutdown().await; + Ok(()) +} + +fn reason_phrase(status: u16) -> &'static str { + match status { + 200 => "OK", + 204 => "No Content", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 409 => "Conflict", + 422 => "Unprocessable Entity", + _ => "Error", + } +} + +fn err_body(code: &str) -> Value { + serde_json::json!({ "error": { "code": code, "message": code } }) +} + +fn print_help() { + eprintln!( + "allmystuff-cec-mock — a local reference CEC backend\n\n\ + USAGE:\n allmystuff-cec-mock [--port ] [--demo]\n\n\ + OPTIONS:\n \ + -p, --port port to listen on (default 8787)\n \ + --demo grant every account Concierge + agent role\n \ + -h, --help show this help\n" + ); +} + +fn die(msg: &str) -> ! { + eprintln!("allmystuff-cec-mock: {msg}"); + std::process::exit(2); +} diff --git a/crates/allmystuff-cec/CONTRACT.md b/crates/allmystuff-cec/CONTRACT.md new file mode 100644 index 00000000..13baf042 --- /dev/null +++ b/crates/allmystuff-cec/CONTRACT.md @@ -0,0 +1,114 @@ +# The CEC service HTTP contract + +This is the source-of-truth contract between AllMyStuff (and the headless +`allmystuff-agent`) and the hosted **CEC** — Critical Error Computing — +backend. The Rust types in [`src/model.rs`](src/model.rs) **are** this +contract; [`src/mock.rs`](src/mock.rs) is a faithful reference implementation +of it (run it with `allmystuff-cec-mock`). + +The free app needs none of this. An **optional account** unlocks the two +services advertised on allmystuff.works: + +- **Concierge** — the *Ask-for-Help* button ("Summon" on the site). A real CEC + technician, one tap away. +- **Private Line** — "a venue of your own": CEC-hosted signaling/STUN/TURN + serving only the customer's devices. + +## Conventions + +- Base URL is configurable in the app (Settings → Account). The reference + default is `https://api.allmystuff.works`; a local mock is + `http://127.0.0.1:8787`. +- All bodies are JSON; all field names are `snake_case`. +- Authenticated requests carry `Authorization: Bearer `. +- Errors are a non-2xx status with a body of either `{"error":"message"}` or + `{"error":{"code":"...","message":"..."}}`. Known codes: `bad_email`, + `bad_code`, `no_token`, `bad_token`, `not_agent`, `offline`, `taken`, + `already_done`, `no_help`, `no_mesh`, `no_private_line`, `no_venue`. +- Identity proof is the **email one-time code**. Binding a `device_id` (the + bare mesh pubkey) associates the device with the account so the backend can + provision the customer's mesh and pre-trust the CEC Service node. (The + daemon control socket exposes no signing op, so binding is association, not + a signed challenge — a signed-challenge upgrade is possible later without a + wire change.) + +## Endpoints + +### Auth + +| Method | Path | Body | → | Notes | +| --- | --- | --- | --- | --- | +| POST | `/v1/auth/start` | `StartSignIn { email }` | `StartSignInResponse { sent, masked_email }` | Emails a one-time code. The mock also returns `dev_code` and prints it. | +| POST | `/v1/auth/verify` | `VerifySignIn { email, code, device_id?, device_label? }` | `Session { token, account, entitlements }` | Creates the account on first sign-in; binds the device in the same call. | +| GET | `/v1/me` | — | `Me { account, entitlements }` | Bearer. | +| POST | `/v1/auth/signout` | — | `{ ok }` | Bearer. Invalidates the token. | +| POST | `/v1/me/device` | `BindDevice { device_id, label? }` | `Account` | Bearer. Bind/relabel a mesh device. | + +### The CEC mesh + +| Method | Path | Body | → | Notes | +| --- | --- | --- | --- | --- | +| POST | `/v1/mesh/provision` | `{ device_id }` | `MeshProvision { network_id, label, venue, cec_service_node_id, auto_approve }` | Bearer. Idempotent — the same account always gets the same `cec-customer-` network and CEC Service node. | +| GET | `/v1/mesh` | — | `MeshProvision` or 404 | Bearer. | + +`network_id` is `cec-customer-` where `` is a stable, opaque +16-hex digest of the account id (no PII reaches the mesh). The `venue` carries +both inline servers and a live venue-file `url`, so the app adds it as an +ordinary **remote venue**. The single `cec_service_node_id` is the only +non-customer peer that ever appears on this network — every CEC connection +rides it; individual agents live behind the backend. + +### Private Line + +| Method | Path | Body | → | Notes | +| --- | --- | --- | --- | --- | +| POST | `/v1/private-line` | `RentPrivateLine { label? }` | `PrivateLine { id, label, status, venue, monthly_price_cents }` | Bearer. $10/mo. | +| GET | `/v1/private-line` | — | `PrivateLine[]` | Bearer. | +| DELETE | `/v1/private-line/{id}` | — | `{ ok }` | Bearer. Cancels. | +| GET | `/v1/venues/{token}` | — | `VenueFile` (`allmystuff.venue` envelope) | Public — the live servers for a remote venue. | + +### Ask-for-Help — customer + +| Method | Path | Body | → | Notes | +| --- | --- | --- | --- | --- | +| POST | `/v1/help` | `AskForHelp { network_id, room_id, device_id, topic? }` | `HelpSession` | Bearer. The app has already minted the host-side help room (`room:{host}:cec-{nonce}`). | +| GET | `/v1/help/{id}` | — | `HelpSession` | Bearer. Poll status: `queued → assigned → connected → ended`. | +| POST | `/v1/help/{id}/cancel` | — | `{ ok }` | Bearer. | + +### Agent + +| Method | Path | Body | → | Notes | +| --- | --- | --- | --- | --- | +| POST | `/v1/agent/presence` | `SetPresence { online }` | `AgentPresence { online, since }` | Bearer (agent role). | +| GET | `/v1/agent/queue` | — | `HelpSession[]` | Bearer (agent role, online). Oldest first. | +| POST | `/v1/agent/help/{id}/accept` | — | `AgentAssignment { session, venue }` | Bearer (agent). Receive the venue + room to join as the CEC Service node. | +| POST | `/v1/agent/help/{id}/decline` | — | `{ ok }` | Leaves it queued. | +| POST | `/v1/agent/help/{id}/end` | — | `{ ok }` | Ends the session. | + +### Mock-only + +| Method | Path | Body | → | Notes | +| --- | --- | --- | --- | --- | +| POST | `/v1/dev/grant` | `DevGrant { email, entitlements?, agent }` | `{ ok }` | **Mock only.** Sets entitlements / agent role for local testing. A real backend would not expose this. | +| GET | `/v1/health` | — | `{ ok, service }` | Liveness. | + +## The flows + +**Customer asks for help** + +1. App ensures the CEC mesh exists (`POST /v1/mesh/provision`), joins + `cec-customer-` with the returned venue, and pre-approves the CEC + Service node. +2. App mints a host-side help room on that network and `POST /v1/help`. +3. Backend exposes the queued session to online agents. +4. An agent accepts; the backend bridges them onto the CEC Service node and + into the room. The customer polls `GET /v1/help/{id}` and sees `assigned`, + then `connected`, with the agent's name. + +**Agent handles a request** + +1. `verify` → `online` → `GET /v1/agent/queue`. +2. `accept` returns the venue + room. The agent's live link to the customer is + the backend's job — it operates the single CEC Service node and bridges the + agent to it (connections managed by the provider, not the mesh engine). +3. `end` when done. diff --git a/crates/allmystuff-cec/Cargo.toml b/crates/allmystuff-cec/Cargo.toml new file mode 100644 index 00000000..cc8d0d0b --- /dev/null +++ b/crates/allmystuff-cec/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "allmystuff-cec" +description = "The CEC (Critical Error Computing) service client: the typed HTTP contract AllMyStuff speaks to the hosted backend for optional accounts, Ask-for-Help (Concierge), Private Line venues, the per-customer CEC mesh, and agent presence — plus an in-memory mock backend for tests and a local reference server." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[lints.rust] +unsafe_code = "forbid" + +[features] +# `reqwest` (default) compiles the real HTTP transport. Turn it off for a +# pure-logic build (e.g. a wasm or strictly-mock consumer) that only wants +# the contract types, the client over a custom transport, and the mock. +default = ["reqwest"] +reqwest = ["dep:reqwest"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +reqwest = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/allmystuff-cec/src/client.rs b/crates/allmystuff-cec/src/client.rs new file mode 100644 index 00000000..72efc5ee --- /dev/null +++ b/crates/allmystuff-cec/src/client.rs @@ -0,0 +1,304 @@ +//! The CEC service client — one typed method per endpoint in the contract. +//! +//! Generic over a [`Transport`], so production code holds a +//! `CecClient` and tests hold a `CecClient` +//! over the same logic. The bearer token, once obtained from +//! [`verify_sign_in`](CecClient::verify_sign_in), is carried on every +//! authenticated call. + +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::model::*; +use crate::transport::{ApiRequest, Method, Transport}; +use crate::Error; + +/// A client for the CEC backend. Cheap to clone if the transport is. +pub struct CecClient { + transport: T, + token: Option, +} + +impl CecClient { + /// Build a client over `transport`, signed out. + pub fn new(transport: T) -> Self { + CecClient { + transport, + token: None, + } + } + + /// Build a client over `transport` that's already holding a session token + /// (e.g. one restored from disk). + pub fn with_token(transport: T, token: Option) -> Self { + CecClient { transport, token } + } + + /// The current bearer token, if signed in. + pub fn token(&self) -> Option<&str> { + self.token.as_deref() + } + + pub fn is_signed_in(&self) -> bool { + self.token.is_some() + } + + /// Set (or replace) the session token. + pub fn set_token(&mut self, token: Option) { + self.token = token; + } + + /// Forget the session token (local sign-out). + pub fn clear_token(&mut self) { + self.token = None; + } + + /// Borrow the underlying transport (e.g. to read a base URL). + pub fn transport(&self) -> &T { + &self.transport + } + + // --- low-level plumbing ------------------------------------------------ + + /// Send `req`, attaching the held bearer token unless one is already set, + /// then map the response: 2xx → decode `T2`, otherwise → [`Error::Api`]. + async fn call(&self, mut req: ApiRequest) -> Result { + if req.bearer.is_none() { + req.bearer = self.token.clone(); + } + let resp = self.transport.send(req).await?; + if resp.is_success() { + // An empty 2xx body decodes to `R` only when `R` allows it (e.g. + // unit or an all-optional struct); otherwise it's a decode error, + // which is the honest outcome. + serde_json::from_value(resp.body).map_err(|e| Error::Decode(e.to_string())) + } else { + Err(api_error(resp.status, resp.body)) + } + } + + /// Like [`call`](Self::call) but for endpoints with no useful body. + async fn call_unit(&self, mut req: ApiRequest) -> Result<(), Error> { + if req.bearer.is_none() { + req.bearer = self.token.clone(); + } + let resp = self.transport.send(req).await?; + if resp.is_success() { + Ok(()) + } else { + Err(api_error(resp.status, resp.body)) + } + } + + fn require_token(&self) -> Result<(), Error> { + if self.token.is_some() { + Ok(()) + } else { + Err(Error::Unauthenticated) + } + } + + // --- auth -------------------------------------------------------------- + + /// `POST /v1/auth/start` — email a one-time sign-in code. + pub async fn start_sign_in(&self, email: &str) -> Result { + let body = serde_json::to_value(StartSignIn { + email: email.to_string(), + }) + .map_err(enc)?; + self.call(ApiRequest::post("/v1/auth/start").body(body)).await + } + + /// `POST /v1/auth/verify` — exchange the code for a session and (if given) + /// bind this device's mesh identity. On success the returned session's + /// token is **stored on the client** for subsequent calls. + pub async fn verify_sign_in( + &mut self, + email: &str, + code: &str, + device_id: Option<&str>, + device_label: Option<&str>, + ) -> Result { + let body = serde_json::to_value(VerifySignIn { + email: email.to_string(), + code: code.to_string(), + device_id: device_id.map(str::to_string), + device_label: device_label.map(str::to_string), + }) + .map_err(enc)?; + let session: Session = self.call(ApiRequest::post("/v1/auth/verify").body(body)).await?; + self.token = Some(session.token.clone()); + Ok(session) + } + + /// `GET /v1/me` — the current account + entitlements. + pub async fn me(&self) -> Result { + self.require_token()?; + self.call(ApiRequest::get("/v1/me")).await + } + + /// `POST /v1/auth/signout` — invalidate the session server-side, then drop + /// the local token. + pub async fn sign_out(&mut self) -> Result<(), Error> { + if self.token.is_some() { + // Best-effort: a network blip shouldn't trap the user signed in. + let _ = self.call_unit(ApiRequest::post("/v1/auth/signout")).await; + } + self.token = None; + Ok(()) + } + + /// `POST /v1/me/device` — bind (or re-label) a mesh device on the account. + pub async fn bind_device(&self, device_id: &str, label: Option<&str>) -> Result { + self.require_token()?; + let body = serde_json::to_value(BindDevice { + device_id: device_id.to_string(), + label: label.map(str::to_string), + }) + .map_err(enc)?; + self.call(ApiRequest::post("/v1/me/device").body(body)).await + } + + // --- the CEC mesh ------------------------------------------------------ + + /// `POST /v1/mesh/provision` — get the descriptor for the customer's + /// isolated `cec-customer-` network. + pub async fn provision_mesh(&self, device_id: &str) -> Result { + self.require_token()?; + let body = serde_json::json!({ "device_id": device_id }); + self.call(ApiRequest::post("/v1/mesh/provision").body(body)).await + } + + // --- Private Line ------------------------------------------------------ + + /// `POST /v1/private-line` — rent a new Private Line venue. + pub async fn rent_private_line(&self, label: Option<&str>) -> Result { + self.require_token()?; + let body = serde_json::to_value(RentPrivateLine { + label: label.map(str::to_string), + }) + .map_err(enc)?; + self.call(ApiRequest::post("/v1/private-line").body(body)).await + } + + /// `GET /v1/private-line` — the customer's Private Lines. + pub async fn list_private_lines(&self) -> Result, Error> { + self.require_token()?; + self.call(ApiRequest::get("/v1/private-line")).await + } + + /// `DELETE /v1/private-line/{id}` — cancel a Private Line. + pub async fn cancel_private_line(&self, id: &str) -> Result<(), Error> { + self.require_token()?; + self.call_unit(ApiRequest::delete(format!("/v1/private-line/{id}"))).await + } + + // --- Ask-for-Help (customer) ------------------------------------------ + + /// `POST /v1/help` — open a help session for an already-minted help room. + pub async fn ask_for_help(&self, req: &AskForHelp) -> Result { + self.require_token()?; + let body = serde_json::to_value(req).map_err(enc)?; + self.call(ApiRequest::post("/v1/help").body(body)).await + } + + /// `GET /v1/help/{id}` — poll a help session's status. + pub async fn help_status(&self, id: &str) -> Result { + self.require_token()?; + self.call(ApiRequest::get(format!("/v1/help/{id}"))).await + } + + /// `POST /v1/help/{id}/cancel` — cancel a queued help session. + pub async fn cancel_help(&self, id: &str) -> Result<(), Error> { + self.require_token()?; + self.call_unit(ApiRequest::post(format!("/v1/help/{id}/cancel"))).await + } + + // --- agent side -------------------------------------------------------- + + /// `POST /v1/agent/presence` — go online / offline. + pub async fn set_presence(&self, online: bool) -> Result { + self.require_token()?; + let body = serde_json::to_value(SetPresence { online }).map_err(enc)?; + self.call(ApiRequest::post("/v1/agent/presence").body(body)).await + } + + /// `GET /v1/agent/queue` — help sessions waiting for an online agent. + pub async fn agent_queue(&self) -> Result, Error> { + self.require_token()?; + self.call(ApiRequest::get("/v1/agent/queue")).await + } + + /// `POST /v1/agent/help/{id}/accept` — take a session; receive the venue + /// and room to join as the CEC Service node. + pub async fn accept_help(&self, id: &str) -> Result { + self.require_token()?; + self.call(ApiRequest::post(format!("/v1/agent/help/{id}/accept"))).await + } + + /// `POST /v1/agent/help/{id}/decline` — pass on a session. + pub async fn decline_help(&self, id: &str) -> Result<(), Error> { + self.require_token()?; + self.call_unit(ApiRequest::post(format!("/v1/agent/help/{id}/decline"))).await + } + + /// `POST /v1/agent/help/{id}/end` — end a session you're handling. + pub async fn end_help(&self, id: &str) -> Result<(), Error> { + self.require_token()?; + self.call_unit(ApiRequest::post(format!("/v1/agent/help/{id}/end"))).await + } + + // --- dev / mock-only --------------------------------------------------- + + /// `POST /v1/dev/grant` — MOCK ONLY. Set up entitlements / agent role. + pub async fn dev_grant(&self, grant: &DevGrant) -> Result<(), Error> { + let body = serde_json::to_value(grant).map_err(enc)?; + self.call_unit(ApiRequest::post("/v1/dev/grant").body(body)).await + } + + /// Issue an arbitrary request — an escape hatch for tooling. Prefer the + /// typed methods above. + pub async fn raw(&self, method: Method, path: &str, body: Option) -> Result { + let mut req = ApiRequest::new(method, path); + req.body = body; + let resp = { + req.bearer = self.token.clone(); + self.transport.send(req).await? + }; + if resp.is_success() { + Ok(resp.body) + } else { + Err(api_error(resp.status, resp.body)) + } + } +} + +fn enc(e: serde_json::Error) -> Error { + Error::Decode(e.to_string()) +} + +/// Turn a non-2xx response into a structured [`Error::Api`]. +fn api_error(status: u16, body: Value) -> Error { + let parsed: Option = body + .get("error") + .and_then(|e| { + if e.is_string() { + e.as_str().map(|s| ApiErrorBody { + code: None, + message: Some(s.to_string()), + }) + } else { + serde_json::from_value(e.clone()).ok() + } + }) + .or_else(|| serde_json::from_value(body.clone()).ok()); + let (code, message) = match parsed { + Some(b) => (b.code, b.message), + None => (None, None), + }; + Error::Api { + status, + code, + message: message.unwrap_or_else(|| format!("HTTP {status}")), + } +} diff --git a/crates/allmystuff-cec/src/convention.rs b/crates/allmystuff-cec/src/convention.rs new file mode 100644 index 00000000..169f3360 --- /dev/null +++ b/crates/allmystuff-cec/src/convention.rs @@ -0,0 +1,122 @@ +//! Shared CEC conventions — the few string shapes the app backend, the agent +//! binary, the reference server, and the GUI all have to agree on. Keeping +//! them in one place (and mirroring them in `gui/src/cec.ts`) means a help +//! room minted on one side is recognised on the other. + +use sha2::{Digest, Sha256}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The prefix every per-customer CEC network id carries. A mesh `network_id` +/// must match `[a-z0-9_-]+`, so this — and the hex hash after it — is +/// deliberately lowercase even though the product calls it "CEC-Customer". +pub const CEC_NETWORK_PREFIX: &str = "cec-customer-"; + +/// The label the app shows for the customer's CEC network and for the single +/// service node on it. +pub const CEC_NETWORK_LABEL: &str = "CEC"; +pub const CEC_SERVICE_LABEL: &str = "CEC Service"; + +/// The marker segment in a help room's nonce. A CEC help room id reads +/// `room:{host}:cec-{nonce}` — still an ordinary, on-the-wire-valid room id, +/// but recognisable as a Concierge session by either side. +pub const CEC_ROOM_MARKER: &str = "cec-"; + +/// Derive the stable, isolated network id for a customer account. The hash is +/// opaque (no email or account id leaks onto the mesh) and stable (the same +/// account always lands on the same network), so a customer's devices and the +/// CEC Service node always rendezvous in the same private place. +pub fn customer_network_id(account_id: &str) -> String { + format!("{CEC_NETWORK_PREFIX}{}", short_hash(account_id)) +} + +/// Whether a network id is a CEC customer network. +pub fn is_cec_network(network_id: &str) -> bool { + network_id.starts_with(CEC_NETWORK_PREFIX) +} + +/// A 16-hex-char (64-bit) hash of the input — enough to be collision-free +/// across any plausible customer base, short enough to read. +pub fn short_hash(input: &str) -> String { + let digest = Sha256::digest(input.as_bytes()); + hex::encode(&digest[..8]) +} + +/// Mint a help room id hosted by `host` (a bare pubkey). The `:cec-` marker +/// lets [`is_help_room`] recognise it later without any side state. +pub fn help_room_id(host: &str) -> String { + format!("room:{host}:{CEC_ROOM_MARKER}{}", new_nonce()) +} + +/// Whether a room id is a CEC help room (minted by [`help_room_id`]). +pub fn is_help_room(room_id: &str) -> bool { + // room:{host}:cec-{nonce} — the nonce segment starts with the marker. + room_id + .rsplit_once(':') + .map(|(_, nonce)| nonce.starts_with(CEC_ROOM_MARKER)) + .unwrap_or(false) +} + +/// A short, process-unique nonce: time millis mixed with a monotonic counter, +/// base36-ish. Not a secret — just needs to not collide within a host. +pub fn new_nonce() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + // 11-char base36 of (millis<<20 ^ counter) — plenty of room, no deps. + base36(millis.wrapping_shl(20) ^ n) +} + +fn base36(mut v: u64) -> String { + const ALPHABET: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + if v == 0 { + return "0".into(); + } + let mut buf = Vec::new(); + while v > 0 { + buf.push(ALPHABET[(v % 36) as usize]); + v /= 36; + } + buf.reverse(); + String::from_utf8(buf).unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn customer_network_id_is_stable_and_lowercase() { + let a = customer_network_id("acct_123"); + let b = customer_network_id("acct_123"); + assert_eq!(a, b); + assert!(a.starts_with(CEC_NETWORK_PREFIX)); + assert!(is_cec_network(&a)); + assert!(a.chars().all(|c| c.is_ascii_lowercase() + || c.is_ascii_digit() + || c == '-')); + assert_ne!(a, customer_network_id("acct_456")); + } + + #[test] + fn non_cec_network_is_not_misread() { + assert!(!is_cec_network("home")); + assert!(!is_cec_network("net_abc123")); + } + + #[test] + fn help_room_ids_are_recognisable_and_unique() { + let host = "abckeypubkey"; + let r1 = help_room_id(host); + let r2 = help_room_id(host); + assert!(is_help_room(&r1)); + assert!(is_help_room(&r2)); + assert_ne!(r1, r2, "nonce must differ"); + assert!(r1.starts_with(&format!("room:{host}:"))); + // A normal room id is not mistaken for a help room. + assert!(!is_help_room(&format!("room:{host}:plain123"))); + } +} diff --git a/crates/allmystuff-cec/src/lib.rs b/crates/allmystuff-cec/src/lib.rs new file mode 100644 index 00000000..2941ddf2 --- /dev/null +++ b/crates/allmystuff-cec/src/lib.rs @@ -0,0 +1,96 @@ +//! # allmystuff-cec +//! +//! The client half of the **CEC service** — Critical Error Computing's hosted +//! backend — that AllMyStuff (and the headless agent) talk to over HTTP. +//! +//! The free app needs none of this. An *optional* account unlocks the two +//! advertised services: +//! +//! * **Concierge** — the *Ask-for-Help* button. A press opens a help session +//! that's exposed to online CEC agents; one accepts and joins the customer's +//! help room as the single **CEC Service** node. +//! * **Private Line** — "a venue of your own": CEC-hosted signaling/STUN/TURN +//! serving only the customer's devices. +//! +//! When a customer has CEC hardware *or* a service, the app stands up an +//! isolated `cec-customer-` mesh (see [`convention`]) on which the only +//! non-customer peer is the CEC Service node — agents live behind the backend, +//! never as mesh peers. +//! +//! ## Shape +//! +//! * [`model`] — the contract types (the wire shapes; see `CONTRACT.md`). +//! * [`convention`] — the few strings the app, agent, and server must agree on. +//! * [`transport`] — the HTTP seam: a [`Transport`] trait, the real +//! [`ReqwestTransport`](transport::ReqwestTransport), and (in [`mock`]) a +//! socket-free [`MockTransport`](mock::MockTransport). +//! * [`CecClient`] — one typed method per endpoint, generic over the transport. +//! * [`mock`] — an in-memory reference backend, the source of truth the +//! `allmystuff-cec-mock` server and every test share. +//! +//! Everything builds and tests with nothing heavier than `serde` + `reqwest` +//! (and `reqwest` is optional) — the same dependency discipline the rest of +//! the workspace keeps. + +pub mod client; +pub mod convention; +pub mod mock; +pub mod model; +pub mod transport; + +pub use client::CecClient; +pub use model::*; +pub use transport::{ApiRequest, ApiResponse, Method, Transport}; + +#[cfg(feature = "reqwest")] +pub use transport::ReqwestTransport; + +/// Everything that can go wrong talking to the CEC backend. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The request never got a well-formed HTTP response (DNS, TLS, socket, + /// timeout, …). + #[error("transport error: {0}")] + Transport(String), + + /// The backend answered with a non-2xx status. + #[error("CEC backend error (HTTP {status}): {message}")] + Api { + status: u16, + /// A machine-readable code when the backend supplied one + /// (e.g. `bad_code`, `offline`, `not_agent`). + code: Option, + message: String, + }, + + /// A response body didn't match the expected shape. + #[error("decode error: {0}")] + Decode(String), + + /// A call that requires a session was made while signed out. + #[error("not signed in to a CEC account")] + Unauthenticated, +} + +impl Error { + /// The backend's machine-readable error code, if any. Lets callers branch + /// on `bad_code` vs `offline` without string-matching the message. + pub fn code(&self) -> Option<&str> { + match self { + Error::Api { code, .. } => code.as_deref(), + _ => None, + } + } + + /// Whether this looks like an expired/invalid session the UI should treat + /// as "signed out". + pub fn is_auth(&self) -> bool { + matches!(self, Error::Unauthenticated) + || matches!( + self, + Error::Api { status: 401, .. } | Error::Api { status: 403, .. } + ) + } +} + +pub type Result = std::result::Result; diff --git a/crates/allmystuff-cec/src/mock.rs b/crates/allmystuff-cec/src/mock.rs new file mode 100644 index 00000000..84964c26 --- /dev/null +++ b/crates/allmystuff-cec/src/mock.rs @@ -0,0 +1,713 @@ +//! An in-memory reference implementation of the CEC backend. +//! +//! [`MockBackend`] is the single source of behaviour for the whole contract: +//! tests drive it through [`MockTransport`] (no sockets), and the +//! `allmystuff-cec-mock` binary wraps the very same [`MockBackend::handle`] +//! in a tiny HTTP server so a human can run the real app against it. +//! +//! It is deliberately faithful — a fresh account has no entitlements, the +//! Ask-for-Help queue is only visible to *online* agents, a customer can only +//! see their own help sessions — with one concession to local life: it prints +//! sign-in codes (the start response carries a `dev_code` field, and tests +//! read [`MockBackend::last_code`]) instead of sending email, and it exposes +//! the mock-only `/v1/dev/grant` endpoint for setting up state. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::convention; +use crate::model::*; +use crate::transport::{ApiRequest, ApiResponse, Method, Transport}; +use crate::Error; + +/// Reference servers a mock CEC venue points at. Isolation between customers +/// comes from the unique `network_id` (the signaling room is derived from it), +/// not from the servers — so a working mock can share the public reference +/// venue and still hand every customer their own isolated mesh. +const REF_SIGNALING: &str = "wss://myownmesh.com"; +const REF_STUN: &str = "stun:stun.myownmesh.com:3478"; +const REF_TURN_URL: &str = "turn:turn.myownmesh.com:3478"; +const REF_TURN_USER: &str = "guest"; +const REF_TURN_PASS: &str = "theguestpassword"; + +/// Startup configuration for the reference backend. +#[derive(Debug, Clone, Default)] +pub struct MockConfig { + /// Entitlements every freshly created account gets. Empty by default + /// (faithful); the `--demo` mode of the server sets a generous default so + /// the buttons light up. + pub default_entitlements: Entitlements, + /// If set, every account that signs in is also made an agent — handy for + /// a one-laptop demo where the same person is customer and technician. + pub everyone_is_agent: bool, +} + +#[derive(Debug, Clone)] +struct AccountRec { + account: Account, + entitlements: Entitlements, + private_lines: Vec, + provision: Option, +} + +#[derive(Default)] +struct State { + next: u64, + /// account_id → record + accounts: HashMap, + /// email (lowercased) → account_id + by_email: HashMap, + /// email → last code + codes: HashMap, + /// bearer token → account_id + tokens: HashMap, + /// help session id → session + help: HashMap, + /// account_id → online-since (unix secs); absence means offline + agents_online: HashMap, + /// venue token → file (served at /v1/venues/{token}) + venues: HashMap, +} + +/// The in-memory CEC backend. Cheap to share via `Arc`. +pub struct MockBackend { + cfg: MockConfig, + state: Mutex, +} + +impl Default for MockBackend { + fn default() -> Self { + Self::new() + } +} + +impl MockBackend { + pub fn new() -> Self { + Self::with_config(MockConfig::default()) + } + + pub fn with_config(cfg: MockConfig) -> Self { + MockBackend { + cfg, + state: Mutex::new(State::default()), + } + } + + pub fn shared() -> Arc { + Arc::new(Self::new()) + } + + /// Read the last sign-in code emailed for `email` (tests use this in place + /// of an inbox). + pub fn last_code(&self, email: &str) -> Option { + self.state + .lock() + .unwrap() + .codes + .get(&email.to_lowercase()) + .cloned() + } + + /// Route one request. This is the whole backend; the HTTP server is just a + /// socket around it. + pub fn handle(&self, req: ApiRequest) -> ApiResponse { + let segs: Vec<&str> = req.path.trim_matches('/').split('/').collect(); + let mut st = self.state.lock().unwrap(); + match (req.method, segs.as_slice()) { + // ---- auth ---------------------------------------------------- + (Method::Post, ["v1", "auth", "start"]) => self.auth_start(&mut st, &req), + (Method::Post, ["v1", "auth", "verify"]) => self.auth_verify(&mut st, &req), + (Method::Post, ["v1", "auth", "signout"]) => match account_for(&st, &req) { + Ok(_) => { + if let Some(tok) = bearer(&req) { + st.tokens.remove(&tok); + } + ok(json!({ "ok": true })) + } + Err(e) => e, + }, + (Method::Get, ["v1", "me"]) => match account_for(&st, &req) { + Ok(id) => { + let rec = &st.accounts[&id]; + ok(serde_json::to_value(Me { + account: rec.account.clone(), + entitlements: rec.entitlements.clone(), + }) + .unwrap()) + } + Err(e) => e, + }, + (Method::Post, ["v1", "me", "device"]) => self.bind_device(&mut st, &req), + + // ---- the CEC mesh ------------------------------------------- + (Method::Post, ["v1", "mesh", "provision"]) => self.provision(&mut st, &req), + (Method::Get, ["v1", "mesh"]) => match account_for(&st, &req) { + Ok(id) => match &st.accounts[&id].provision { + Some(p) => ok(serde_json::to_value(p).unwrap()), + None => err(404, "no_mesh", "no CEC mesh provisioned"), + }, + Err(e) => e, + }, + + // ---- Private Line ------------------------------------------- + (Method::Post, ["v1", "private-line"]) => self.rent_private_line(&mut st, &req), + (Method::Get, ["v1", "private-line"]) => match account_for(&st, &req) { + Ok(id) => ok(serde_json::to_value(&st.accounts[&id].private_lines).unwrap()), + Err(e) => e, + }, + (Method::Delete, ["v1", "private-line", pl_id]) => { + self.cancel_private_line(&mut st, &req, pl_id) + } + + // ---- venue files (served for remote venues) ----------------- + (Method::Get, ["v1", "venues", token]) => match st.venues.get(*token) { + Some(file) => ok(serde_json::to_value(file).unwrap()), + None => err(404, "no_venue", "unknown venue"), + }, + + // ---- Ask-for-Help (customer) -------------------------------- + (Method::Post, ["v1", "help"]) => self.ask_for_help(&mut st, &req), + (Method::Get, ["v1", "help", id]) => self.help_status(&st, &req, id), + (Method::Post, ["v1", "help", id, "cancel"]) => self.cancel_help(&mut st, &req, id), + + // ---- agent --------------------------------------------------- + (Method::Post, ["v1", "agent", "presence"]) => self.agent_presence(&mut st, &req), + (Method::Get, ["v1", "agent", "queue"]) => self.agent_queue(&st, &req), + (Method::Post, ["v1", "agent", "help", id, "accept"]) => { + self.agent_accept(&mut st, &req, id) + } + (Method::Post, ["v1", "agent", "help", id, "decline"]) => { + self.agent_decline(&st, &req, id) + } + (Method::Post, ["v1", "agent", "help", id, "end"]) => self.agent_end(&mut st, &req, id), + + // ---- dev / mock-only ---------------------------------------- + (Method::Post, ["v1", "dev", "grant"]) => self.dev_grant(&mut st, &req), + + // ---- health -------------------------------------------------- + (Method::Get, ["v1", "health"]) | (Method::Get, []) => { + ok(json!({ "ok": true, "service": "allmystuff-cec-mock" })) + } + + _ => err(404, "not_found", &format!("no route for {} {}", req.method.as_str(), req.path)), + } + } + + // --- auth -------------------------------------------------------------- + + fn auth_start(&self, st: &mut State, req: &ApiRequest) -> ApiResponse { + let body: StartSignIn = match parse(req) { + Ok(b) => b, + Err(e) => return e, + }; + let email = body.email.trim().to_lowercase(); + if !email.contains('@') { + return err(422, "bad_email", "that doesn't look like an email"); + } + self.ensure_account(st, &email); + let code = gen_code(&mut st.next); + st.codes.insert(email.clone(), code.clone()); + // `dev_code` is mock-only: a real backend emails the code. Kept in the + // body so the server can print it and a human can sign in locally; the + // typed client ignores the unknown field. + ok(json!({ + "sent": true, + "masked_email": mask_email(&email), + "dev_code": code, + })) + } + + fn auth_verify(&self, st: &mut State, req: &ApiRequest) -> ApiResponse { + let body: VerifySignIn = match parse(req) { + Ok(b) => b, + Err(e) => return e, + }; + let email = body.email.trim().to_lowercase(); + match st.codes.get(&email) { + Some(expected) if *expected == body.code.trim() => {} + _ => return err(401, "bad_code", "that code didn't match"), + } + st.codes.remove(&email); + let id = self.ensure_account(st, &email); + // Bind the device on the same call. + if let Some(dev) = &body.device_id { + bind(st.accounts.get_mut(&id).unwrap(), dev); + } + let token = gen_token(&mut st.next); + st.tokens.insert(token.clone(), id.clone()); + let rec = &st.accounts[&id]; + ok(serde_json::to_value(Session { + token, + account: rec.account.clone(), + entitlements: rec.entitlements.clone(), + }) + .unwrap()) + } + + fn bind_device(&self, st: &mut State, req: &ApiRequest) -> ApiResponse { + let id = match account_for(st, req) { + Ok(id) => id, + Err(e) => return e, + }; + let body: BindDevice = match parse(req) { + Ok(b) => b, + Err(e) => return e, + }; + let rec = st.accounts.get_mut(&id).unwrap(); + bind(rec, &body.device_id); + ok(serde_json::to_value(&rec.account).unwrap()) + } + + // --- mesh -------------------------------------------------------------- + + fn provision(&self, st: &mut State, req: &ApiRequest) -> ApiResponse { + let id = match account_for(st, req) { + Ok(id) => id, + Err(e) => return e, + }; + // Bind the asking device if provided. + if let Ok(b) = parse::(req) { + if let Some(dev) = b.get("device_id").and_then(|v| v.as_str()) { + bind(st.accounts.get_mut(&id).unwrap(), dev); + } + } + let p = ensure_provision(st, &id); + ok(serde_json::to_value(p).unwrap()) + } + + // --- Private Line ------------------------------------------------------ + + fn rent_private_line(&self, st: &mut State, req: &ApiRequest) -> ApiResponse { + let id = match account_for(st, req) { + Ok(id) => id, + Err(e) => return e, + }; + let body: RentPrivateLine = parse(req).unwrap_or_default(); + let pl_id = format!("pl_{}", next_id(&mut st.next)); + let token = format!("pl-{pl_id}"); + let label = body.label.unwrap_or_else(|| "Private Line".to_string()); + let spec = cec_venue_spec(&token, "private"); + st.venues + .insert(token.clone(), VenueFile::new(label.clone(), &spec)); + let pl = PrivateLine { + id: pl_id, + label, + status: SubscriptionStatus::Active, + venue: spec, + monthly_price_cents: 1000, + }; + let rec = st.accounts.get_mut(&id).unwrap(); + rec.private_lines.push(pl.clone()); + rec.entitlements.private_line = true; + ok(serde_json::to_value(pl).unwrap()) + } + + fn cancel_private_line(&self, st: &mut State, req: &ApiRequest, pl_id: &str) -> ApiResponse { + let id = match account_for(st, req) { + Ok(id) => id, + Err(e) => return e, + }; + let rec = st.accounts.get_mut(&id).unwrap(); + let mut found = false; + for pl in rec.private_lines.iter_mut() { + if pl.id == pl_id { + pl.status = SubscriptionStatus::Cancelled; + found = true; + } + } + if !found { + return err(404, "no_private_line", "no such Private Line"); + } + rec.entitlements.private_line = rec + .private_lines + .iter() + .any(|pl| pl.status == SubscriptionStatus::Active); + ok(json!({ "ok": true })) + } + + // --- help (customer) --------------------------------------------------- + + fn ask_for_help(&self, st: &mut State, req: &ApiRequest) -> ApiResponse { + let id = match account_for(st, req) { + Ok(id) => id, + Err(e) => return e, + }; + let body: AskForHelp = match parse(req) { + Ok(b) => b, + Err(e) => return e, + }; + let p = ensure_provision(st, &id); + let rec = &st.accounts[&id]; + let session = HelpSession { + id: format!("help_{}", next_id(&mut st.next)), + status: HelpStatus::Queued, + network_id: body.network_id, + room_id: body.room_id, + cec_service_node_id: p.cec_service_node_id.clone(), + customer_device_id: body.device_id, + customer_label: display_label(&rec.account), + topic: body.topic, + agent_label: None, + created_at: now_secs(), + }; + st.help.insert(session.id.clone(), session.clone()); + ok(serde_json::to_value(session).unwrap()) + } + + fn help_status(&self, st: &State, req: &ApiRequest, id: &str) -> ApiResponse { + let acct = match account_for(st, req) { + Ok(a) => a, + Err(e) => return e, + }; + match st.help.get(id) { + Some(s) => { + let owns = st.accounts[&acct] + .account + .device_ids + .contains(&s.customer_device_id); + if owns || st.accounts[&acct].account.is_agent() { + ok(serde_json::to_value(s).unwrap()) + } else { + err(403, "forbidden", "not your help session") + } + } + None => err(404, "no_help", "no such help session"), + } + } + + fn cancel_help(&self, st: &mut State, req: &ApiRequest, id: &str) -> ApiResponse { + if let Err(e) = account_for(st, req) { + return e; + } + match st.help.get_mut(id) { + Some(s) if !s.status.is_terminal() => { + s.status = HelpStatus::Cancelled; + ok(json!({ "ok": true })) + } + Some(_) => err(409, "already_done", "session already finished"), + None => err(404, "no_help", "no such help session"), + } + } + + // --- agent ------------------------------------------------------------- + + fn agent_presence(&self, st: &mut State, req: &ApiRequest) -> ApiResponse { + let id = match agent_for(st, req) { + Ok(id) => id, + Err(e) => return e, + }; + let body: SetPresence = match parse(req) { + Ok(b) => b, + Err(e) => return e, + }; + let since = if body.online { + let s = now_secs(); + st.agents_online.insert(id.clone(), s); + s + } else { + st.agents_online.remove(&id); + 0 + }; + ok(serde_json::to_value(AgentPresence { + online: body.online, + since, + }) + .unwrap()) + } + + fn agent_queue(&self, st: &State, req: &ApiRequest) -> ApiResponse { + let id = match agent_for(st, req) { + Ok(id) => id, + Err(e) => return e, + }; + if !st.agents_online.contains_key(&id) { + return err(409, "offline", "go online to see the queue"); + } + let mut queued: Vec = st + .help + .values() + .filter(|s| s.status == HelpStatus::Queued) + .cloned() + .collect(); + queued.sort_by_key(|s| s.created_at); + ok(serde_json::to_value(queued).unwrap()) + } + + fn agent_accept(&self, st: &mut State, req: &ApiRequest, id: &str) -> ApiResponse { + let agent = match agent_for(st, req) { + Ok(a) => a, + Err(e) => return e, + }; + if !st.agents_online.contains_key(&agent) { + return err(409, "offline", "go online before accepting"); + } + let label = display_label(&st.accounts[&agent].account); + let session = match st.help.get_mut(id) { + Some(s) if s.status == HelpStatus::Queued => { + s.status = HelpStatus::Assigned; + s.agent_label = Some(label); + s.clone() + } + Some(_) => return err(409, "taken", "that session is no longer waiting"), + None => return err(404, "no_help", "no such help session"), + }; + // The venue is whatever serves the customer's CEC network. Re-derive + // from the venue token convention so we don't have to store a back-ref. + let venue = cec_venue_spec( + &cec_mesh_venue_token(&session.network_id), + "service", + ); + ok(serde_json::to_value(AgentAssignment { session, venue }).unwrap()) + } + + fn agent_decline(&self, st: &State, req: &ApiRequest, id: &str) -> ApiResponse { + if let Err(e) = agent_for(st, req) { + return e; + } + // Decline just leaves it queued for another agent. + if st.help.contains_key(id) { + ok(json!({ "ok": true })) + } else { + err(404, "no_help", "no such help session") + } + } + + fn agent_end(&self, st: &mut State, req: &ApiRequest, id: &str) -> ApiResponse { + if let Err(e) = agent_for(st, req) { + return e; + } + match st.help.get_mut(id) { + Some(s) => { + s.status = HelpStatus::Ended; + ok(json!({ "ok": true })) + } + None => err(404, "no_help", "no such help session"), + } + } + + // --- dev --------------------------------------------------------------- + + fn dev_grant(&self, st: &mut State, req: &ApiRequest) -> ApiResponse { + let body: DevGrant = match parse(req) { + Ok(b) => b, + Err(e) => return e, + }; + let email = body.email.trim().to_lowercase(); + if !email.contains('@') { + return err(422, "bad_email", "that doesn't look like an email"); + } + let id = self.ensure_account(st, &email); + let rec = st.accounts.get_mut(&id).unwrap(); + if let Some(ent) = body.entitlements { + rec.entitlements = ent; + } + if body.agent && !rec.account.roles.contains(&AccountRole::Agent) { + rec.account.roles.push(AccountRole::Agent); + } + ok(json!({ "ok": true })) + } + + // --- helpers ----------------------------------------------------------- + + /// Find-or-create an account by email, applying the configured defaults. + fn ensure_account(&self, st: &mut State, email: &str) -> String { + if let Some(id) = st.by_email.get(email) { + return id.clone(); + } + let id = format!("acct_{}", next_id(&mut st.next)); + let mut roles = vec![AccountRole::Customer]; + if self.cfg.everyone_is_agent { + roles.push(AccountRole::Agent); + } + let account = Account { + id: id.clone(), + email: email.to_string(), + display_name: email.split('@').next().unwrap_or(email).to_string(), + roles, + device_ids: Vec::new(), + }; + st.accounts.insert( + id.clone(), + AccountRec { + account, + entitlements: self.cfg.default_entitlements.clone(), + private_lines: Vec::new(), + provision: None, + }, + ); + st.by_email.insert(email.to_string(), id.clone()); + id + } +} + +/// Find-or-create the CEC mesh provision for an account, registering its +/// venue file so the app's remote-venue fetch resolves it. +fn ensure_provision(st: &mut State, account_id: &str) -> MeshProvision { + if let Some(p) = &st.accounts[account_id].provision { + return p.clone(); + } + let network_id = convention::customer_network_id(account_id); + let cec_service_node_id = format!("cecservice{}", convention::short_hash(account_id)); + let token = cec_mesh_venue_token(&network_id); + let spec = cec_venue_spec(&token, "service"); + st.venues + .insert(token, VenueFile::new(convention::CEC_NETWORK_LABEL, &spec)); + let provision = MeshProvision { + network_id, + label: convention::CEC_NETWORK_LABEL.to_string(), + venue: spec, + cec_service_node_id, + auto_approve: true, + }; + st.accounts.get_mut(account_id).unwrap().provision = Some(provision.clone()); + provision +} + +fn cec_mesh_venue_token(network_id: &str) -> String { + format!("mesh-{network_id}") +} + +/// A CEC venue spec: a live venue-file URL plus inline reference servers. +fn cec_venue_spec(token: &str, _kind: &str) -> VenueSpec { + VenueSpec { + url: Some(format!("/v1/venues/{token}")), + signaling: vec![REF_SIGNALING.into()], + stun: vec![REF_STUN.into()], + turn: vec![TurnCredential { + url: REF_TURN_URL.into(), + username: REF_TURN_USER.into(), + credential: REF_TURN_PASS.into(), + }], + } +} + +fn bind(rec: &mut AccountRec, device_id: &str) { + if !device_id.is_empty() && !rec.account.device_ids.iter().any(|d| d == device_id) { + rec.account.device_ids.push(device_id.to_string()); + } +} + +fn display_label(a: &Account) -> String { + if a.display_name.is_empty() { + a.email.split('@').next().unwrap_or(&a.email).to_string() + } else { + a.display_name.clone() + } +} + +/// Resolve the bearer token to an account id, or an auth error. +fn account_for(st: &State, req: &ApiRequest) -> Result { + let token = bearer(req).ok_or_else(|| err(401, "no_token", "sign in first"))?; + st.tokens + .get(&token) + .cloned() + .ok_or_else(|| err(401, "bad_token", "session expired")) +} + +/// Like [`account_for`] but also requires the agent role. +fn agent_for(st: &State, req: &ApiRequest) -> Result { + let id = account_for(st, req)?; + if st.accounts[&id].account.is_agent() { + Ok(id) + } else { + Err(err(403, "not_agent", "this account isn't a CEC agent")) + } +} + +fn bearer(req: &ApiRequest) -> Option { + req.bearer.clone() +} + +fn parse(req: &ApiRequest) -> Result { + let body = req.body.clone().unwrap_or(Value::Null); + serde_json::from_value(body).map_err(|e| err(422, "bad_body", &e.to_string())) +} + +fn ok(body: Value) -> ApiResponse { + ApiResponse::ok(body) +} + +fn err(status: u16, code: &str, message: &str) -> ApiResponse { + ApiResponse::new( + status, + json!({ "error": { "code": code, "message": message } }), + ) +} + +fn next_id(next: &mut u64) -> u64 { + *next += 1; + *next +} + +fn gen_token(next: &mut u64) -> String { + let n = next_id(next); + format!("tok_{n:06}_{}", convention::new_nonce()) +} + +fn gen_code(next: &mut u64) -> String { + // A 6-digit code, varied by a global counter so concurrent tests differ. + static SALT: AtomicU64 = AtomicU64::new(0); + let n = next_id(next); + let salt = SALT.fetch_add(1, Ordering::Relaxed); + let v = (now_secs().wrapping_add(n).wrapping_add(salt.wrapping_mul(7))) % 1_000_000; + format!("{v:06}") +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn mask_email(email: &str) -> String { + match email.split_once('@') { + Some((local, domain)) => { + let shown = local.chars().next().unwrap_or('•'); + format!("{shown}•••@{domain}") + } + None => "•••".into(), + } +} + +/// A [`Transport`] that calls a shared [`MockBackend`] directly — no sockets, +/// no async I/O, perfect for tests and for an embedded demo backend. +#[derive(Clone)] +pub struct MockTransport { + backend: Arc, +} + +impl MockTransport { + pub fn new(backend: Arc) -> Self { + MockTransport { backend } + } + + /// A fresh mock + transport in one go. + pub fn fresh() -> (Arc, Self) { + let backend = MockBackend::shared(); + let transport = MockTransport::new(backend.clone()); + (backend, transport) + } + + pub fn backend(&self) -> &Arc { + &self.backend + } +} + +impl Transport for MockTransport { + async fn send(&self, req: ApiRequest) -> Result { + // Synchronous, in-memory — the lock is never held across an await + // (there is no await), so the future is trivially `Send`. + Ok(self.backend.handle(req)) + } +} + +/// Convenience: serialise a value to a JSON body for ad-hoc requests. +pub fn body(v: &T) -> Value { + serde_json::to_value(v).unwrap_or(Value::Null) +} diff --git a/crates/allmystuff-cec/src/model.rs b/crates/allmystuff-cec/src/model.rs new file mode 100644 index 00000000..0b46493b --- /dev/null +++ b/crates/allmystuff-cec/src/model.rs @@ -0,0 +1,493 @@ +//! The CEC service data model — every value that crosses the HTTP boundary +//! between AllMyStuff and the hosted Critical Error Computing backend. +//! +//! These types **are** the API contract (see `CONTRACT.md`). They serialise +//! as `snake_case` JSON, every enum is tagged so the wire stays +//! self-describing, and every response struct tolerates extra fields so the +//! backend can grow without breaking an older app — the same forward-compat +//! discipline `allmystuff-protocol` keeps for the mesh wire. + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Account & entitlements +// --------------------------------------------------------------------------- + +/// What an account is allowed to do. An account is *optional* — the free app +/// never needs one — and a single account can be a customer, an agent, or +/// both (the same human can be a household's IT person and a CEC technician). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountRole { + /// A customer: presses Ask-for-Help, rents a Private Line, owns the CEC + /// mesh for their devices. + Customer, + /// A CEC technician: signs in, goes online, handles help sessions. + Agent, +} + +/// A signed-in account. Identity proof is the email one-time code; the +/// `device_ids` are the mesh pubkeys the human has bound, so the backend +/// knows which nodes to provision the CEC mesh for and pre-trust. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Account { + /// Opaque, stable account id. The per-customer CEC network id is derived + /// from this (see [`crate::convention`]). + pub id: String, + pub email: String, + /// Display name shown to the other side of a help session ("Casey", or a + /// technician's "Sam @ CEC"). Defaults to the email's local part. + #[serde(default)] + pub display_name: String, + /// The hats this account wears. Always at least `Customer`. + #[serde(default)] + pub roles: Vec, + /// Mesh device pubkeys bound to this account (bare `public_id`, never the + /// `pubkey-SUFFIX` display form). + #[serde(default)] + pub device_ids: Vec, +} + +impl Account { + pub fn is_agent(&self) -> bool { + self.roles.contains(&AccountRole::Agent) + } + pub fn is_customer(&self) -> bool { + self.roles.contains(&AccountRole::Customer) + } +} + +/// The three Concierge tiers exactly as advertised on the site. The display +/// strings and prices live with the tier so the app and the reference server +/// agree on what each one promises. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConciergeTier { + /// "Pay as you go" — $25 / 15 min, no monthly. + PayAsYouGo, + /// "Priority" — $19 / mo, 30 min included, priority queue. + Priority, + /// "Looked after" — $69 / mo, 90 min included, scheduled check-ins. + LookedAfter, +} + +impl ConciergeTier { + /// The marketing label, verbatim from allmystuff.works/service. + pub fn label(self) -> &'static str { + match self { + ConciergeTier::PayAsYouGo => "Pay as you go", + ConciergeTier::Priority => "Priority", + ConciergeTier::LookedAfter => "Looked after", + } + } + /// The product code on the site (SV-01 … SV-03). + pub fn product_code(self) -> &'static str { + match self { + ConciergeTier::PayAsYouGo => "SV-01", + ConciergeTier::Priority => "SV-02", + ConciergeTier::LookedAfter => "SV-03", + } + } +} + +/// What a customer's account currently entitles them to. The app reads this +/// to decide which buttons to show: Ask-for-Help needs `concierge.is_some()`, +/// and the CEC mesh is provisioned when `hardware || private_line || +/// concierge.is_some()`. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Entitlements { + /// They own CEC hardware (an Access box) registered to this account. + #[serde(default)] + pub hardware: bool, + /// They have at least one active Private Line subscription. + #[serde(default)] + pub private_line: bool, + /// Their active Concierge tier, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub concierge: Option, +} + +impl Entitlements { + /// Whether anything here warrants the dedicated, isolated CEC mesh for + /// this customer — "CEC hardware OR services" in product terms. + pub fn wants_cec_mesh(&self) -> bool { + self.hardware || self.private_line || self.concierge.is_some() + } + /// Whether the Ask-for-Help button should be live. + pub fn can_ask_for_help(&self) -> bool { + self.concierge.is_some() + } +} + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +/// `POST /v1/auth/start` — request a one-time sign-in code by email. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StartSignIn { + pub email: String, +} + +/// The backend's acknowledgement that a code was sent (it never reveals the +/// code, naturally — except the mock, which prints it locally). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StartSignInResponse { + #[serde(default = "default_true")] + pub sent: bool, + /// e.g. "c•••@gmail.com" for the UI to echo back. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub masked_email: Option, +} + +fn default_true() -> bool { + true +} + +/// `POST /v1/auth/verify` — exchange the email + code for a session, binding +/// this device's mesh identity in the same call. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifySignIn { + pub email: String, + pub code: String, + /// This device's bare mesh pubkey, bound to the account so the backend can + /// provision its CEC mesh. Optional: a headless agent may bind later. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_id: Option, + /// A friendly label for the bound device ("Casey's laptop"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_label: Option, +} + +/// A live session: the bearer token plus a snapshot of the account so the app +/// can render immediately without a second round trip. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub token: String, + pub account: Account, + #[serde(default)] + pub entitlements: Entitlements, +} + +/// `GET /v1/me` — the current account + entitlements for a bearer token. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Me { + pub account: Account, + #[serde(default)] + pub entitlements: Entitlements, +} + +/// `POST /v1/me/device` — bind (or re-label) a mesh device on the account. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BindDevice { + pub device_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +// --------------------------------------------------------------------------- +// Venues (Private Line + the CEC mesh share this shape) +// --------------------------------------------------------------------------- + +/// A TURN relay credential, matching the app's `TurnEntry` / network-config +/// `turn_servers` shape so a venue drops straight into a `NetworkConfig`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TurnCredential { + pub url: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub credential: String, +} + +/// The three servers that make up a "venue" — signaling, STUN, TURN. The +/// backend can either hand the resolved servers inline *and* point at a live +/// venue file via `url` (so the app tracks the host's updates the way a +/// remote venue does in `venue-settings.ts`). +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct VenueSpec { + /// A live venue-file URL the app can re-fetch (`GET` → an + /// `allmystuff.venue` envelope). Present for CEC-hosted venues. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default)] + pub signaling: Vec, + #[serde(default)] + pub stun: Vec, + #[serde(default)] + pub turn: Vec, +} + +/// The `allmystuff.venue` file envelope served at a [`VenueSpec::url`]. This +/// is byte-compatible with the app's `venue-settings.ts::fetchVenueServers`, +/// so a CEC venue is just a remote venue the app already knows how to load. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VenueFile { + pub kind: String, + pub version: u32, + pub label: String, + #[serde(default)] + pub signaling_servers: Vec, + #[serde(default)] + pub stun_servers: Vec, + #[serde(default)] + pub turn_servers: Vec, +} + +impl VenueFile { + pub const KIND: &'static str = "allmystuff.venue"; + pub const VERSION: u32 = 1; + + pub fn new(label: impl Into, spec: &VenueSpec) -> Self { + VenueFile { + kind: Self::KIND.into(), + version: Self::VERSION, + label: label.into(), + signaling_servers: spec.signaling.clone(), + stun_servers: spec.stun.clone(), + turn_servers: spec.turn.clone(), + } + } +} + +/// One Private Line subscription — "a venue of your own", $10/mo. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrivateLine { + pub id: String, + pub label: String, + pub status: SubscriptionStatus, + /// The servers (and live venue-file URL) this Private Line provides. + pub venue: VenueSpec, + #[serde(default)] + pub monthly_price_cents: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubscriptionStatus { + Active, + Cancelled, + PastDue, +} + +/// `POST /v1/private-line` — rent a new Private Line. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RentPrivateLine { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +// --------------------------------------------------------------------------- +// The CEC mesh +// --------------------------------------------------------------------------- + +/// `POST /v1/mesh/provision` — the descriptor the app needs to stand up the +/// customer's isolated `cec-customer-` network: which network id to +/// join, which venue serves it, and the single **CEC Service** node id to +/// pre-trust. Every CEC connection to this customer rides that one node; +/// individual agents live *behind* the backend, never as mesh peers. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeshProvision { + /// The normalised network id (`cec-customer-`). + pub network_id: String, + /// What to label the network in the app ("CEC"). + pub label: String, + /// The CEC-hosted venue (its own signaling/STUN/TURN), serving only this + /// customer's devices and the CEC Service node. + pub venue: VenueSpec, + /// The bare pubkey of the single CEC Service node. Pre-approve this in the + /// network's roster; it is the only non-customer peer that ever appears. + pub cec_service_node_id: String, + /// Always true for a CEC mesh — the customer auto-approves CEC's node and + /// their own fleet without a verification dance. + #[serde(default = "default_true")] + pub auto_approve: bool, +} + +// --------------------------------------------------------------------------- +// Ask-for-Help (Concierge) — customer side +// --------------------------------------------------------------------------- + +/// `POST /v1/help` — a customer presses Ask-for-Help. The app has already +/// minted a host-side help room on the CEC network; it hands the room id over +/// so the dispatched agent can join it as the CEC Service node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AskForHelp { + /// The CEC network the room lives on (`cec-customer-`). + pub network_id: String, + /// The customer-hosted room id (`room:{host}:cec-{nonce}`). + pub room_id: String, + /// The device that's asking (bare pubkey), so the agent knows where to + /// land. + pub device_id: String, + /// One line of "what's wrong", optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topic: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HelpStatus { + /// Waiting for an online agent to pick it up. + Queued, + /// An agent accepted; they're connecting as the CEC Service node. + Assigned, + /// The agent is in the room. + Connected, + /// The session is over. + Ended, + /// The customer cancelled before an agent arrived. + Cancelled, +} + +impl HelpStatus { + pub fn is_terminal(self) -> bool { + matches!(self, HelpStatus::Ended | HelpStatus::Cancelled) + } +} + +/// A help session, as seen by both the customer (polling `GET /v1/help/{id}`) +/// and the agent queue. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HelpSession { + pub id: String, + pub status: HelpStatus, + pub network_id: String, + pub room_id: String, + pub cec_service_node_id: String, + /// The customer device that asked (bare pubkey). + pub customer_device_id: String, + /// A label for the customer the agent sees ("Casey"). + #[serde(default)] + pub customer_label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topic: Option, + /// The handling technician's display name, once assigned. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_label: Option, + /// Unix seconds. + #[serde(default)] + pub created_at: u64, +} + +// --------------------------------------------------------------------------- +// Agent side +// --------------------------------------------------------------------------- + +/// `POST /v1/agent/presence` — a technician toggles "online and available to +/// handle requests". +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetPresence { + pub online: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentPresence { + pub online: bool, + /// Unix seconds the agent has been online since (when online). + #[serde(default)] + pub since: u64, +} + +/// What an agent receives when they accept a help session: everything needed +/// to join the customer's CEC network as the CEC Service node. The agent's +/// link to the customer is brokered by the backend through that one node — +/// the agent is not, itself, a mesh peer the customer sees. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentAssignment { + pub session: HelpSession, + /// The venue to connect through (the CEC-hosted servers for this + /// customer). + pub venue: VenueSpec, +} + +// --------------------------------------------------------------------------- +// Errors on the wire +// --------------------------------------------------------------------------- + +/// The body shape of a non-2xx response. Both fields optional so a bare +/// `{ "error": "..." }` or a richer `{ "error": { "code", "message" } }` both +/// parse. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ApiErrorBody { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +// --------------------------------------------------------------------------- +// Mock-only / dev helpers (the reference server implements these; a real +// backend would not). Kept in the contract so tests and tooling share them. +// --------------------------------------------------------------------------- + +/// `POST /v1/dev/grant` — MOCK ONLY. Set up an account's entitlements / agent +/// role for tests and local demos. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DevGrant { + pub email: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entitlements: Option, + /// Make this account an agent. + #[serde(default)] + pub agent: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enums_are_snake_case_tagged() { + assert_eq!( + serde_json::to_value(ConciergeTier::PayAsYouGo).unwrap(), + serde_json::json!("pay_as_you_go") + ); + assert_eq!( + serde_json::to_value(HelpStatus::Queued).unwrap(), + serde_json::json!("queued") + ); + assert_eq!( + serde_json::to_value(AccountRole::Agent).unwrap(), + serde_json::json!("agent") + ); + } + + #[test] + fn account_tolerates_missing_optional_fields() { + let a: Account = serde_json::from_str(r#"{"id":"a1","email":"x@y.z"}"#).unwrap(); + assert_eq!(a.id, "a1"); + assert!(a.roles.is_empty()); + assert!(a.device_ids.is_empty()); + } + + #[test] + fn entitlements_drive_the_two_decisions() { + let none = Entitlements::default(); + assert!(!none.wants_cec_mesh()); + assert!(!none.can_ask_for_help()); + + let hw = Entitlements { + hardware: true, + ..Default::default() + }; + assert!(hw.wants_cec_mesh()); + assert!(!hw.can_ask_for_help()); + + let concierge = Entitlements { + concierge: Some(ConciergeTier::Priority), + ..Default::default() + }; + assert!(concierge.wants_cec_mesh()); + assert!(concierge.can_ask_for_help()); + } + + #[test] + fn error_body_parses_both_shapes() { + let rich: ApiErrorBody = + serde_json::from_str(r#"{"code":"bad_code","message":"nope"}"#).unwrap(); + assert_eq!(rich.code.as_deref(), Some("bad_code")); + let bare: ApiErrorBody = serde_json::from_str(r#"{}"#).unwrap(); + assert!(bare.message.is_none()); + } +} diff --git a/crates/allmystuff-cec/src/transport.rs b/crates/allmystuff-cec/src/transport.rs new file mode 100644 index 00000000..b4aaec1a --- /dev/null +++ b/crates/allmystuff-cec/src/transport.rs @@ -0,0 +1,166 @@ +//! The HTTP transport seam. +//! +//! [`CecClient`](crate::CecClient) is generic over a [`Transport`] so the same +//! client code drives the real backend (`reqwest`), an in-process mock (for +//! tests, no sockets), or anything else. Futures are `Send` so the Tauri +//! backend can hold a `CecClient` inside an async command. + +use std::future::Future; + +use serde_json::Value; + +use crate::Error; + +/// The HTTP method an endpoint uses. (The CEC contract only needs these +/// three.) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Method { + Get, + Post, + Delete, +} + +impl Method { + pub fn as_str(self) -> &'static str { + match self { + Method::Get => "GET", + Method::Post => "POST", + Method::Delete => "DELETE", + } + } +} + +/// One request to the backend, already resolved to a path (the transport +/// prepends the base URL) plus an optional bearer token and JSON body. +#[derive(Debug, Clone)] +pub struct ApiRequest { + pub method: Method, + /// Leading-slash path, e.g. `/v1/me`. + pub path: String, + pub query: Vec<(String, String)>, + pub body: Option, + pub bearer: Option, +} + +impl ApiRequest { + pub fn new(method: Method, path: impl Into) -> Self { + ApiRequest { + method, + path: path.into(), + query: Vec::new(), + body: None, + bearer: None, + } + } + pub fn get(path: impl Into) -> Self { + Self::new(Method::Get, path) + } + pub fn post(path: impl Into) -> Self { + Self::new(Method::Post, path) + } + pub fn delete(path: impl Into) -> Self { + Self::new(Method::Delete, path) + } + pub fn body(mut self, body: Value) -> Self { + self.body = Some(body); + self + } + pub fn bearer(mut self, token: Option) -> Self { + self.bearer = token; + self + } + pub fn query(mut self, key: impl Into, value: impl Into) -> Self { + self.query.push((key.into(), value.into())); + self + } +} + +/// The raw HTTP response — status plus parsed JSON body (or `Value::Null` for +/// an empty body). The client turns non-2xx into [`Error::Api`]. +#[derive(Debug, Clone)] +pub struct ApiResponse { + pub status: u16, + pub body: Value, +} + +impl ApiResponse { + pub fn new(status: u16, body: Value) -> Self { + ApiResponse { status, body } + } + pub fn ok(body: Value) -> Self { + ApiResponse::new(200, body) + } + pub fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } +} + +/// The seam a [`CecClient`](crate::CecClient) talks through. +pub trait Transport { + fn send(&self, req: ApiRequest) -> impl Future> + Send; +} + +/// The real HTTP transport: a `reqwest::Client` against a base URL. +#[cfg(feature = "reqwest")] +#[derive(Clone)] +pub struct ReqwestTransport { + base_url: String, + http: reqwest::Client, +} + +#[cfg(feature = "reqwest")] +impl ReqwestTransport { + /// Build a transport for `base_url` (e.g. `https://api.allmystuff.works`). + /// Trailing slashes are trimmed so path joining stays predictable. + pub fn new(base_url: impl Into) -> Result { + let http = reqwest::Client::builder() + .user_agent(concat!("allmystuff-cec/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| Error::Transport(e.to_string()))?; + Ok(ReqwestTransport { + base_url: base_url.into().trim_end_matches('/').to_string(), + http, + }) + } + + pub fn base_url(&self) -> &str { + &self.base_url + } +} + +#[cfg(feature = "reqwest")] +impl Transport for ReqwestTransport { + async fn send(&self, req: ApiRequest) -> Result { + let url = format!("{}{}", self.base_url, req.path); + let method = match req.method { + Method::Get => reqwest::Method::GET, + Method::Post => reqwest::Method::POST, + Method::Delete => reqwest::Method::DELETE, + }; + let mut builder = self.http.request(method, &url); + if !req.query.is_empty() { + builder = builder.query(&req.query); + } + if let Some(token) = &req.bearer { + builder = builder.bearer_auth(token); + } + if let Some(body) = &req.body { + builder = builder.json(body); + } + let resp = builder + .send() + .await + .map_err(|e| Error::Transport(e.to_string()))?; + let status = resp.status().as_u16(); + let text = resp + .text() + .await + .map_err(|e| Error::Transport(e.to_string()))?; + let body = if text.trim().is_empty() { + Value::Null + } else { + serde_json::from_str(&text).map_err(|e| Error::Decode(e.to_string()))? + }; + Ok(ApiResponse { status, body }) + } +} diff --git a/crates/allmystuff-cec/tests/flows.rs b/crates/allmystuff-cec/tests/flows.rs new file mode 100644 index 00000000..5612c2f2 --- /dev/null +++ b/crates/allmystuff-cec/tests/flows.rs @@ -0,0 +1,243 @@ +//! End-to-end flows against the in-memory reference backend. These exercise +//! the same `CecClient` code paths the app and the agent binary use — the +//! only difference is the transport (here: no sockets). + +use std::sync::Arc; + +use allmystuff_cec::convention; +use allmystuff_cec::mock::{MockBackend, MockConfig, MockTransport}; +use allmystuff_cec::model::*; +use allmystuff_cec::{CecClient, Error, Method}; + +/// Sign a customer in (granting them a Concierge tier first) and bind a device. +async fn signed_in_customer( + backend: &Arc, + email: &str, + device_id: &str, +) -> CecClient { + let mut client = CecClient::new(MockTransport::new(backend.clone())); + client + .dev_grant(&DevGrant { + email: email.into(), + entitlements: Some(Entitlements { + concierge: Some(ConciergeTier::Priority), + ..Default::default() + }), + agent: false, + }) + .await + .unwrap(); + client.start_sign_in(email).await.unwrap(); + let code = backend.last_code(email).expect("a code was sent"); + client + .verify_sign_in(email, &code, Some(device_id), Some("Casey's laptop")) + .await + .unwrap(); + client +} + +#[tokio::test] +async fn customer_signs_in_provisions_mesh_and_asks_for_help() { + let backend = MockBackend::shared(); + let client = signed_in_customer(&backend, "casey@example.com", "dev-casey-1").await; + + // me() reflects the grant + binding. + let me = client.me().await.unwrap(); + assert!(me.account.is_customer()); + assert_eq!(me.entitlements.concierge, Some(ConciergeTier::Priority)); + assert!(me.entitlements.can_ask_for_help()); + assert!(me.entitlements.wants_cec_mesh()); + assert!(me.account.device_ids.contains(&"dev-casey-1".to_string())); + + // Provision the isolated CEC mesh. + let prov = client.provision_mesh("dev-casey-1").await.unwrap(); + assert!(convention::is_cec_network(&prov.network_id)); + assert!(prov.auto_approve); + assert!(!prov.cec_service_node_id.is_empty()); + assert!(prov.venue.url.is_some()); + // Stable: a second provision is the same network + service node. + let prov2 = client.provision_mesh("dev-casey-1").await.unwrap(); + assert_eq!(prov.network_id, prov2.network_id); + assert_eq!(prov.cec_service_node_id, prov2.cec_service_node_id); + + // The venue file is fetchable (this is the remote-venue the app loads). + let venue_path = prov.venue.url.unwrap(); + let file = client + .raw(Method::Get, &venue_path, None) + .await + .unwrap(); + assert_eq!(file["kind"], VenueFile::KIND); + assert!(file["signaling_servers"].as_array().unwrap().len() >= 1); + + // Ask for help: mint a help room hosted by this device, then open a session. + let room_id = convention::help_room_id("dev-casey-1"); + assert!(convention::is_help_room(&room_id)); + let session = client + .ask_for_help(&AskForHelp { + network_id: prov.network_id.clone(), + room_id: room_id.clone(), + device_id: "dev-casey-1".into(), + topic: Some("printer won't print".into()), + }) + .await + .unwrap(); + assert_eq!(session.status, HelpStatus::Queued); + assert_eq!(session.room_id, room_id); + assert_eq!(session.cec_service_node_id, prov.cec_service_node_id); + + // The customer can poll their own session. + let polled = client.help_status(&session.id).await.unwrap(); + assert_eq!(polled.status, HelpStatus::Queued); +} + +#[tokio::test] +async fn agent_sees_queue_accepts_and_customer_sees_assignment() { + let backend = MockBackend::shared(); + + // A customer queues a help request. + let customer = signed_in_customer(&backend, "casey@example.com", "dev-casey-1").await; + let prov = customer.provision_mesh("dev-casey-1").await.unwrap(); + let room_id = convention::help_room_id("dev-casey-1"); + let session = customer + .ask_for_help(&AskForHelp { + network_id: prov.network_id.clone(), + room_id, + device_id: "dev-casey-1".into(), + topic: None, + }) + .await + .unwrap(); + + // An agent signs in. + let mut agent = CecClient::new(MockTransport::new(backend.clone())); + agent + .dev_grant(&DevGrant { + email: "sam@cec.example".into(), + agent: true, + ..Default::default() + }) + .await + .unwrap(); + agent.start_sign_in("sam@cec.example").await.unwrap(); + let code = backend.last_code("sam@cec.example").unwrap(); + agent + .verify_sign_in("sam@cec.example", &code, None, None) + .await + .unwrap(); + assert!(agent.me().await.unwrap().account.is_agent()); + + // Offline → the queue is hidden. + let offline = agent.agent_queue().await.unwrap_err(); + assert_eq!(offline.code(), Some("offline")); + + // Go online → the queued session shows up. + let presence = agent.set_presence(true).await.unwrap(); + assert!(presence.online); + let queue = agent.agent_queue().await.unwrap(); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].id, session.id); + + // Accept → assignment carries the venue to join as the CEC Service node. + let assignment = agent.accept_help(&session.id).await.unwrap(); + assert_eq!(assignment.session.status, HelpStatus::Assigned); + assert!(assignment.session.agent_label.is_some()); + assert!(!assignment.venue.signaling.is_empty()); + + // The customer now sees it assigned, with the agent's name. + let polled = customer.help_status(&session.id).await.unwrap(); + assert_eq!(polled.status, HelpStatus::Assigned); + assert_eq!(polled.agent_label.as_deref(), Some("sam")); + + // A second agent can't double-accept. + let taken = agent.accept_help(&session.id).await.unwrap_err(); + assert_eq!(taken.code(), Some("taken")); + + // End the session. + agent.end_help(&session.id).await.unwrap(); + assert_eq!( + customer.help_status(&session.id).await.unwrap().status, + HelpStatus::Ended + ); +} + +#[tokio::test] +async fn private_line_rent_list_and_cancel_updates_entitlements() { + let backend = MockBackend::shared(); + let client = signed_in_customer(&backend, "casey@example.com", "dev-1").await; + + assert!(!client.me().await.unwrap().entitlements.private_line); + + let pl = client.rent_private_line(Some("Home")).await.unwrap(); + assert_eq!(pl.status, SubscriptionStatus::Active); + assert_eq!(pl.label, "Home"); + assert!(pl.venue.url.is_some()); + assert_eq!(pl.monthly_price_cents, 1000); + + assert!(client.me().await.unwrap().entitlements.private_line); + assert_eq!(client.list_private_lines().await.unwrap().len(), 1); + + client.cancel_private_line(&pl.id).await.unwrap(); + assert!(!client.me().await.unwrap().entitlements.private_line); + assert_eq!( + client.list_private_lines().await.unwrap()[0].status, + SubscriptionStatus::Cancelled + ); +} + +#[tokio::test] +async fn auth_failures_are_typed() { + let backend = MockBackend::shared(); + let mut client = CecClient::new(MockTransport::new(backend.clone())); + + // Unauthenticated calls fail before hitting the wire. + assert!(matches!(client.me().await, Err(Error::Unauthenticated))); + + // Wrong code → 401 bad_code. + client.start_sign_in("x@y.z").await.unwrap(); + let err = client + .verify_sign_in("x@y.z", "000000", None, None) + .await + .unwrap_err(); + match err { + Error::Api { status, code, .. } => { + assert_eq!(status, 401); + assert_eq!(code.as_deref(), Some("bad_code")); + } + other => panic!("expected Api error, got {other:?}"), + } + + // A non-agent can't touch agent endpoints. + let real_code = { + client.start_sign_in("x@y.z").await.unwrap(); + backend.last_code("x@y.z").unwrap() + }; + client + .verify_sign_in("x@y.z", &real_code, None, None) + .await + .unwrap(); + let not_agent = client.set_presence(true).await.unwrap_err(); + assert_eq!(not_agent.code(), Some("not_agent")); +} + +#[tokio::test] +async fn demo_config_lights_everything_up() { + // The server's --demo mode: every account gets a tier and is an agent. + let backend = Arc::new(MockBackend::with_config(MockConfig { + default_entitlements: Entitlements { + concierge: Some(ConciergeTier::PayAsYouGo), + private_line: false, + hardware: false, + }, + everyone_is_agent: true, + })); + let mut client = CecClient::new(MockTransport::new(backend.clone())); + client.start_sign_in("solo@example.com").await.unwrap(); + let code = backend.last_code("solo@example.com").unwrap(); + let session = client + .verify_sign_in("solo@example.com", &code, Some("dev-solo"), None) + .await + .unwrap(); + assert!(session.entitlements.can_ask_for_help()); + assert!(session.account.is_agent()); + assert!(session.account.is_customer()); +} From 5fee3fb6b047b151a972f68120ba350f92044b2e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 00:37:10 +0000 Subject: [PATCH 2/4] Wire the CEC service into the app (Tauri backend + GUI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-backend side of the CEC service: - gui/src-tauri/src/cec.rs: a Cec state holding the allmystuff-cec client and persisting account state to ~/.myownmesh/allmystuff-cec.json (the same home as the mesh identity). Thirteen cec_* Tauri commands: set backend URL, the email-code sign-in (binding this device's mesh id), refresh/sign-out, provision the CEC mesh, rent/list/cancel a Private Line, and open/poll/cancel an Ask-for-Help session. Venue URLs are absolutised so the front-end can add them as ordinary remote venues. - gui/src/cec.ts: the typed front-end client (mirrors the Rust contract + conventions) over the cec_* commands, degrading to a demo off the desktop. - store.svelte.ts: a CEC slice that orchestrates the mesh side on top of the existing room/network/venue verbs — provisioning joins the isolated cec-customer- network with auto-approve and pre-trusts the single CEC Service node; Ask-for-Help mints a host-side help room holding just this device and that node, then tells the backend (which exposes it to online agents) and pops the call window. - AccountSection (a new Settings tab) and a reusable AskForHelpButton in the top bar. svelte-check passes clean (0 errors, 0 warnings). --- gui/src-tauri/Cargo.lock | 124 ++++---- gui/src-tauri/Cargo.toml | 3 + gui/src-tauri/src/cec.rs | 348 +++++++++++++++++++++ gui/src-tauri/src/main.rs | 102 +++++++ gui/src/cec.ts | 184 +++++++++++ gui/src/store.svelte.ts | 352 +++++++++++++++++++++- gui/src/ui/App.svelte | 4 + gui/src/ui/AskForHelpButton.svelte | 62 ++++ gui/src/ui/SettingsPanel.svelte | 5 + gui/src/ui/settings/AccountSection.svelte | 329 ++++++++++++++++++++ 10 files changed, 1455 insertions(+), 58 deletions(-) create mode 100644 gui/src-tauri/src/cec.rs create mode 100644 gui/src/cec.ts create mode 100644 gui/src/ui/AskForHelpButton.svelte create mode 100644 gui/src/ui/settings/AccountSection.svelte diff --git a/gui/src-tauri/Cargo.lock b/gui/src-tauri/Cargo.lock index 62a0778c..23521964 100644 --- a/gui/src-tauri/Cargo.lock +++ b/gui/src-tauri/Cargo.lock @@ -19,16 +19,28 @@ dependencies = [ [[package]] name = "allmystuff-bridge" -version = "0.1.11" +version = "0.1.12" dependencies = [ "allmystuff-graph", "allmystuff-inventory", "allmystuff-protocol", ] +[[package]] +name = "allmystuff-cec" +version = "0.1.12" +dependencies = [ + "hex", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", +] + [[package]] name = "allmystuff-graph" -version = "0.1.11" +version = "0.1.12" dependencies = [ "serde", "serde_json", @@ -40,6 +52,7 @@ name = "allmystuff-gui" version = "0.1.12" dependencies = [ "allmystuff-bridge", + "allmystuff-cec", "allmystuff-graph", "allmystuff-inventory", "allmystuff-pixels", @@ -83,7 +96,7 @@ dependencies = [ [[package]] name = "allmystuff-inventory" -version = "0.1.11" +version = "0.1.12" dependencies = [ "serde", "serde_json", @@ -96,7 +109,7 @@ version = "0.1.0" [[package]] name = "allmystuff-protocol" -version = "0.1.11" +version = "0.1.12" dependencies = [ "allmystuff-graph", "serde", @@ -105,7 +118,7 @@ dependencies = [ [[package]] name = "allmystuff-session" -version = "0.1.11" +version = "0.1.12" dependencies = [ "allmystuff-graph", "allmystuff-protocol", @@ -116,7 +129,7 @@ dependencies = [ [[package]] name = "allmystuff-updater" -version = "0.1.11" +version = "0.1.12" dependencies = [ "dirs 5.0.1", "flate2", @@ -140,9 +153,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -536,9 +549,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -547,9 +560,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.1" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -680,9 +693,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "jobserver", @@ -1170,7 +1183,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -2235,9 +2247,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2668,13 +2680,12 @@ checksum = "0b0b36cbb4e6704f12f5b5d7b01dac593982c6550859ebd5a66fb15c9ea27fd5" [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if 1.0.4", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2947,9 +2958,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmap2" @@ -4351,9 +4362,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -4374,9 +4385,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -4995,9 +5006,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -5669,12 +5680,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -5684,15 +5694,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" dependencies = [ "num-conv", "time-core", @@ -6171,9 +6181,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -6266,9 +6276,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen 0.57.1", ] @@ -6284,9 +6294,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if 1.0.4", "once_cell", @@ -6297,9 +6307,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -6307,9 +6317,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6317,9 +6327,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -6330,9 +6340,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -6487,9 +6497,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -7676,18 +7686,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -7717,9 +7727,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index c5814424..6c5f2e6b 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -64,6 +64,9 @@ allmystuff-protocol = { path = "../../crates/allmystuff-protocol" } allmystuff-bridge = { path = "../../crates/allmystuff-bridge" } allmystuff-session = { path = "../../crates/allmystuff-session" } allmystuff-updater = { path = "../../crates/allmystuff-updater" } +# The CEC service client — optional accounts, Ask-for-Help, Private Line, and +# the per-customer CEC mesh. Brings the real reqwest transport (its default). +allmystuff-cec = { path = "../../crates/allmystuff-cec" } # Cross-platform audio capture + playback for the media plane — CoreAudio # on macOS, WASAPI on Windows, ALSA on Linux. Same crate (and version) diff --git a/gui/src-tauri/src/cec.rs b/gui/src-tauri/src/cec.rs new file mode 100644 index 00000000..377d9075 --- /dev/null +++ b/gui/src-tauri/src/cec.rs @@ -0,0 +1,348 @@ +//! The CEC service, app-backend side. +//! +//! This is the Tauri half of the [`allmystuff-cec`] client: it owns the +//! persisted account state (`~/.myownmesh/allmystuff-cec.json` — the same home +//! as the rest of AllMyStuff's state) and a `reqwest`-backed client, and +//! exposes the small set of operations the front-end drives through Tauri +//! commands: set the backend URL, sign in with an email code (binding this +//! device's mesh identity), provision the customer's CEC mesh, rent a Private +//! Line, and open / poll an Ask-for-Help session. +//! +//! The actual mesh work — joining the `cec-customer-` network, approving +//! the CEC Service node, minting the help room — is orchestrated in the +//! front-end store on top of the existing network/venue/room commands; this +//! module just talks to the backend and remembers what it learned. + +use std::path::PathBuf; + +use allmystuff_cec::model::{Account, Entitlements, MeshProvision, VenueSpec}; +use allmystuff_cec::{AskForHelp, CecClient, Error as CecError, ReqwestTransport}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +/// The reference backend the app talks to by default; overridable in +/// Settings → Account (point it at a local `allmystuff-cec-mock` to try the +/// whole flow offline). +const DEFAULT_BACKEND: &str = "https://api.allmystuff.works"; + +/// On-disk state. Additive + `#[serde(default)]` so an older file (or none) +/// still loads, exactly like `networks_store`. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Persisted { + #[serde(default = "default_backend")] + backend_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + account: Option, + #[serde(default)] + entitlements: Entitlements, + /// The last CEC mesh descriptor the backend handed us, so the graph can + /// label the network and its single service node even before a refresh. + #[serde(default, skip_serializing_if = "Option::is_none")] + provision: Option, +} + +impl Default for Persisted { + fn default() -> Self { + Persisted { + backend_url: default_backend(), + token: None, + account: None, + entitlements: Entitlements::default(), + provision: None, + } + } +} + +fn default_backend() -> String { + DEFAULT_BACKEND.to_string() +} + +/// The live CEC state behind the Tauri `State`. Cheap to share. +pub struct Cec { + path: Option, + inner: Mutex, +} + +impl Cec { + /// Load persisted state from disk (or start signed-out with the default + /// backend). + pub fn load() -> Self { + let path = store_path(); + let inner = path + .as_ref() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or_default(); + Cec { + path, + inner: Mutex::new(inner), + } + } + + // ---- snapshot for the UI --------------------------------------------- + + /// The `{ backend_url, signed_in, account, entitlements, provision }` blob + /// the front-end renders from. Never includes the bearer token. + pub fn snapshot(&self) -> Value { + let st = self.inner.lock(); + snapshot_of(&st) + } + + // ---- backend URL ------------------------------------------------------ + + pub fn set_backend_url(&self, url: String) -> Result { + let url = url.trim().trim_end_matches('/').to_string(); + if url.is_empty() { + return Err("backend URL can't be empty".into()); + } + // Changing the backend invalidates any session against the old one. + { + let mut st = self.inner.lock(); + if st.backend_url != url { + st.backend_url = url; + st.token = None; + st.account = None; + st.entitlements = Entitlements::default(); + st.provision = None; + } + persist(&self.path, &st); + } + Ok(self.snapshot()) + } + + // ---- auth ------------------------------------------------------------- + + pub async fn start_sign_in(&self, email: String) -> Result { + let client = self.client(); + let resp = client.start_sign_in(&email).await.map_err(describe)?; + Ok(json!({ + "sent": resp.sent, + "masked_email": resp.masked_email.unwrap_or(email), + })) + } + + pub async fn verify_sign_in( + &self, + email: String, + code: String, + device_id: Option, + device_label: Option, + ) -> Result { + let mut client = self.client(); + let session = client + .verify_sign_in(&email, &code, device_id.as_deref(), device_label.as_deref()) + .await + .map_err(describe)?; + let mut st = self.inner.lock(); + st.token = Some(session.token); + st.account = Some(session.account); + st.entitlements = session.entitlements; + persist(&self.path, &st); + Ok(snapshot_of(&st)) + } + + /// Re-fetch the account + entitlements (e.g. after a purchase on the web). + pub async fn refresh(&self) -> Result { + let client = self.client(); + match client.me().await { + Ok(me) => { + let mut st = self.inner.lock(); + st.account = Some(me.account); + st.entitlements = me.entitlements; + persist(&self.path, &st); + Ok(snapshot_of(&st)) + } + // An expired/invalid session signs the user out locally rather + // than wedging the UI in a half-signed-in state. + Err(e) if e.is_auth() => { + let mut st = self.inner.lock(); + clear_session(&mut st); + persist(&self.path, &st); + Ok(snapshot_of(&st)) + } + Err(e) => Err(describe(e)), + } + } + + pub async fn sign_out(&self) -> Result { + let mut client = self.client(); + let _ = client.sign_out().await; // best-effort + let mut st = self.inner.lock(); + clear_session(&mut st); + persist(&self.path, &st); + Ok(snapshot_of(&st)) + } + + // ---- the CEC mesh ----------------------------------------------------- + + /// Provision (or fetch) the customer's CEC mesh descriptor. The venue URL + /// is absolutised against the backend so the front-end can add it as an + /// ordinary remote venue and fetch it directly. + pub async fn provision_mesh(&self, device_id: String) -> Result { + let client = self.client(); + let mut prov = client.provision_mesh(&device_id).await.map_err(describe)?; + let base = self.backend_url(); + absolutize_venue(&base, &mut prov.venue); + { + let mut st = self.inner.lock(); + st.provision = Some(prov.clone()); + persist(&self.path, &st); + } + serde_json::to_value(prov).map_err(|e| e.to_string()) + } + + // ---- Private Line ----------------------------------------------------- + + pub async fn rent_private_line(&self, label: Option) -> Result { + let client = self.client(); + let mut pl = client + .rent_private_line(label.as_deref()) + .await + .map_err(describe)?; + absolutize_venue(&self.backend_url(), &mut pl.venue); + // A new subscription flips the entitlement; reflect it without waiting + // for the next refresh. + { + let mut st = self.inner.lock(); + st.entitlements.private_line = true; + persist(&self.path, &st); + } + serde_json::to_value(pl).map_err(|e| e.to_string()) + } + + pub async fn list_private_lines(&self) -> Result { + let client = self.client(); + let mut lines = client.list_private_lines().await.map_err(describe)?; + let base = self.backend_url(); + for pl in &mut lines { + absolutize_venue(&base, &mut pl.venue); + } + serde_json::to_value(lines).map_err(|e| e.to_string()) + } + + pub async fn cancel_private_line(&self, id: String) -> Result { + let client = self.client(); + client.cancel_private_line(&id).await.map_err(describe)?; + Ok(self.snapshot()) + } + + // ---- Ask-for-Help ----------------------------------------------------- + + pub async fn ask_for_help( + &self, + network_id: String, + room_id: String, + device_id: String, + topic: Option, + ) -> Result { + let client = self.client(); + let session = client + .ask_for_help(&AskForHelp { + network_id, + room_id, + device_id, + topic, + }) + .await + .map_err(describe)?; + serde_json::to_value(session).map_err(|e| e.to_string()) + } + + pub async fn help_status(&self, id: String) -> Result { + let client = self.client(); + let session = client.help_status(&id).await.map_err(describe)?; + serde_json::to_value(session).map_err(|e| e.to_string()) + } + + pub async fn cancel_help(&self, id: String) -> Result { + let client = self.client(); + client.cancel_help(&id).await.map_err(describe)?; + Ok(json!({ "ok": true })) + } + + // ---- helpers ---------------------------------------------------------- + + fn backend_url(&self) -> String { + self.inner.lock().backend_url.clone() + } + + /// Build a client for the current backend + token. Cheap enough for these + /// user-driven, low-frequency calls; the lock is released before any + /// `await`, so this never blocks the runtime. + fn client(&self) -> CecClient { + let (url, token) = { + let st = self.inner.lock(); + (st.backend_url.clone(), st.token.clone()) + }; + // `ReqwestTransport::new` only fails building the HTTP client, which is + // effectively infallible here; fall back to the default backend so a + // method still returns a real (transport) error rather than panicking. + let transport = ReqwestTransport::new(&url) + .or_else(|_| ReqwestTransport::new(DEFAULT_BACKEND)) + .expect("build reqwest client"); + CecClient::with_token(transport, token) + } +} + +fn clear_session(st: &mut Persisted) { + st.token = None; + st.account = None; + st.entitlements = Entitlements::default(); + st.provision = None; +} + +fn snapshot_of(st: &Persisted) -> Value { + json!({ + "backend_url": st.backend_url, + "signed_in": st.token.is_some(), + "account": st.account, + "entitlements": st.entitlements, + "provision": st.provision, + }) +} + +/// Turn a relative venue URL (`/v1/venues/…`) into an absolute one against the +/// backend, so the front-end (and the daemon, indirectly) can fetch it. +fn absolutize_venue(base: &str, venue: &mut VenueSpec) { + if let Some(url) = &venue.url { + if url.starts_with('/') { + venue.url = Some(format!("{}{}", base.trim_end_matches('/'), url)); + } + } +} + +fn describe(e: CecError) -> String { + match &e { + CecError::Api { + code: Some(c), + message, + .. + } => format!("{message} ({c})"), + _ => e.to_string(), + } +} + +fn persist(path: &Option, value: &Persisted) -> bool { + let Some(path) = path else { + return false; + }; + let Ok(json) = serde_json::to_string_pretty(value) else { + return false; + }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + std::fs::write(path, json).is_ok() +} + +/// `~/.myownmesh/allmystuff-cec.json`, honouring `MYOWNMESH_HOME` — the same +/// home as the mesh identity and the rest of AllMyStuff's state. +fn store_path() -> Option { + let home = std::env::var_os("MYOWNMESH_HOME") + .map(PathBuf::from) + .or_else(dirs::home_dir)?; + Some(home.join(".myownmesh").join("allmystuff-cec.json")) +} diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index e1aa1a91..292dcef6 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -20,6 +20,7 @@ mod audio; mod byte_queues; mod camera_capture; +mod cec; mod clipboard; mod control_client; mod daemon_spawn; @@ -922,6 +923,93 @@ async fn mesh_identity_set_label( // ---- self-update (AllMyStuff's own updater, not the daemon's) ---------- +// ---- CEC service ------------------------------------------------------ +// +// The optional Critical Error Computing account and the two services it +// unlocks: Ask-for-Help (Concierge) and the Private Line. State lives in +// `cec::Cec`; the front-end orchestrates the mesh side (join the CEC network, +// approve the service node, mint the help room) on top of these. + +#[tauri::command] +fn cec_state(cec: State<'_, cec::Cec>) -> Value { + cec.snapshot() +} + +#[tauri::command] +fn cec_set_backend_url(cec: State<'_, cec::Cec>, url: String) -> Result { + cec.set_backend_url(url) +} + +#[tauri::command] +async fn cec_start_sign_in(cec: State<'_, cec::Cec>, email: String) -> Result { + cec.start_sign_in(email).await +} + +#[tauri::command] +async fn cec_verify_sign_in( + cec: State<'_, cec::Cec>, + email: String, + code: String, + device_id: Option, + device_label: Option, +) -> Result { + cec.verify_sign_in(email, code, device_id, device_label).await +} + +#[tauri::command] +async fn cec_refresh(cec: State<'_, cec::Cec>) -> Result { + cec.refresh().await +} + +#[tauri::command] +async fn cec_sign_out(cec: State<'_, cec::Cec>) -> Result { + cec.sign_out().await +} + +#[tauri::command] +async fn cec_provision_mesh(cec: State<'_, cec::Cec>, device_id: String) -> Result { + cec.provision_mesh(device_id).await +} + +#[tauri::command] +async fn cec_rent_private_line( + cec: State<'_, cec::Cec>, + label: Option, +) -> Result { + cec.rent_private_line(label).await +} + +#[tauri::command] +async fn cec_list_private_lines(cec: State<'_, cec::Cec>) -> Result { + cec.list_private_lines().await +} + +#[tauri::command] +async fn cec_cancel_private_line(cec: State<'_, cec::Cec>, id: String) -> Result { + cec.cancel_private_line(id).await +} + +#[tauri::command] +async fn cec_ask_for_help( + cec: State<'_, cec::Cec>, + network_id: String, + room_id: String, + device_id: String, + topic: Option, +) -> Result { + cec.ask_for_help(network_id, room_id, device_id, topic).await +} + +#[tauri::command] +async fn cec_help_status(cec: State<'_, cec::Cec>, id: String) -> Result { + cec.help_status(id).await +} + +#[tauri::command] +async fn cec_cancel_help(cec: State<'_, cec::Cec>, id: String) -> Result { + cec.cancel_help(id).await +} + #[tauri::command] async fn update_status() -> Result { serde_json::to_value(allmystuff_updater::status().map_err(|e| e.to_string())?) @@ -1002,6 +1090,7 @@ fn main() { daemon_child: Mutex::new(None), disabled_networks: networks_store::DisabledNetworks::load(), }) + .manage(cec::Cec::load()) .invoke_handler(tauri::generate_handler![ scan_self, scan_full, @@ -1065,6 +1154,19 @@ fn main() { mesh_roster_remove, mesh_roster_list, mesh_identity_set_label, + cec_state, + cec_set_backend_url, + cec_start_sign_in, + cec_verify_sign_in, + cec_refresh, + cec_sign_out, + cec_provision_mesh, + cec_rent_private_line, + cec_list_private_lines, + cec_cancel_private_line, + cec_ask_for_help, + cec_help_status, + cec_cancel_help, update_status, update_check, update_apply, diff --git a/gui/src/cec.ts b/gui/src/cec.ts new file mode 100644 index 00000000..10693852 --- /dev/null +++ b/gui/src/cec.ts @@ -0,0 +1,184 @@ +// The front-end client for the CEC (Critical Error Computing) service — the +// optional account behind the two advertised services, Ask-for-Help and the +// Private Line. Thin wrappers over the Tauri `cec_*` commands; each returns +// `null` off the desktop backend (a plain web preview), so the store can fall +// back to a believable demo, exactly like the rest of `tauri.ts`. +// +// The conventions here mirror `crates/allmystuff-cec/src/convention.rs` — keep +// the two in step. + +import { isTauri } from "./tauri"; + +// --- contract types (mirror crates/allmystuff-cec/src/model.rs) ------------ + +export type AccountRole = "customer" | "agent"; + +export interface CecAccount { + id: string; + email: string; + display_name: string; + roles: AccountRole[]; + device_ids: string[]; +} + +export type ConciergeTier = "pay_as_you_go" | "priority" | "looked_after"; + +export interface CecEntitlements { + hardware: boolean; + private_line: boolean; + concierge?: ConciergeTier | null; +} + +export interface CecTurn { + url: string; + username: string; + credential: string; +} + +export interface CecVenue { + url?: string | null; + signaling: string[]; + stun: string[]; + turn: CecTurn[]; +} + +export interface CecProvision { + network_id: string; + label: string; + venue: CecVenue; + cec_service_node_id: string; + auto_approve: boolean; +} + +export type SubscriptionStatus = "active" | "cancelled" | "past_due"; + +export interface CecPrivateLine { + id: string; + label: string; + status: SubscriptionStatus; + venue: CecVenue; + monthly_price_cents: number; +} + +export type HelpStatus = "queued" | "assigned" | "connected" | "ended" | "cancelled"; + +export interface CecHelpSession { + id: string; + status: HelpStatus; + network_id: string; + room_id: string; + cec_service_node_id: string; + customer_device_id: string; + customer_label: string; + topic?: string | null; + agent_label?: string | null; + created_at: number; +} + +/** The `{ backend_url, signed_in, account, entitlements, provision }` blob the + * Tauri `cec_state` command returns. */ +export interface CecSnapshot { + backend_url: string; + signed_in: boolean; + account: CecAccount | null; + entitlements: CecEntitlements; + provision: CecProvision | null; +} + +export interface SignInStartResult { + sent: boolean; + masked_email: string; +} + +// --- product copy (verbatim from allmystuff.works/service) ----------------- + +export const CONCIERGE_TIERS: Record = { + pay_as_you_go: { + label: "Pay as you go", + price: "$25 / 15 min", + blurb: "One rate, billed by the quarter hour. No monthly.", + }, + priority: { + label: "Priority", + price: "$19 / mo", + blurb: "Priority queue, 30 minutes included, $20 / 15 min after.", + }, + looked_after: { + label: "Looked after", + price: "$69 / mo", + blurb: "Front of the queue, 90 minutes included, scheduled check-ins.", + }, +}; + +export const PRIVATE_LINE_PRICE = "$10 / month"; + +// --- conventions (mirror convention.rs) ------------------------------------ + +export const CEC_NETWORK_PREFIX = "cec-customer-"; +export const CEC_NETWORK_LABEL = "CEC"; +export const CEC_SERVICE_LABEL = "CEC Service"; +const CEC_ROOM_MARKER = "cec-"; + +export function isCecNetwork(networkId: string | null | undefined): boolean { + return !!networkId && networkId.startsWith(CEC_NETWORK_PREFIX); +} + +/** Whether a room id is a CEC help room (minted by `helpRoomId`). */ +export function isHelpRoom(roomId: string): boolean { + const idx = roomId.lastIndexOf(":"); + return idx >= 0 && roomId.slice(idx + 1).startsWith(CEC_ROOM_MARKER); +} + +/** Mint a help room id hosted by `host` (a canonical node id). The `cec-` + * marker makes it recognisable as a Concierge session on either side. */ +export function helpRoomId(host: string): string { + const nonce = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; + return `room:${host}:${CEC_ROOM_MARKER}${nonce}`; +} + +/** A human label for a Concierge tier. */ +export function conciergeLabel(tier: ConciergeTier | null | undefined): string | null { + return tier ? CONCIERGE_TIERS[tier].label : null; +} + +/** The two product decisions, computed the same way as the Rust side. */ +export function wantsCecMesh(e: CecEntitlements | null | undefined): boolean { + return !!e && (e.hardware || e.private_line || !!e.concierge); +} +export function canAskForHelp(e: CecEntitlements | null | undefined): boolean { + return !!e && !!e.concierge; +} + +// --- Tauri command wrappers ------------------------------------------------ + +async function call(cmd: string, args?: Record): Promise { + if (!isTauri()) return null; + const { invoke } = await import("@tauri-apps/api/core"); + return (await invoke(cmd, args)) as T; +} + +export const cecState = () => call("cec_state"); +export const cecSetBackendUrl = (url: string) => call("cec_set_backend_url", { url }); +export const cecStartSignIn = (email: string) => call("cec_start_sign_in", { email }); +export const cecVerifySignIn = ( + email: string, + code: string, + deviceId?: string, + deviceLabel?: string, +) => call("cec_verify_sign_in", { email, code, deviceId, deviceLabel }); +export const cecRefresh = () => call("cec_refresh"); +export const cecSignOut = () => call("cec_sign_out"); +export const cecProvisionMesh = (deviceId: string) => + call("cec_provision_mesh", { deviceId }); +export const cecRentPrivateLine = (label?: string) => + call("cec_rent_private_line", { label }); +export const cecListPrivateLines = () => call("cec_list_private_lines"); +export const cecCancelPrivateLine = (id: string) => call("cec_cancel_private_line", { id }); +export const cecAskForHelp = ( + networkId: string, + roomId: string, + deviceId: string, + topic?: string, +) => call("cec_ask_for_help", { networkId, roomId, deviceId, topic }); +export const cecHelpStatus = (id: string) => call("cec_help_status", { id }); +export const cecCancelHelp = (id: string) => call<{ ok: boolean }>("cec_cancel_help", { id }); diff --git a/gui/src/store.svelte.ts b/gui/src/store.svelte.ts index f7051a10..c94a757a 100644 --- a/gui/src/store.svelte.ts +++ b/gui/src/store.svelte.ts @@ -32,6 +32,8 @@ import { type Venue, } from "./venues"; import { fetchVenueServers } from "./venue-settings"; +import * as cecApi from "./cec"; +import type { CecSnapshot, CecHelpSession, CecPrivateLine } from "./cec"; import { canonicalNetworkId, generateNetworkPhrase } from "./network-phrase"; import { buildNetworkConfig, @@ -155,7 +157,7 @@ import { } from "./types"; /** Which pane the settings panel is showing. */ -export type SettingsTab = "networks" | "venues" | "updates" | "fleet" | "sharing"; +export type SettingsTab = "networks" | "venues" | "account" | "updates" | "fleet" | "sharing"; /** Sub-pane within the Networks settings tab (MyOwnLLM-style sub-tabs). */ export type NetworksSubtab = "status" | "servers" | "devices"; @@ -759,6 +761,7 @@ class AppStore { await onNodeSites((e) => this.applyNodeSites(e)); await this.loadUpdateStatus(); await this.loadDisabledNetworks(); + await this.loadCec(); this.startMeshPolling(); await onSubscription((s) => { const live = s.status === "live"; @@ -772,6 +775,7 @@ class AppStore { void this.loadOwnedFleet(); void this.loadSites(); void this.loadDisabledNetworks(); + void this.loadCec(); } this.backendConnected = live; }); @@ -4699,6 +4703,352 @@ class AppStore { this.toasts = this.toasts.filter((t) => t.id !== id); }, 3200); } + + // =================================================================== + // CEC service — the optional Critical Error Computing account and the + // two services it unlocks: Ask-for-Help (Concierge) and the Private + // Line. The HTTP side lives in the Tauri backend (`cec.rs` → + // `allmystuff-cec`); this orchestrates the mesh side on top of the + // existing room / network / venue verbs. Everything degrades to a + // believable demo off the desktop backend (web preview). + // =================================================================== + + cec = $state({ + backend_url: "https://api.allmystuff.works", + signed_in: false, + account: null, + entitlements: { hardware: false, private_line: false, concierge: null }, + provision: null, + }); + /** A sign-in code has been requested and we're waiting for it. */ + cecCodeSent = $state(false); + cecBusy = $state(false); + cecPrivateLines = $state([]); + /** The help session the user is currently in (or just opened), if any. */ + activeHelp = $state(null); + private helpPoll: ReturnType | null = null; + + get cecSignedIn(): boolean { + return this.cec.signed_in; + } + /** Ask-for-Help is live only with an account *and* an active Concierge plan. */ + get cecCanAskForHelp(): boolean { + return this.cec.signed_in && cecApi.canAskForHelp(this.cec.entitlements); + } + get cecConciergeTier(): string | null { + return cecApi.conciergeLabel(this.cec.entitlements.concierge); + } + /** The canonical id of the single CEC Service node, once a mesh exists. */ + get cecServiceNodeId(): string | null { + const id = this.cec.provision?.cec_service_node_id; + return id ? canonicalNodeId(id) : null; + } + isCecNetworkId(networkId: string | null | undefined): boolean { + return cecApi.isCecNetwork(networkId ?? undefined); + } + /** "CEC Service" for the one service node, else null — so the graph shows a + * single CEC node rather than our plumbing. */ + cecNodeLabel(nodeId: string): string | null { + const svc = this.cecServiceNodeId; + return svc && sameMachine(nodeId, svc) ? cecApi.CEC_SERVICE_LABEL : null; + } + + async loadCec() { + try { + const s = await cecApi.cecState(); + if (s) this.cec = s; + } catch { + /* a missing/old backend leaves the signed-out default in place */ + } + if (this.cec.signed_in) void this.cecLoadLines(); + } + + async cecSetBackend(url: string) { + const s = await cecApi.cecSetBackendUrl(url); + this.cec = s ?? { + ...this.cec, + backend_url: url.trim().replace(/\/+$/, ""), + signed_in: false, + account: null, + provision: null, + }; + this.toast("ok", "Saved the CEC service address"); + } + + async cecBeginSignIn(email: string) { + const e = email.trim(); + if (!e.includes("@")) { + this.toast("warn", "Enter a valid email address"); + return; + } + this.cecBusy = true; + try { + const r = await cecApi.cecStartSignIn(e); + this.cecCodeSent = true; + this.toast("ok", r ? `Code sent to ${r.masked_email}` : "Demo mode — enter any 6-digit code"); + } catch (err) { + this.toast("warn", `Couldn't send a code: ${errMsg(err)}`); + } finally { + this.cecBusy = false; + } + } + + async cecVerifyCode(email: string, code: string) { + this.cecBusy = true; + try { + const me = isTauri() ? canonicalNodeId(this.localId) : "demo-device"; + const s = await cecApi.cecVerifySignIn(email.trim(), code.trim(), me, undefined); + this.cec = s ?? { + ...this.cec, + signed_in: true, + account: { + id: "acct_demo", + email: email.trim(), + display_name: email.trim().split("@")[0], + roles: ["customer"], + device_ids: [me], + }, + entitlements: { hardware: false, private_line: false, concierge: "pay_as_you_go" }, + }; + this.cecCodeSent = false; + this.toast("ok", `Signed in to CEC as ${this.cec.account?.email ?? email}`); + if (cecApi.wantsCecMesh(this.cec.entitlements)) await this.cecProvision(); + void this.cecLoadLines(); + } catch (err) { + this.toast("warn", `Sign-in failed: ${errMsg(err)}`); + } finally { + this.cecBusy = false; + } + } + + async cecRefreshAccount() { + try { + const s = await cecApi.cecRefresh(); + if (s) { + this.cec = s; + void this.cecLoadLines(); + } + } catch (err) { + this.toast("warn", `Couldn't refresh your account: ${errMsg(err)}`); + } + } + + async cecLogOut() { + const s = await cecApi.cecSignOut(); + this.cec = s ?? { + backend_url: this.cec.backend_url, + signed_in: false, + account: null, + entitlements: { hardware: false, private_line: false, concierge: null }, + provision: null, + }; + this.cecPrivateLines = []; + this.cecStopHelpPoll(); + this.activeHelp = null; + this.toast("ok", "Signed out of CEC"); + } + + /** Stand up (or refresh) the isolated `cec-customer-` mesh: join the + * network with auto-approve, add its venue, and pre-trust the single CEC + * Service node — the only non-customer peer that ever appears on it. */ + async cecProvision() { + const me = canonicalNodeId(this.localId); + try { + const prov = await cecApi.cecProvisionMesh(me); + if (!prov) { + // Demo: a stand-in descriptor so the UI can show the CEC node. + this.cec.provision = { + network_id: "cec-customer-demo", + label: cecApi.CEC_NETWORK_LABEL, + venue: { url: null, signaling: [], stun: [], turn: [] }, + cec_service_node_id: "cecservice-demo", + auto_approve: true, + }; + return; + } + this.cec.provision = prov; + const venueId = this.cecEnsureVenue(prov.label || cecApi.CEC_NETWORK_LABEL, prov.venue); + // Join the network (ignore "already joined" on a re-provision). + try { + await meshNetworkAdd( + buildNetworkConfig({ + networkId: prov.network_id, + label: cecApi.CEC_NETWORK_LABEL, + autoApprove: true, + signaling: prov.venue.signaling, + stun: prov.venue.stun, + turn: prov.venue.turn, + }), + ); + } catch { + /* already on this network — fine, keep going */ + } + this.networkVenues[prov.network_id] = [venueId]; + this.persistNetworkVenues(); + try { + await meshRosterApprove( + prov.network_id, + canonicalNodeId(prov.cec_service_node_id), + cecApi.CEC_SERVICE_LABEL, + ); + } catch { + /* the auto-approve network will admit it regardless */ + } + await this.refreshNetworks(); + } catch (err) { + this.toast("warn", `Couldn't set up your CEC connection: ${errMsg(err)}`); + } + } + + /** Find or create the local venue record for a CEC venue (so it shows in the + * Venues pane and can be re-fetched), returning its id. */ + private cecEnsureVenue( + label: string, + v: { url?: string | null; signaling: string[]; stun: string[]; turn: { url: string; username: string; credential: string }[] }, + ): string { + const url = v.url ?? undefined; + const existing = this.venues.find((x) => (url && x.url === url) || (!url && x.label === label)); + if (existing) return existing.id; + const venue: Venue = { + id: newVenueId(), + label, + ...(url ? { url } : {}), + signaling: [...v.signaling], + stun: [...v.stun], + turn: v.turn.map((t) => ({ url: t.url, username: t.username, credential: t.credential })), + }; + this.venues.push(venue); + saveVenues(this.venues); + return venue.id; + } + + async cecRentLine(label?: string) { + this.cecBusy = true; + try { + const pl = await cecApi.cecRentPrivateLine(label); + if (pl) { + this.cecEnsureVenue(pl.label, pl.venue); + this.cec.entitlements.private_line = true; + await this.cecLoadLines(); + this.toast( + "ok", + `Your Private Line “${pl.label}” is ready — assign it to a network in Venues`, + ); + } else { + this.toast("info", "Renting a Private Line needs the desktop app and a CEC account"); + } + } catch (err) { + this.toast("warn", `Couldn't rent a Private Line: ${errMsg(err)}`); + } finally { + this.cecBusy = false; + } + } + + async cecLoadLines() { + try { + const lines = await cecApi.cecListPrivateLines(); + if (lines) this.cecPrivateLines = lines; + } catch { + /* signed out / offline — keep whatever we have */ + } + } + + async cecCancelLine(id: string) { + const s = await cecApi.cecCancelPrivateLine(id); + if (s) this.cec = s; + await this.cecLoadLines(); + this.toast("ok", "Private Line cancelled"); + } + + /** The Ask-for-Help button. Opens a host-side help room holding just this + * device and the single CEC Service node, tells the backend (which exposes + * it to online agents), and pops the call window. */ + async cecAskHelp(topic?: string) { + if (!this.cecSignedIn) { + this.toast("info", "Create a free CEC account to ask for help"); + this.openSettings("account"); + return; + } + if (!cecApi.canAskForHelp(this.cec.entitlements)) { + this.toast("info", "Ask-for-Help needs an active Concierge plan"); + this.openSettings("account"); + return; + } + this.cecBusy = true; + try { + if (!this.cec.provision) await this.cecProvision(); + const prov = this.cec.provision; + if (!prov) { + this.toast("warn", "Couldn't reach your CEC connection"); + return; + } + const me = canonicalNodeId(this.localId); + const cecNode = canonicalNodeId(prov.cec_service_node_id); + const roomId = cecApi.helpRoomId(me); + // Host-side help room: just you and the one CEC Service node. + const room: VirtualRoom = { + id: roomId, + name: cecApi.CEC_SERVICE_LABEL, + members: [me, cecNode], + owner: me, + access: "invite", + }; + this.rooms.push(room); + this.saveRooms(); + this.broadcastRoom(room, this.inviteMessage(room)); + const session = await cecApi.cecAskForHelp(prov.network_id, roomId, me, topic); + this.activeHelp = session ?? { + id: "help_demo", + status: "assigned", + network_id: prov.network_id, + room_id: roomId, + cec_service_node_id: prov.cec_service_node_id, + customer_device_id: me, + customer_label: this.cec.account?.display_name ?? "you", + topic: topic ?? null, + agent_label: "Sam @ CEC", + created_at: Date.now() / 1000, + }; + this.toast("ok", "Connecting you to CEC…"); + void openRoomWindow(roomId); + if (session) this.cecStartHelpPoll(session.id); + } catch (err) { + this.toast("warn", `Couldn't ask for help: ${errMsg(err)}`); + } finally { + this.cecBusy = false; + } + } + + private cecStartHelpPoll(id: string) { + this.cecStopHelpPoll(); + if (!isTauri()) return; + this.helpPoll = setInterval(() => { + void cecApi + .cecHelpStatus(id) + .then((s) => { + if (!s) return; + this.activeHelp = s; + if (s.status === "ended" || s.status === "cancelled") this.cecStopHelpPoll(); + }) + .catch(() => { + /* transient — try again next tick */ + }); + }, 3000); + } + + private cecStopHelpPoll() { + if (this.helpPoll) { + clearInterval(this.helpPoll); + this.helpPoll = null; + } + } + + async cecEndHelp() { + const h = this.activeHelp; + this.cecStopHelpPoll(); + this.activeHelp = null; + if (h && !h.id.startsWith("help_demo")) await cecApi.cecCancelHelp(h.id); + } } function requestToGrant(req: GrantRequest): Grant { diff --git a/gui/src/ui/App.svelte b/gui/src/ui/App.svelte index 430e710b..4c977f73 100644 --- a/gui/src/ui/App.svelte +++ b/gui/src/ui/App.svelte @@ -18,6 +18,7 @@ import RoomHost from "./RoomHost.svelte"; import RoomPanel from "./RoomPanel.svelte"; import SettingsPanel from "./SettingsPanel.svelte"; + import AskForHelpButton from "./AskForHelpButton.svelte"; import ApprovalsPopup from "./ApprovalsPopup.svelte"; import ClaimSheet from "./ClaimSheet.svelte"; import ShareSheet from "./ShareSheet.svelte"; @@ -173,6 +174,9 @@ re-join from the parked config, a clean transport restart for when a network goes quiet. (Scanning *this* machine's hardware now lives in its device drawer, above "Its stuff".) --> + + + + diff --git a/gui/src/ui/SettingsPanel.svelte b/gui/src/ui/SettingsPanel.svelte index 172431a3..f2934f95 100644 --- a/gui/src/ui/SettingsPanel.svelte +++ b/gui/src/ui/SettingsPanel.svelte @@ -9,10 +9,12 @@ import UpdatesSection from "./settings/UpdatesSection.svelte"; import FleetSection from "./settings/FleetSection.svelte"; import SharingSection from "./settings/SharingSection.svelte"; + import AccountSection from "./settings/AccountSection.svelte"; const tabs: { id: SettingsTab; label: string; icon: string }[] = [ { id: "networks", label: "Meshes", icon: "🌐" }, { id: "venues", label: "Venues", icon: "📡" }, + { id: "account", label: "Account", icon: "🆘" }, { id: "fleet", label: "Fleet", icon: "🔗" }, { id: "sharing", label: "Sharing", icon: "🤝" }, { id: "updates", label: "Updates", icon: "⬆️" }, @@ -25,6 +27,7 @@ app.settingsTab = tab; if (tab === "updates") void app.loadUpdateStatus(); if (tab === "fleet") void app.loadOwnedFleet(); + if (tab === "account") void app.loadCec(); } @@ -53,6 +56,8 @@ {:else if app.settingsTab === "venues"} + {:else if app.settingsTab === "account"} + {:else if app.settingsTab === "fleet"} {:else if app.settingsTab === "sharing"} diff --git a/gui/src/ui/settings/AccountSection.svelte b/gui/src/ui/settings/AccountSection.svelte new file mode 100644 index 00000000..c4a40a2b --- /dev/null +++ b/gui/src/ui/settings/AccountSection.svelte @@ -0,0 +1,329 @@ + + +
+

Account & service

+

+ Optional. The free app needs no account — this unlocks a real CEC technician + one tap away (Concierge) and a Private Line of your own. A + Critical Error Computing service. +

+ + {#if !cec.signed_in} + +
+
Create or sign in to your account
+

+ Enter your email and we'll send a one-time code. Your account is tied to + this device's mesh identity — no password to forget. +

+ {#if !app.cecCodeSent} +
+ e.key === "Enter" && app.cecBeginSignIn(email)} + /> + +
+ {:else} +
+ e.key === "Enter" && app.cecVerifyCode(email, code)} + /> + +
+

Sent a code to {email}.

+ {/if} +
+ {:else} + +
+
+
{cec.account?.display_name} · {cec.account?.email}
+
+ {#if ent.concierge} + Concierge · {tierInfo(ent.concierge)?.label} + {/if} + {#if ent.private_line}Private Line{/if} + {#if ent.hardware}CEC hardware{/if} + {#if !ent.concierge && !ent.private_line && !ent.hardware} + Free app · no services yet + {/if} +
+
+
+ + +
+
+ + +
+
Ask for Help — Concierge
+ {#if app.cecCanAskForHelp} +

+ {tierInfo(ent.concierge)?.label} · {tierInfo(ent.concierge)?.price}. + Press the button and a CEC technician picks up in a private session — + it starts with your yes, every action is logged, and you can pull the + plug any time. +

+
+ + +
+ {#if app.activeHelp} +
+ + {#if app.activeHelp.status === "queued"} + Waiting for a technician… + {:else if app.activeHelp.status === "assigned"} + {app.activeHelp.agent_label ?? "A technician"} is connecting… + {:else if app.activeHelp.status === "connected"} + Connected to {app.activeHelp.agent_label ?? "your technician"}. + {:else} + Session {app.activeHelp.status}. + {/if} + +
+ {/if} + {:else} +

+ Concierge is by invitation while we grow the team. Add a plan and the + button lights up — Pay as you go ({CONCIERGE_TIERS.pay_as_you_go.price}), + Priority ({CONCIERGE_TIERS.priority.price}), or Looked after + ({CONCIERGE_TIERS.looked_after.price}). +

+ {/if} +
+ + +
+
Private Line — a venue of your own
+

+ CEC-hosted signaling, STUN and TURN serving only your devices. + {PRIVATE_LINE_PRICE}, cancel anytime. Add one, then assign it to a mesh + in Venues. +

+ {#each app.cecPrivateLines as pl (pl.id)} +
+ {pl.label} · {pl.status} + {#if pl.status === "active"} + + {/if} +
+ {/each} + +
+ + + {#if cec.provision} +
+
Your CEC connection{cec.provision.label}
+
Network{cec.provision.network_id}
+
+ A private mesh just for you and CEC. You see one CEC Service node; + whichever technician helps you connects behind it. +
+
+ {/if} + {/if} + + +
+ + {#if showAdvanced} +

The CEC service address. Point it at a local mock to try the flow offline.

+
+ + +
+ {/if} +
+
+ + From 7e812213cad1c3be312b094e10523723b6338164 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 00:38:15 +0000 Subject: [PATCH 3/4] Document the CEC service architecture Add a CEC section to ARCHITECTURE.md (accounts, the single-service-node CEC mesh, the Ask-for-Help room flow, and how it's testable) and list the three new crates in the crate map. --- ARCHITECTURE.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7a3875b1..aaac080a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -38,7 +38,10 @@ crates/ ├── allmystuff-bridge # Inventory ──► graph Capabilities (+ presence summary) ├── allmystuff-session # live presence + the route offer/accept handshake + media frame types (audio/video/input/terminal/files/clipboard) ├── allmystuff-updater # self-update: release feed, SHA-256 verify, stage-then-apply -└── allmystuff-cli # `allmystuff` (opens the GUI) + scan / capabilities / update +├── allmystuff-cli # `allmystuff` (opens the GUI) + scan / capabilities / update +├── allmystuff-cec # the CEC service client: the typed HTTP contract, a transport-generic client, and an in-memory mock backend +├── allmystuff-cec-mock # `allmystuff-cec-mock` — a runnable reference CEC backend over the mock, for end-to-end testing +└── allmystuff-agent # `allmystuff-agent` — the headless CEC technician tool (sign in, go online, accept help sessions) ``` ### allmystuff-inventory @@ -574,6 +577,76 @@ carries traffic when something happens, never on a timer. The fleet roster (it holds the grouping key) is only ever *handed* to fleet members; presence goes to everyone. +## The CEC service + +**CEC** — Critical Error Computing — is the company behind AllMyStuff, and the +hosted backend behind the two services advertised on allmystuff.works. The free +app needs none of it; an **optional account** unlocks: + +- **Ask-for-Help** (Concierge) — a real CEC technician, one tap away. +- **Private Line** — "a venue of your own": CEC-hosted signaling/STUN/TURN + serving only your devices. + +The app is a *client* of this backend exactly the way it's a client of the +daemon: `allmystuff-cec` mirrors the HTTP contract (the typed Rust DTOs in +`model.rs` **are** the contract — see `crates/allmystuff-cec/CONTRACT.md`) +without owning the server, and the Tauri backend (`gui/src-tauri/src/cec.rs`) +holds the `reqwest`-backed client plus the persisted account state +(`~/.myownmesh/allmystuff-cec.json`). A runnable reference backend +(`allmystuff-cec-mock`) and a headless agent tool (`allmystuff-agent`) round +out the picture, both built on the same client crate. + +### Accounts + +Optional, and passwordless: enter an email, get a one-time code. Identity proof +is the code; the same `verify` call **binds this device's mesh pubkey** to the +account, so the backend knows which nodes to provision a mesh for and pre-trust. +The token lives app-side (never the webview); the entitlements blob +(`{ hardware, private_line, concierge }`) drives the two product decisions — +whether to stand up the CEC mesh (`hardware || private_line || concierge`) and +whether Ask-for-Help is live (`concierge.is_some()`). + +### The CEC mesh — one service node, agents behind it + +When a customer has CEC hardware *or* a service, the app provisions a dedicated, +isolated network — `cec-customer-`, where `` is a stable, opaque +digest of the account id (no PII reaches the mesh). It rides the **existing +networks-as-venues machinery**: the backend's `MeshProvision` carries a venue +(its own signaling/STUN/TURN, plus a live venue-file URL the app adds as an +ordinary **remote venue**) and the bare pubkey of a single **CEC Service** +node. The app joins the network with `auto_approve`, assigns the CEC venue, and +pre-approves that one node in the roster. + +The isolation is the point: because the network id is unique, its signaling +room (`SHA-256(app-id : network-id)`) is too, so the only peers a customer ever +sees there are their own devices and the one CEC Service node. **Individual +agents are never mesh peers** — the backend operates that single node identity +and bridges whichever technician is handling the customer to it. "Agents log in +through the app, but their connections are managed by the backend provider, not +the mesh engine" falls straight out of this: no MyOwnMesh changes were needed. + +### Ask-for-Help — a help room exposed to agents + +Pressing Ask-for-Help (the front-end's `cecAskHelp`) mints a host-side help +room on the CEC network — an ordinary virtual room (`CHANNEL_ROOMS`) with a +`room:{host}:cec-{nonce}` id, holding just this device and the CEC Service node +— then `POST`s it to the backend, which exposes the queued session to online +agents. An agent (via `allmystuff-agent` → the backend) accepts, the backend +joins the room as the CEC Service node, and the customer's existing room call +UI carries the session — screen share, control, voice, chat — over normal +routes. The customer polls `GET /v1/help/{id}` and watches it go +`queued → assigned → connected`. Nothing about the mesh's room plane changed; +the CEC help room is just a recognisably-named one. + +### Testing + +`allmystuff-cec` is fully testable without a network: the `MockBackend` is a +faithful in-memory implementation of the contract, driven through a socket-free +`MockTransport` in unit/integration tests (the customer and agent flows both), +and through a tiny HTTP shim in `allmystuff-cec-mock` for running the real app +and agent against it (`allmystuff-cec-mock --demo`). The mesh side is the app's +usual control-socket client; only the HTTP layer is new. + ## Next milestones - **Storage transport** over the same route pipe that audio, screen, From 55fcd9a998b4545c1a0079aa41e1eae0be687a54 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 00:42:04 +0000 Subject: [PATCH 4/4] Fix CI: rustfmt, clippy lints, and a borrow in absolutize_venue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cargo fmt across the new crates and the GUI Tauri additions (the Rust CI fmt gate). - clippy: use !is_empty() over len()>=1, and construct Config directly instead of field-reassigning a Default. - absolutize_venue: take the url out before reassigning so no borrow of venue.url is alive across the mutation (would have failed the GUI backend build) — and it reads cleaner for clippy. --- crates/allmystuff-agent/src/main.rs | 11 +++++- crates/allmystuff-agent/tests/agent.rs | 9 +++-- crates/allmystuff-cec-mock/src/main.rs | 5 ++- crates/allmystuff-cec/src/client.rs | 50 ++++++++++++++++++------- crates/allmystuff-cec/src/convention.rs | 6 +-- crates/allmystuff-cec/src/mock.rs | 16 +++++--- crates/allmystuff-cec/tests/flows.rs | 7 +--- gui/src-tauri/src/cec.rs | 10 +++-- gui/src-tauri/src/main.rs | 6 ++- 9 files changed, 79 insertions(+), 41 deletions(-) diff --git a/crates/allmystuff-agent/src/main.rs b/crates/allmystuff-agent/src/main.rs index 4319781f..98e81905 100644 --- a/crates/allmystuff-agent/src/main.rs +++ b/crates/allmystuff-agent/src/main.rs @@ -109,7 +109,10 @@ async fn run( } "online" => { let p = client.set_presence(true).await?; - println!("You're online. {}", if p.online { "Available." } else { "" }); + println!( + "You're online. {}", + if p.online { "Available." } else { "" } + ); Ok(()) } "offline" => { @@ -252,7 +255,11 @@ fn io(e: std::io::Error) -> Error { fn describe(e: &Error) -> String { match e { - Error::Api { code: Some(c), message, .. } => format!("{message} ({c})"), + Error::Api { + code: Some(c), + message, + .. + } => format!("{message} ({c})"), _ => e.to_string(), } } diff --git a/crates/allmystuff-agent/tests/agent.rs b/crates/allmystuff-agent/tests/agent.rs index dfa39fc0..a19f276d 100644 --- a/crates/allmystuff-agent/tests/agent.rs +++ b/crates/allmystuff-agent/tests/agent.rs @@ -17,10 +17,11 @@ fn config_round_trips_and_defaults() { assert_eq!(fresh.backend_url, allmystuff_agent::DEFAULT_BACKEND); // Save then reload. - let mut c = Config::default(); - c.backend_url = "http://127.0.0.1:8787".into(); - c.token = Some("tok_abc".into()); - c.email = Some("sam@cec.example".into()); + let c = Config { + backend_url: "http://127.0.0.1:8787".into(), + token: Some("tok_abc".into()), + email: Some("sam@cec.example".into()), + }; c.save(&path).unwrap(); let back = Config::load(&path); diff --git a/crates/allmystuff-cec-mock/src/main.rs b/crates/allmystuff-cec-mock/src/main.rs index 9d70b057..7ee915d3 100644 --- a/crates/allmystuff-cec-mock/src/main.rs +++ b/crates/allmystuff-cec-mock/src/main.rs @@ -114,7 +114,10 @@ async fn serve_one(mut stream: TcpStream, backend: Arc) -> std::io: let mut bearer = None; for line in lines { if let Some(v) = header_value(line, "authorization") { - if let Some(tok) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) { + if let Some(tok) = v + .strip_prefix("Bearer ") + .or_else(|| v.strip_prefix("bearer ")) + { bearer = Some(tok.trim().to_string()); } } diff --git a/crates/allmystuff-cec/src/client.rs b/crates/allmystuff-cec/src/client.rs index 72efc5ee..f45a6aed 100644 --- a/crates/allmystuff-cec/src/client.rs +++ b/crates/allmystuff-cec/src/client.rs @@ -106,7 +106,8 @@ impl CecClient { email: email.to_string(), }) .map_err(enc)?; - self.call(ApiRequest::post("/v1/auth/start").body(body)).await + self.call(ApiRequest::post("/v1/auth/start").body(body)) + .await } /// `POST /v1/auth/verify` — exchange the code for a session and (if given) @@ -126,7 +127,9 @@ impl CecClient { device_label: device_label.map(str::to_string), }) .map_err(enc)?; - let session: Session = self.call(ApiRequest::post("/v1/auth/verify").body(body)).await?; + let session: Session = self + .call(ApiRequest::post("/v1/auth/verify").body(body)) + .await?; self.token = Some(session.token.clone()); Ok(session) } @@ -149,14 +152,19 @@ impl CecClient { } /// `POST /v1/me/device` — bind (or re-label) a mesh device on the account. - pub async fn bind_device(&self, device_id: &str, label: Option<&str>) -> Result { + pub async fn bind_device( + &self, + device_id: &str, + label: Option<&str>, + ) -> Result { self.require_token()?; let body = serde_json::to_value(BindDevice { device_id: device_id.to_string(), label: label.map(str::to_string), }) .map_err(enc)?; - self.call(ApiRequest::post("/v1/me/device").body(body)).await + self.call(ApiRequest::post("/v1/me/device").body(body)) + .await } // --- the CEC mesh ------------------------------------------------------ @@ -166,7 +174,8 @@ impl CecClient { pub async fn provision_mesh(&self, device_id: &str) -> Result { self.require_token()?; let body = serde_json::json!({ "device_id": device_id }); - self.call(ApiRequest::post("/v1/mesh/provision").body(body)).await + self.call(ApiRequest::post("/v1/mesh/provision").body(body)) + .await } // --- Private Line ------------------------------------------------------ @@ -178,7 +187,8 @@ impl CecClient { label: label.map(str::to_string), }) .map_err(enc)?; - self.call(ApiRequest::post("/v1/private-line").body(body)).await + self.call(ApiRequest::post("/v1/private-line").body(body)) + .await } /// `GET /v1/private-line` — the customer's Private Lines. @@ -190,7 +200,8 @@ impl CecClient { /// `DELETE /v1/private-line/{id}` — cancel a Private Line. pub async fn cancel_private_line(&self, id: &str) -> Result<(), Error> { self.require_token()?; - self.call_unit(ApiRequest::delete(format!("/v1/private-line/{id}"))).await + self.call_unit(ApiRequest::delete(format!("/v1/private-line/{id}"))) + .await } // --- Ask-for-Help (customer) ------------------------------------------ @@ -211,7 +222,8 @@ impl CecClient { /// `POST /v1/help/{id}/cancel` — cancel a queued help session. pub async fn cancel_help(&self, id: &str) -> Result<(), Error> { self.require_token()?; - self.call_unit(ApiRequest::post(format!("/v1/help/{id}/cancel"))).await + self.call_unit(ApiRequest::post(format!("/v1/help/{id}/cancel"))) + .await } // --- agent side -------------------------------------------------------- @@ -220,7 +232,8 @@ impl CecClient { pub async fn set_presence(&self, online: bool) -> Result { self.require_token()?; let body = serde_json::to_value(SetPresence { online }).map_err(enc)?; - self.call(ApiRequest::post("/v1/agent/presence").body(body)).await + self.call(ApiRequest::post("/v1/agent/presence").body(body)) + .await } /// `GET /v1/agent/queue` — help sessions waiting for an online agent. @@ -233,19 +246,22 @@ impl CecClient { /// and room to join as the CEC Service node. pub async fn accept_help(&self, id: &str) -> Result { self.require_token()?; - self.call(ApiRequest::post(format!("/v1/agent/help/{id}/accept"))).await + self.call(ApiRequest::post(format!("/v1/agent/help/{id}/accept"))) + .await } /// `POST /v1/agent/help/{id}/decline` — pass on a session. pub async fn decline_help(&self, id: &str) -> Result<(), Error> { self.require_token()?; - self.call_unit(ApiRequest::post(format!("/v1/agent/help/{id}/decline"))).await + self.call_unit(ApiRequest::post(format!("/v1/agent/help/{id}/decline"))) + .await } /// `POST /v1/agent/help/{id}/end` — end a session you're handling. pub async fn end_help(&self, id: &str) -> Result<(), Error> { self.require_token()?; - self.call_unit(ApiRequest::post(format!("/v1/agent/help/{id}/end"))).await + self.call_unit(ApiRequest::post(format!("/v1/agent/help/{id}/end"))) + .await } // --- dev / mock-only --------------------------------------------------- @@ -253,12 +269,18 @@ impl CecClient { /// `POST /v1/dev/grant` — MOCK ONLY. Set up entitlements / agent role. pub async fn dev_grant(&self, grant: &DevGrant) -> Result<(), Error> { let body = serde_json::to_value(grant).map_err(enc)?; - self.call_unit(ApiRequest::post("/v1/dev/grant").body(body)).await + self.call_unit(ApiRequest::post("/v1/dev/grant").body(body)) + .await } /// Issue an arbitrary request — an escape hatch for tooling. Prefer the /// typed methods above. - pub async fn raw(&self, method: Method, path: &str, body: Option) -> Result { + pub async fn raw( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result { let mut req = ApiRequest::new(method, path); req.body = body; let resp = { diff --git a/crates/allmystuff-cec/src/convention.rs b/crates/allmystuff-cec/src/convention.rs index 169f3360..395bfae7 100644 --- a/crates/allmystuff-cec/src/convention.rs +++ b/crates/allmystuff-cec/src/convention.rs @@ -95,9 +95,9 @@ mod tests { assert_eq!(a, b); assert!(a.starts_with(CEC_NETWORK_PREFIX)); assert!(is_cec_network(&a)); - assert!(a.chars().all(|c| c.is_ascii_lowercase() - || c.is_ascii_digit() - || c == '-')); + assert!(a + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')); assert_ne!(a, customer_network_id("acct_456")); } diff --git a/crates/allmystuff-cec/src/mock.rs b/crates/allmystuff-cec/src/mock.rs index 84964c26..2a8d4c90 100644 --- a/crates/allmystuff-cec/src/mock.rs +++ b/crates/allmystuff-cec/src/mock.rs @@ -194,7 +194,11 @@ impl MockBackend { ok(json!({ "ok": true, "service": "allmystuff-cec-mock" })) } - _ => err(404, "not_found", &format!("no route for {} {}", req.method.as_str(), req.path)), + _ => err( + 404, + "not_found", + &format!("no route for {} {}", req.method.as_str(), req.path), + ), } } @@ -458,10 +462,7 @@ impl MockBackend { }; // The venue is whatever serves the customer's CEC network. Re-derive // from the venue token convention so we don't have to store a back-ref. - let venue = cec_venue_spec( - &cec_mesh_venue_token(&session.network_id), - "service", - ); + let venue = cec_venue_spec(&cec_mesh_venue_token(&session.network_id), "service"); ok(serde_json::to_value(AgentAssignment { session, venue }).unwrap()) } @@ -654,7 +655,10 @@ fn gen_code(next: &mut u64) -> String { static SALT: AtomicU64 = AtomicU64::new(0); let n = next_id(next); let salt = SALT.fetch_add(1, Ordering::Relaxed); - let v = (now_secs().wrapping_add(n).wrapping_add(salt.wrapping_mul(7))) % 1_000_000; + let v = (now_secs() + .wrapping_add(n) + .wrapping_add(salt.wrapping_mul(7))) + % 1_000_000; format!("{v:06}") } diff --git a/crates/allmystuff-cec/tests/flows.rs b/crates/allmystuff-cec/tests/flows.rs index 5612c2f2..d007c85b 100644 --- a/crates/allmystuff-cec/tests/flows.rs +++ b/crates/allmystuff-cec/tests/flows.rs @@ -62,12 +62,9 @@ async fn customer_signs_in_provisions_mesh_and_asks_for_help() { // The venue file is fetchable (this is the remote-venue the app loads). let venue_path = prov.venue.url.unwrap(); - let file = client - .raw(Method::Get, &venue_path, None) - .await - .unwrap(); + let file = client.raw(Method::Get, &venue_path, None).await.unwrap(); assert_eq!(file["kind"], VenueFile::KIND); - assert!(file["signaling_servers"].as_array().unwrap().len() >= 1); + assert!(!file["signaling_servers"].as_array().unwrap().is_empty()); // Ask for help: mint a help room hosted by this device, then open a session. let room_id = convention::help_room_id("dev-casey-1"); diff --git a/gui/src-tauri/src/cec.rs b/gui/src-tauri/src/cec.rs index 377d9075..1e092f44 100644 --- a/gui/src-tauri/src/cec.rs +++ b/gui/src-tauri/src/cec.rs @@ -307,10 +307,12 @@ fn snapshot_of(st: &Persisted) -> Value { /// Turn a relative venue URL (`/v1/venues/…`) into an absolute one against the /// backend, so the front-end (and the daemon, indirectly) can fetch it. fn absolutize_venue(base: &str, venue: &mut VenueSpec) { - if let Some(url) = &venue.url { - if url.starts_with('/') { - venue.url = Some(format!("{}{}", base.trim_end_matches('/'), url)); - } + // Take the url out before reassigning so no borrow of `venue.url` is alive + // across the mutation (and it reads cleanly for clippy). + let needs_abs = venue.url.as_deref().is_some_and(|u| u.starts_with('/')); + if needs_abs { + let url = venue.url.take().unwrap_or_default(); + venue.url = Some(format!("{}{}", base.trim_end_matches('/'), url)); } } diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 292dcef6..8f2eeb0a 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -953,7 +953,8 @@ async fn cec_verify_sign_in( device_id: Option, device_label: Option, ) -> Result { - cec.verify_sign_in(email, code, device_id, device_label).await + cec.verify_sign_in(email, code, device_id, device_label) + .await } #[tauri::command] @@ -997,7 +998,8 @@ async fn cec_ask_for_help( device_id: String, topic: Option, ) -> Result { - cec.ask_for_help(network_id, room_id, device_id, topic).await + cec.ask_for_help(network_id, room_id, device_id, topic) + .await } #[tauri::command]