Skip to main content

meradomo_engine/
lib.rs

1//! # meradomo-engine — the reusable launcher for a shared Meradomo engine
2//!
3//! An embedding app uses this crate to serve its local app through Meradomo
4//! without a second download. It handles the whole shared-engine dance:
5//!
6//! 1. **discover** a Meradomo engine already running on this machine
7//!    ([`discover`]) and decide whether to attach to it or start its own
8//!    ([`decide_start_action`]);
9//! 2. **spawn** the bundled engine ([`EngineConfig::spawn`]) or **attach** to a
10//!    running one ([`spawn_or_attach`]);
11//! 3. **register** itself as a live consumer so the engine's reference-counted
12//!    lifecycle keeps serving while the app is open ([`register`], [`heartbeat`],
13//!    [`deregister`]);
14//! 4. **publish** its local app to a public address ([`publish`], [`unpublish`],
15//!    [`status`]).
16//!
17//! It is **Tauri-agnostic**: it takes plain config and returns
18//! [`std::process::Child`] / typed results, so any host — a Tauri app, a CLI, a
19//! service — can drive it. The transport is HTTP over loopback to the engine's
20//! management endpoint (default `http://127.0.0.1:8765`).
21//!
22//! ## The raw wire protocol (for non-Rust hosts)
23//!
24//! All calls are plain HTTP to the management base URL; no auth for the engine
25//! surface (loopback + same-user is the trust boundary). JSON bodies use
26//! camelCase keys.
27//!
28//! | Method & path              | Body                          | Purpose |
29//! |----------------------------|-------------------------------|---------|
30//! | `GET  /engine/info`        | —                             | discovery: `{engineVersion, protocol, pid, mode, connected, host, name, firstPartyApp, registrants}` |
31//! | `POST /engine/register`    | `{appId, pid}`                | attach as a live consumer |
32//! | `POST /engine/heartbeat`   | `{appId, pid}`                | stay attached (idempotently registers) |
33//! | `POST /engine/deregister`  | `{appId}`                     | detach (last one out stops the engine) |
34//! | `POST /publish`            | `{name, label, localPort, appId?}` | request a public address (first-party `appId` auto-approves) |
35//! | `GET  /publish/:name`      | —                             | poll publish status |
36//! | `DELETE /publish/:name`    | —                             | unpublish (keeps approval) |
37//! | `GET  /status`             | —                             | connection state + published apps |
38//!
39//! A host attaches to an incumbent only when its `protocol` matches
40//! [`ENGINE_PROTOCOL`]; a different number means "incompatible — start your own".
41//!
42//! ## Managing people (owner tier)
43//!
44//! These change who may reach the computer, so unlike the surface above they are
45//! guarded. `POST /engine/register` replies with a `capability`; send it as
46//! `x-engine-capability` on every call below. It is minted per engine run and
47//! lives only in memory, so one from a previous run is worthless.
48//!
49//! | Method & path          | Body                                  | Purpose |
50//! |------------------------|---------------------------------------|---------|
51//! | `GET  /people`         | —                                     | `{name, members: [{email, accountId, role, status, apps}], publishedApps}` |
52//! | `POST /people/invite`  | `{email, apps?}`                      | invite by email, handing over `apps` in the same step |
53//! | `POST /people/grant`   | `{accountId, app, granted}`           | give or withdraw one app |
54//! | `POST /people/revoke`  | `{accountId}`                         | remove somebody |
55//!
56//! Every rule lives in the control plane, including the rate limit on
57//! invitations, and its status and message are passed back untouched — a `429`
58//! here means a `429` there.
59
60use std::path::PathBuf;
61use std::process::{Child, Command, Stdio};
62use std::time::{Duration, Instant};
63
64use serde::Deserialize;
65
66/// Wire protocol version this crate speaks. Must match `ENGINE_PROTOCOL` in the
67/// agent (`agent/src/engine-registry.js`). Bump together on any breaking change.
68pub const ENGINE_PROTOCOL: u32 = 1;
69
70/// The engine's default management base URL (loopback only).
71pub const DEFAULT_MGMT_BASE: &str = "http://127.0.0.1:8765";
72
73const CALL_TIMEOUT: Duration = Duration::from_millis(1500);
74
75fn client() -> reqwest::blocking::Client {
76    reqwest::blocking::Client::new()
77}
78
79// ---------------------------------------------------------------------------
80// Discovery
81// ---------------------------------------------------------------------------
82
83/// The account's billing standing (P2.4), surfaced so an embed can show a clear
84/// "renew to keep serving" state instead of a silent route-down.
85#[derive(Debug, Clone, Deserialize, Default)]
86#[serde(rename_all = "camelCase")]
87pub struct BillingInfo {
88    #[serde(default)]
89    pub entitled: bool,
90    /// "comp" | "active" | "trialing" | "past_due" | "hold"
91    #[serde(default)]
92    pub status: String,
93    #[serde(default)]
94    pub trial_ends_at: Option<i64>,
95}
96
97/// The `GET /engine/info` discovery shape.
98#[derive(Debug, Clone, Deserialize, Default)]
99#[serde(rename_all = "camelCase")]
100pub struct EngineInfo {
101    #[serde(default)]
102    pub engine_version: String,
103    #[serde(default)]
104    pub protocol: u32,
105    #[serde(default)]
106    pub pid: u32,
107    #[serde(default)]
108    pub mode: String,
109    #[serde(default)]
110    pub connected: bool,
111    #[serde(default)]
112    pub host: Option<String>,
113    #[serde(default)]
114    pub name: Option<String>,
115    #[serde(default)]
116    pub first_party_app: Option<String>,
117    #[serde(default)]
118    pub registrants: u32,
119    #[serde(default)]
120    pub billing: Option<BillingInfo>,
121}
122
123impl EngineInfo {
124    /// True when the subscription has lapsed and the person must renew to keep
125    /// serving. An embed maps this to a plain "renew to keep serving" prompt.
126    pub fn needs_renewal(&self) -> bool {
127        matches!(
128            self.billing.as_ref().map(|b| b.status.as_str()),
129            Some("hold") | Some("past_due")
130        )
131    }
132}
133
134/// Probe for a Meradomo engine on `mgmt_base`. Returns `None` if nothing answers,
135/// the answer is not an engine, or the request fails.
136pub fn discover(mgmt_base: &str) -> Option<EngineInfo> {
137    client()
138        .get(format!("{mgmt_base}/engine/info"))
139        .timeout(CALL_TIMEOUT)
140        .send()
141        .ok()?
142        .json::<EngineInfo>()
143        .ok()
144}
145
146/// What a launching app should do when it finds the port already held.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum StartAction {
149    /// A healthy, protocol-compatible engine is running — attach to it.
150    Attach,
151    /// No/failed answer or an incompatible protocol — start your own engine.
152    Takeover,
153}
154
155/// Decide attach-vs-takeover from a discovery result. Mirrors the agent's
156/// `decideStartAction`: a compatible engine → attach; anything else → takeover.
157pub fn decide_start_action(info: Option<&EngineInfo>, protocol: u32) -> StartAction {
158    match info {
159        Some(i) if i.protocol == protocol => StartAction::Attach,
160        _ => StartAction::Takeover,
161    }
162}
163
164// ---------------------------------------------------------------------------
165// Registration (reference-counted lifecycle)
166// ---------------------------------------------------------------------------
167
168/// Attach this app to the engine so its lifecycle counts us as alive.
169///
170/// Also collects the engine's owner-tier capability (see [`people`]) and stores
171/// it for the rest of this process. Registering is what proves we are a real app
172/// on this machine, so registering is what earns the key — including for an app
173/// that attached to an engine somebody else spawned and therefore never saw its
174/// management secret.
175pub fn register(mgmt_base: &str, app_id: &str, pid: u32) -> reqwest::Result<()> {
176    let res = client()
177        .post(format!("{mgmt_base}/engine/register"))
178        .json(&serde_json::json!({ "appId": app_id, "pid": pid }))
179        .timeout(CALL_TIMEOUT)
180        .send()?;
181    if let Ok(body) = res.json::<RegisterReply>() {
182        if let Some(cap) = body.capability {
183            store_capability(cap);
184        }
185    }
186    Ok(())
187}
188
189#[derive(Debug, Deserialize)]
190struct RegisterReply {
191    capability: Option<String>,
192}
193
194/// The owner-tier capability handed back by the last successful [`register`].
195/// Older engines do not issue one, so this stays `None` and the people calls
196/// below report that plainly rather than failing in a confusing way.
197static CAPABILITY: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
198
199fn store_capability(cap: String) {
200    if let Ok(mut slot) = CAPABILITY.write() {
201        *slot = Some(cap);
202    }
203}
204
205fn capability() -> Option<String> {
206    CAPABILITY.read().ok().and_then(|slot| slot.clone())
207}
208
209/// Keep this app's registration fresh (idempotently registers if unknown).
210/// Best-effort: a failure (engine still coming up) is silently ignored.
211pub fn heartbeat(mgmt_base: &str, app_id: &str, pid: u32) {
212    let _ = client()
213        .post(format!("{mgmt_base}/engine/heartbeat"))
214        .json(&serde_json::json!({ "appId": app_id, "pid": pid }))
215        .timeout(CALL_TIMEOUT)
216        .send();
217}
218
219/// Detach this app. When it was the last registrant the engine stops serving
220/// after its grace window. Best-effort — the engine also reaps a dead pid.
221pub fn deregister(mgmt_base: &str, app_id: &str) {
222    let _ = client()
223        .post(format!("{mgmt_base}/engine/deregister"))
224        .json(&serde_json::json!({ "appId": app_id }))
225        .timeout(CALL_TIMEOUT)
226        .send();
227}
228
229// ---------------------------------------------------------------------------
230// Publish
231// ---------------------------------------------------------------------------
232
233/// The `POST /publish` / `GET /publish/:name` result.
234#[derive(Debug, Clone, Deserialize, Default)]
235#[serde(rename_all = "camelCase")]
236pub struct PublishResult {
237    #[serde(default)]
238    pub status: String,
239    #[serde(default)]
240    pub host: Option<String>,
241    #[serde(default)]
242    pub url: Option<String>,
243}
244
245/// Request a public address for a local app. When `app_id` matches the engine's
246/// configured first-party app the route goes live immediately; otherwise it is
247/// `pending` until the owner approves it.
248pub fn publish(
249    mgmt_base: &str,
250    name: &str,
251    label: &str,
252    local_port: u16,
253    app_id: Option<&str>,
254) -> reqwest::Result<PublishResult> {
255    let mut body = serde_json::json!({ "name": name, "label": label, "localPort": local_port });
256    if let Some(id) = app_id {
257        body["appId"] = serde_json::Value::String(id.to_string());
258    }
259    client()
260        .post(format!("{mgmt_base}/publish"))
261        .json(&body)
262        .timeout(CALL_TIMEOUT)
263        .send()?
264        .json::<PublishResult>()
265}
266
267/// Poll the current publish status of a named app.
268pub fn publish_status(mgmt_base: &str, name: &str) -> reqwest::Result<PublishResult> {
269    client()
270        .get(format!("{mgmt_base}/publish/{name}"))
271        .timeout(CALL_TIMEOUT)
272        .send()?
273        .json::<PublishResult>()
274}
275
276/// Remove a live route but keep the owner's approval on record.
277pub fn unpublish(mgmt_base: &str, name: &str) {
278    let _ = client()
279        .delete(format!("{mgmt_base}/publish/{name}"))
280        .timeout(CALL_TIMEOUT)
281        .send();
282}
283
284/// The engine's `GET /status` (connection state + published apps).
285pub fn status(mgmt_base: &str) -> Option<serde_json::Value> {
286    client()
287        .get(format!("{mgmt_base}/status"))
288        .timeout(CALL_TIMEOUT)
289        .send()
290        .ok()?
291        .json::<serde_json::Value>()
292        .ok()
293}
294
295// ---------------------------------------------------------------------------
296// People — who may reach this computer
297// ---------------------------------------------------------------------------
298
299/// One person with access, as the engine reports them.
300#[derive(Debug, Clone, Deserialize)]
301#[serde(rename_all = "camelCase")]
302pub struct Person {
303    pub email: String,
304    pub account_id: String,
305    /// `"owner"` or `"member"`.
306    pub role: String,
307    /// `"active"` (accepted), `"pending"` (invited), or `"revoked"` (removed).
308    pub status: String,
309    /// The apps this person may open. Empty for the owner, who reaches everything.
310    #[serde(default)]
311    pub apps: Vec<String>,
312}
313
314/// The people surface: everyone with access, plus the apps that can be granted.
315#[derive(Debug, Clone, Deserialize, Default)]
316#[serde(rename_all = "camelCase")]
317pub struct People {
318    /// This computer's address label.
319    #[serde(default)]
320    pub name: String,
321    #[serde(default)]
322    pub members: Vec<Person>,
323    /// App labels this computer is serving — what an invitation may hand over.
324    #[serde(default)]
325    pub published_apps: Vec<String>,
326}
327
328/// What went wrong managing people. Carries the engine's own message where there
329/// is one, because those messages come from the control plane and are written to
330/// be shown to a person ("that does not look like an email address").
331#[derive(Debug, thiserror::Error)]
332pub enum PeopleError {
333    /// This app has not registered with the engine, so it holds no capability.
334    #[error("not attached to an engine yet")]
335    NotAttached,
336    /// The engine (or the service behind it) refused, with its own wording.
337    #[error("{0}")]
338    Refused(String),
339    /// The engine could not be reached at all.
340    #[error("could not reach the sharing service")]
341    Unreachable,
342}
343
344fn people_call(
345    mgmt_base: &str,
346    method: reqwest::Method,
347    path: &str,
348    body: Option<serde_json::Value>,
349) -> Result<serde_json::Value, PeopleError> {
350    let cap = capability().ok_or(PeopleError::NotAttached)?;
351    let mut req = client()
352        .request(method, format!("{mgmt_base}{path}"))
353        .header("x-engine-capability", cap)
354        .timeout(CALL_TIMEOUT);
355    if let Some(b) = body {
356        req = req.json(&b);
357    }
358    let res = req.send().map_err(|_| PeopleError::Unreachable)?;
359    let status = res.status();
360    let parsed: serde_json::Value = res.json().unwrap_or(serde_json::Value::Null);
361    if status.is_success() {
362        return Ok(parsed);
363    }
364    // The message is the useful part: it explains a rate limit, a bad address, or
365    // an app this computer does not serve, in words already meant for a person.
366    Err(PeopleError::Refused(
367        parsed
368            .get("error")
369            .and_then(|e| e.as_str())
370            .unwrap_or("that did not work")
371            .to_string(),
372    ))
373}
374
375/// Everyone who may reach this computer, and the apps that can be shared.
376pub fn people(mgmt_base: &str) -> Result<People, PeopleError> {
377    let raw = people_call(mgmt_base, reqwest::Method::GET, "/people", None)?;
378    serde_json::from_value(raw).map_err(|_| PeopleError::Refused("unexpected reply".into()))
379}
380
381/// Invite somebody by email, handing them the named apps in the same step.
382///
383/// The apps must be ones this computer is actually serving; anything else is
384/// refused rather than quietly dropped, so the owner is never told they shared
385/// something they did not.
386pub fn invite(mgmt_base: &str, email: &str, apps: &[String]) -> Result<(), PeopleError> {
387    people_call(
388        mgmt_base,
389        reqwest::Method::POST,
390        "/people/invite",
391        Some(serde_json::json!({ "email": email, "apps": apps })),
392    )
393    .map(|_| ())
394}
395
396/// Give or withdraw one app for one person.
397pub fn grant(
398    mgmt_base: &str,
399    account_id: &str,
400    app: &str,
401    granted: bool,
402) -> Result<(), PeopleError> {
403    people_call(
404        mgmt_base,
405        reqwest::Method::POST,
406        "/people/grant",
407        Some(serde_json::json!({ "accountId": account_id, "app": app, "granted": granted })),
408    )
409    .map(|_| ())
410}
411
412/// Remove somebody. Their access stops within one of the engine's poll cycles.
413pub fn revoke(mgmt_base: &str, account_id: &str) -> Result<(), PeopleError> {
414    people_call(
415        mgmt_base,
416        reqwest::Method::POST,
417        "/people/revoke",
418        Some(serde_json::json!({ "accountId": account_id })),
419    )
420    .map(|_| ())
421}
422
423// ---------------------------------------------------------------------------
424// Spawn
425// ---------------------------------------------------------------------------
426
427/// Everything needed to launch a bundled engine. The host resolves the paths
428/// (from its Tauri resources / sidecars) and the credential, then hands them off.
429#[derive(Debug, Clone)]
430pub struct EngineConfig {
431    /// Program to run (the bundled Node runtime, or `"node"` in dev).
432    pub node_bin: PathBuf,
433    /// The bundled `agent.mjs`.
434    pub agent_path: PathBuf,
435    /// `--mode` (usually `"portal"`).
436    pub mode: String,
437    /// Per-device credential (env `AGENT_DEVICE_TOKEN` — never argv).
438    pub device_token: String,
439    /// `--relay-addr` (may be `host` or `host:port`).
440    pub relay_addr: String,
441    /// `--control-plane` URL.
442    pub control_plane: String,
443    /// `--local-port` the agent serves on (default 8443).
444    pub local_port: u16,
445    /// Owner-tier secret (env `AGENT_MGMT_SECRET` — never argv).
446    pub mgmt_secret: String,
447    /// Relay token (env `AGENT_FRP_TOKEN` — never argv; omitted when empty).
448    pub frp_token: Option<String>,
449    /// `--frpc-bin` — the pinned sidecar (omitted in dev / with an override).
450    pub frpc_bin: Option<PathBuf>,
451    /// `--cert-mode` (`acme` in release; None keeps the agent's `static` default).
452    pub cert_mode: Option<String>,
453    /// `--first-party-app` — auto-approve this app's own publish (embeds only).
454    pub first_party_app: Option<String>,
455    /// `--engine-version` — stamp reported by `/engine/info` (from bundle.json).
456    pub engine_version: Option<String>,
457    /// `--work-dir` — private state dir (None = platform default).
458    pub work_dir: Option<PathBuf>,
459    /// `--mgmt-port` — override the default 8765 (None = default).
460    pub mgmt_port: Option<u16>,
461}
462
463impl EngineConfig {
464    /// A minimal portal-mode config; fill in the optionals as needed.
465    pub fn portal(
466        node_bin: PathBuf,
467        agent_path: PathBuf,
468        device_token: String,
469        relay_addr: String,
470        control_plane: String,
471        mgmt_secret: String,
472    ) -> Self {
473        EngineConfig {
474            node_bin,
475            agent_path,
476            mode: "portal".into(),
477            device_token,
478            relay_addr,
479            control_plane,
480            local_port: 8443,
481            mgmt_secret,
482            frp_token: None,
483            frpc_bin: None,
484            cert_mode: None,
485            first_party_app: None,
486            engine_version: None,
487            work_dir: None,
488            mgmt_port: None,
489        }
490    }
491
492    /// Build the agent argument vector (everything after the program + agent.mjs).
493    /// Optional flags are emitted only when set, so a bare config produces exactly
494    /// the flags a plain portal agent needs.
495    ///
496    /// SECRETS ARE NEVER HERE. argv is world-readable on the machine (`ps`,
497    /// Activity Monitor), so the device token, relay token, and mgmt secret
498    /// travel via [`to_envs`] instead — the agent's `arg()` helper already
499    /// falls back to `AGENT_<NAME>` env vars, and older agents that only read
500    /// argv simply never receive them from THIS launcher (they get them from
501    /// their own, older launcher).
502    pub fn to_args(&self) -> Vec<String> {
503        let mut a: Vec<String> = vec![
504            "--mode".into(),
505            self.mode.clone(),
506            "--relay-addr".into(),
507            self.relay_addr.clone(),
508            "--control-plane".into(),
509            self.control_plane.clone(),
510            "--local-port".into(),
511            self.local_port.to_string(),
512        ];
513        if let Some(fb) = &self.frpc_bin {
514            a.push("--frpc-bin".into());
515            a.push(fb.display().to_string());
516        }
517        if let Some(cm) = &self.cert_mode {
518            a.push("--cert-mode".into());
519            a.push(cm.clone());
520        }
521        if let Some(fp) = &self.first_party_app {
522            a.push("--first-party-app".into());
523            a.push(fp.clone());
524        }
525        if let Some(ev) = &self.engine_version {
526            a.push("--engine-version".into());
527            a.push(ev.clone());
528        }
529        if let Some(wd) = &self.work_dir {
530            a.push("--work-dir".into());
531            a.push(wd.display().to_string());
532        }
533        if let Some(mp) = self.mgmt_port {
534            a.push("--mgmt-port".into());
535            a.push(mp.to_string());
536        }
537        a
538    }
539
540    /// The secrets, as env vars for the agent's `AGENT_<NAME>` fallback —
541    /// invisible to `ps`, unlike argv. Empty values are skipped: the agent's
542    /// own defaults for them are empty too, so absence means the same thing.
543    pub fn to_envs(&self) -> Vec<(String, String)> {
544        let mut e = Vec::new();
545        if !self.device_token.is_empty() {
546            e.push(("AGENT_DEVICE_TOKEN".into(), self.device_token.clone()));
547        }
548        if !self.mgmt_secret.is_empty() {
549            e.push(("AGENT_MGMT_SECRET".into(), self.mgmt_secret.clone()));
550        }
551        if let Some(ft) = self.frp_token.as_ref().filter(|s| !s.is_empty()) {
552            e.push(("AGENT_FRP_TOKEN".into(), ft.clone()));
553        }
554        e
555    }
556
557    /// Build the spawn [`Command`] (program + agent.mjs + args + secret envs).
558    /// The caller may still set stdio, extra env, and platform creation flags
559    /// before spawning.
560    pub fn command(&self) -> Command {
561        let mut c = Command::new(&self.node_bin);
562        c.arg(&self.agent_path);
563        c.args(self.to_args());
564        c.envs(self.to_envs());
565        c
566    }
567
568    /// Spawn the engine, inheriting null stdio unless the caller sets it first.
569    pub fn spawn(&self) -> std::io::Result<Child> {
570        let mut c = self.command();
571        c.stdout(Stdio::null()).stderr(Stdio::null());
572        c.spawn()
573    }
574
575    /// The management base URL this config's engine will listen on.
576    pub fn mgmt_base(&self) -> String {
577        format!("http://127.0.0.1:{}", self.mgmt_port.unwrap_or(8765))
578    }
579}
580
581/// The result of [`spawn_or_attach`].
582pub enum StartOutcome {
583    /// A compatible engine was already running; we registered against it.
584    Attached(EngineInfo),
585    /// No compatible engine — we spawned our own.
586    Spawned(Child),
587}
588
589/// Discover a running engine and either **attach** to it (registering `app_id`)
590/// or **spawn** a new one from `cfg`. This is the one call an embedding app makes
591/// to guarantee exactly one engine is serving on this machine.
592pub fn spawn_or_attach(
593    cfg: &EngineConfig,
594    app_id: &str,
595    pid: u32,
596) -> std::io::Result<StartOutcome> {
597    let base = cfg.mgmt_base();
598    if let Some(info) = discover(&base) {
599        if decide_start_action(Some(&info), ENGINE_PROTOCOL) == StartAction::Attach {
600            let _ = register(&base, app_id, pid);
601            return Ok(StartOutcome::Attached(info));
602        }
603    }
604    Ok(StartOutcome::Spawned(cfg.spawn()?))
605}
606
607/// Poll `GET /engine/info` until the engine answers or the deadline passes — a
608/// freshly spawned engine needs a moment to bind its management port and learn
609/// its identity before it can accept a publish.
610pub fn wait_engine(mgmt_base: &str, timeout: Duration) -> bool {
611    let deadline = Instant::now() + timeout;
612    loop {
613        if discover(mgmt_base).is_some() {
614            return true;
615        }
616        if Instant::now() >= deadline {
617            return false;
618        }
619        std::thread::sleep(Duration::from_millis(200));
620    }
621}
622
623// ---------------------------------------------------------------------------
624// Headless connect (Model A: user pays Meradomo, no Meradomo app download)
625// ---------------------------------------------------------------------------
626
627/// `POST /device/code` result: the one-time code and the URL the person approves
628/// at in a browser.
629#[derive(Debug, Clone, Deserialize)]
630#[serde(rename_all = "camelCase")]
631pub struct DeviceCode {
632    pub code: String,
633    pub verify_url: String,
634}
635
636/// `GET /device/exchange` result.
637#[derive(Debug, Clone, Deserialize, Default)]
638#[serde(rename_all = "camelCase")]
639pub struct Exchange {
640    #[serde(default)]
641    pub status: String, // "pending" | "approved" | "unknown"
642    #[serde(default)]
643    pub device_token: Option<String>,
644    #[serde(default)]
645    pub host: Option<String>,
646}
647
648/// Errors from the connect orchestration.
649#[derive(Debug)]
650pub enum ConnectError {
651    Http(reqwest::Error),
652    Io(std::io::Error),
653    /// The device code expired or was never issued.
654    CodeExpired,
655    /// The approval window elapsed before the person finished in the browser.
656    Timeout,
657    /// The exchange succeeded but carried no device credential.
658    NoCredential,
659    /// The engine never became reachable after spawn.
660    EngineUnreachable,
661}
662
663impl std::fmt::Display for ConnectError {
664    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
665        match self {
666            ConnectError::Http(e) => write!(f, "network error: {e}"),
667            ConnectError::Io(e) => write!(f, "spawn error: {e}"),
668            ConnectError::CodeExpired => write!(f, "the approval code expired — please try again"),
669            ConnectError::Timeout => write!(f, "timed out waiting for approval in the browser"),
670            ConnectError::NoCredential => write!(f, "approval returned no credential"),
671            ConnectError::EngineUnreachable => write!(f, "the engine did not come up in time"),
672        }
673    }
674}
675impl std::error::Error for ConnectError {}
676impl From<reqwest::Error> for ConnectError {
677    fn from(e: reqwest::Error) -> Self {
678        ConnectError::Http(e)
679    }
680}
681impl From<std::io::Error> for ConnectError {
682    fn from(e: std::io::Error) -> Self {
683        ConnectError::Io(e)
684    }
685}
686
687/// Ask the control plane for a device code and the browser approval URL.
688pub fn request_device_code(control_plane: &str) -> reqwest::Result<DeviceCode> {
689    client()
690        .post(format!("{control_plane}/device/code"))
691        .timeout(Duration::from_secs(10))
692        .send()?
693        .json::<DeviceCode>()
694}
695
696/// Poll `GET /device/exchange` until the person finishes approving in the browser
697/// (sign-in → name-claim → trial), or the window elapses.
698pub fn poll_exchange(
699    control_plane: &str,
700    code: &str,
701    timeout: Duration,
702    interval: Duration,
703) -> Result<Exchange, ConnectError> {
704    let deadline = Instant::now() + timeout;
705    loop {
706        let ex: Exchange = client()
707            .get(format!("{control_plane}/device/exchange?code={code}"))
708            .timeout(Duration::from_secs(10))
709            .send()?
710            .json()?;
711        match ex.status.as_str() {
712            "approved" => return Ok(ex),
713            "unknown" => return Err(ConnectError::CodeExpired),
714            _ => {}
715        }
716        if Instant::now() >= deadline {
717            return Err(ConnectError::Timeout);
718        }
719        std::thread::sleep(interval);
720    }
721}
722
723/// What one app needs to go from a cold machine to a live public address.
724pub struct ConnectRequest<'a> {
725    /// Control-plane public URL (where the browser approves).
726    pub control_plane: &'a str,
727    /// This app's stable id (also the first-party id used for auto-approve).
728    pub app_id: &'a str,
729    /// The app label to publish (e.g. `"music"`).
730    pub publish_name: &'a str,
731    /// Human label shown for the published app.
732    pub publish_label: &'a str,
733    /// The app's local port to route to.
734    pub local_port: u16,
735    /// How long to wait for the person to finish approving in the browser.
736    pub poll_timeout: Duration,
737}
738
739/// The result of a successful [`connect`].
740pub struct Connected {
741    pub device_token: String,
742    pub host: String,
743    pub publish: PublishResult,
744    /// True if we attached to an engine already running; false if we spawned one.
745    pub attached: bool,
746}
747
748/// The whole Model-A onboarding in one call: request a code, send the person to
749/// the browser to sign in / claim their address / start the trial, wait for the
750/// credential, persist it, start (or attach to) the engine, and publish this
751/// app. Side-effects are injected so any host — and the tests — can drive it:
752///
753/// - `open_url(url)` opens the browser (a Tauri app uses its opener plugin).
754/// - `persist(token, host)` stores the credential wherever the host keeps it.
755/// - `build_config(token)` builds the [`EngineConfig`] once the token is known.
756pub fn connect<O, P, B>(
757    req: &ConnectRequest,
758    pid: u32,
759    open_url: O,
760    persist: P,
761    build_config: B,
762) -> Result<Connected, ConnectError>
763where
764    O: FnOnce(&str),
765    P: FnOnce(&str, &str),
766    B: FnOnce(&str) -> EngineConfig,
767{
768    let dc = request_device_code(req.control_plane)?;
769    open_url(&dc.verify_url);
770    let ex = poll_exchange(req.control_plane, &dc.code, req.poll_timeout, Duration::from_secs(2))?;
771    let token = ex.device_token.ok_or(ConnectError::NoCredential)?;
772    let host = ex.host.unwrap_or_default();
773    persist(&token, &host);
774
775    let cfg = build_config(&token);
776    let base = cfg.mgmt_base();
777    let outcome = spawn_or_attach(&cfg, req.app_id, pid)?;
778    let attached = matches!(outcome, StartOutcome::Attached(_));
779
780    if !wait_engine(&base, Duration::from_secs(30)) {
781        return Err(ConnectError::EngineUnreachable);
782    }
783    let publish = publish(&base, req.publish_name, req.publish_label, req.local_port, Some(req.app_id))?;
784    Ok(Connected { device_token: token, host, publish, attached })
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790
791    fn info(protocol: u32) -> EngineInfo {
792        EngineInfo { protocol, ..Default::default() }
793    }
794
795    #[test]
796    fn attach_only_on_matching_protocol() {
797        assert_eq!(decide_start_action(Some(&info(ENGINE_PROTOCOL)), ENGINE_PROTOCOL), StartAction::Attach);
798        assert_eq!(decide_start_action(Some(&info(ENGINE_PROTOCOL + 1)), ENGINE_PROTOCOL), StartAction::Takeover);
799        assert_eq!(decide_start_action(Some(&info(0)), ENGINE_PROTOCOL), StartAction::Takeover);
800    }
801
802    #[test]
803    fn takeover_when_no_engine_answers() {
804        assert_eq!(decide_start_action(None, ENGINE_PROTOCOL), StartAction::Takeover);
805    }
806
807    #[test]
808    fn engine_info_parses_camelcase() {
809        let j = r#"{"engineVersion":"0.4.0","protocol":1,"pid":42,"mode":"portal",
810                    "connected":true,"host":"alice.meradomo.com","name":"alice",
811                    "firstPartyApp":"com.example.app","registrants":2}"#;
812        let i: EngineInfo = serde_json::from_str(j).unwrap();
813        assert_eq!(i.engine_version, "0.4.0");
814        assert_eq!(i.protocol, 1);
815        assert_eq!(i.pid, 42);
816        assert_eq!(i.connected, true);
817        assert_eq!(i.name.as_deref(), Some("alice"));
818        assert_eq!(i.first_party_app.as_deref(), Some("com.example.app"));
819        assert_eq!(i.registrants, 2);
820    }
821
822    #[test]
823    fn bare_config_emits_exactly_the_portal_flags() {
824        let cfg = EngineConfig::portal(
825            "node".into(),
826            "agent.mjs".into(),
827            "tok".into(),
828            "relay:7000".into(),
829            "http://cp:9002".into(),
830            "secret".into(),
831        );
832        let args = cfg.to_args();
833        assert_eq!(
834            args,
835            vec![
836                "--mode", "portal",
837                "--relay-addr", "relay:7000",
838                "--control-plane", "http://cp:9002",
839                "--local-port", "8443",
840            ]
841        );
842    }
843
844    #[test]
845    fn secrets_travel_by_env_never_argv() {
846        let mut cfg = EngineConfig::portal(
847            "node".into(),
848            "agent.mjs".into(),
849            "device-tok".into(),
850            "relay:7000".into(),
851            "http://cp:9002".into(),
852            "owner-secret".into(),
853        );
854        cfg.frp_token = Some("relay-tok".into());
855
856        let joined = cfg.to_args().join(" ");
857        for secret in ["device-tok", "owner-secret", "relay-tok"] {
858            assert!(!joined.contains(secret), "argv leaked {secret}: {joined}");
859        }
860        let envs = cfg.to_envs();
861        assert!(envs.contains(&("AGENT_DEVICE_TOKEN".into(), "device-tok".into())));
862        assert!(envs.contains(&("AGENT_MGMT_SECRET".into(), "owner-secret".into())));
863        assert!(envs.contains(&("AGENT_FRP_TOKEN".into(), "relay-tok".into())));
864
865        // Empty secrets are simply absent — same meaning as the agent's own
866        // empty-string defaults.
867        cfg.mgmt_secret = String::new();
868        cfg.frp_token = None;
869        let envs = cfg.to_envs();
870        assert_eq!(envs.len(), 1, "only the device token remains: {envs:?}");
871    }
872
873    #[test]
874    fn optional_flags_appear_only_when_set() {
875        let mut cfg = EngineConfig::portal(
876            "node".into(), "a.mjs".into(), "t".into(), "r".into(), "c".into(), "s".into(),
877        );
878        cfg.frpc_bin = Some("/side/frpc".into());
879        cfg.cert_mode = Some("acme".into());
880        cfg.first_party_app = Some("com.example.app".into());
881        cfg.engine_version = Some("0.4.0".into());
882        let args = cfg.to_args();
883        assert!(args.windows(2).any(|w| w == ["--frpc-bin", "/side/frpc"]));
884        assert!(args.windows(2).any(|w| w == ["--cert-mode", "acme"]));
885        assert!(args.windows(2).any(|w| w == ["--first-party-app", "com.example.app"]));
886        assert!(args.windows(2).any(|w| w == ["--engine-version", "0.4.0"]));
887    }
888
889    #[test]
890    fn empty_frp_token_is_omitted() {
891        let mut cfg = EngineConfig::portal(
892            "node".into(), "a.mjs".into(), "t".into(), "r".into(), "c".into(), "s".into(),
893        );
894        cfg.frp_token = Some(String::new());
895        assert!(!cfg.to_envs().iter().any(|(k, _)| k == "AGENT_FRP_TOKEN"));
896    }
897
898    #[test]
899    fn mgmt_base_reflects_port() {
900        let mut cfg = EngineConfig::portal(
901            "node".into(), "a.mjs".into(), "t".into(), "r".into(), "c".into(), "s".into(),
902        );
903        assert_eq!(cfg.mgmt_base(), "http://127.0.0.1:8765");
904        cfg.mgmt_port = Some(8790);
905        assert_eq!(cfg.mgmt_base(), "http://127.0.0.1:8790");
906    }
907
908    #[test]
909    fn device_code_parses() {
910        let dc: DeviceCode = serde_json::from_str(
911            r#"{"code":"abc123","verifyUrl":"https://account.meradomo.com/device/approve?code=abc123"}"#,
912        )
913        .unwrap();
914        assert_eq!(dc.code, "abc123");
915        assert!(dc.verify_url.contains("device/approve"));
916    }
917
918    #[test]
919    fn exchange_pending_then_approved() {
920        let pending: Exchange = serde_json::from_str(r#"{"status":"pending"}"#).unwrap();
921        assert_eq!(pending.status, "pending");
922        assert!(pending.device_token.is_none());
923
924        let approved: Exchange = serde_json::from_str(
925            r#"{"status":"approved","deviceToken":"tok-xyz","host":"alice.meradomo.com"}"#,
926        )
927        .unwrap();
928        assert_eq!(approved.status, "approved");
929        assert_eq!(approved.device_token.as_deref(), Some("tok-xyz"));
930        assert_eq!(approved.host.as_deref(), Some("alice.meradomo.com"));
931    }
932
933    #[test]
934    fn connect_error_messages_are_human() {
935        assert!(ConnectError::Timeout.to_string().contains("browser"));
936        assert!(ConnectError::CodeExpired.to_string().contains("expired"));
937        assert!(ConnectError::EngineUnreachable.to_string().contains("engine"));
938    }
939
940    #[test]
941    fn needs_renewal_only_on_lapse() {
942        let mk = |s: &str| EngineInfo {
943            billing: Some(BillingInfo { status: s.into(), ..Default::default() }),
944            ..Default::default()
945        };
946        assert!(mk("hold").needs_renewal());
947        assert!(mk("past_due").needs_renewal());
948        assert!(!mk("active").needs_renewal());
949        assert!(!mk("trialing").needs_renewal());
950        assert!(!mk("comp").needs_renewal());
951        // No billing info at all (e.g. attached to an engine that hasn't polled) → no prompt.
952        assert!(!EngineInfo::default().needs_renewal());
953    }
954
955    #[test]
956    fn engine_info_parses_billing() {
957        let j = r#"{"protocol":1,"billing":{"entitled":false,"status":"hold","trialEndsAt":123}}"#;
958        let i: EngineInfo = serde_json::from_str(j).unwrap();
959        let b = i.billing.as_ref().unwrap();
960        assert_eq!(b.entitled, false);
961        assert_eq!(b.status, "hold");
962        assert_eq!(b.trial_ends_at, Some(123));
963        assert!(i.needs_renewal());
964    }
965
966    // ------------------------------------------------------------------
967    // People — header construction, error mapping, and the capability rule.
968    //
969    // Driven against a hand-rolled loopback server rather than a mock, so the
970    // request that goes out is the real one: if the header name or the JSON key
971    // ever drifts from what the engine reads, these fail.
972    // ------------------------------------------------------------------
973
974    use std::io::{BufRead, BufReader, Read, Write};
975    use std::net::TcpListener;
976    use std::sync::mpsc;
977
978    /// One-shot HTTP server. Returns its base URL and a channel carrying the
979    /// request it received (method+path, headers, body).
980    fn one_shot(status: u16, reply: &str) -> (String, mpsc::Receiver<(String, String, String)>) {
981        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
982        let base = format!("http://{}", listener.local_addr().unwrap());
983        let (tx, rx) = mpsc::channel();
984        let reply = reply.to_string();
985        std::thread::spawn(move || {
986            let (mut sock, _) = listener.accept().expect("accept");
987            let mut reader = BufReader::new(sock.try_clone().unwrap());
988            let mut start = String::new();
989            reader.read_line(&mut start).ok();
990            let mut headers = String::new();
991            let mut len = 0usize;
992            loop {
993                let mut line = String::new();
994                if reader.read_line(&mut line).unwrap_or(0) == 0 { break; }
995                if line.trim().is_empty() { break; }
996                if let Some(v) = line.to_lowercase().strip_prefix("content-length:") {
997                    len = v.trim().parse().unwrap_or(0);
998                }
999                headers.push_str(&line);
1000            }
1001            let mut body = vec![0u8; len];
1002            if len > 0 { reader.read_exact(&mut body).ok(); }
1003            tx.send((
1004                start.trim().to_string(),
1005                headers,
1006                String::from_utf8_lossy(&body).to_string(),
1007            )).ok();
1008            let out = format!(
1009                "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{reply}",
1010                reply.len()
1011            );
1012            sock.write_all(out.as_bytes()).ok();
1013            sock.flush().ok();
1014        });
1015        (base, rx)
1016    }
1017
1018    /// The capability is process-wide, so the people tests share one lock and run
1019    /// as a single sequence rather than racing each other.
1020    #[test]
1021    fn people_surface() {
1022        // 1. Without a capability, nothing is even attempted.
1023        if let Ok(mut slot) = CAPABILITY.write() { *slot = None; }
1024        let err = people("http://127.0.0.1:1").unwrap_err();
1025        assert!(matches!(err, PeopleError::NotAttached),
1026            "an app that never registered must not be able to manage people");
1027
1028        // 2. Registering stores the capability the engine handed back.
1029        let (base, rx) = one_shot(200, r#"{"ok":true,"capability":"cap-xyz-123456789012345"}"#);
1030        register(&base, "com.example.app", 42).expect("register");
1031        let (start, _h, body) = rx.recv().expect("no request arrived");
1032        assert!(start.starts_with("POST /engine/register"), "{start}");
1033        assert!(body.contains("com.example.app"));
1034        assert_eq!(capability().as_deref(), Some("cap-xyz-123456789012345"));
1035
1036        // 3. Reading people sends that capability, under the name the engine reads.
1037        let (base, rx) = one_shot(
1038            200,
1039            r#"{"name":"kamran","members":[{"email":"a@b.c","accountId":"acc1","role":"member","status":"active","apps":["Music"]}],"publishedApps":["Music"]}"#,
1040        );
1041        let got = people(&base).expect("people");
1042        let (start, headers, _b) = rx.recv().unwrap();
1043        assert!(start.starts_with("GET /people"), "{start}");
1044        assert!(headers.to_lowercase().contains("x-engine-capability: cap-xyz-123456789012345"),
1045            "the capability header was not sent: {headers}");
1046        assert_eq!(got.name, "kamran");
1047        assert_eq!(got.members.len(), 1);
1048        assert_eq!(got.members[0].account_id, "acc1");
1049        assert_eq!(got.members[0].apps, vec!["Music".to_string()]);
1050        assert_eq!(got.published_apps, vec!["Music".to_string()]);
1051
1052        // 4. An invitation carries the address and the apps.
1053        let (base, rx) = one_shot(201, r#"{"email":"a@b.c","status":"pending"}"#);
1054        invite(&base, "a@b.c", &["Music".to_string()]).expect("invite");
1055        let (start, _h, body) = rx.recv().unwrap();
1056        assert!(start.starts_with("POST /people/invite"), "{start}");
1057        assert!(body.contains("\"email\":\"a@b.c\""), "{body}");
1058        assert!(body.contains("Music"), "{body}");
1059
1060        // 5. A refusal keeps the words the person is meant to read.
1061        let (base, _rx) = one_shot(429, r#"{"error":"too many requests, try again shortly"}"#);
1062        let err = invite(&base, "a@b.c", &[]).unwrap_err();
1063        assert_eq!(err.to_string(), "too many requests, try again shortly",
1064            "a rate limit must reach the person as the service worded it");
1065
1066        // 6. Nothing listening reads as unreachable, never as success.
1067        let err = people("http://127.0.0.1:1").unwrap_err();
1068        assert!(matches!(err, PeopleError::Unreachable));
1069
1070        // 7. Granting and revoking name the right person and app.
1071        let (base, rx) = one_shot(200, "{}");
1072        grant(&base, "acc1", "Music", false).expect("grant");
1073        let (start, _h, body) = rx.recv().unwrap();
1074        assert!(start.starts_with("POST /people/grant"), "{start}");
1075        assert!(body.contains("\"accountId\":\"acc1\"") && body.contains("\"granted\":false"), "{body}");
1076
1077        let (base, rx) = one_shot(200, "{}");
1078        revoke(&base, "acc1").expect("revoke");
1079        let (start, _h, body) = rx.recv().unwrap();
1080        assert!(start.starts_with("POST /people/revoke"), "{start}");
1081        assert!(body.contains("\"accountId\":\"acc1\""), "{body}");
1082    }
1083}