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, 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..98e81905 --- /dev/null +++ b/crates/allmystuff-agent/src/main.rs @@ -0,0 +1,284 @@ +//! `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..a19f276d --- /dev/null +++ b/crates/allmystuff-agent/tests/agent.rs @@ -0,0 +1,95 @@ +//! 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 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); + 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..7ee915d3 --- /dev/null +++ b/crates/allmystuff-cec-mock/src/main.rs @@ -0,0 +1,350 @@ +//! `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..f45a6aed --- /dev/null +++ b/crates/allmystuff-cec/src/client.rs @@ -0,0 +1,326 @@ +//! 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..395bfae7 --- /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..2a8d4c90 --- /dev/null +++ b/crates/allmystuff-cec/src/mock.rs @@ -0,0 +1,717 @@ +//! 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..d007c85b --- /dev/null +++ b/crates/allmystuff-cec/tests/flows.rs @@ -0,0 +1,240 @@ +//! 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().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"); + 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()); +} 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..1e092f44 --- /dev/null +++ b/gui/src-tauri/src/cec.rs @@ -0,0 +1,350 @@ +//! 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) { + // 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)); + } +} + +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..8f2eeb0a 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,95 @@ 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 +1092,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 +1156,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} +
+
+ +