From 01b1e17ea25c76b9296b50553d009f2717039e4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 11:04:49 +0000 Subject: [PATCH] Add the CEC diagnostic-purchase flow: protocol, node relay, technician UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A technician can now require the $50 diagnostic session at any point in a help call — before answering (straight from the help queue), mid-session, or after disconnecting (the request control on a stored machine). It is optional, technician-triggered only, and no payment data ever rides the mesh: the customer's app opens its own built-in checkout page in their browser, and the technician confirms against the order that lands in the store (it carries the customer's support number), person checking person, no webhooks, no server. allmystuff-cec-protocol: - New ControlMessage::Purchase family — Request (ids, the asker's agent_name, display strings), customer Status beats (seen / opened / claimed / declined), and the technician closes (Confirm / Cancel) — with the same internal-tag + Unknown-catch-all skew discipline as the rest of the wire: an older peer drops the whole envelope, and no Seen coming back is the technician's cue to handle payment by phone. session_id is empty for asks made outside a session. - The ask carries the connect prompt's trust, deliberately: no grant is needed to ASK (a technician quotes the work before the customer has ever let them in), the prompt names the asker exactly like the connect prompt does, and the customer verifies by phone and declines freely. - DIAGNOSTIC_BUY_URL / _ITEM / _PRICE constants: the checkout URL is a client-side constant by design, never wire-supplied, so no peer can steer a customer's browser. Wire form pinned by tests (20 total). node: - cec.rs: PurchaseFlow bookkeeping (in-memory, one live ask per peer — a re-ask supersedes; confirmed/cancelled terminal), session->peer links so a live session is attached when one exists, agent_name_for() so a prompt prefers the grant's name (the one the customer actually approved) over the wire's claim. - mesh.rs: cec_purchase_request reaches the customer in every shape a help call takes — a known/connected machine directly, a never-dialed one by joining its number room, discovering, and connecting exactly like a dial — then re-sends the Request every 2s until the customer's app answers (their `seen` is the ack; the customer re-asserts current state on a duplicate), the same delivery discipline as the connect handshake. cec_purchase_status / _confirm / _cancel + cec_purchases; only the ask's own peer can move it; a session End cancels its ask. - node_control.rs: the five dispatch arms. gui (technician CEC tab): - "Request $50 diagnostic" on help-queue rows (bill before answering) and on Client-meshes rows (works connected or not), with live status from the customer (waiting / on their screen / at the checkout / says paid / declined), Confirm received (after checking the order in the store admin) and Withdraw; toasts on the two beats worth interrupting a phone call for. - cecPurchase* bridge wrappers + cec://purchase listener + re-sync pull. Also refreshes the stale node/ and gui/src-tauri lockfiles (workspace crates 0.2.34 -> 0.2.35 to match the manifests). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MzGQaTFkYtzYR7Hq1jBoGR --- crates/allmystuff-cec-protocol/src/lib.rs | 21 +- crates/allmystuff-cec-protocol/src/wire.rs | 195 ++++++++- gui/src-tauri/src/main.rs | 74 ++++ gui/src/store.svelte.ts | 77 ++++ gui/src/tauri.ts | 88 ++++ gui/src/ui/settings/CecSection.svelte | 189 +++++++++ node/src/cec.rs | 329 ++++++++++++++- node/src/mesh.rs | 449 ++++++++++++++++++++- node/src/node_control.rs | 34 ++ 9 files changed, 1451 insertions(+), 5 deletions(-) diff --git a/crates/allmystuff-cec-protocol/src/lib.rs b/crates/allmystuff-cec-protocol/src/lib.rs index 7ed61d69..cf747b74 100644 --- a/crates/allmystuff-cec-protocol/src/lib.rs +++ b/crates/allmystuff-cec-protocol/src/lib.rs @@ -55,7 +55,10 @@ pub use ids::{ pub use media::{ decode_media_frame, encode_media_frame, MediaFrame, MEDIA_KIND_AUDIO, MEDIA_KIND_VIDEO, }; -pub use wire::{AppControl, ApprovalScope, ConnectControl, ControlMessage, Role, SupportPresence}; +pub use wire::{ + AppControl, ApprovalScope, ConnectControl, ControlMessage, PurchaseControl, PurchaseState, + Role, SupportPresence, +}; /// Prefix the retired per-number rooms carried (`cec-<9 digits>`). Kept /// solely so upgrading nodes can recognise and purge the legacy rooms older @@ -107,5 +110,21 @@ pub const ROLE_TECH_TAG: &str = "cec-tech"; /// Seconds in the "Auto-Approve for 3 hours" window. pub const THREE_HOURS_SECS: u64 = 3 * 60 * 60; +/// The one place a customer is ever sent to pay: the CEC-owned purchase page, +/// which hands off to the store's hosted checkout. The customer's app builds +/// this URL **itself** (appending its own support number / reference — see +/// [`PurchaseControl`](wire::PurchaseControl)); it is deliberately never taken +/// from the wire, so no peer can steer a customer's browser anywhere else. +/// Store domain / product config live behind this page, so they can change +/// without shipping a new app. +pub const DIAGNOSTIC_BUY_URL: &str = "https://support.cec.direct/buy/diagnostic/"; + +/// Display fallbacks for the diagnostic-session ask, used when a +/// [`PurchaseControl::Request`](wire::PurchaseControl::Request) arrives with +/// empty display fields. The checkout page stays authoritative for the charge. +pub const DIAGNOSTIC_ITEM: &str = "CEC Diagnostic Session"; +/// See [`DIAGNOSTIC_ITEM`]. +pub const DIAGNOSTIC_PRICE: &str = "$50"; + /// This build's version string (`CARGO_PKG_VERSION`). pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/allmystuff-cec-protocol/src/wire.rs b/crates/allmystuff-cec-protocol/src/wire.rs index 8ae88b8d..9e1c69c2 100644 --- a/crates/allmystuff-cec-protocol/src/wire.rs +++ b/crates/allmystuff-cec-protocol/src/wire.rs @@ -2,7 +2,8 @@ //! //! Two things ride the mesh: a [`SupportPresence`] beacon (broadcast, so a //! technician can find a customer), and point-to-point [`ControlMessage`]s -//! (the connect-request → approve/deny → end handshake, plus app control). +//! (the connect-request → approve/deny → end handshake, app control, and the +//! mid-session purchase-request handshake). //! //! Every enum is internally-tagged serde with an `Unknown` catch-all and every //! additive field is `#[serde(default)]`, so a newer peer's extra variant or @@ -197,6 +198,101 @@ pub enum AppControl { Unknown, } +/// Where a requested purchase stands, as reported by the customer's app in +/// [`PurchaseControl::Status`]. Display-state only — the *authoritative* record +/// of payment is the store order the technician verifies out-of-band before +/// sending [`PurchaseControl::Confirm`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum PurchaseState { + /// The customer's app displayed the request (also tells the technician the + /// customer's app is new enough to know about purchases at all). + Seen, + /// The customer opened the secure checkout in their browser. + Opened, + /// The customer says they completed the purchase. A *claim*, not proof — + /// the technician verifies the order in the store before confirming. + Claimed, + /// The customer declined to purchase. + Declined, + /// Forward-compat catch-all. + #[serde(other)] + Unknown, +} + +/// The purchase handshake, carried inside [`ControlMessage::Purchase`] on +/// [`CHANNEL_CONTROL`](crate::CHANNEL_CONTROL). +/// +/// This is how a technician asks the customer to complete a purchase (the $50 +/// diagnostic session) — before answering their help call, mid-session, or +/// after disconnecting to quote the work. Deliberate properties: +/// +/// - **Technician-triggered only.** The customer's app never initiates one; it +/// only ever answers a [`Request`](PurchaseControl::Request). +/// - **Same trust bar as the connect prompt.** A Request needs no prior grant +/// — reaching the customer's number-derived room is the discovery gate, the +/// prompt names the asker (`agent_name`, exactly like +/// [`ConnectControl::Request`]), and the customer verifies the name against +/// the technician on the phone and can always decline. When a live grant +/// exists, the customer's node prefers the *grant's* name over the wire's. +/// - **No money moves on the mesh.** The wire carries only *display strings* +/// and state. Payment happens in the customer's own browser on the store's +/// hosted checkout; the checkout URL is a constant built into the customer's +/// app ([`DIAGNOSTIC_BUY_URL`](crate::DIAGNOSTIC_BUY_URL)) — never taken from +/// the wire, so a peer can't steer the customer's browser anywhere else. +/// - **Human confirmation closes the loop.** The technician sees the order +/// arrive in the store (tagged with the customer's support number), then +/// sends [`Confirm`](PurchaseControl::Confirm). No webhooks, no server — +/// the same person-checks-person shape as the agent-name verification. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum PurchaseControl { + /// Technician → customer: "please complete this purchase before we + /// continue." `item`/`price`/`note` are display strings for the customer's + /// prompt (the checkout page stays authoritative for the real charge); a + /// customer app with no defaults of its own may show them verbatim. + /// + /// Re-sent on a short loop until a [`Status`](PurchaseControl::Status) + /// answers (the same delivery discipline as the connect handshake); the + /// customer's node dedupes by `purchase_id`. + Request { + /// Unique id for this ask, minted by the technician's node — lets a + /// withdrawn or re-issued request be told apart from the original. + purchase_id: String, + /// The CEC session this ask belongs to — empty for an ask made outside + /// one (before answering a help call, or after disconnecting). + session_id: String, + /// The asker's Agent Name, for the prompt — same trust as the connect + /// prompt's name: the customer checks it against the person on the + /// phone. Ignored in favour of the grant's name when one is live. + #[serde(default)] + agent_name: String, + /// What's being purchased, e.g. "CEC Diagnostic Session". + #[serde(default)] + item: String, + /// Display price, e.g. "$50". The checkout page is authoritative. + #[serde(default)] + price: String, + /// Optional free-text from the technician, shown under the prompt. + #[serde(default)] + note: String, + }, + /// Customer → technician: where the customer is in the flow. + Status { + purchase_id: String, + state: PurchaseState, + }, + /// Technician → customer: order verified in the store — all set. The + /// customer's prompt turns into a "confirmed, continuing" note. + Confirm { purchase_id: String }, + /// Technician → customer: withdraw the ask (never mind / wrong click / + /// taking payment another way). Dismisses the customer's prompt. + Cancel { purchase_id: String }, + /// Forward-compat: an unrecognised kind decodes here and is ignored. + #[serde(other)] + Unknown, +} + /// The single point-to-point control envelope, dispatched on the outer `t` /// tag. Mirrors AllMyStuff's `ControlMessage` shape, trimmed to what CEC /// Support uses. @@ -207,6 +303,11 @@ pub enum ControlMessage { Connect(ConnectControl), /// App/service control. App(AppControl), + /// The mid-session purchase handshake. An older peer decodes this whole + /// envelope to [`Unknown`](ControlMessage::Unknown) and ignores it — which + /// is why [`PurchaseState::Seen`] exists: no `Seen` back means the other + /// side may be too old to know about purchases. + Purchase(PurchaseControl), /// Forward-compat catch-all. #[serde(other)] Unknown, @@ -284,6 +385,98 @@ mod tests { assert_eq!(cc, ConnectControl::Unknown); let ac: AppControl = serde_json::from_str(r#"{"kind":"reboot_bios"}"#).unwrap(); assert_eq!(ac, AppControl::Unknown); + let pc: PurchaseControl = + serde_json::from_str(r#"{"kind":"refund","purchase_id":"p"}"#).unwrap(); + assert_eq!(pc, PurchaseControl::Unknown); + let ps: PurchaseState = serde_json::from_str(r#"{"kind":"escrowed"}"#).unwrap(); + assert_eq!(ps, PurchaseState::Unknown); + } + + #[test] + fn purchase_round_trips_each_variant() { + let msgs = vec![ + ControlMessage::Purchase(PurchaseControl::Request { + purchase_id: "p1".into(), + session_id: "s1".into(), + agent_name: "Alex at CEC".into(), + item: "CEC Diagnostic Session".into(), + price: "$50".into(), + note: "So we can dig into the blue screens.".into(), + }), + // A sessionless ask — made before answering a help call, or after + // disconnecting. + ControlMessage::Purchase(PurchaseControl::Request { + purchase_id: "p2".into(), + session_id: String::new(), + agent_name: "Alex at CEC".into(), + item: "CEC Diagnostic Session".into(), + price: "$50".into(), + note: String::new(), + }), + ControlMessage::Purchase(PurchaseControl::Status { + purchase_id: "p1".into(), + state: PurchaseState::Seen, + }), + ControlMessage::Purchase(PurchaseControl::Status { + purchase_id: "p1".into(), + state: PurchaseState::Opened, + }), + ControlMessage::Purchase(PurchaseControl::Status { + purchase_id: "p1".into(), + state: PurchaseState::Claimed, + }), + ControlMessage::Purchase(PurchaseControl::Status { + purchase_id: "p1".into(), + state: PurchaseState::Declined, + }), + ControlMessage::Purchase(PurchaseControl::Confirm { + purchase_id: "p1".into(), + }), + ControlMessage::Purchase(PurchaseControl::Cancel { + purchase_id: "p1".into(), + }), + ]; + for m in msgs { + let json = serde_json::to_string(&m).unwrap(); + let back: ControlMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(m, back, "round-trip {json}"); + } + } + + #[test] + fn purchase_request_tolerates_missing_display_fields() { + // An older (or minimal) technician sends only the ids; the display + // strings must default to empty so the customer's app falls back to + // its own built-in "CEC Diagnostic Session — $50" copy. + let pc: PurchaseControl = + serde_json::from_str(r#"{"kind":"request","purchase_id":"p1","session_id":"s1"}"#) + .unwrap(); + assert_eq!( + pc, + PurchaseControl::Request { + purchase_id: "p1".into(), + session_id: "s1".into(), + agent_name: String::new(), + item: String::new(), + price: String::new(), + note: String::new(), + } + ); + } + + #[test] + fn purchase_wire_form_is_tagged() { + // Pin the exact wire shape: outer `t`, inner `kind`, tagged state — + // the JSON contract the node relays and the GUIs consume. + let m = ControlMessage::Purchase(PurchaseControl::Status { + purchase_id: "p1".into(), + state: PurchaseState::Claimed, + }); + let json = serde_json::to_string(&m).unwrap(); + assert_eq!( + json, + r#"{"t":"purchase","kind":"status","purchase_id":"p1","state":{"kind":"claimed"}}"# + ); } #[test] diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 987c758b..ac42a586 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -1386,6 +1386,76 @@ async fn cec_pending(state: State<'_, AppState>) -> Result { .map_err(|e| e.to_string()) } +/// Technician: ask a customer to complete the $50 diagnostic-session +/// purchase — from the help queue before answering, mid-session, or after +/// disconnecting. Optional, technician-triggered only. `number` lets the node +/// reach a machine with no standing connection (it joins the number room like +/// a dial); the session, when one is live, is resolved node-side. Returns the +/// purchase flow; progress arrives on `cec://purchase`. +#[tauri::command] +async fn cec_purchase_request( + state: State<'_, AppState>, + node: String, + number: String, + session_id: String, + item: String, + price: String, + note: String, +) -> Result { + state + .node + .request( + "cec_purchase_request", + json!({ + "node": node, + "number": number, + "session_id": session_id, + "item": item, + "price": price, + "note": note, + }), + ) + .await + .map_err(|e| e.to_string()) +} + +/// Technician: the order landed in the store and checks out — settle the ask +/// and turn the customer's prompt into "confirmed, continuing". +#[tauri::command] +async fn cec_purchase_confirm( + state: State<'_, AppState>, + purchase_id: String, +) -> Result { + state + .node + .request("cec_purchase_confirm", json!({ "purchase_id": purchase_id })) + .await + .map_err(|e| e.to_string()) +} + +/// Technician: withdraw a purchase ask — dismisses the customer's prompt. +#[tauri::command] +async fn cec_purchase_cancel( + state: State<'_, AppState>, + purchase_id: String, +) -> Result { + state + .node + .request("cec_purchase_cancel", json!({ "purchase_id": purchase_id })) + .await + .map_err(|e| e.to_string()) +} + +/// Every tracked purchase ask — the GUI re-sync pull. +#[tauri::command] +async fn cec_purchases(state: State<'_, AppState>) -> Result { + state + .node + .request("cec_purchases", json!({})) + .await + .map_err(|e| e.to_string()) +} + /// Customer: approve a technician at a scope (once / three_hours / forever). #[tauri::command] async fn cec_approve( @@ -2400,6 +2470,10 @@ fn main() { cec_help_watch, cec_forget_number, cec_cancel_dial, + cec_purchase_request, + cec_purchase_confirm, + cec_purchase_cancel, + cec_purchases, forget_node, mesh_status, mesh_identity, diff --git a/gui/src/store.svelte.ts b/gui/src/store.svelte.ts index 5288878d..5c307fe9 100644 --- a/gui/src/store.svelte.ts +++ b/gui/src/store.svelte.ts @@ -177,11 +177,17 @@ import { onCecPeer, onCecSession, onCecGrants, + cecPurchaseRequest, + cecPurchaseConfirm, + cecPurchaseCancel, + cecPurchases, + onCecPurchase, type CecStatus, type CecPending, type CecGrant, type CecPeer, type CecScope, + type CecPurchase, type ServiceActionResult, type ServiceStatus, type SessionSnapshot, @@ -581,6 +587,11 @@ class AppStore { * goes active, the technician's remote-control console opens for them (the * AnyDesk-style "connect and you're in"). Cleared once opened. */ cecAutoOpenNode = $state(null); + /** The latest purchase ask per customer (keyed by the flow's `peer` id) — + * the $50 diagnostic-session flow. Fed by the `cec://purchase` event + + * the `cec_purchases` re-sync pull; a closed ask stays visible as its + * final state until the next one replaces it. */ + cecPurchases = $state>({}); /** The customers currently asking for help on the global help room — * longest-waiting first (the node keeps queue order). Drives the CEC tab's * "Asking for help" section; fed by `cec_help_list` + the `cec://help` @@ -1478,6 +1489,15 @@ class AppStore { await onCecGrants((grants) => { this.cecGrantList = grants; }); + await onCecPurchase((p) => { + this.cecPurchases = { ...this.cecPurchases, [p.peer]: p }; + // The two beats a technician on the phone is actually waiting on. + if (p.state === "claimed") { + this.toast("info", "Customer says the purchase is complete — check the store, then confirm"); + } else if (p.state === "declined") { + this.toast("warn", "The customer declined the purchase"); + } + }); await onCecHelp((e) => { // Technician side of `cec://help`: the node pushes the whole fresh // queue on every membership change. (The `asking` field is the @@ -6906,6 +6926,16 @@ class AppStore { // the CEC tab is enough to start hearing the beacons. const waiting = await cecHelpList(); if (waiting) this.cecHelpWaiting = waiting; + // Live purchase asks — so a reopened window recovers the flow's state. + const purchases = await cecPurchases(); + if (purchases) { + const byPeer: Record = {}; + for (const p of purchases) { + const prior = byPeer[p.peer]; + if (!prior || (p.updated_at ?? 0) >= (prior.updated_at ?? 0)) byPeer[p.peer] = p; + } + this.cecPurchases = byPeer; + } } /** Technician: answer a raised hand — dial the customer's device directly @@ -7126,6 +7156,53 @@ class AppStore { void this.loadCec(); } + /** The latest purchase ask with a customer, by their node id. */ + cecPurchaseFor(node: string): CecPurchase | null { + return (node && this.cecPurchases[node]) || null; + } + + /** Technician: ask a customer to complete the $50 diagnostic-session + * purchase — works from the help queue (before answering them), during a + * session, or after disconnecting; the node re-reaches the machine by its + * number room when nothing is connected. The customer's app opens the + * store's secure checkout; their progress comes back on `cec://purchase`. */ + async requestCecPurchase(who: { node: string; number: string; name?: string }) { + if (!who.node) return; + try { + const flow = await cecPurchaseRequest(who.node, who.number); + if (flow) { + this.cecPurchases = { ...this.cecPurchases, [flow.peer]: flow }; + this.toast( + "ok", + `Asked ${who.name?.trim() || "the customer"} to purchase the diagnostic session`, + ); + } + } catch (e) { + this.toast("warn", `Couldn't send the purchase request: ${errMsg(e)}`); + } + } + + /** Technician: the order is in the store and checks out — settle the ask. + * Verify it in the store admin first; this tells the customer they're set. */ + async confirmCecPurchase(p: CecPurchase) { + try { + const flow = await cecPurchaseConfirm(p.purchase_id); + if (flow) this.cecPurchases = { ...this.cecPurchases, [flow.peer]: flow }; + } catch (e) { + this.toast("warn", `Couldn't confirm — the customer may be offline: ${errMsg(e)}`); + } + } + + /** Technician: withdraw a purchase ask — the customer's prompt comes down. */ + async cancelCecPurchase(p: CecPurchase) { + try { + const flow = await cecPurchaseCancel(p.purchase_id); + if (flow) this.cecPurchases = { ...this.cecPurchases, [flow.peer]: flow }; + } catch (e) { + this.toast("warn", `Couldn't withdraw it: ${errMsg(e)}`); + } + } + /** The per-node gear "Forget this node": drop it from the graph + roster, * tear its session down, end any CEC session. Removes it locally at once so * the graph reacts immediately; the next snapshot confirms. diff --git a/gui/src/tauri.ts b/gui/src/tauri.ts index 89450b27..3e009664 100644 --- a/gui/src/tauri.ts +++ b/gui/src/tauri.ts @@ -1456,6 +1456,94 @@ export async function onCecGrants( ); } +/** Where a mid-session purchase ask stands. `requested` → the customer-reported + * beats (`seen` / `opened` / `claimed` / `declined`) → the technician's close + * (`confirmed` / `cancelled`). */ +export type CecPurchaseState = + | "requested" + | "seen" + | "opened" + | "claimed" + | "declined" + | "confirmed" + | "cancelled"; + +/** One mid-session purchase ask — the $50 diagnostic session (the + * `cec://purchase` event + `cec_purchases` rows). Display strings only: the + * customer pays in their own browser on the store's checkout, and the + * technician confirms against the store order — no payment data rides the + * mesh or this app. */ +export interface CecPurchase { + purchase_id: string; + session_id: string; + /** The other side: the customer (technician side) / the technician + * (customer side). */ + peer: string; + agent_name: string; + item: string; + price: string; + note: string; + state: CecPurchaseState; + updated_at: number; +} + +/** Technician: ask a customer to complete the $50 diagnostic-session + * purchase — from the help queue before answering them, mid-session, or + * after disconnecting. Strictly optional and technician-triggered. Pass the + * machine's support `number` so the node can reach it even with no standing + * connection (it joins the number room exactly like a dial); a live session, + * when one exists, is attached node-side. Throws with the backend's reason + * (e.g. unjoinable room). Null in web mode. */ +export async function cecPurchaseRequest( + node: string, + number = "", + note = "", +): Promise { + if (!isTauri()) return null; + const { invoke } = await import("@tauri-apps/api/core"); + return (await invoke("cec_purchase_request", { + node, + number, + sessionId: "", + item: "", + price: "", + note, + })) as CecPurchase; +} + +/** Technician: the order landed in the store and checks out — settle the ask + * and turn the customer's prompt into "confirmed, continuing". Throws when + * the customer is unreachable (the confirm never sent). */ +export async function cecPurchaseConfirm(purchaseId: string): Promise { + if (!isTauri()) return null; + const { invoke } = await import("@tauri-apps/api/core"); + return (await invoke("cec_purchase_confirm", { purchaseId })) as CecPurchase; +} + +/** Technician: withdraw the ask (never mind / taking payment another way) — + * dismisses the customer's prompt. */ +export async function cecPurchaseCancel(purchaseId: string): Promise { + if (!isTauri()) return null; + const { invoke } = await import("@tauri-apps/api/core"); + return (await invoke("cec_purchase_cancel", { purchaseId })) as CecPurchase; +} + +/** Every tracked purchase ask — the re-sync pull so a reopened window shows + * the live ask instead of losing it. Null = couldn't fetch (keep the last + * snapshot); empty in web mode. */ +export async function cecPurchases(): Promise { + const r = await tryInvoke("cec_purchases"); + return Array.isArray(r) ? r : null; +} + +/** A purchase ask moved (`cec://purchase`) — requested, a customer beat, or a + * close. No-op in web mode. */ +export async function onCecPurchase(cb: (p: CecPurchase) => void): Promise<() => void> { + if (!isTauri()) return () => {}; + const { listen } = await import("@tauri-apps/api/event"); + return listen("cec://purchase", (e) => cb(e.payload)); +} + // ---- virtual rooms (the rooms plane) ------------------------------------ /** Fan one room-plane message out to `members` (canonical or display node diff --git a/gui/src/ui/settings/CecSection.svelte b/gui/src/ui/settings/CecSection.svelte index 12ec8ea7..721ff92c 100644 --- a/gui/src/ui/settings/CecSection.svelte +++ b/gui/src/ui/settings/CecSection.svelte @@ -110,6 +110,33 @@ void app.dialCec(); } + /** The purchase strip's status line, by flow state. The two waiting states + * read the same to the technician ("sent, not answered"); `seen` just + * additionally proves the customer's app is new enough to show it. */ + function purchaseLabel(state: string): string { + switch (state) { + case "requested": + return "waiting on the customer"; + case "seen": + return "on the customer's screen"; + case "opened": + return "they're at the checkout"; + case "claimed": + return "they say it's paid — check the store's orders"; + case "declined": + return "the customer declined"; + case "confirmed": + return "paid — all set"; + default: + return state; + } + } + + /** Whether a purchase ask is still in flight (withdrawable). */ + function purchaseLive(state: string): boolean { + return ["requested", "seen", "opened", "claimed", "declined"].includes(state); + } + onMount(() => { void app.loadCec(); }); @@ -207,6 +234,7 @@
    {#each app.cecHelpWaiting as w (w.node)} {@const shownName = app.cecAliases[w.number]?.trim() || w.label?.trim() || "Customer"} + {@const queuePurchase = app.cecPurchaseFor(w.node)}
  • @@ -227,7 +255,49 @@ > Control + {#if !queuePurchase || !purchaseLive(queuePurchase.state)} + + + {/if}
    + {#if queuePurchase && purchaseLive(queuePurchase.state)} +
    + {queuePurchase.price || "$50"} + + {queuePurchase.item || "Diagnostic session"} + {purchaseLabel(queuePurchase.state)} + + {#if queuePurchase.state === "claimed"} + + {/if} + +
    + {/if}
  • {/each}
@@ -276,6 +346,7 @@

    {#each customers as c (c.number)} + {@const purchase = app.cecPurchaseFor(c.node)}
  • @@ -341,6 +412,76 @@ {/if}
    + + {#if editingKey !== c.number && c.node} + {#if purchase && purchaseLive(purchase.state)} +
    + {purchase.price || "$50"} + + {purchase.item || "Diagnostic session"} + {purchaseLabel(purchase.state)} + + {#if purchase.state === "claimed"} + + {/if} + {#if purchase.state === "declined"} + + {/if} + +
    + {:else if purchase && purchase.state === "confirmed"} +
    + {purchase.price || "$50"} + + {purchase.item || "Diagnostic session"} + {purchaseLabel(purchase.state)} + +
    + {:else if c.online} +
    + +
    + {/if} + {/if}
  • {/each}
@@ -653,6 +794,54 @@ vertical-align: middle; } + /* The diagnostic-purchase strip — a quiet sub-row under the connection + actions. Idle it's just the ask button; live it carries the price, the + status, and the close controls. The claimed state gets the accent border: + it's the one beat that's waiting on the technician. */ + .purchase-strip { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + border-top: 1px solid var(--line); + padding-top: 0.5rem; + } + .purchase-strip.idle { + border-top-style: dashed; + } + .purchase-strip.paid { + border-top-color: var(--accent); + } + .p-price { + font-family: var(--mono); + font-size: 0.8rem; + font-weight: 600; + color: var(--ink); + border: 1px solid var(--line-strong, var(--line)); + border-radius: var(--r-pill, 999px); + padding: 0.08rem 0.5rem; + flex-shrink: 0; + } + .p-status { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; + font-size: 0.8rem; + line-height: 1.35; + } + .p-status b { + font-weight: 600; + } + .p-sub { + font-size: 0.72rem; + color: var(--ink-soft); + } + .p-sub.ok { + color: var(--ok, #35c26a); + font-weight: 600; + } + /* The stale marker — a connection unused past the threshold, the cleanup candidate. The row gets a dashed danger outline and a small badge. */ .stale-tag { diff --git a/node/src/cec.rs b/node/src/cec.rs index feb1c345..e5d3638a 100644 --- a/node/src/cec.rs +++ b/node/src/cec.rs @@ -38,7 +38,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use allmystuff_cec_consent::{capabilities_for, Capability, ConsentStore}; -use allmystuff_cec_protocol::{format_support_id, support_id_from_device, ApprovalScope, Role}; +use allmystuff_cec_protocol::{ + format_support_id, support_id_from_device, ApprovalScope, PurchaseState, Role, +}; /// Where the customer's consent store lives: /// `~/.myownmesh/cec-consent.json`, honouring `MYOWNMESH_HOME` — the same home @@ -93,6 +95,66 @@ impl PendingRequest { } } +/// One purchase ask (the $50 diagnostic session), tracked by both roles: the +/// technician records it when they send the [`PurchaseControl::Request`], the +/// customer when it arrives. In-memory only — a purchase ask is a live +/// interaction on a help call (the technician simply re-asks after a +/// restart), and the *authoritative* record of payment is the store order, +/// never this bookkeeping. +/// +/// [`PurchaseControl::Request`]: allmystuff_cec_protocol::PurchaseControl::Request +#[derive(Clone, Debug)] +pub struct PurchaseFlow { + /// Unique id for this ask, minted by the technician's node. + pub purchase_id: String, + /// The CEC session the ask belongs to — empty for an ask made outside one + /// (before answering a help call, or after disconnecting). One live ask + /// per **peer** either way. + pub session_id: String, + /// The other side, as first seen: the customer (technician side) or the + /// technician (customer side). Compared canonically via [`pubkey_part`]. + pub peer: String, + /// The technician's Agent Name. Customer side prefers the *grant's* name + /// (the one the customer approved) when a live grant exists, falling back + /// to the wire's claim — the same trust the connect prompt's name carries + /// (verified against the person on the phone). + pub agent_name: String, + /// What's being purchased, e.g. "CEC Diagnostic Session" (display only). + pub item: String, + /// Display price, e.g. "$50" — the checkout page stays authoritative. + pub price: String, + /// Optional free-text from the technician. + pub note: String, + /// Where the ask stands: `requested` → `seen`/`opened`/`claimed`/`declined` + /// → `confirmed` | `cancelled`. The two closing states are terminal. + pub state: String, + /// Unix seconds of the last state change. + pub updated_at: u64, +} + +impl PurchaseFlow { + /// The `cec://purchase` event / `cec_purchases` result shape. + pub fn to_value(&self) -> Value { + json!({ + "purchase_id": self.purchase_id, + "session_id": self.session_id, + "peer": self.peer, + "agent_name": self.agent_name, + "item": self.item, + "price": self.price, + "note": self.note, + "state": self.state, + "updated_at": self.updated_at, + }) + } + + /// Whether this flow can still move (`confirmed` / `cancelled` are the + /// closing states; everything else is live). + pub fn is_terminal(&self) -> bool { + matches!(self.state.as_str(), "confirmed" | "cancelled") + } +} + /// One customer this technician has dialed (technician side). Keyed in /// [`CecInner::dialed`] by the customer's canonical (bare-pubkey) id. A dialed /// customer is an ordinary mesh peer on the graph — the CEC tab lists these @@ -207,6 +269,11 @@ struct CecInner { dialed: HashMap, /// Live session states by session id, for `cec://session`. sessions: HashMap, + /// Which peer each session is with (session id → device id as-seen) — + /// linked by whichever side learns it (the dial, or the inbound Request / + /// Approve), so "the active session with this customer" is answerable + /// without parsing ids. + session_peers: HashMap, /// Cancellation flag for the in-flight dial (one at a time — the GUI /// serializes dials). `begin_dial` mints a fresh flag; `cancel_dial` trips /// it; the discovery poll and the connect-request re-send loop both honor @@ -228,6 +295,9 @@ struct CecInner { /// last beacon; an `available: false` beacon (cancel / help arrived) /// removes one immediately. help_wanted: HashMap, + /// Live purchase asks by purchase id (both roles, one live ask per + /// session). In-memory only — see [`PurchaseFlow`]. + purchases: HashMap, } /// One customer waiting on the global help mesh, as heard from their beacon. @@ -283,11 +353,13 @@ impl Cec { pending: Vec::new(), dialed, sessions: HashMap::new(), + session_peers: HashMap::new(), dial_cancel: None, asking_help: false, watching_help: false, help_epoch: 0, help_wanted: HashMap::new(), + purchases: HashMap::new(), }), } } @@ -801,6 +873,32 @@ impl Cec { self.inner.lock().sessions.get(session_id).cloned() } + /// Remember which peer `session_id` is with — linked by whichever side + /// learns it (the dial mints it against a customer; an inbound Request / + /// Approve names its sender), so per-session features (the purchase ask) + /// can resolve "the active session with this peer" without parsing ids. + pub fn link_session_peer(&self, session_id: &str, peer: &str) { + self.inner + .lock() + .session_peers + .insert(session_id.to_string(), peer.to_string()); + } + + /// The **active** session with `peer`, if any (canonical compare). + pub fn active_session_for(&self, peer: &str) -> Option { + let inner = self.inner.lock(); + let key = pubkey_part(peer); + inner.session_peers.iter().find_map(|(sid, p)| { + let active = pubkey_part(p) == key + && inner + .sessions + .get(sid) + .map(|s| s == "active") + .unwrap_or(false); + active.then(|| sid.clone()) + }) + } + /// Whether a pending connect-request is already recorded for `session_id`. /// The technician retransmits its Request every 2s until answered, so this /// lets the customer refresh the pending record on each beat *without* @@ -850,6 +948,83 @@ impl Cec { pub fn retire_once(&self, tech: &str) -> bool { self.inner.lock().consent.revoke_once(tech) } + + /// The Agent Name the customer approved for `tech` — read from the live + /// grant, so a purchase prompt names the person the customer actually let + /// in (never a wire-supplied claim). Empty when no live grant. + pub fn agent_name_for(&self, tech: &str) -> String { + let inner = self.inner.lock(); + let key = pubkey_part(tech); + inner + .consent + .active_grants(now_secs()) + .into_iter() + .find(|g| pubkey_part(&g.technician) == key) + .map(|g| g.agent_name) + .unwrap_or_default() + } + + // ---- purchase asks (the $50 diagnostic session) ---------------------- + + /// Record a purchase ask, superseding any earlier ask with the same peer + /// (a technician's re-ask replaces, it never stacks prompts) — mirroring + /// how [`record_pending`](Cec::record_pending) dedupes connect-requests + /// by technician. Keyed by peer, not session, because an ask can be made + /// *outside* a session (before answering a help call, or after + /// disconnecting) where `session_id` is empty. + pub fn record_purchase(&self, flow: PurchaseFlow) { + let mut inner = self.inner.lock(); + let key = pubkey_part(&flow.peer).to_string(); + inner.purchases.retain(|_, p| pubkey_part(&p.peer) != key); + inner.purchases.insert(flow.purchase_id.clone(), flow); + } + + /// Look up a purchase ask by id. + pub fn purchase(&self, purchase_id: &str) -> Option { + self.inner.lock().purchases.get(purchase_id).cloned() + } + + /// Move a purchase ask to `state`, returning the updated flow for the + /// `cec://purchase` emit. `None` for an unknown id, an already-closed flow + /// (`confirmed` / `cancelled` never reopen — a late or replayed message + /// can't resurrect a settled ask), or a no-op same-state write (so callers + /// don't re-emit an event for a retransmitted status). + pub fn set_purchase_state(&self, purchase_id: &str, state: &str) -> Option { + let mut inner = self.inner.lock(); + let flow = inner.purchases.get_mut(purchase_id)?; + if flow.is_terminal() || flow.state == state { + return None; + } + flow.state = state.to_string(); + flow.updated_at = now_secs(); + Some(flow.clone()) + } + + /// The live (non-terminal) purchase ask on `session_id`, if any. Empty + /// never matches — a sessionless ask isn't tied to any session's end. + pub fn purchase_for_session(&self, session_id: &str) -> Option { + if session_id.is_empty() { + return None; + } + self.inner + .lock() + .purchases + .values() + .find(|p| p.session_id == session_id && !p.is_terminal()) + .cloned() + } + + /// Every tracked purchase ask, for the `cec_purchases` re-sync query (a + /// GUI that restarts mid-session pulls these rather than losing the + /// prompt). + pub fn purchases(&self) -> Vec { + self.inner + .lock() + .purchases + .values() + .map(PurchaseFlow::to_value) + .collect() + } } /// The customer-facing scope word for a grant (the `cec_grants` shape) — @@ -883,6 +1058,33 @@ pub fn parse_scope(s: &str) -> Result { } } +/// Map the node-control purchase `state` argument — the states a *customer* +/// reports ([`PurchaseState`] minus the technician-only closes) — to the wire +/// enum. Unknown values are an error the dispatch surfaces. +pub fn parse_purchase_state(s: &str) -> Result { + match s { + "seen" => Ok(PurchaseState::Seen), + "opened" => Ok(PurchaseState::Opened), + "claimed" => Ok(PurchaseState::Claimed), + "declined" => Ok(PurchaseState::Declined), + other => Err(format!( + "unknown purchase state '{other}' (want seen | opened | claimed | declined)" + )), + } +} + +/// The bookkeeping word for a wire [`PurchaseState`] — the same strings +/// [`PurchaseFlow::state`] holds and the `cec://purchase` event carries. +pub fn purchase_state_str(state: PurchaseState) -> &'static str { + match state { + PurchaseState::Seen => "seen", + PurchaseState::Opened => "opened", + PurchaseState::Claimed => "claimed", + PurchaseState::Declined => "declined", + PurchaseState::Unknown => "unknown", + } +} + /// A short, stable verification code for a connect attempt — the first 6 /// digits of the Support ID of the concatenated technician id and session id, /// so both ends compute the same code to read back out-of-band. @@ -1214,6 +1416,131 @@ mod tests { assert!(!cec.remove_help_beacon(ME), "second withdrawal is a no-op"); } + fn purchase(id: &str, session: &str) -> PurchaseFlow { + PurchaseFlow { + purchase_id: id.into(), + session_id: session.into(), + peer: TECH.into(), + agent_name: "Alex at CEC".into(), + item: "CEC Diagnostic Session".into(), + price: "$50".into(), + note: String::new(), + state: "requested".into(), + updated_at: now_secs(), + } + } + + #[test] + fn purchase_flow_moves_and_closes() { + let cec = Cec::new(None); + cec.record_purchase(purchase("p1", "s1")); + assert_eq!(cec.purchase("p1").unwrap().state, "requested"); + assert_eq!(cec.purchase_for_session("s1").unwrap().purchase_id, "p1"); + + // Ordinary progress, one emit per real change… + assert!(cec.set_purchase_state("p1", "seen").is_some()); + assert!( + cec.set_purchase_state("p1", "seen").is_none(), + "a retransmitted status is not a change" + ); + assert!(cec.set_purchase_state("p1", "opened").is_some()); + assert!(cec.set_purchase_state("p1", "claimed").is_some()); + + // …until a close, which is final: nothing reopens a settled ask. + assert!(cec.set_purchase_state("p1", "confirmed").is_some()); + assert!(cec.set_purchase_state("p1", "opened").is_none()); + assert!(cec.set_purchase_state("p1", "cancelled").is_none()); + assert_eq!(cec.purchase("p1").unwrap().state, "confirmed"); + // A closed ask no longer counts as the session's live one. + assert!(cec.purchase_for_session("s1").is_none()); + assert!(cec.set_purchase_state("nope", "seen").is_none()); + } + + #[test] + fn purchase_reask_supersedes_per_peer() { + let cec = Cec::new(None); + cec.record_purchase(purchase("p1", "s1")); + cec.record_purchase(purchase("p2", "s1")); + // The re-ask replaced the original — one live ask per peer. + assert!(cec.purchase("p1").is_none()); + assert_eq!(cec.purchase_for_session("s1").unwrap().purchase_id, "p2"); + assert_eq!(cec.purchases().len(), 1); + // Same peer, later sessionless re-ask (post-disconnect): still one. + let mut sessionless = purchase("p3", ""); + sessionless.peer = format!("{TECH}-AB12C"); // display form, same key + cec.record_purchase(sessionless); + assert_eq!(cec.purchases().len(), 1); + assert!(cec.purchase_for_session("s1").is_none()); + assert!(cec.purchase("p3").is_some()); + // A different peer keeps its own ask. + let mut other = purchase("p4", ""); + other.peer = ME.into(); + cec.record_purchase(other); + assert_eq!(cec.purchases().len(), 2); + // An empty session id never reads as "the session's ask". + assert!(cec.purchase_for_session("").is_none()); + } + + #[test] + fn parse_purchase_state_accepts_customer_states_only() { + assert_eq!(parse_purchase_state("seen").unwrap(), PurchaseState::Seen); + assert_eq!( + parse_purchase_state("opened").unwrap(), + PurchaseState::Opened + ); + assert_eq!( + parse_purchase_state("claimed").unwrap(), + PurchaseState::Claimed + ); + assert_eq!( + parse_purchase_state("declined").unwrap(), + PurchaseState::Declined + ); + // The closing states are technician verbs (`cec_purchase_confirm` / + // `cec_purchase_cancel`), never a reportable customer status. + assert!(parse_purchase_state("confirmed").is_err()); + assert!(parse_purchase_state("cancelled").is_err()); + for s in [ + PurchaseState::Seen, + PurchaseState::Opened, + PurchaseState::Claimed, + PurchaseState::Declined, + ] { + assert_eq!(parse_purchase_state(purchase_state_str(s)).unwrap(), s); + } + } + + #[test] + fn active_session_resolves_by_peer() { + let cec = Cec::new(None); + assert!(cec.active_session_for(TECH).is_none()); + cec.set_session("s1", "requested"); + cec.link_session_peer("s1", TECH); + // Requested isn't active yet. + assert!(cec.active_session_for(TECH).is_none()); + cec.set_session("s1", "active"); + assert_eq!(cec.active_session_for(TECH).as_deref(), Some("s1")); + // Display-suffix and bare forms are the same peer. + let display = format!("{TECH}-AB12C"); + assert_eq!(cec.active_session_for(&display).as_deref(), Some("s1")); + cec.set_session("s1", "ended"); + assert!(cec.active_session_for(TECH).is_none()); + } + + #[test] + fn agent_name_comes_from_the_grant() { + let cec = Cec::new(None); + assert_eq!(cec.agent_name_for(TECH), ""); + cec.approve(TECH, "Alex at CEC", ApprovalScope::Once, true) + .unwrap(); + assert_eq!(cec.agent_name_for(TECH), "Alex at CEC"); + // Display-suffix and bare forms resolve to the same grant. + let display = format!("{TECH}-AB12C"); + assert_eq!(cec.agent_name_for(&display), "Alex at CEC"); + cec.revoke(TECH).unwrap(); + assert_eq!(cec.agent_name_for(TECH), ""); + } + #[test] fn asking_help_transitions_bump_the_epoch_once() { let cec = Cec::new(None); diff --git a/node/src/mesh.rs b/node/src/mesh.rs index e5842230..879b72ce 100644 --- a/node/src/mesh.rs +++ b/node/src/mesh.rs @@ -3126,6 +3126,7 @@ impl Mesh { let session_id = format!("cec-{}-{}", short_id(&customer), fresh_boot_id()); let want_control = true; self.cec.set_session(&session_id, "requested"); + self.cec.link_session_peer(&session_id, &customer); let request = allmystuff_cec_protocol::ControlMessage::Connect( allmystuff_cec_protocol::ConnectControl::Request { session_id: session_id.clone(), @@ -3313,6 +3314,291 @@ impl Mesh { Ok(Value::Array(out)) } + /// `cec_purchase_request` (technician): ask the customer to complete a + /// purchase — the $50 diagnostic session. **Optional and + /// technician-triggered only**; the customer's app never initiates one. + /// Works in every shape a help call takes: + /// + /// - **before answering** (from the help queue): pin-connects to the + /// customer on the shared support area, exactly like a dial — no screen + /// session, no approval needed to *ask* (the same trust bar as the + /// connect prompt: the prompt names the asker, the customer declines + /// freely); + /// - **mid-session**: the ask rides the live connection and carries its + /// session id; + /// - **after disconnecting** (the Diag button on a stored machine): the + /// ask goes out sessionless over the still-standing peer connection, or + /// re-reaches the machine through the support area. + /// + /// The wire carries display strings and ids only — the customer's app + /// opens its own built-in checkout URL, and no payment data ever rides the + /// mesh. Empty `item`/`price` fall back to the canonical + /// diagnostic-session copy so every prompt is fully labelled. The Request + /// goes out under the daemon's acknowledged-delivery contract (the same + /// discipline as the connect request), so the returned flow is + /// immediately "requested" and progress arrives on `cec://purchase`; an + /// undelivered ask settles itself cancelled so the strip tells the truth. + pub async fn cec_purchase_request( + self: &Arc, + node: String, + number: String, + session_id: String, + item: String, + price: String, + note: String, + ) -> Result { + let canonical = crate::cec::pubkey_part(&node).to_string(); + // The number is key-derived from the device id; caller-supplied digits + // are only a cosmetic fallback for the directory row. + let digits = crate::cec::number_digits(&number); + let number = if digits.is_empty() { + allmystuff_cec_protocol::support_id_from_device(&node) + } else { + digits + }; + // Attach the live session when there is one; an ask made outside a + // session (help queue / post-disconnect) is simply sessionless. + let session_id = if session_id.is_empty() { + self.cec.active_session_for(&node).unwrap_or_default() + } else { + session_id + }; + // Where to reach them: the network their frames already arrive on, + // else the shared support area — joined idempotently and + // pin-connected exactly like a dial, so a machine billed straight + // from the help queue earns its directory row AND the standing + // relationship the technician's later Control click rides. + let network_id = match self.network_for_peer(&node) { + Some(n) => n, + None => { + let (area, config) = crate::cec::help_network_config(); + self.cec_join_silent(&area, config).await?; + self.client + .request(&Request::NetworkConnectPeer { + network: area.clone(), + peer: canonical.clone(), + // A support relationship is a standing dial, whether + // it began with a screen or a bill. + pin: true, + wait_ms: 0, + }) + .await + .map_err(|e| e.to_string()) + .and_then(|resp| { + if resp.ok { + Ok(()) + } else { + Err(resp + .error + .unwrap_or_else(|| "connect_peer refused by the daemon".into())) + } + })?; + let (label, hostname) = self.cec_peer_ident(&canonical).unwrap_or_default(); + let record = + self.cec + .record_dialed(node.clone(), number.clone(), label, hostname, true); + self.sink.emit("cec://peer", record.to_value()); + area + } + }; + let item = if item.trim().is_empty() { + allmystuff_cec_protocol::DIAGNOSTIC_ITEM.to_string() + } else { + item + }; + let price = if price.trim().is_empty() { + allmystuff_cec_protocol::DIAGNOSTIC_PRICE.to_string() + } else { + price + }; + let purchase_id = format!("buy-{}-{}", short_id(&canonical), fresh_boot_id()); + let request = allmystuff_cec_protocol::ControlMessage::Purchase( + allmystuff_cec_protocol::PurchaseControl::Request { + purchase_id: purchase_id.clone(), + session_id: session_id.clone(), + agent_name: self.cec.agent_name(), + item: item.clone(), + price: price.clone(), + note: note.clone(), + }, + ); + let flow = crate::cec::PurchaseFlow { + purchase_id: purchase_id.clone(), + session_id, + peer: node.clone(), + agent_name: self.cec.agent_name(), + item, + price, + note, + state: "requested".into(), + updated_at: crate::cec::now_secs(), + }; + self.cec.record_purchase(flow.clone()); + self.sink.emit("cec://purchase", flow.to_value()); + + // Deliver under the acked contract: queued until the customer's link + // is up, retransmitted across session rebuilds, resolved when their + // node has actually taken the frame. The customer dedupes by + // purchase_id and re-asserts current state on a redelivery. A + // delivery that genuinely lapses settles the ask cancelled, so the + // technician's strip clears instead of waiting on a ghost. + { + let mesh = self.clone(); + let net = network_id.clone(); + let peer = canonical.clone(); + let pid = purchase_id.clone(); + tokio::spawn(async move { + if let Err(e) = mesh + .cec_send_control_acked(&net, &peer, &request, CEC_CONNECT_TTL) + .await + { + tracing::warn!("cec purchase request undelivered: {e}"); + if let Some(updated) = mesh.cec.set_purchase_state(&pid, "cancelled") { + mesh.sink.emit("cec://purchase", updated.to_value()); + } + } + }); + } + Ok(flow.to_value()) + } + + /// The network a purchase word travels on: wherever the peer's frames + /// already arrive, else the shared support area — joined idempotently so + /// the acked send has a channel to queue on. Reaching the peer itself is + /// the standing dial's job (pinned when the relationship started); this + /// only guarantees the channel exists. + async fn cec_purchase_network(self: &Arc, peer: &str) -> Result { + if let Some(network_id) = self.network_for_peer(peer) { + return Ok(network_id); + } + let (area, config) = crate::cec::help_network_config(); + self.cec_join_silent(&area, config).await?; + Ok(area) + } + + /// `cec_purchase_status` (customer): report where the customer is in the + /// purchase flow — `seen` / `opened` / `claimed` / `declined`. The local + /// record and the customer's own UI move immediately; the wire word + /// follows under the acked contract in a spawned task and stays + /// best-effort (the phone call is the backstop; the technician verifies + /// the order in the store regardless). + pub async fn cec_purchase_status( + self: &Arc, + purchase_id: String, + state: String, + ) -> Result { + let wire_state = crate::cec::parse_purchase_state(&state)?; + let flow = self + .cec + .purchase(&purchase_id) + .ok_or_else(|| "unknown purchase".to_string())?; + if flow.is_terminal() { + // Settled asks don't reopen — echo the final state back. + return Ok(flow.to_value()); + } + let updated = self + .cec + .set_purchase_state(&purchase_id, crate::cec::purchase_state_str(wire_state)); + if let Some(f) = &updated { + self.sink.emit("cec://purchase", f.to_value()); + } + match self.cec_purchase_network(&flow.peer).await { + Ok(network_id) => { + let mesh = self.clone(); + let canonical = crate::cec::pubkey_part(&flow.peer).to_string(); + let message = allmystuff_cec_protocol::ControlMessage::Purchase( + allmystuff_cec_protocol::PurchaseControl::Status { + purchase_id, + state: wire_state, + }, + ); + tokio::spawn(async move { + if let Err(e) = mesh + .cec_send_control_acked(&network_id, &canonical, &message, CEC_CONNECT_TTL) + .await + { + tracing::warn!("cec purchase status undelivered: {e}"); + } + }); + } + Err(e) => tracing::warn!("cec purchase status has no channel: {e}"), + } + Ok(updated.map(|f| f.to_value()).unwrap_or(flow.to_value())) + } + + /// `cec_purchase_confirm` (technician): the order landed in the store and + /// checks out against this customer — settle the ask and tell the + /// customer's app, whose prompt turns into "confirmed, continuing". + pub async fn cec_purchase_confirm( + self: &Arc, + purchase_id: String, + ) -> Result { + self.cec_purchase_close(purchase_id, true).await + } + + /// `cec_purchase_cancel` (technician): withdraw the ask (never mind / + /// taking payment another way). Dismisses the customer's prompt. + pub async fn cec_purchase_cancel( + self: &Arc, + purchase_id: String, + ) -> Result { + self.cec_purchase_close(purchase_id, false).await + } + + /// The shared close: settle the local ask, then tell the customer under + /// the acked contract in a spawned task — a Confirm/Withdraw click never + /// blocks on delivery (the word is queued until the customer's link is + /// up and retransmitted across rebuilds). The money truth lives in the + /// store, not on the wire: a close the customer's app never hears leaves + /// their prompt behind, and the phone call — or a re-click, since + /// re-closing a settled ask re-sends the wire word without moving or + /// re-announcing anything — covers that gap. + async fn cec_purchase_close( + self: &Arc, + purchase_id: String, + confirm: bool, + ) -> Result { + let flow = self + .cec + .purchase(&purchase_id) + .ok_or_else(|| "unknown purchase".to_string())?; + let canonical = crate::cec::pubkey_part(&flow.peer).to_string(); + let network_id = self.cec_purchase_network(&flow.peer).await?; + let message = if confirm { + allmystuff_cec_protocol::PurchaseControl::Confirm { + purchase_id: purchase_id.clone(), + } + } else { + allmystuff_cec_protocol::PurchaseControl::Cancel { + purchase_id: purchase_id.clone(), + } + }; + let state = if confirm { "confirmed" } else { "cancelled" }; + let updated = self.cec.set_purchase_state(&purchase_id, state); + if let Some(f) = &updated { + self.sink.emit("cec://purchase", f.to_value()); + } + { + let mesh = self.clone(); + let message = allmystuff_cec_protocol::ControlMessage::Purchase(message); + tokio::spawn(async move { + if let Err(e) = mesh + .cec_send_control_acked(&network_id, &canonical, &message, CEC_CONNECT_TTL) + .await + { + tracing::warn!("cec purchase close undelivered: {e}"); + } + }); + } + Ok(updated.map(|f| f.to_value()).unwrap_or(flow.to_value())) + } + + /// `cec_purchases`: every tracked purchase ask — the GUI re-sync pull, so + /// a restarted GUI mid-session recovers the prompt instead of losing it. + pub async fn cec_purchases(&self) -> Result { + Ok(Value::Array(self.cec.purchases())) + } + /// `forget_node` — an **app-wide** feature on every node's gear, not a CEC /// one: drop `node` from the graph + roster and tear its live routes down. /// Any AllMyStuff node can forget any peer this way. When the peer happens to @@ -3620,7 +3906,9 @@ impl Mesh { /// Handle one inbound CEC control message (the `cec.control` channel). /// Customer side: a `Request` raises the 3-choice prompt (`cec://request`); - /// technician side: an `Approve`/`Deny`/`End` moves the session. + /// technician side: an `Approve`/`Deny`/`End` moves the session. The + /// purchase handshake rides the same channel — see + /// [`Self::handle_cec_purchase`]. async fn handle_cec_control(self: &Arc, from: String, network: String, payload: Value) { let msg: allmystuff_cec_protocol::ControlMessage = match serde_json::from_value(payload) { Ok(m) => m, @@ -3629,7 +3917,17 @@ impl Mesh { return; } }; - if let allmystuff_cec_protocol::ControlMessage::Connect(connect) = msg { + let connect = match msg { + allmystuff_cec_protocol::ControlMessage::Purchase(purchase) => { + self.handle_cec_purchase(from, network, purchase).await; + return; + } + allmystuff_cec_protocol::ControlMessage::Connect(connect) => connect, + // App control and unknown kinds: nothing wired (yet) — dropped, by + // the same forward-compat discipline the wire contract promises. + _ => return, + }; + { tracing::info!( "cec connect message from {}: {}", short_id(&from), @@ -3651,6 +3949,10 @@ impl Mesh { agent_name, want_control, } => { + // Whatever this Request becomes, the session is *with* its + // sender — link it so per-session features (the purchase + // ask) can resolve the peer later. + self.cec.link_session_peer(&session_id, &from); // The technician retransmits its Request every 2s until it // sees an answer — because a single send can be dropped // before the data channel is up (the very race the Request @@ -3800,6 +4102,7 @@ impl Mesh { } allmystuff_cec_protocol::ConnectControl::Approve { session_id, .. } => { self.cec.set_session(&session_id, "active"); + self.cec.link_session_peer(&session_id, &from); // The customer just approved — this connection is now in // active use. Stamp its `last_used` (and re-emit the peer so // the CEC tab's time-since refreshes) so the technician's @@ -3829,6 +4132,18 @@ impl Mesh { if self.cec.retire_once(&from) { self.cec_emit_grants(); } + // A purchase ask was "before we continue" — with the + // session over there's nothing to continue, so a live ask + // settles as cancelled and the prompt comes down. + if !session_id.is_empty() { + if let Some(flow) = self.cec.purchase_for_session(&session_id) { + if let Some(updated) = + self.cec.set_purchase_state(&flow.purchase_id, "cancelled") + { + self.sink.emit("cec://purchase", updated.to_value()); + } + } + } self.sink.emit( "cec://session", json!({ "session_id": session_id, "state": "ended" }), @@ -3839,6 +4154,136 @@ impl Mesh { } } + /// Handle one inbound purchase message. Customer side: a `Request` from a + /// technician holding a **live grant** raises the purchase prompt + /// (`cec://purchase`), and a `Confirm`/`Cancel` settles it; technician + /// side: a `Status` from the asked customer moves the ask along. Anything + /// else — strangers, unknown ids, settled asks — drops without an event. + async fn handle_cec_purchase( + self: &Arc, + from: String, + network: String, + purchase: allmystuff_cec_protocol::PurchaseControl, + ) { + match purchase { + allmystuff_cec_protocol::PurchaseControl::Request { + purchase_id, + session_id, + agent_name, + item, + price, + note, + } => { + // The trust bar is the connect prompt's: reaching this room + // took the number (told out-of-band, on the phone), the prompt + // names the asker, and the customer can always decline. No + // grant is required to *ask* — a purchase request is how a + // technician quotes the work before the customer lets them in + // (from the help queue), or after they've disconnected. When a + // grant does exist, its name — the one the customer actually + // approved — outranks the wire's claim. + // + // The technician re-sends the Request until answered (the + // channel can land a beat after connect), so a beat for the + // ask already on screen just re-asserts where it stands — + // that re-Status is what stops the re-sends. Same manners as + // the connect handshake. + if let Some(existing) = self.cec.purchase(&purchase_id) { + if !existing.is_terminal() { + if let Ok(state) = crate::cec::parse_purchase_state(&existing.state) { + let _ = self + .cec_send_control( + &network, + &from, + &allmystuff_cec_protocol::ControlMessage::Purchase( + allmystuff_cec_protocol::PurchaseControl::Status { + purchase_id, + state, + }, + ), + ) + .await; + } + return; + } + } + let item = if item.trim().is_empty() { + allmystuff_cec_protocol::DIAGNOSTIC_ITEM.to_string() + } else { + item + }; + let price = if price.trim().is_empty() { + allmystuff_cec_protocol::DIAGNOSTIC_PRICE.to_string() + } else { + price + }; + tracing::info!( + "cec purchase request from {}: {item} {price}", + short_id(&from) + ); + let granted_name = self.cec.agent_name_for(&from); + let flow = crate::cec::PurchaseFlow { + purchase_id, + session_id, + peer: from.clone(), + // The grant's name (the one the customer approved) when + // there is one; the wire's claim otherwise — exactly the + // trust the connect prompt's name carries. + agent_name: if granted_name.is_empty() { + agent_name + } else { + granted_name + }, + item, + price, + note, + state: "requested".into(), + updated_at: crate::cec::now_secs(), + }; + self.cec.record_purchase(flow.clone()); + self.sink.emit("cec://purchase", flow.to_value()); + } + allmystuff_cec_protocol::PurchaseControl::Status { purchase_id, state } => { + // A state minted by a newer peer than us: leave the ask where + // it is rather than record a word we can't render. + if matches!(state, allmystuff_cec_protocol::PurchaseState::Unknown) { + return; + } + self.cec_purchase_settle( + &from, + &purchase_id, + crate::cec::purchase_state_str(state), + ); + } + allmystuff_cec_protocol::PurchaseControl::Confirm { purchase_id } => { + self.cec_purchase_settle(&from, &purchase_id, "confirmed"); + } + allmystuff_cec_protocol::PurchaseControl::Cancel { purchase_id } => { + self.cec_purchase_settle(&from, &purchase_id, "cancelled"); + } + allmystuff_cec_protocol::PurchaseControl::Unknown => {} + } + } + + /// Move a purchase ask on word from the wire — but only on the word of + /// the peer the ask belongs to (the customer can't confirm their own + /// purchase; a third party can't decline someone else's). + fn cec_purchase_settle(&self, from: &str, purchase_id: &str, state: &str) { + let Some(flow) = self.cec.purchase(purchase_id) else { + return; + }; + if crate::cec::pubkey_part(&flow.peer) != crate::cec::pubkey_part(from) { + tracing::warn!( + "dropping CEC purchase update from {} — not the ask's peer", + short_id(from) + ); + return; + } + if let Some(updated) = self.cec.set_purchase_state(purchase_id, state) { + self.sink.emit("cec://purchase", updated.to_value()); + } + } + // ---- shares (durable, person-scoped grants) ----------------------- // // The GUI resolves the person + node and hands them down; the node is the diff --git a/node/src/node_control.rs b/node/src/node_control.rs index ca038ebb..6c386333 100644 --- a/node/src/node_control.rs +++ b/node/src/node_control.rs @@ -1469,6 +1469,40 @@ pub async fn dispatch( let on: bool = try_arg!(opt(a, "on")).unwrap_or(true); json_result(mesh.cec_help_watch(on).await) } + // The diagnostic-purchase handshake (the $50 diagnostic session). + // Technician verbs: request / confirm / cancel; customer verb: status. + // Optional, technician-triggered only; no payment data rides the node — + // the customer's app opens its own built-in checkout URL. `number` + // lets a request reach a machine with no standing connection (the + // help queue, or a stored machine after disconnecting), same as a + // dial; `session_id` is normally left empty and resolved (or omitted) + // node-side. + "cec_purchase_request" => { + let node: String = try_arg!(arg(a, "node")); + let number: String = try_arg!(opt(a, "number")).unwrap_or_default(); + let session_id: String = try_arg!(opt(a, "session_id")).unwrap_or_default(); + let item: String = try_arg!(opt(a, "item")).unwrap_or_default(); + let price: String = try_arg!(opt(a, "price")).unwrap_or_default(); + let note: String = try_arg!(opt(a, "note")).unwrap_or_default(); + json_result( + mesh.cec_purchase_request(node, number, session_id, item, price, note) + .await, + ) + } + "cec_purchase_status" => { + let purchase_id: String = try_arg!(arg(a, "purchase_id")); + let state: String = try_arg!(arg(a, "state")); + json_result(mesh.cec_purchase_status(purchase_id, state).await) + } + "cec_purchase_confirm" => { + let purchase_id: String = try_arg!(arg(a, "purchase_id")); + json_result(mesh.cec_purchase_confirm(purchase_id).await) + } + "cec_purchase_cancel" => { + let purchase_id: String = try_arg!(arg(a, "purchase_id")); + json_result(mesh.cec_purchase_cancel(purchase_id).await) + } + "cec_purchases" => json_result(mesh.cec_purchases().await), // "Forget this node" is an app-wide feature on every node's gear (drops // any node from the graph/roster + tears its session down). It lives on // the general `forget_node` op; `cec_forget_node` is kept as an alias so