Skip to main content

pubhubs/cli/
enter.rs

1use std::collections::HashMap;
2
3use anyhow::{Context as _, Result};
4use futures::stream::StreamExt as _;
5use futures_util::FutureExt as _;
6
7use crate::api;
8use crate::attr;
9use crate::client;
10use crate::handle::Handle;
11use crate::misc::jwt;
12use crate::servers::Constellation;
13use crate::servers::yivi;
14
15use super::common::{self, Environment};
16
17use api::phc::user::AuthToken;
18
19/// Wrapper around `Vec<Handle>` that parses from a `|`-separated list of handles
20#[derive(Debug, Clone)]
21struct HandleChoice {
22    inner: Vec<Handle>,
23}
24
25impl std::ops::Deref for HandleChoice {
26    type Target = Vec<Handle>;
27
28    fn deref(&self) -> &Vec<Handle> {
29        &self.inner
30    }
31}
32
33impl core::str::FromStr for HandleChoice {
34    type Err = anyhow::Error;
35
36    fn from_str(s: &str) -> Result<Self, Self::Err> {
37        let mut handles: Vec<Handle> = Default::default();
38
39        for part in s.split('|') {
40            handles.push(Handle::from_str(part)?);
41        }
42
43        Ok(Self { inner: handles })
44    }
45}
46
47#[derive(clap::Args, Debug)]
48pub struct EnterArgs {
49    /// Enter this pubhubs environment
50    #[arg(short, long, value_name = "ENVIRONMENT", default_value = "stable")]
51    environment: Environment,
52
53    /// Contact PHC at this url, overriding --environment
54    #[arg(short, long, value_name = "PHC_URL")]
55    url: Option<url::Url>,
56
57    /// Whether to wait for a pubhubs yivi card
58    #[arg(short, long)]
59    wait_for_card: bool,
60
61    /// Whether to use 'chained session drip', AuthStartReq::yivi_chained_session_drip
62    #[arg(long)]
63    chained_session_drip: bool,
64
65    /// Ask for confirmation before proceeding at certain points.  Useful for letting something
66    /// time out.
67    #[arg(long)]
68    confirm: bool,
69
70    /// Comment to use on the pubhubs card, provided a card is requested
71    #[arg(long, value_name = "COMMENT")]
72    card_comment: Option<String>,
73
74    /// Handle identifying the hub
75    #[arg(value_name = "HUB")]
76    hub_handle: Option<Handle>,
77
78    /// Instead of the displaying the actual client url after entering a hub,
79    /// display the _local_ client url.  Useful when running your local client against main.
80    #[arg(short, long)]
81    local_client: bool,
82
83    /// The local client url used by  --local-client.
84    #[arg(long, value_name = "URL", default_value = "http://localhost:8001")]
85    local_client_url: url::Url,
86
87    /// Use this pubhubs authentication token
88    #[arg(short, long, value_name = "AUTH_TOKEN")]
89    auth_token: Option<AuthToken>,
90
91    /// Identifying attribute type to use
92    #[arg(
93        long,
94        default_value = "email",
95        value_name = "ATTR_TYPE",
96        conflicts_with = "auth_token"
97    )]
98    id_attr_type: HandleChoice,
99
100    /// Add these attributes when entering pubhubs
101    #[arg(
102        long,
103        default_value = "phone",
104        value_name = "ATTR_TYPE",
105        conflicts_with = "auth_token"
106    )]
107    add_attr_type: Vec<Handle>,
108
109    /// Don't add any attributes when entering pubhubs
110    #[arg(long, conflicts_with = "add_attr_type", conflicts_with = "auth_token")]
111    dont_add_attrs: bool,
112
113    /// Don't create a new account if one of the supplied attributes already bans another account
114    #[arg(long, conflicts_with = "auth_token")]
115    register_only_with_unique_attrs: bool,
116}
117
118impl EnterArgs {
119    pub fn run(mut self, _spec: &mut clap::Command) -> Result<()> {
120        env_logger::init();
121
122        if self.dont_add_attrs {
123            self.add_attr_type.clear();
124        }
125
126        tokio::runtime::Builder::new_current_thread()
127            .enable_all()
128            .build()?
129            .block_on(tokio::task::LocalSet::new().run_until(self.run_async()))
130    }
131
132    fn url(&self) -> std::borrow::Cow<'_, url::Url> {
133        common::phc_url(self.environment, &self.url)
134    }
135
136    async fn confirm(&self, msg: &str) {
137        if !self.confirm {
138            return;
139        }
140
141        use tokio::io::AsyncBufReadExt as _;
142
143        let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
144        println!("{msg} - press <ENTER> to continue");
145        let _ = stdin.read_line(&mut String::new()).await;
146    }
147
148    async fn run_async(self) -> Result<()> {
149        let client = client::Client::builder().agent(client::Agent::Cli).finish();
150
151        let url = self.url();
152        log::info!("contacting pubhubs central at {}", url);
153
154        let Ok(api::phc::user::WelcomeResp {
155            constellation,
156            hubs,
157        }) = client
158            .query_with_retry::<api::phc::user::WelcomeEP, _, _>(url.as_ref(), api::NoPayload)
159            .await
160        else {
161            anyhow::bail!("cannot reach pubhubs central at {}", url);
162        };
163
164        if let Some(hub_handle) = &self.hub_handle
165            && !hubs.contains_key(hub_handle)
166        {
167            anyhow::bail!(
168                "no such hub {}; choose from: {}",
169                hub_handle,
170                hubs.keys()
171                    .map(Handle::as_str)
172                    .collect::<Vec<&str>>()
173                    .join(", ")
174            )
175        }
176
177        let api::auths::WelcomeResp { attr_types, .. } = client
178            .query_with_retry::<api::auths::WelcomeEP, _, _>(
179                &constellation.auths_url,
180                api::NoPayload,
181            )
182            .await
183            .with_context(|| {
184                format!(
185                    "cannot reach authentication server at {}",
186                    constellation.auths_url
187                )
188            })?;
189
190        let auth_token = match self.auth_token {
191            Some(auth_token) => auth_token,
192            None => {
193                let auth_token = self
194                    .get_auth_token(&client, &constellation, &attr_types)
195                    .await?;
196                println!("global auth token: {auth_token}");
197                auth_token
198            }
199        };
200
201        let Some(hub_handle) = self.hub_handle else {
202            return Ok(());
203        };
204
205        let Some(hub_info) = hubs.get(&hub_handle) else {
206            panic!("did we not already check we have details on this hub?!");
207        };
208
209        let api::hub::EnterStartResp {
210            state: hub_state,
211            nonce: hub_nonce,
212            hhpp_signature_scheme,
213            hub_mac_key,
214        } = client
215            .query_with_retry::<api::hub::EnterStartEP, _, _>(&hub_info.url, api::NoPayload)
216            .await
217            .with_context(|| format!("cannot reach hub at {}", hub_info.url))?;
218
219        let ppp_resp = client
220            .query::<api::phc::user::PppEP>(&constellation.phc_url, api::NoPayload)
221            .auth_header(auth_token.clone())
222            .with_retry()
223            .await
224            .context("failed to obtain ppp from phc")?;
225
226        let api::phc::user::PppResp::Success(ppp) = ppp_resp else {
227            anyhow::bail!("failed to obtain ppp from phc: {ppp_resp:?}");
228        };
229
230        let ehpp_resp = client
231            .query::<api::tr::EhppEP>(
232                &constellation.transcryptor_url,
233                api::tr::EhppReq {
234                    hub_nonce,
235                    hub: hub_info.id,
236                    ppp,
237                    hub_mac_key,
238                },
239            )
240            .with_retry()
241            .await
242            .context("failed to obtain ehpp from transcryptor")?;
243
244        let api::tr::EhppResp::Success(ehpp) = ehpp_resp else {
245            anyhow::bail!("failed to obtain ehpp from transcryptor: {ehpp_resp:?}");
246        };
247
248        let hhpp_resp = client
249            .query::<api::phc::user::HhppEP>(
250                &constellation.phc_url,
251                api::phc::user::HhppReq {
252                    ehpp,
253                    hhpp_signature_scheme,
254                },
255            )
256            .auth_header(auth_token.clone())
257            .with_retry()
258            .await
259            .context("failed to obtain hhpp from phc")?;
260
261        let api::phc::user::HhppResp::Success(hhpp) = hhpp_resp else {
262            anyhow::bail!("failed to obtain hhpp from phc: {hhpp_resp:?}");
263        };
264
265        let enter_complete_resp = client
266            .query::<api::hub::EnterCompleteEP>(
267                &hub_info.url,
268                api::hub::EnterCompleteReq {
269                    state: hub_state,
270                    hhpp,
271                },
272            )
273            .with_retry()
274            .await
275            .context("failed to complete entering hub")?;
276
277        let api::hub::EnterCompleteResp::Entered {
278            access_token: hub_access_token,
279            device_id,
280            new_user,
281            mxid,
282        } = enter_complete_resp
283        else {
284            anyhow::bail!("failed to complete entering hub: {enter_complete_resp:?}");
285        };
286
287        let mut hub_client_url: url::Url = if self.local_client {
288            self.local_client_url
289        } else {
290            let api::hub::InfoResp { hub_client_url, .. } = client
291                .query_with_retry::<api::hub::InfoEP, _, _>(&hub_info.url, api::NoPayload)
292                .await
293                .context("failed to obtain hub information")?;
294
295            hub_client_url
296        };
297
298        hub_client_url.query_pairs_mut().append_pair(
299            "accessToken",
300            &serde_json::json!({
301                "token": hub_access_token,
302                "userId": mxid,
303            })
304            .to_string(),
305        );
306
307        println!("access token:   {hub_access_token}");
308        println!("mxid:           {mxid}");
309        println!("device id:      {device_id}");
310        println!("first time?:    {new_user}");
311        println!();
312        println!("hub client url: {hub_client_url}");
313        Ok(())
314    }
315
316    /// Enter pubhubs using a QR code on the command line; returns an auth token.
317    async fn get_auth_token(
318        &self,
319        client: &client::Client,
320        constellation: &Constellation,
321        attr_types: &HashMap<Handle, attr::Type>,
322    ) -> Result<AuthToken> {
323        for id_attr_type_choice in self.id_attr_type.iter() {
324            let Some(_id_attr_info) = attr_types.get(id_attr_type_choice) else {
325                anyhow::bail!(
326                    "no such attribute type {}; choose from: {}",
327                    id_attr_type_choice,
328                    attr_types
329                        .keys()
330                        .map(Handle::as_str)
331                        .collect::<Vec<&str>>()
332                        .join(", ")
333                )
334            };
335        }
336
337        let mut add_attrs_info =
338            HashMap::<Handle, attr::Type>::with_capacity(self.add_attr_type.len());
339
340        for attr_type in self.add_attr_type.iter() {
341            let Some(attr_info) = attr_types.get(attr_type) else {
342                anyhow::bail!(
343                    "no such attribute type {attr_type}; choose from: {}",
344                    attr_types
345                        .keys()
346                        .map(Handle::as_str)
347                        .collect::<Vec<&str>>()
348                        .join(", ")
349                )
350            };
351
352            anyhow::ensure!(
353                add_attrs_info
354                    .insert(attr_type.clone(), attr_info.clone())
355                    .is_none(),
356                "duplicate attribute type {attr_type}"
357            );
358        }
359
360        let mut attr_type_choices: Vec<Vec<Handle>> = Default::default();
361
362        attr_type_choices.push(Vec::<Handle>::clone(&self.id_attr_type));
363
364        for add_attr_ty_handle in self.add_attr_type.iter() {
365            attr_type_choices.push(vec![add_attr_ty_handle.clone()]);
366        }
367
368        let auth_start_resp = client
369            .query_with_retry::<api::auths::AuthStartEP, _, _>(
370                &constellation.auths_url,
371                api::auths::AuthStartReq {
372                    source: attr::Source::Yivi,
373                    yivi_chained_session: self.wait_for_card,
374                    yivi_chained_session_drip: self.chained_session_drip,
375                    attr_types: Default::default(),
376                    attr_type_choices,
377                },
378            )
379            .await
380            .context("failed to start authentication")?;
381
382        let api::auths::AuthStartResp::Success {
383            task: auth_task,
384            state: auth_state,
385        } = auth_start_resp
386        else {
387            anyhow::bail!("failed to start authentication: AS returned {auth_start_resp:?}");
388        };
389
390        let api::auths::AuthTask::Yivi {
391            disclosure_request,
392            yivi_requestor_url,
393        } = auth_task;
394
395        // Getting the disclosure is a bit tricky, as it is retrieved in two different ways
396        // depending on whether we're waiting for a card.
397        //
398        // If we're *not* waiting for a card, we simply call `yivi_cli_session` to get a disclosure, and that's that.
399        //
400        // But if we're waiting for a card, `yivi_cli_session` will not return the disclosure until
401        // we release the issuance request to the yivi server.  In this case, we obtain the
402        // disclosure via YiviWaitForResult, which is not available otherwise.
403        //
404        // We deal with these two ways to a disclosure by taking both paths in separate (tokio)
405        // tasks, and letting these tasks both send their disclosure over disclosure_sender
406        let (disclosure_sender, mut disclosure_receiver) = tokio::sync::mpsc::channel(1);
407
408        if self.wait_for_card {
409            // before we can move a future to a separate task via spawn_local, we must first clone
410            // anything we want to pass to it by reference
411            let client = client.clone();
412            let auth_state = auth_state.clone();
413            let auths_url = constellation.auths_url.clone();
414
415            let fut = async move {
416                client.query::<api::auths::YiviWaitForResultEP>(
417                    &auths_url,
418                    api::auths::YiviWaitForResultReq {
419                        state: auth_state.clone(),
420                    },
421                )
422                .timeout(core::time::Duration::from_secs(24*3600))
423                .with_retry().map(|wait_result| -> anyhow::Result<jwt::JWT> {
424                    let wait_result = wait_result.context("waiting for result of yivi to be submitted to the authentication server failed")?;
425
426                    let api::auths::YiviWaitForResultResp::Success { disclosure } = wait_result else {
427                        anyhow::bail!("waiting for result of yivi server to be submitted to authentication server failed: {wait_result:?} ");
428                    };
429
430                    Ok(disclosure)
431                }).await
432            };
433
434            let disclosure_sender = disclosure_sender.clone();
435
436            tokio::task::spawn_local(async move {
437                disclosure_sender
438                    .send(fut.await)
439                    .await
440                    .expect("did not expect disclosure channel to be closed already");
441            });
442        }
443
444        let fut = yivi_cli_session(yivi_requestor_url.clone(), disclosure_request);
445
446        let yivi_requestor_url_clone = yivi_requestor_url.clone();
447        let disclosure_sender_clone = disclosure_sender.clone();
448
449        tokio::task::spawn_local(async move {
450            let _ = disclosure_sender_clone
451                .send(fut.await.with_context(|| {
452                    format!("Yivi disclosure to {yivi_requestor_url_clone} failed")
453                }))
454                .await;
455        });
456
457        let disclosure = disclosure_receiver
458            .recv()
459            .await
460            .context("disclosure channel closed early")??;
461
462        let auth_complete_resp = client
463            .query_with_retry::<api::auths::AuthCompleteEP, _, _>(
464                &constellation.auths_url,
465                api::auths::AuthCompleteReq {
466                    proof: api::auths::AuthProof::Yivi { disclosure },
467                    state: auth_state.clone(),
468                },
469            )
470            .await
471            .context("failed to complete authentication")?;
472
473        let api::auths::AuthCompleteResp::Success { mut attrs } = auth_complete_resp else {
474            anyhow::bail!("failed to complete authentication: AS returned {auth_complete_resp:?}");
475        };
476
477        let Some((id_attr_type, identifying_attr)) = attrs.shift_remove_index(0) else {
478            anyhow::bail!("did not receive any attribute from authentication server");
479        };
480
481        if !self.id_attr_type.contains(&id_attr_type) {
482            anyhow::bail!(
483                "authentication server returned unexpected attribute type {id_attr_type} for identifying attribute; we were expecting one of {}",
484                self.id_attr_type
485                    .iter()
486                    .map(Handle::as_str)
487                    .collect::<Vec<&str>>()
488                    .join(", ")
489            );
490        }
491
492        let enter_resp = client
493            .query_with_retry::<api::phc::user::EnterEP, _, _>(
494                &constellation.phc_url,
495                api::phc::user::EnterReq {
496                    identifying_attr: Some(identifying_attr),
497                    mode: api::phc::user::EnterMode::LoginOrRegister,
498                    add_attrs: attrs.values().map(Clone::clone).collect(),
499                    register_only_with_unique_attrs: self.register_only_with_unique_attrs,
500                },
501            )
502            .await
503            .context("failed to enter pubhubs")?;
504
505        let api::phc::user::EnterResp::Entered {
506            auth_token_package: Ok(api::phc::user::AuthTokenPackage { auth_token, .. }),
507            new_account: _new_account,
508            attr_status,
509        } = enter_resp
510        else {
511            anyhow::bail!("failed to enter pubhubs: phc returned {enter_resp:?}");
512        };
513
514        for (attr, attr_status) in attr_status.iter() {
515            if *attr_status == api::phc::user::AttrAddStatus::PleaseTryAgain {
516                log::warn!("adding attribute {} failed", attr.value);
517            }
518        }
519
520        if self.wait_for_card {
521            let api::phc::user::CardPseudResp::Success(card_pseud_package) = client
522                .query::<api::phc::user::CardPseudEP>(&constellation.phc_url, api::NoPayload)
523                .auth_header(auth_token.clone())
524                .with_retry()
525                .await
526                .context("retrieving registration pseudonym failed")?
527            else {
528                anyhow::bail!("failed to retrieve registration pseudonym");
529            };
530
531            let api::auths::CardResp::Success {
532                attr,
533                issuance_request,
534                ..
535            } = client
536                .query_with_retry::<api::auths::CardEP, _, _>(
537                    &constellation.auths_url,
538                    api::auths::CardReq {
539                        card_pseud_package,
540                        comment: self.card_comment.clone(),
541                    },
542                )
543                .await?
544            else {
545                anyhow::bail!("failed to obtain pubhubs card from authentication server");
546            };
547
548            let enter_resp = client
549                .query::<api::phc::user::EnterEP>(
550                    &constellation.phc_url,
551                    api::phc::user::EnterReq {
552                        identifying_attr: None,
553                        mode: api::phc::user::EnterMode::Login,
554                        add_attrs: vec![attr],
555                        register_only_with_unique_attrs: false,
556                    },
557                )
558                .auth_header(auth_token.clone())
559                .with_retry()
560                .await
561                .context("failed to add pubhubs card to account")?;
562
563            let api::phc::user::EnterResp::Entered {
564                auth_token_package: Ok(api::phc::user::AuthTokenPackage { .. }),
565                attr_status,
566                ..
567            } = enter_resp
568            else {
569                anyhow::bail!("failed to add pubhubs card to account: phc returned {enter_resp:?}");
570            };
571
572            for (attr, attr_status) in attr_status.iter() {
573                match *attr_status {
574                    api::phc::user::AttrAddStatus::PleaseTryAgain => {
575                        anyhow::bail!("adding attribute {} failed", attr.value);
576                    }
577                    api::phc::user::AttrAddStatus::Added => {
578                        println!("pubhubs card was added to account");
579                    }
580                    api::phc::user::AttrAddStatus::AlreadyThere => {
581                        println!("pubhubs card already present");
582                    }
583                }
584            }
585
586            self.confirm("releasing yivi server").await;
587
588            let api::auths::YiviReleaseNextSessionResp::Success {} = client
589                .query_with_retry::<api::auths::YiviReleaseNextSessionEP, _, _>(
590                    &constellation.auths_url,
591                    api::auths::YiviReleaseNextSessionReq {
592                        state: auth_state.clone(),
593                        next_session: Some(issuance_request),
594                        stale_after: None,
595                    },
596                )
597                .await
598                .context("starting next yivi session failed")?
599            else {
600                anyhow::bail!("failed to start next yivi session");
601            };
602        }
603
604        Ok(auth_token)
605    }
606}
607
608/// Starts a yivi session with `yivi_requestor_url`, printing the QR code to the command line.
609async fn yivi_cli_session(
610    yivi_requestor_url: impl std::borrow::Borrow<url::Url>,
611    request: jwt::JWT,
612) -> Result<jwt::JWT> {
613    let yivi_requestor_url = yivi_requestor_url.borrow();
614    let client = awc::Client::default();
615
616    let mut resp = client
617        .post(yivi_requestor_url.join("/session")?.as_str())
618        .insert_header(("Content-Type", "text/plain"))
619        .send_body(request.as_str().to_string())
620        .await
621        .map_err(|err| anyhow::anyhow!("failed to start Yivi session: {err}"))?;
622
623    let YiviSessionPackage {
624        session_ptr: session_ptr_json,
625        token: requestor_token,
626        frontend_request: FrontendSessionRequest { authorization, .. },
627    } = resp.json().await?;
628
629    let SessionPtr {
630        url: mut frontend_url,
631        ..
632    } = serde_json::from_value(session_ptr_json.clone())
633        .with_context(|| "failed to parse session pointer returned by yivi server")?;
634
635    // make sure frontend_url ends with a '/' so Url::join works as expected
636    if !frontend_url.path().ends_with("/") {
637        frontend_url.set_path(format!("{}/", frontend_url.path()).as_str());
638    }
639
640    log::debug!("requestor token: {requestor_token}; frontend_url: {frontend_url}");
641
642    println!();
643    println!("Please scan the following QR code using your Yivi app.");
644
645    let qr = qrcode::QrCode::new(session_ptr_json.to_string().as_bytes())?;
646
647    let qr_render = qr
648        .render()
649        .light_color(qrcode::render::unicode::Dense1x2::Light)
650        .dark_color(qrcode::render::unicode::Dense1x2::Dark)
651        .build();
652    print!("{qr_render}\n\n");
653
654    let statusevents_url = frontend_url.join("frontend/statusevents")?;
655    //yivi_requestor_url
656    //    .join(&format!("/session/{requestor_token}/statusevents"))?
657    //    .as_str(),
658
659    log::debug!("{}", statusevents_url);
660
661    let mut statusevents = client
662        .get(statusevents_url.as_str())
663        .insert_header(("Authorization", authorization))
664        .send()
665        .await
666        .map_err(|err| anyhow::anyhow!("failed to listen to statusevents: {err}"))?;
667
668    loop {
669        let data: bytes::Bytes = statusevents
670            .next()
671            .await
672            .ok_or_else(|| anyhow::anyhow!("status events aborted early"))??;
673
674        log::debug!(
675            "received status event: {}",
676            crate::misc::fmt_ext::Bytes(&data)
677        );
678
679        let Some(data) = data.strip_prefix(b"data:") else {
680            continue;
681        };
682
683        let FrontendSessionStatus { status, .. } = serde_json::from_slice(data)?;
684
685        match status {
686            yivi::Status::Done => break,
687            yivi::Status::Pairing | yivi::Status::Connected | yivi::Status::Initialized => continue,
688            yivi::Status::Cancelled => anyhow::bail!("yivi session was cancelled"),
689            yivi::Status::Timeout => anyhow::bail!("yivi session timed out"),
690        }
691    }
692
693    let mut resp = client
694        .get(
695            yivi_requestor_url
696                .join(&format!("/session/{requestor_token}/result-jwt"))?
697                .as_str(),
698        )
699        .send()
700        .await
701        .map_err(|err| anyhow::anyhow!("failed to retrieve session result: {err}"))?;
702
703    Ok(std::str::from_utf8(&resp.body().await?)?.to_string().into())
704}
705
706/// Represents a [yivi session package](https://github.com/privacybydesign/irmago/blob/f9718c334af76a3ad2fa23019d17957878cd2032/server/api.go#L30).
707#[derive(serde::Deserialize, Debug, Clone)]
708#[serde(rename_all = "camelCase")]
709struct YiviSessionPackage {
710    session_ptr: serde_json::Value,
711
712    /// Requestor token
713    token: String,
714
715    frontend_request: FrontendSessionRequest,
716}
717
718#[derive(serde::Deserialize, Debug, Clone)]
719struct SessionPtr {
720    #[serde(rename = "u")]
721    url: url::Url,
722
723    #[serde(rename = "irmaqr")]
724    #[expect(dead_code)]
725    session_type: yivi::SessionType,
726}
727
728// https://github.com/privacybydesign/irmago/blob/773a229329a063043831a4c21e72b139b9600f4b/requests.go#L235
729#[derive(serde::Deserialize, Debug, Clone)]
730struct FrontendSessionRequest {
731    authorization: String,
732    // some fields omitted
733}
734
735/// <https://github.com/privacybydesign/irmago/blob/773a229329a063043831a4c21e72b139b9600f4b/messages.go#L571>
736#[derive(serde::Deserialize, Debug, Clone)]
737#[serde(rename_all = "camelCase")]
738struct FrontendSessionStatus {
739    status: yivi::Status,
740    #[expect(dead_code)]
741    next_session: Option<serde_json::Value>,
742}