Skip to main content

agentmux_srv/server/
muxbus_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! MuxBus cloud connectivity RPC handlers.
5//!
6//! Five commands:
7//!   * `muxbus.login`        — PKCE browser flow (blocks until complete or timeout)
8//!   * `muxbus.login.cancel` — abort an in-flight `muxbus.login` (e.g. user closed the browser)
9//!   * `muxbus.status`       — current credential status
10//!   * `muxbus.disconnect`   — clear stored credentials
11//!   * `muxbus.cloudconfig`  — the cloud settings srv resolved, and where they came from
12
13use std::sync::Arc;
14
15use serde::{Deserialize, Serialize};
16
17use crate::backend::rpc::engine::WshRpcEngine;
18
19use super::AppState;
20
21pub const COMMAND_MUXBUS_LOGIN: &str = "muxbus.login";
22pub const COMMAND_MUXBUS_LOGIN_CANCEL: &str = "muxbus.login.cancel";
23pub const COMMAND_MUXBUS_STATUS: &str = "muxbus.status";
24pub const COMMAND_MUXBUS_DISCONNECT: &str = "muxbus.disconnect";
25pub const COMMAND_MUXBUS_CLOUDCONFIG: &str = "muxbus.cloudconfig";
26
27#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
28#[ts(export, export_to = "../../../frontend/types/rpc/")]
29#[serde(rename_all = "camelCase")]
30pub struct MuxBusLoginReq {
31    pub cognito_domain: String,
32    pub client_id: String,
33}
34
35#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
36#[ts(export, export_to = "../../../frontend/types/rpc/")]
37#[serde(rename_all = "camelCase")]
38pub struct MuxBusLoginResp {
39    pub success: bool,
40    pub email: String,
41    /// skip_serializing_if, so the key is OMITTED on success rather than
42    /// carrying null -- `error?: string`, which is what the hand-written
43    /// inline type already said.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    #[ts(optional)]
46    pub error: Option<String>,
47}
48
49#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
50#[ts(export, export_to = "../../../frontend/types/rpc/")]
51#[serde(rename_all = "camelCase")]
52pub struct MuxBusLoginCancelResp {
53    /// False when there was no in-flight login to cancel (already resolved,
54    /// or never started) — not an error, just nothing to do.
55    pub cancelled: bool,
56}
57
58#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
59#[ts(export, export_to = "../../../frontend/types/rpc/")]
60#[serde(rename_all = "camelCase")]
61pub struct MuxBusStatusResp {
62    pub connected: bool,
63    pub email: String,
64    pub cognito_domain: String,
65    #[ts(type = "number")]
66    pub expires_at: i64,
67    pub valid: bool,
68    /// The stored sign-in can't work anymore and only a new sign-in helps:
69    /// the refresh was refused, or it was issued by a client the cloud no
70    /// longer publishes (SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md §3.3).
71    /// The UI shows "Sign in again" with `email`.
72    pub needs_reauth: bool,
73    /// Whether cloud messages are reaching this channel's agents
74    /// (`muxbus::delivery_status`); live changes arrive as the
75    /// `muxbus:status` event. Always present from this srv.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    #[ts(optional)]
78    pub delivery: Option<crate::backend::rpc_types::MuxBusDeliveryStatus>,
79}
80
81/// Empty request shapes for `muxbus.login.cancel`, `muxbus.status` and
82/// `muxbus.disconnect`. The handlers ignore their payload, but these must be
83/// structs rather than `()`: the stub calls all three with `{}`, and serde
84/// deserializes `()` only from JSON `null`, so a unit Req would reject every
85/// real call at runtime while compiling and passing every CI gate -- the
86/// `bookmarks.list` bug.
87#[derive(Debug, Default, Serialize, Deserialize, ts_rs::TS)]
88#[ts(export, export_to = "../../../frontend/types/rpc/")]
89pub struct MuxBusLoginCancelReq {}
90
91#[derive(Debug, Default, Serialize, Deserialize, ts_rs::TS)]
92#[ts(export, export_to = "../../../frontend/types/rpc/")]
93pub struct MuxBusStatusReq {}
94
95#[derive(Debug, Default, Serialize, Deserialize, ts_rs::TS)]
96#[ts(export, export_to = "../../../frontend/types/rpc/")]
97pub struct MuxBusDisconnectReq {}
98
99/// Result of `muxbus.disconnect`. Was an inline `json!({})`; the stub already
100/// typed it `Record<string, never>`, which is exactly what an empty struct
101/// generates.
102#[derive(Debug, Default, Serialize, Deserialize, ts_rs::TS)]
103#[ts(export, export_to = "../../../frontend/types/rpc/")]
104pub struct MuxBusDisconnectResp {}
105
106/// Empty request for `muxbus.cloudconfig`: a struct rather than `()` so the
107/// stub's `{}` deserializes, like the request shapes above.
108#[derive(Debug, Default, Serialize, Deserialize, ts_rs::TS)]
109#[ts(export, export_to = "../../../frontend/types/rpc/")]
110pub struct MuxBusCloudConfigReq {}
111
112/// Where `muxbus.cloudconfig`'s settings came from.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
114#[ts(export, export_to = "../../../frontend/types/rpc/")]
115#[serde(rename_all = "lowercase")]
116pub enum MuxBusCloudConfigSource {
117    /// The relay's `/.well-known/agentmux-cloud.json` (the last good copy
118    /// when a later fetch failed).
119    Discovered,
120    /// No document: srv's compiled relay URLs, and no sign-in settings.
121    Default,
122}
123
124/// Result of `muxbus.cloudconfig`: the AgentMux Cloud settings srv resolved
125/// (SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md §3.2). A build compiled
126/// without a Cognito client id asks this whether sign-in is possible anyway.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
128#[ts(export, export_to = "../../../frontend/types/rpc/")]
129#[serde(rename_all = "camelCase")]
130pub struct MuxBusCloudConfigResp {
131    pub source: MuxBusCloudConfigSource,
132    /// The relay REST base srv uses. Always `relay::rest_base_url()`, the
133    /// root of trust the document is fetched from, whatever the document says.
134    pub api: String,
135    /// The relay WebSocket URL the cloud subscriber connects to.
136    pub ws: String,
137    /// The Cloud Console URL; empty when none is published.
138    pub console: String,
139    /// The Cognito hosted-UI domain and desktop client id `muxbus.login`
140    /// prefers over the ones in its request. Empty with `source: "default"`:
141    /// srv has no compiled sign-in settings, only the frontend build does.
142    pub cognito_domain: String,
143    pub client_id: String,
144    pub region: String,
145    pub user_pool_id: String,
146}
147
148/// What `muxbus.cloudconfig` reports for a discovery result.
149fn cloud_config(discovered: Option<crate::muxbus::discovery::CloudSettings>, rest_base: String) -> MuxBusCloudConfigResp {
150    match discovered {
151        Some(s) => MuxBusCloudConfigResp {
152            source: MuxBusCloudConfigSource::Discovered,
153            api: rest_base,
154            ws: s.ws,
155            console: s.console,
156            cognito_domain: s.cognito.domain,
157            client_id: s.cognito.client_id,
158            region: s.cognito.region,
159            user_pool_id: s.cognito.user_pool_id,
160        },
161        None => MuxBusCloudConfigResp {
162            source: MuxBusCloudConfigSource::Default,
163            api: rest_base,
164            ws: crate::muxbus::cloud_subscriber::MUXBUS_WS_URL.to_string(),
165            console: String::new(),
166            cognito_domain: String::new(),
167            client_id: String::new(),
168            region: String::new(),
169            user_pool_id: String::new(),
170        },
171    }
172}
173
174pub fn register_muxbus_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
175    // muxbus.login — PKCE browser flow, returns when browser login completes
176    let mstore_login = state.id_store.clone();
177    let http_client_login = state.http_client.clone();
178    engine.register_typed(
179        COMMAND_MUXBUS_LOGIN,
180        move |req: MuxBusLoginReq, _ctx| {
181            let mstore = mstore_login.clone();
182            let http = http_client_login.clone();
183            async move {
184                // The cloud's published sign-in settings win over the build's
185                // compiled ones in the request, so a new user pool reaches
186                // installed builds (SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md §3.2).
187                let discovered = crate::muxbus::discovery::cloud_settings(&http).await;
188                let (cognito_domain, client_id) = match crate::muxbus::discovery::resolve_login(
189                    discovered.as_ref(),
190                    &req.cognito_domain,
191                    &req.client_id,
192                ) {
193                    Ok(pair) => pair,
194                    // Same shape as a failed PKCE flow, so the UI shows it the same way.
195                    Err(e) => {
196                        return Ok(MuxBusLoginResp { success: false, email: String::new(), error: Some(e) });
197                    }
198                };
199
200                match crate::muxbus::pkce::run_pkce_login(
201                    &cognito_domain,
202                    &client_id,
203                    &http,
204                )
205                .await
206                {
207                    Ok(result) => {
208                        // reagent P1: previously this only logged a warning on a
209                        // save failure and still reported success — now that
210                        // MuxBus tokens live in the OS keychain (which can
211                        // genuinely fail: locked, no Secret Service daemon on
212                        // headless Linux, permission denied), that meant the UI
213                        // could show "logged in" for a credential that was never
214                        // actually persisted anywhere. Report the real outcome.
215                        // spawn_blocking — reagent P1 on #2260: muxbus_save
216                        // does a synchronous OS-keychain write, which can
217                        // hang on a slow/unresponsive Secret Service D-Bus
218                        // daemon (headless Linux) and must not stall this
219                        // tokio worker thread.
220                        let email = result.credentials.user_email.clone();
221                        let save_store = mstore.clone();
222                        let save_result = tokio::task::spawn_blocking(move || {
223                            save_store.muxbus_save(&result.credentials)
224                        })
225                        .await
226                        .map_err(|e| format!("muxbus.login: save task: {e}"))?;
227                        if let Err(e) = save_result {
228                            tracing::warn!(error = %e, "muxbus.login: failed to save credentials");
229                            let resp = MuxBusLoginResp {
230                                success: false,
231                                email: String::new(),
232                                error: Some(format!("login succeeded but credentials couldn't be saved: {e}")),
233                            };
234                            return Ok(resp);
235                        }
236                        // Kick the cloud subscriber to open a WS with the new
237                        // token. On an isolated local-package channel
238                        // (reagentx P1 on PR #3248, round 2), boot skipped
239                        // `CloudSubscriber::init_global` entirely, so there is
240                        // no subscriber to reload yet — an explicit, successful
241                        // login is exactly the signal that should lazily bring
242                        // one up now, rather than leaving `muxbus.login` report
243                        // "success" while no WebSocket ever opens and agents
244                        // silently never receive WAN injections. `init_global`
245                        // itself is idempotent (`OnceLock::set`, no-ops if
246                        // already initialized), so this is safe to call
247                        // unconditionally rather than re-deriving "was this
248                        // isolated at boot" here.
249                        match crate::muxbus::cloud_subscriber::get_global_subscriber() {
250                            Some(sub) => sub.reload_token(),
251                            None => {
252                                crate::muxbus::cloud_subscriber::CloudSubscriber::init_global(
253                                    mstore.clone(),
254                                );
255                            }
256                        }
257                        // W3-S: a login may be an account switch — forget
258                        // peer records fetched under the old account (§2.3),
259                        // and publish any keys that were waiting on a login.
260                        // A login to another relay or account is a directory
261                        // that has never seen this install's keys; publications
262                        // are recorded per directory, so the pass this nudge
263                        // starts finds them all pending there.
264                        crate::muxbus::delivery_status::fresh_sign_in(Some(email.clone()));
265                        clear_wan_peer_cache();
266                        crate::muxbus::wan_publish::nudge();
267                        // And publish this install's presence now, whatever
268                        // the presence publisher was waiting out.
269                        crate::muxbus::wan_presence::sign_in_changed();
270                        let resp = MuxBusLoginResp {
271                            success: true,
272                            email,
273                            error: None,
274                        };
275                        Ok(resp)
276                    }
277                    Err(e) => {
278                        tracing::warn!(error = %e, "muxbus.login: PKCE flow failed");
279                        let resp = MuxBusLoginResp {
280                            success: false,
281                            email: String::new(),
282                            error: Some(e),
283                        };
284                        Ok(resp)
285                    }
286                }
287            }
288        },
289    );
290
291    // muxbus.login.cancel — abort an in-flight muxbus.login. The aborted
292    // flow's own task.await (in run_pkce_login) is what actually resolves
293    // the original muxbus.login RPC call with a "sign-in cancelled" error —
294    // this handler just fires the abort and returns immediately, it does
295    // not wait for that resolution.
296    engine.register_typed(
297        COMMAND_MUXBUS_LOGIN_CANCEL,
298        move |_req: MuxBusLoginCancelReq, _ctx| {
299            async move {
300                let cancelled = crate::muxbus::pkce::cancel_active_login();
301                let resp = MuxBusLoginCancelResp { cancelled };
302                Ok(resp)
303            }
304        },
305    );
306
307    // muxbus.status — return current credential state
308    let mstore_status = state.id_store.clone();
309    let http_client_status = state.http_client.clone();
310    engine.register_typed(
311        COMMAND_MUXBUS_STATUS,
312        move |_req: MuxBusStatusReq, _ctx| {
313            let mstore = mstore_status.clone();
314            let http = http_client_status.clone();
315            async move {
316                // reagentx P0 on PR #3248, round 2: `frontend/app/statusbar/
317                // HostPopover.tsx` mounts globally and polls this handler on
318                // mount + every 60s, unconditionally — so an unguarded
319                // `muxbus_load()` here hit Keychain within a minute of launch
320                // on a fresh local build regardless of the boot-time and
321                // agent-spawn fixes elsewhere in this PR, defeating the whole
322                // point of it. Gated on subscriber presence, not the static
323                // channel flag: on an isolated channel the subscriber only
324                // starts at an explicit `muxbus.login`, so presence is what
325                // says a session exists. Before any MuxBus session has ever been
326                // established on this process, report "not connected"
327                // without touching the keychain at all — identical to the
328                // real Ok(None) response below, just without the read.
329                if crate::muxbus::cloud_subscriber::get_global_subscriber().is_none() {
330                    let resp = MuxBusStatusResp {
331                        connected: false,
332                        email: String::new(),
333                        cognito_domain: String::new(),
334                        expires_at: 0,
335                        valid: false,
336                        needs_reauth: false,
337                        delivery: Some(crate::muxbus::delivery_status::status()),
338                    };
339                    return Ok(resp);
340                }
341                // spawn_blocking — reagent P1 on #2260: same
342                // synchronous-keychain-read concern as muxbus.login's save.
343                let load_store = mstore.clone();
344                let load_result = tokio::task::spawn_blocking(move || load_store.muxbus_load())
345                    .await
346                    .map_err(|e| format!("muxbus.status: load task: {e}"))?;
347                match load_result {
348                    Ok(Some(creds)) => {
349                        let valid = creds.is_valid();
350                        let needs_reauth = crate::muxbus::stale_sign_in(&creds, &http).await.is_some();
351                        let resp = MuxBusStatusResp {
352                            connected: !creds.access_token.is_empty(),
353                            email: creds.user_email,
354                            cognito_domain: creds.cognito_domain,
355                            expires_at: creds.expires_at,
356                            valid,
357                            needs_reauth,
358                            delivery: Some(crate::muxbus::delivery_status::status()),
359                        };
360                        Ok(resp)
361                    }
362                    Ok(None) => {
363                        let resp = MuxBusStatusResp {
364                            connected: false,
365                            email: String::new(),
366                            cognito_domain: String::new(),
367                            expires_at: 0,
368                            valid: false,
369                            needs_reauth: false,
370                            delivery: Some(crate::muxbus::delivery_status::status()),
371                        };
372                        Ok(resp)
373                    }
374                    // The UI already reads a failed status as disconnected;
375                    // answering that shape keeps it so, and still carries
376                    // `delivery`, which is what says the sign-in is unreadable.
377                    Err(e) => {
378                        tracing::warn!(error = %e, "muxbus.status: stored sign-in unreadable");
379                        Ok(MuxBusStatusResp {
380                            connected: false,
381                            email: String::new(),
382                            cognito_domain: String::new(),
383                            expires_at: 0,
384                            valid: false,
385                            needs_reauth: false,
386                            delivery: Some(crate::muxbus::delivery_status::status()),
387                        })
388                    }
389                }
390            }
391        },
392    );
393
394    // muxbus.disconnect — clear credentials
395    let mstore_disconnect = state.id_store.clone();
396    engine.register_typed(
397        COMMAND_MUXBUS_DISCONNECT,
398        move |_req: MuxBusDisconnectReq, _ctx| {
399            let mstore = mstore_disconnect.clone();
400            async move {
401                // The presence goodbye goes first: it needs the sign-in
402                // being cleared. At most 2 s; nothing is published after it
403                // until `signed_out`, which runs whether or not the clear
404                // below works.
405                let said_goodbye = crate::muxbus::wan_presence::goodbye_before_sign_out().await;
406                // spawn_blocking — reagent P1 on #2260: muxbus_clear does a
407                // synchronous OS-keychain delete, same concern as every
408                // other muxbus call site in this module.
409                let cleared = tokio::task::spawn_blocking(move || mstore.muxbus_clear()).await;
410                crate::muxbus::wan_presence::signed_out(said_goodbye);
411                cleared
412                    .map_err(|e| format!("muxbus.disconnect: task: {e}"))?
413                    .map_err(|e| format!("muxbus.disconnect: {e}"))?;
414                clear_wan_peer_cache();
415                // A deliberate sign-out, not a lost sign-in: no notification,
416                // no agent notes. And close the relay connection, which would
417                // otherwise keep delivering on the old token until it ends.
418                crate::muxbus::delivery_status::signed_out();
419                if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
420                    sub.reload_token();
421                }
422                Ok(MuxBusDisconnectResp {})
423            }
424        },
425    );
426
427    // muxbus.cloudconfig — the resolved cloud settings. No credential or
428    // keychain access; discovery itself is cached (`discovery::cloud_settings`).
429    let http_client_cloudconfig = state.http_client.clone();
430    engine.register_typed(
431        COMMAND_MUXBUS_CLOUDCONFIG,
432        move |_req: MuxBusCloudConfigReq, _ctx| {
433            let http = http_client_cloudconfig.clone();
434            async move {
435                let discovered = crate::muxbus::discovery::cloud_settings(&http).await;
436                Ok(cloud_config(discovered, crate::muxbus::relay::rest_base_url()))
437            }
438        },
439    );
440}
441
442/// W3-S (`SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md` §2.3): cached peer
443/// records belong to the account they were fetched under. Best-effort — the
444/// records are self-certifying, so a stale one can't verify a forgery; this
445/// only keeps one account's directory from answering for another's.
446fn clear_wan_peer_cache() {
447    if let Some(wan) = crate::backend::storage::wan_identity::global() {
448        if let Err(e) = wan.peer_cache_clear() {
449            tracing::warn!(error = %e, "wan identity: could not clear the peer-record cache");
450        }
451    }
452}
453
454// Request-shape tests for the `muxbus.*` commands.
455//
456// These types were private to this file until now, so the frontend's inline
457// copies were hand-maintained against nothing.
458#[cfg(test)]
459mod req_shape_tests {
460    use super::*;
461    use serde_json::json;
462
463    // The stub sends camelCase keys; the Rust fields are snake_case and rely
464    // on `rename_all = "camelCase"`. If that attribute were ever dropped the
465    // struct would still compile and the binding would still generate -- it
466    // would just silently stop parsing real payloads. Pin it.
467    #[test]
468    fn login_accepts_the_camel_case_payload_the_stub_sends() {
469        let r: MuxBusLoginReq =
470            serde_json::from_value(json!({"cognitoDomain": "d", "clientId": "c"}))
471                .expect("muxbus.login must accept camelCase keys");
472        assert_eq!((r.cognito_domain.as_str(), r.client_id.as_str()), ("d", "c"));
473
474        assert!(
475            serde_json::from_value::<MuxBusLoginReq>(json!({"cognito_domain": "d", "client_id": "c"}))
476                .is_err(),
477            "snake_case keys are NOT the wire format here"
478        );
479    }
480
481    // All three payload-ignoring commands are called with `{}`. A unit Req
482    // would reject that (serde deserializes `()` only from null).
483    #[test]
484    fn the_payload_ignoring_commands_accept_the_empty_object() {
485        serde_json::from_value::<MuxBusLoginCancelReq>(json!({})).expect("login.cancel");
486        serde_json::from_value::<MuxBusStatusReq>(json!({})).expect("status");
487        serde_json::from_value::<MuxBusDisconnectReq>(json!({})).expect("disconnect");
488        serde_json::from_value::<MuxBusCloudConfigReq>(json!({})).expect("cloudconfig");
489        assert!(serde_json::from_value::<()>(json!({})).is_err());
490    }
491
492    // `error` is skip_serializing_if, so a successful login OMITS the key
493    // rather than sending null -- which is why the binding says `error?`.
494    #[test]
495    fn login_response_omits_error_on_success() {
496        let ok = serde_json::to_value(MuxBusLoginResp {
497            success: true,
498            email: "a@b.c".to_string(),
499            error: None,
500        })
501        .expect("serializable");
502        assert_eq!(ok, json!({"success": true, "email": "a@b.c"}));
503    }
504
505    // disconnect answered with an inline `json!({})`; the stub already typed it
506    // `Record<string, never>`, which is what an empty struct generates.
507    #[test]
508    fn disconnect_response_is_an_empty_object() {
509        assert_eq!(serde_json::to_value(MuxBusDisconnectResp {}).unwrap(), json!({}));
510    }
511
512    fn published() -> crate::muxbus::discovery::CloudSettings {
513        crate::muxbus::discovery::parse(
514            r#"{"version":1,"api":"https://api.example.test","ws":"wss://ws.example.test","console":"https://console.example.test",
515                "cognito":{"domain":"https://auth.example.test","clientId":"abc123","region":"us-east-1","userPoolId":"us-east-1_X"}}"#,
516            "https://relay.example.test",
517        )
518        .unwrap()
519    }
520
521    // SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md §3.2: a published document
522    // supplies the sign-in settings and the WebSocket URL; REST stays srv's.
523    #[test]
524    fn cloudconfig_reports_a_published_document_as_discovered() {
525        let r = cloud_config(Some(published()), "https://relay.example.test".to_string());
526        assert_eq!(r.source, MuxBusCloudConfigSource::Discovered);
527        assert_eq!(r.api, "https://relay.example.test", "REST is never taken from the document");
528        assert_eq!(r.ws, "wss://ws.example.test");
529        assert_eq!(r.console, "https://console.example.test");
530        assert_eq!(r.cognito_domain, "https://auth.example.test");
531        assert_eq!(r.client_id, "abc123");
532        assert_eq!(r.region, "us-east-1");
533        assert_eq!(r.user_pool_id, "us-east-1_X");
534    }
535
536    // No document (an older relay, or discovery failed with no earlier copy):
537    // srv's compiled relay URLs, and no client id, so a build without its
538    // own can't offer sign-in.
539    #[test]
540    fn cloudconfig_without_a_document_reports_the_defaults() {
541        let r = cloud_config(None, "https://relay.example.test".to_string());
542        assert_eq!(r.source, MuxBusCloudConfigSource::Default);
543        assert_eq!(r.api, "https://relay.example.test");
544        assert_eq!(r.ws, crate::muxbus::cloud_subscriber::MUXBUS_WS_URL);
545        assert!(r.client_id.is_empty() && r.cognito_domain.is_empty() && r.console.is_empty());
546    }
547
548    // The wire shape the frontend reads: camelCase keys, lowercase source.
549    #[test]
550    fn cloudconfig_serializes_camel_case_with_a_lowercase_source() {
551        let v = serde_json::to_value(cloud_config(Some(published()), "https://relay.example.test".to_string())).unwrap();
552        assert_eq!(v["source"], "discovered");
553        assert_eq!(v["clientId"], "abc123");
554        assert_eq!(v["cognitoDomain"], "https://auth.example.test");
555        assert_eq!(v["userPoolId"], "us-east-1_X");
556        let v = serde_json::to_value(cloud_config(None, String::new())).unwrap();
557        assert_eq!(v["source"], "default");
558    }
559}