heddle_client/grpc_hosted/session.rs
1//! Hosted-session open seam.
2//!
3//! "Open a usable hosted session for a remote" used to be hand-assembled at
4//! every hosted entry point (push, pull, fetch, clone, support, approval,
5//! lazy hydration): resolve the auth token, fall back to the credential
6//! store, attach the proof key, build the validated client config, connect,
7//! then run mandatory credential rotation. This module owns that assembly so
8//! the command modules choose only intent + remote and call one seam.
9
10use std::net::SocketAddr;
11
12use anyhow::Result;
13use cli_shared::{ClientConfig, UserConfig};
14use wire::{AuthToken, ProtocolError};
15
16use crate::{credentials, grpc_hosted::HostedGrpcClient};
17
18/// How a hosted session resolves its auth token.
19pub enum HostedAuthMode {
20 /// Use only the token from env/user config (`remote_token`). Used by
21 /// fetch, support, approval, clone, and lazy hydration.
22 ConfigToken,
23 /// Use the config token, falling back to the per-server credential store
24 /// (and its proof key) when no config token is present. Used by push and
25 /// pull.
26 CredentialFallback,
27}
28
29/// A validated, connectable hosted-session configuration.
30///
31/// Building runs the fallible TLS/auth validation up front, so callers that
32/// must not leave partial on-disk artifacts (clone, push) — or that build the
33/// config on one thread and connect on another (lazy hydration) — can
34/// prevalidate before any irreversible work, then `connect()` afterwards.
35/// Callers with no such ordering constraint use
36/// [`HostedGrpcClient::open_session`], which builds and connects in one call.
37pub struct HostedSession {
38 config: ClientConfig,
39}
40
41impl HostedSession {
42 /// Resolve auth + build the validated client config for a hosted session.
43 /// Owns credential-store fallback (per `mode`), server-key attachment, and
44 /// proof-key attachment — the assembly the command modules used to
45 /// hand-roll.
46 pub fn build(
47 user_config: &UserConfig,
48 server_key: Option<String>,
49 mode: HostedAuthMode,
50 ) -> Result<Self> {
51 let (token, credential_proof_key) = match mode {
52 HostedAuthMode::ConfigToken => (user_config.remote_token()?, None),
53 HostedAuthMode::CredentialFallback => {
54 let mut token = user_config.remote_token()?;
55 let mut credential_proof_key = None;
56 if token.is_none()
57 && let Some(ref key) = server_key
58 && let Ok(Some(cred)) = credentials::resolve_credential_for_server(key)
59 {
60 token = Some(AuthToken::new(cred.token, "credential-store"));
61 credential_proof_key = cred.private_key_pem;
62 }
63 (token, credential_proof_key)
64 }
65 };
66
67 let mut config = user_config.heddle_client_config(token)?;
68 if let Some(key) = server_key {
69 config = config.with_server_key(key);
70 }
71 if let Some(pem) = credential_proof_key
72 && config.auth_proof_key_pem.is_none()
73 {
74 config = config.with_auth_proof_key_pem(pem);
75 }
76 Ok(Self { config })
77 }
78
79 /// Connect and run mandatory credential rotation.
80 ///
81 /// The rotation MUST run immediately after connect — every hosted entry
82 /// point relies on a fresh token before its first RPC. This is the single
83 /// place that pairs connect with rotation; see the source-presence guard
84 /// in this module's tests.
85 pub async fn connect(&self, addr: SocketAddr) -> Result<HostedGrpcClient, ProtocolError> {
86 let mut client = HostedGrpcClient::connect(addr, &self.config).await?;
87 client.auto_rotate_if_needed().await;
88 Ok(client)
89 }
90}
91
92impl HostedGrpcClient {
93 /// Open a usable hosted session in one call: resolve auth, build the
94 /// validated client config, connect, and run mandatory rotation. Callers
95 /// that must prevalidate the config before irreversible work build a
96 /// [`HostedSession`] first, then [`HostedSession::connect`].
97 pub async fn open_session(
98 addr: SocketAddr,
99 user_config: &UserConfig,
100 server_key: Option<String>,
101 mode: HostedAuthMode,
102 ) -> Result<Self> {
103 Ok(HostedSession::build(user_config, server_key, mode)?
104 .connect(addr)
105 .await?)
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 //! The connect/rotate invariant now lives here, in the one seam every
112 //! hosted entry point opens its session through. This source-presence
113 //! guard replaces the per-call-site rotation checks: rotation MUST run
114 //! immediately after `HostedGrpcClient::connect`, or a process whose
115 //! cached token has slipped past expiry hits an auth failure on its first
116 //! RPC even though the rotation data is on disk.
117
118 #[test]
119 fn session_connect_rotates_credentials_after_connect() {
120 let source = include_str!("session.rs");
121 let connect_idx = source
122 .find("HostedGrpcClient::connect(addr, &self.config)")
123 .expect("session.rs must connect with the resolved addr");
124 let after_connect = &source[connect_idx..];
125 let rotate_offset = after_connect
126 .find("auto_rotate_if_needed")
127 .expect("auto_rotate_if_needed must appear in session.rs");
128 assert!(
129 rotate_offset < 400,
130 "auto_rotate_if_needed must follow HostedGrpcClient::connect within the \
131 same async block (found {rotate_offset} chars later)",
132 );
133 }
134}