Skip to main content

tacenta_client/
tenant.rs

1//! The tenant handle: the first layer of the SDK surface (decision record
2//! 0090).
3//!
4//! An app holds one [`Tacenta`] per tenant. It is built from the API key and
5//! the server's name, fetches the server's service document once to learn
6//! where the four services are, and hands out signed-in
7//! [`Client`](crate::Client)s. Nothing
8//! above this layer sees a host or a port: the four `host:port` pairs that
9//! every sample used to carry live in the document the server publishes at
10//! `/.well-known/tacenta` (`tacenta-discovery` is the shared definition), and
11//! a deployment that moves a service moves it there.
12//!
13//! The server is a parameter with a default, never a constant baked in: a
14//! self-hosted deployment (decision 0047) points the handle at itself.
15//!
16//! # What a document may and may not say
17//!
18//! A document fetched over `https://` arrives authenticated by the web PKI,
19//! and the handle holds it to that: it may name services on the host it came
20//! from or on subdomains of it, and it may ask for web-PKI or a private trust
21//! root under such a name, but it may not turn TLS off and it may not send
22//! the tenant's key and its users' passwords to some other domain. A
23//! misconfigured or compromised gateway is therefore bounded to its own
24//! domain, and cannot downgrade a client below the trust the caller
25//! configured. The rule is deliberately the origin host and its subdomains,
26//! not "the same registrable domain": deciding that without the public
27//! suffix list admits every neighbour on a shared platform host, so a
28//! deployment serves its document from the host its services are on, or an
29//! ancestor of it (the shipped deployment uses the apex for both).
30//!
31//! A document fetched over `http://` has no authentication to hold it to, so
32//! it is accepted only from loopback: the development server on the same
33//! machine. Anywhere else, plaintext discovery would let one intercepted GET
34//! redirect the credentials, and is refused; a caller that has a document
35//! it vouches for by other means builds the handle from it with
36//! [`Tacenta::from_document`].
37
38use std::net::SocketAddr;
39use std::sync::Arc;
40
41use tacenta_discovery::{ServiceDocument, Tls, host_of};
42use tacenta_transport::ClientTls;
43
44use crate::dial::{Connector, Dialer};
45use crate::{AccountConfig, DefaultClient, Error, Result, SecureStore};
46
47/// The four services, resolved to socket addresses.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Endpoints {
50    pub directory: SocketAddr,
51    pub relay: SocketAddr,
52    pub accounts: SocketAddr,
53    pub provisioning: SocketAddr,
54}
55
56/// One tenant's handle on one server: the API key, where the services are,
57/// and how the server is trusted. Cheap to clone; hold one per tenant.
58#[derive(Clone)]
59pub struct Tacenta {
60    api_key: String,
61    endpoints: Endpoints,
62    /// The name the certificate presents and the trust to check it against,
63    /// or `None` for a plaintext development server.
64    trust: Option<(String, ClientTls)>,
65    /// The trust the document itself arrived under: what a `wss://` carriage
66    /// is checked against, since it terminates at the discovery origin
67    /// rather than at the services.
68    origin_tls: ClientTls,
69    carriage: Carriage,
70}
71
72/// Whether the document offered a WebSocket carriage, and whether this
73/// handle takes it.
74#[derive(Clone)]
75enum Carriage {
76    /// No carriage offered: TCP only.
77    Tcp,
78    /// Offered at this base URL; the handle dials TCP.
79    Offered(String),
80    /// Offered at this base URL, and the handle dials through it.
81    Active(String),
82    /// The caller opens the streams (see [`Connector`]).
83    Custom(Arc<dyn Connector>),
84}
85
86impl std::fmt::Debug for Carriage {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            Carriage::Tcp => f.write_str("Tcp"),
90            Carriage::Offered(b) => f.debug_tuple("Offered").field(b).finish(),
91            Carriage::Active(b) => f.debug_tuple("Active").field(b).finish(),
92            Carriage::Custom(_) => f.write_str("Custom"),
93        }
94    }
95}
96
97/// Hand-written so the API key never reaches a log line: a `{:?}` on a handle
98/// shows where it points and how it trusts the server, and a redaction where
99/// the key would be.
100impl std::fmt::Debug for Tacenta {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("Tacenta")
103            .field("server_name", &self.server_name())
104            .field("endpoints", &self.endpoints)
105            .field("tls", &self.is_tls())
106            .field("carriage", &self.carriage)
107            .field("api_key", &"<redacted>")
108            .finish()
109    }
110}
111
112impl Tacenta {
113    /// The hosted service.
114    pub const DEFAULT_SERVER: &'static str = "tacenta.com";
115
116    /// Connect to hosted Tacenta: fetch its service document over HTTPS and
117    /// trust its certificate through the public web PKI.
118    #[cfg(not(target_arch = "wasm32"))]
119    pub async fn connect(api_key: &str) -> Result<Tacenta> {
120        Tacenta::connect_to(api_key, Tacenta::DEFAULT_SERVER).await
121    }
122
123    /// Connect to the Tacenta server at `server` (a host name), fetching
124    /// `https://{server}/.well-known/tacenta` with web-PKI trust.
125    #[cfg(not(target_arch = "wasm32"))]
126    pub async fn connect_to(api_key: &str, server: &str) -> Result<Tacenta> {
127        let url = format!("https://{server}{}", tacenta_discovery::WELL_KNOWN_PATH);
128        Tacenta::connect_via(api_key, &url, &ClientTls::web_pki()).await
129    }
130
131    /// Connect by fetching the service document at an explicit URL, with
132    /// `tls` deciding the trust for an `https://` URL. This is the local
133    /// development path (`http://127.0.0.1:4780/.well-known/tacenta`, the
134    /// gateway's own port; `http://` is accepted from loopback only) and the
135    /// path for a private certificate on the discovery host itself.
136    #[cfg(not(target_arch = "wasm32"))]
137    pub async fn connect_via(api_key: &str, url: &str, tls: &ClientTls) -> Result<Tacenta> {
138        let doc = Tacenta::fetch_document(url, tls).await?;
139        Tacenta::from_discovered(api_key, url, &doc, tls).await
140    }
141
142    /// Fetch and parse the service document at `url`, without building a
143    /// handle: for a caller that caches documents. Pair it with
144    /// [`from_discovered`](Self::from_discovered).
145    #[cfg(not(target_arch = "wasm32"))]
146    pub async fn fetch_document(url: &str, tls: &ClientTls) -> Result<ServiceDocument> {
147        let body = tacenta_transport::http_get(url, tls)
148            .await
149            .map_err(|e| Error::Discovery(format!("{}: {e}", shown(url))))?;
150        serde_json::from_slice(&body)
151            .map_err(|e| Error::Discovery(format!("{}: not a service document: {e}", shown(url))))
152    }
153
154    /// Build a handle from a document fetched from `url`, holding the
155    /// document to what a document from that origin may say (see the module
156    /// documentation). [`connect_via`](Self::connect_via) is a fetch followed
157    /// by this.
158    pub async fn from_discovered(
159        api_key: &str,
160        url: &str,
161        doc: &ServiceDocument,
162        tls: &ClientTls,
163    ) -> Result<Tacenta> {
164        check_against_origin(url, doc)?;
165        Tacenta::from_document(api_key, doc, tls).await
166    }
167
168    /// Build a handle from a service document already in hand, exactly as it
169    /// says. No origin check: the caller vouches for the document (it wrote
170    /// it, or fetched it and checked it). Resolves each `host:port` once;
171    /// `tls` is used when the document says web-PKI.
172    pub async fn from_document(
173        api_key: &str,
174        doc: &ServiceDocument,
175        tls: &ClientTls,
176    ) -> Result<Tacenta> {
177        if doc.version != tacenta_discovery::VERSION {
178            return Err(Error::Discovery(format!(
179                "service document version {} is not one this client reads",
180                doc.version
181            )));
182        }
183        let trust = match &doc.tls {
184            // The caller's trust, which may be narrower than the web PKI.
185            Tls::WebPki => Some((doc.server_name.clone(), tls.clone())),
186            other => tacenta_transport::trust_for(other, &doc.server_name)
187                .map_err(|e| Error::Discovery(format!("private trust anchors: {e}")))?,
188        };
189        let (directory, relay, accounts, provisioning) = tokio::try_join!(
190            resolve(&doc.directory),
191            resolve(&doc.relay),
192            resolve(&doc.accounts),
193            resolve(&doc.provisioning),
194        )?;
195        Ok(Tacenta {
196            api_key: api_key.to_owned(),
197            endpoints: Endpoints {
198                directory,
199                relay,
200                accounts,
201                provisioning,
202            },
203            trust,
204            origin_tls: tls.clone(),
205            carriage: match &doc.ws {
206                Some(base) => Carriage::Offered(base.trim_end_matches('/').to_owned()),
207                None => Carriage::Tcp,
208            },
209        })
210    }
211
212    /// Reach the services over the document's WebSocket carriage instead of
213    /// their TCP ports: the path a browser or Node client takes, available to
214    /// a native client too (to test it, or to cross a network that admits
215    /// only HTTPS). Fails if the document offered no carriage.
216    pub fn websocket(mut self) -> Result<Tacenta> {
217        self.carriage = match self.carriage {
218            Carriage::Offered(base) | Carriage::Active(base) => Carriage::Active(base),
219            Carriage::Tcp | Carriage::Custom(_) => {
220                return Err(Error::Discovery(
221                    "the service document offers no websocket carriage".to_owned(),
222                ));
223            }
224        };
225        Ok(self)
226    }
227
228    /// Reach the services through streams `connector` opens, one per
229    /// service, ignoring the document's addresses and carriage: the way a
230    /// host that owns the sockets (a browser page, a test harness) lends
231    /// them to the client.
232    pub fn with_connector(mut self, connector: Arc<dyn Connector>) -> Tacenta {
233        self.carriage = Carriage::Custom(connector);
234        self
235    }
236
237    /// Whether the services are reached over the WebSocket carriage.
238    pub fn is_websocket(&self) -> bool {
239        matches!(self.carriage, Carriage::Active(_))
240    }
241
242    /// The WebSocket base URL the document offered, if any.
243    pub fn websocket_url(&self) -> Option<&str> {
244        match &self.carriage {
245            Carriage::Offered(base) | Carriage::Active(base) => Some(base),
246            Carriage::Tcp | Carriage::Custom(_) => None,
247        }
248    }
249
250    /// Build a handle from known endpoints: no discovery. `trust` is `None`
251    /// for a plaintext server, or the name the server's certificate presents
252    /// with the trust to check it against (a pinned certificate, or
253    /// [`ClientTls::web_pki`]).
254    pub fn from_endpoints(
255        api_key: &str,
256        endpoints: Endpoints,
257        trust: Option<(&str, &ClientTls)>,
258    ) -> Tacenta {
259        Tacenta {
260            api_key: api_key.to_owned(),
261            endpoints,
262            trust: trust.map(|(name, tls)| (name.to_owned(), tls.clone())),
263            origin_tls: ClientTls::web_pki(),
264            carriage: Carriage::Tcp,
265        }
266    }
267
268    /// The tenant API key this handle carries.
269    pub fn api_key(&self) -> &str {
270        &self.api_key
271    }
272
273    /// The name the server's certificate presents; `None` for a plaintext
274    /// server.
275    pub fn server_name(&self) -> Option<&str> {
276        self.trust.as_ref().map(|(name, _)| name.as_str())
277    }
278
279    /// Where the four services are.
280    pub fn endpoints(&self) -> &Endpoints {
281        &self.endpoints
282    }
283
284    /// Whether connections are over TLS.
285    pub fn is_tls(&self) -> bool {
286        self.trust.is_some()
287    }
288
289    fn dialer(&self) -> Dialer {
290        match &self.carriage {
291            Carriage::Active(base) => Dialer::WebSocket {
292                base: base.clone(),
293                // The carriage terminates at the discovery origin, so a
294                // wss:// socket is checked under the trust the document came
295                // in under, not the services' own.
296                tls: self.origin_tls.clone(),
297            },
298            Carriage::Custom(connector) => Dialer::Custom(connector.clone()),
299            _ => Dialer::Tcp {
300                trust: self.trust.clone(),
301            },
302        }
303    }
304
305    /// Create a user in this tenant.
306    pub async fn sign_up(&self, username: &str, password: &str) -> Result<()> {
307        DefaultClient::sign_up_trusting(
308            self.endpoints.accounts,
309            &self.dialer(),
310            &self.api_key,
311            username,
312            password,
313        )
314        .await
315    }
316
317    /// Sign a user in on device `1` with a fresh device identity, returning a
318    /// connected client. Persist [`Client::export_state`](crate::Client::export_state)
319    /// and sign in again with [`sign_in_with_state`](Self::sign_in_with_state)
320    /// on later runs.
321    pub async fn sign_in(&self, username: &str, password: &str) -> Result<DefaultClient> {
322        self.sign_in_device(username, password, 1).await
323    }
324
325    /// [`sign_in`](Self::sign_in) for a specific device number.
326    pub async fn sign_in_device(
327        &self,
328        username: &str,
329        password: &str,
330        device: u8,
331    ) -> Result<DefaultClient> {
332        let config = self.account_config(username, password, device);
333        DefaultClient::sign_in_trusting(&config, &self.dialer()).await
334    }
335
336    /// Sign in resuming persisted state (identity and live sessions) from
337    /// [`Client::export_state`](crate::Client::export_state).
338    pub async fn sign_in_with_state(
339        &self,
340        username: &str,
341        password: &str,
342        device: u8,
343        state: &[u8],
344    ) -> Result<DefaultClient> {
345        let config = self.account_config(username, password, device);
346        DefaultClient::sign_in_with_state_trusting(&config, &self.dialer(), state).await
347    }
348
349    /// Sign in resuming **sealed** state (from
350    /// [`Client::export_state_sealed`](crate::Client::export_state_sealed))
351    /// with `store` attached: the rollback-resistant path (decision 0078,
352    /// anchor B). A forged state is refused, one older than the latest send
353    /// is caught by the store's counter.
354    pub async fn sign_in_with_state_sealed(
355        &self,
356        username: &str,
357        password: &str,
358        device: u8,
359        state: &[u8],
360        store: Arc<dyn SecureStore + Send + Sync>,
361    ) -> Result<DefaultClient> {
362        let config = self.account_config(username, password, device);
363        DefaultClient::sign_in_with_state_sealed_trusting(&config, &self.dialer(), state, store)
364            .await
365    }
366
367    fn account_config(&self, username: &str, password: &str, device: u8) -> AccountConfig {
368        AccountConfig {
369            directory: self.endpoints.directory,
370            relay: self.endpoints.relay,
371            accounts: self.endpoints.accounts,
372            provisioning: self.endpoints.provisioning,
373            api_key: self.api_key.clone(),
374            identifier: username.to_owned(),
375            password: password.to_owned(),
376            device,
377        }
378    }
379}
380
381/// Hold a document to its origin (see the module documentation). Over
382/// `https://`: no plaintext, and every name in it (the four hosts and
383/// `server_name`) is the origin host or a subdomain of it. Over `http://`:
384/// only from loopback, and then taken as is.
385/// A URL or a host as an error message shows it: bounded and quoted, so a
386/// document from a hostile origin cannot plant pages of text, or a line
387/// that reads as something else, in what an app logs.
388fn shown(s: &str) -> String {
389    let mut t: String = s.chars().take(200).collect();
390    if t.len() < s.len() {
391        t.push('…');
392    }
393    format!("{t:?}")
394}
395
396fn check_against_origin(url: &str, doc: &ServiceDocument) -> Result<()> {
397    let refuse = |m: String| Err(Error::Discovery(format!("{}: {m}", shown(url))));
398    let Some(rest) = url.strip_prefix("https://") else {
399        let authority = url
400            .strip_prefix("http://")
401            .and_then(|r| r.split('/').next())
402            .unwrap_or("");
403        if is_loopback(host_of(authority)) {
404            return Ok(());
405        }
406        return refuse(
407            "plaintext discovery is accepted from loopback only; use https, or build \
408             the handle from a document you vouch for with Tacenta::from_document"
409                .to_owned(),
410        );
411    };
412    let authority = rest.split('/').next().unwrap_or("");
413    let origin = host_of(authority);
414    if doc.tls == Tls::None {
415        return refuse(
416            "the document offers plaintext services, but was fetched over https; \
417             refusing the downgrade"
418                .to_owned(),
419        );
420    }
421    let names = doc
422        .endpoints()
423        .into_iter()
424        .map(host_of)
425        .chain(std::iter::once(doc.server_name.as_str()));
426    for name in names {
427        if !same_site(name, origin) {
428            return refuse(format!(
429                "the document names {name}, which is not {origin} or a subdomain of it"
430            ));
431        }
432    }
433    // The carriage is a fifth destination, held to the same rule, and it
434    // must be wss: a ws:// URL would carry the credentials in plaintext,
435    // the downgrade the tls check above refuses.
436    if let Some(ws) = &doc.ws {
437        let Some(rest) = ws.strip_prefix("wss://") else {
438            return refuse(format!(
439                "the document offers the carriage at {ws}; over https it must be wss://"
440            ));
441        };
442        let ws_host = host_of(rest.split('/').next().unwrap_or(""));
443        if !same_site(ws_host, origin) {
444            return refuse(format!(
445                "the document offers the carriage at {ws_host}, which is not {origin} or a subdomain of it"
446            ));
447        }
448    }
449    Ok(())
450}
451
452/// The machine itself: `localhost`, `127.0.0.0/8`, or `::1`.
453fn is_loopback(host: &str) -> bool {
454    host.eq_ignore_ascii_case("localhost")
455        || host
456            .parse::<std::net::IpAddr>()
457            .is_ok_and(|ip| ip.is_loopback())
458}
459
460/// `name` is `origin` itself or a subdomain of it, case-insensitively.
461fn same_site(name: &str, origin: &str) -> bool {
462    let name = name.trim_end_matches('.').to_ascii_lowercase();
463    let origin = origin.trim_end_matches('.').to_ascii_lowercase();
464    name == origin || name.ends_with(&format!(".{origin}"))
465}
466
467/// Resolve `host:port` to one socket address.
468#[cfg(not(target_arch = "wasm32"))]
469async fn resolve(hostport: &str) -> Result<SocketAddr> {
470    let mut addrs = tokio::net::lookup_host(hostport)
471        .await
472        .map_err(|e| Error::Discovery(format!("cannot resolve {}: {e}", shown(hostport))))?;
473    addrs
474        .next()
475        .ok_or_else(|| Error::Discovery(format!("no address for {}", shown(hostport))))
476}
477
478/// On wasm nothing dials an address (the browser opens the sockets), so a
479/// name resolves to a placeholder carrying only the port.
480#[cfg(target_arch = "wasm32")]
481async fn resolve(hostport: &str) -> Result<SocketAddr> {
482    if let Ok(addr) = hostport.parse::<SocketAddr>() {
483        return Ok(addr);
484    }
485    let port: u16 = hostport
486        .rsplit(':')
487        .next()
488        .and_then(|p| p.parse().ok())
489        .ok_or_else(|| Error::Discovery(format!("no port in {}", shown(hostport))))?;
490    Ok(SocketAddr::from(([0, 0, 0, 0], port)))
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    fn loopback() -> Endpoints {
498        Endpoints {
499            directory: "127.0.0.1:1".parse().unwrap(),
500            relay: "127.0.0.1:2".parse().unwrap(),
501            accounts: "127.0.0.1:3".parse().unwrap(),
502            provisioning: "127.0.0.1:4".parse().unwrap(),
503        }
504    }
505
506    #[test]
507    fn debug_output_redacts_the_api_key() {
508        let tls = ClientTls::web_pki();
509        let t = Tacenta::from_endpoints(
510            "tct_secret_value",
511            loopback(),
512            Some(("tacenta.example", &tls)),
513        );
514        let shown = format!("{t:?}");
515        assert!(!shown.contains("tct_secret_value"), "{shown}");
516        assert!(
517            shown.contains("redacted") && shown.contains("tacenta.example"),
518            "{shown}"
519        );
520        assert_eq!(t.server_name(), Some("tacenta.example"));
521        let plain = Tacenta::from_endpoints("tct_x", loopback(), None);
522        assert_eq!(plain.server_name(), None);
523        assert!(!plain.is_tls());
524    }
525
526    #[tokio::test]
527    async fn an_unknown_version_is_refused() {
528        let mut doc = ServiceDocument::local("127.0.0.1");
529        doc.version = 2;
530        assert!(matches!(
531            Tacenta::from_document("tct_x", &doc, &ClientTls::web_pki()).await,
532            Err(Error::Discovery(_))
533        ));
534        doc.version = 1;
535        let t = Tacenta::from_document("tct_x", &doc, &ClientTls::web_pki())
536            .await
537            .unwrap();
538        assert!(!t.is_tls());
539        assert_eq!(t.endpoints().accounts, "127.0.0.1:4722".parse().unwrap());
540    }
541
542    #[tokio::test]
543    async fn bad_private_trust_anchors_are_a_discovery_error() {
544        let doc = ServiceDocument::on(
545            "127.0.0.1",
546            "dev.internal",
547            [1, 2, 3, 4],
548            Tls::PrivateCa {
549                trust_anchors_pem: "not a certificate".into(),
550            },
551        );
552        let err = Tacenta::from_document("tct_x", &doc, &ClientTls::web_pki())
553            .await
554            .unwrap_err();
555        assert!(
556            matches!(&err, Error::Discovery(m) if m.contains("private trust anchors")),
557            "{err}"
558        );
559    }
560
561    #[test]
562    fn an_https_document_may_not_turn_tls_off() {
563        let url = "https://tacenta.example/.well-known/tacenta";
564        let err =
565            check_against_origin(url, &ServiceDocument::local("tacenta.example")).unwrap_err();
566        assert!(err.to_string().contains("plaintext"), "{err}");
567    }
568
569    #[test]
570    fn plaintext_discovery_is_loopback_only() {
571        // The development server on this machine: taken as is.
572        for url in [
573            "http://127.0.0.1:4780/.well-known/tacenta",
574            "http://localhost:4780/.well-known/tacenta",
575            "http://[::1]:4780/.well-known/tacenta",
576        ] {
577            check_against_origin(url, &ServiceDocument::local("127.0.0.1")).unwrap();
578            // Even a loopback document may name anything: it is the caller's machine.
579            check_against_origin(url, &ServiceDocument::hosted("anything.example")).unwrap();
580        }
581        // A LAN gateway over http: one intercepted GET could redirect the
582        // credentials, so it is refused with the way out named.
583        let err = check_against_origin(
584            "http://10.0.0.5:4780/.well-known/tacenta",
585            &ServiceDocument::local("10.0.0.5"),
586        )
587        .unwrap_err();
588        assert!(err.to_string().contains("from_document"), "{err}");
589        assert!(
590            check_against_origin(
591                "http://gateway.internal/.well-known/tacenta",
592                &ServiceDocument::local("gateway.internal")
593            )
594            .is_err()
595        );
596    }
597
598    #[test]
599    fn an_https_document_may_not_name_another_domain() {
600        let url = "https://tacenta.example/.well-known/tacenta";
601        check_against_origin(url, &ServiceDocument::hosted("tacenta.example")).unwrap();
602        check_against_origin(url, &ServiceDocument::hosted("Relay.Tacenta.Example")).unwrap();
603        // A subdomain for the services, the apex for the certificate: allowed.
604        let split = ServiceDocument::on(
605            "svc.tacenta.example",
606            "tacenta.example",
607            [1, 2, 3, 4],
608            Tls::WebPki,
609        );
610        check_against_origin(url, &split).unwrap();
611
612        let err =
613            check_against_origin(url, &ServiceDocument::hosted("attacker.example")).unwrap_err();
614        assert!(err.to_string().contains("attacker.example"), "{err}");
615        let err =
616            check_against_origin(url, &ServiceDocument::hosted("nottacenta.example")).unwrap_err();
617        assert!(err.to_string().contains("nottacenta.example"), "{err}");
618        let mut name_only = ServiceDocument::hosted("tacenta.example");
619        name_only.server_name = "attacker.example".into();
620        assert!(check_against_origin(url, &name_only).is_err());
621        // An IP origin admits only itself.
622        check_against_origin(
623            "https://[2001:db8::1]:4780/x",
624            &ServiceDocument::on("2001:db8::1", "2001:db8::1", [1, 2, 3, 4], Tls::WebPki),
625        )
626        .unwrap();
627        assert!(
628            check_against_origin("https://10.0.0.1/x", &ServiceDocument::hosted("10.0.0.2"))
629                .is_err()
630        );
631    }
632
633    #[test]
634    fn the_carriage_is_held_to_the_origin_and_must_be_wss() {
635        let url = "https://tacenta.example/.well-known/tacenta";
636        let hosted = || ServiceDocument::hosted("tacenta.example");
637        check_against_origin(url, &hosted().with_ws("wss://tacenta.example/v1/ws")).unwrap();
638        check_against_origin(url, &hosted().with_ws("wss://ws.tacenta.example/v1/ws")).unwrap();
639        let err = check_against_origin(url, &hosted().with_ws("wss://attacker.example/v1/ws"))
640            .unwrap_err();
641        assert!(err.to_string().contains("attacker.example"), "{err}");
642        let err = check_against_origin(url, &hosted().with_ws("ws://tacenta.example:4780/v1/ws"))
643            .unwrap_err();
644        assert!(err.to_string().contains("must be wss"), "{err}");
645    }
646
647    #[tokio::test]
648    async fn the_carriage_is_offered_then_taken_and_its_slash_trimmed() {
649        let doc: ServiceDocument = serde_json::from_str(
650            r#"{"version":1,"server_name":"127.0.0.1","directory":"127.0.0.1:1","relay":"127.0.0.1:2","accounts":"127.0.0.1:3","provisioning":"127.0.0.1:4","tls":"none","ws":"ws://127.0.0.1:4780/v1/ws/"}"#,
651        )
652        .unwrap();
653        let t = Tacenta::from_document("tct_x", &doc, &ClientTls::web_pki())
654            .await
655            .unwrap();
656        assert!(!t.is_websocket());
657        assert_eq!(t.websocket_url(), Some("ws://127.0.0.1:4780/v1/ws"));
658        let t = t.websocket().unwrap();
659        assert!(t.is_websocket());
660        assert!(
661            matches!(t.dialer(), Dialer::WebSocket { base, .. } if base == "ws://127.0.0.1:4780/v1/ws")
662        );
663        let plain = Tacenta::from_document(
664            "tct_x",
665            &ServiceDocument::local("127.0.0.1"),
666            &ClientTls::web_pki(),
667        )
668        .await
669        .unwrap();
670        assert!(plain.websocket().is_err());
671    }
672}