Skip to main content

heddle_client/grpc_hosted/
user.rs

1use grpc::heddle::v1::{
2    ApproveThreadRequest, BeginWebAuthnAuthenticationRequest, CheckMergeEligibilityRequest,
3    CheckMergeEligibilityResponse, CreateGrantRequest, CreateInvitationRequest,
4    CreateRepositoryRequest, DeleteGrantRequest, DeleteNamespaceRequest, DeleteRepositoryRequest,
5    GetCurrentUserNamespaceRequest, GrantSupportAccessRequest, GrantTargetRef,
6    Invitation as ProtoInvitation, ListGrantsRequest, ListNamespacesRequest,
7    ListRepositoriesRequest, ListSupportAccessGrantsRequest, ListThreadApprovalsRequest,
8    RevokeApprovalRequest, RevokeSupportAccessRequest, SupportAccessGrant, ThreadApproval,
9    UpdateGrantRequest, UpdateNamespaceRequest, UpdateRepositoryRequest,
10    grant_target_ref::Target as GrantTargetKind,
11};
12use wire::ProtocolError;
13use tonic::Request;
14
15use super::{
16    HostedGrpcClient,
17    helpers::{
18        status_to_protocol_error, to_protocol_grant, to_protocol_namespace, to_protocol_repository,
19    },
20};
21
22/// Dispatch an authenticated unary RPC on `self.user`: wrap `$msg` in
23/// a `tonic::Request`, stamp the auth credential via `apply_auth`,
24/// await the call, map a transport `Status` to a `ProtocolError`, and
25/// unwrap the response to its inner message. Every authenticated
26/// user-service call shares this exact prologue; the macro is the one
27/// chokepoint so a change to the auth/await/error-map sequence lands
28/// once. `?`-propagates `ProtocolError` from `apply_auth` and the
29/// mapped status, so it must be invoked inside an `async fn` returning
30/// `Result<_, ProtocolError>`.
31macro_rules! authed_call {
32    ($self:ident, $rpc:ident, $msg:expr) => {{
33        let mut request = Request::new($msg);
34        $self.apply_auth(&mut request)?;
35        $self
36            .user
37            .$rpc(request)
38            .await
39            .map_err(status_to_protocol_error)?
40            .into_inner()
41    }};
42}
43
44impl HostedGrpcClient {
45    pub async fn begin_login(
46        &mut self,
47        username: &str,
48    ) -> Result<(String, String, u64), ProtocolError> {
49        let request = Request::new(BeginWebAuthnAuthenticationRequest {
50            username: username.to_string(),
51        });
52        let response = self
53            .auth
54            .begin_web_authn_authentication(request)
55            .await
56            .map_err(status_to_protocol_error)?
57            .into_inner();
58        let expires_at_secs = response
59            .expires_at
60            .as_ref()
61            .map(|t| t.seconds.max(0) as u64)
62            .unwrap_or(0);
63        Ok((response.challenge_id, response.challenge, expires_at_secs))
64    }
65
66    pub async fn get_current_user_namespace(
67        &mut self,
68    ) -> Result<wire::HostedNamespaceInfo, ProtocolError> {
69        let namespace = authed_call!(
70            self,
71            get_current_user_namespace,
72            GetCurrentUserNamespaceRequest {}
73        );
74        Ok(to_protocol_namespace(namespace))
75    }
76
77    pub async fn list_namespaces(
78        &mut self,
79    ) -> Result<Vec<wire::HostedNamespaceInfo>, ProtocolError> {
80        let response = authed_call!(self, list_namespaces, ListNamespacesRequest {});
81        Ok(response
82            .namespaces
83            .into_iter()
84            .map(to_protocol_namespace)
85            .collect())
86    }
87
88    pub async fn create_namespace(
89        &mut self,
90        kind: &str,
91        slug: &str,
92        parent_path: Option<&str>,
93        display_name: Option<String>,
94    ) -> Result<wire::HostedNamespaceInfo, ProtocolError> {
95        let namespace = authed_call!(
96            self,
97            create_namespace,
98            grpc::heddle::v1::CreateNamespaceRequest {
99                kind: parse_namespace_kind_arg(kind)? as i32,
100                slug: slug.to_string(),
101                parent_path: parent_path.unwrap_or_default().to_string(),
102                display_name: display_name.unwrap_or_default(),
103                client_operation_id: String::new(),
104            }
105        );
106        Ok(to_protocol_namespace(namespace))
107    }
108
109    pub async fn create_repository(
110        &mut self,
111        namespace_path: &str,
112        slug: &str,
113    ) -> Result<wire::HostedRepositoryInfo, ProtocolError> {
114        let repo = authed_call!(
115            self,
116            create_repository,
117            CreateRepositoryRequest {
118                namespace_path: namespace_path.to_string(),
119                slug: slug.to_string(),
120                client_operation_id: String::new(),
121            }
122        );
123        Ok(to_protocol_repository(repo))
124    }
125
126    pub async fn list_repositories(
127        &mut self,
128        namespace_path: Option<&str>,
129    ) -> Result<Vec<wire::HostedRepositoryInfo>, ProtocolError> {
130        let response = authed_call!(
131            self,
132            list_repositories,
133            ListRepositoriesRequest {
134                namespace_path: namespace_path.unwrap_or_default().to_string(),
135            }
136        );
137        Ok(response
138            .repositories
139            .into_iter()
140            .map(to_protocol_repository)
141            .collect())
142    }
143
144    pub async fn update_namespace(
145        &mut self,
146        full_path: &str,
147        new_slug: Option<&str>,
148        display_name: Option<Option<String>>,
149    ) -> Result<wire::HostedNamespaceInfo, ProtocolError> {
150        let (display_name, clear_display_name) = match display_name {
151            Some(Some(value)) => (value, false),
152            Some(None) => (String::new(), true),
153            None => (String::new(), false),
154        };
155        let namespace = authed_call!(
156            self,
157            update_namespace,
158            UpdateNamespaceRequest {
159                full_path: full_path.to_string(),
160                new_slug: new_slug.unwrap_or_default().to_string(),
161                display_name,
162                clear_display_name,
163                client_operation_id: String::new(),
164            }
165        );
166        Ok(to_protocol_namespace(namespace))
167    }
168
169    pub async fn delete_namespace(&mut self, full_path: &str) -> Result<(), ProtocolError> {
170        authed_call!(
171            self,
172            delete_namespace,
173            DeleteNamespaceRequest {
174                full_path: full_path.to_string(),
175                client_operation_id: String::new(),
176            }
177        );
178        Ok(())
179    }
180
181    pub async fn update_repository(
182        &mut self,
183        full_path: &str,
184        new_slug: &str,
185    ) -> Result<wire::HostedRepositoryInfo, ProtocolError> {
186        let repo = authed_call!(
187            self,
188            update_repository,
189            UpdateRepositoryRequest {
190                full_path: full_path.to_string(),
191                new_slug: new_slug.to_string(),
192                client_operation_id: String::new(),
193            }
194        );
195        Ok(to_protocol_repository(repo))
196    }
197
198    pub async fn delete_repository(&mut self, full_path: &str) -> Result<(), ProtocolError> {
199        authed_call!(
200            self,
201            delete_repository,
202            DeleteRepositoryRequest {
203                full_path: full_path.to_string(),
204                client_operation_id: String::new(),
205            }
206        );
207        Ok(())
208    }
209
210    pub async fn create_grant(
211        &mut self,
212        subject: &str,
213        role: &str,
214        namespace_path: Option<&str>,
215        repo_path: Option<&str>,
216    ) -> Result<wire::HostedGrantInfo, ProtocolError> {
217        let target = build_target_ref(namespace_path, repo_path)?;
218        let grant = authed_call!(
219            self,
220            create_grant,
221            CreateGrantRequest {
222                subject: subject.to_string(),
223                role: parse_hosted_role_arg(role)? as i32,
224                target,
225                client_operation_id: String::new(),
226            }
227        );
228        Ok(to_protocol_grant(grant))
229    }
230
231    pub async fn list_grants(
232        &mut self,
233        resource: Option<&str>,
234    ) -> Result<Vec<wire::HostedGrantInfo>, ProtocolError> {
235        let response = authed_call!(
236            self,
237            list_grants,
238            ListGrantsRequest {
239                resource: resource.unwrap_or_default().to_string(),
240            }
241        );
242        Ok(response.grants.into_iter().map(to_protocol_grant).collect())
243    }
244
245    pub async fn update_grant(
246        &mut self,
247        subject: &str,
248        role: &str,
249        namespace_path: Option<&str>,
250        repo_path: Option<&str>,
251    ) -> Result<wire::HostedGrantInfo, ProtocolError> {
252        let target = build_target_ref(namespace_path, repo_path)?;
253        let grant = authed_call!(
254            self,
255            update_grant,
256            UpdateGrantRequest {
257                subject: subject.to_string(),
258                role: parse_hosted_role_arg(role)? as i32,
259                target,
260                client_operation_id: String::new(),
261            }
262        );
263        Ok(to_protocol_grant(grant))
264    }
265
266    pub async fn delete_grant(
267        &mut self,
268        subject: &str,
269        namespace_path: Option<&str>,
270        repo_path: Option<&str>,
271    ) -> Result<(), ProtocolError> {
272        let target = build_target_ref(namespace_path, repo_path)?;
273        authed_call!(
274            self,
275            delete_grant,
276            DeleteGrantRequest {
277                subject: subject.to_string(),
278                target,
279                client_operation_id: String::new(),
280            }
281        );
282        Ok(())
283    }
284
285    /// Track D — create a pending invitation. Returns the raw proto type
286    /// to keep the surface narrow until we settle on a domain shape.
287    pub async fn create_invitation(
288        &mut self,
289        email: &str,
290        namespace_path: &str,
291        role: &str,
292    ) -> Result<ProtoInvitation, ProtocolError> {
293        let invitation = authed_call!(
294            self,
295            create_invitation,
296            CreateInvitationRequest {
297                email: email.to_string(),
298                namespace_path: namespace_path.to_string(),
299                role: parse_hosted_role_arg(role)? as i32,
300                expires_at: None,
301                metadata: String::new(),
302                client_operation_id: String::new(),
303            }
304        );
305        Ok(invitation)
306    }
307
308    /// Record an approval for `(source_thread → target_thread)` at
309    /// the source's current `source_state`. The server's gate decides
310    /// later whether this approval *counts* against any matching
311    /// policy's requirements.
312    pub async fn approve_thread(
313        &mut self,
314        repo_path: &str,
315        source_thread: &str,
316        target_thread: &str,
317        source_state: &str,
318        note: Option<&str>,
319    ) -> Result<ThreadApproval, ProtocolError> {
320        Ok(authed_call!(
321            self,
322            approve_thread,
323            ApproveThreadRequest {
324                repo_path: repo_path.to_string(),
325                source_thread: source_thread.to_string(),
326                target_thread: target_thread.to_string(),
327                source_state: objects::object::ChangeId::parse(source_state)
328                    .map(|id| id.as_bytes().to_vec())
329                    .unwrap_or_default(),
330                note: note.unwrap_or_default().to_string(),
331                client_operation_id: String::new(),
332            }
333        ))
334    }
335
336    pub async fn revoke_approval(&mut self, id: &str) -> Result<(), ProtocolError> {
337        authed_call!(
338            self,
339            revoke_approval,
340            RevokeApprovalRequest {
341                id: id.to_string(),
342                client_operation_id: String::new(),
343            }
344        );
345        Ok(())
346    }
347
348    pub async fn list_thread_approvals(
349        &mut self,
350        repo_path: &str,
351        source_thread: &str,
352        target_thread: &str,
353    ) -> Result<Vec<ThreadApproval>, ProtocolError> {
354        Ok(authed_call!(
355            self,
356            list_thread_approvals,
357            ListThreadApprovalsRequest {
358                repo_path: repo_path.to_string(),
359                source_thread: source_thread.to_string(),
360                target_thread: target_thread.to_string(),
361            }
362        )
363        .approvals)
364    }
365
366    /// Ask the server "can <source> merge into <target> at
367    /// <source_state>, given the diff touches `changed_paths`?" The
368    /// reply lists every unmet requirement and the approvals that
369    /// counted as valid.
370    #[allow(clippy::too_many_arguments)]
371    pub async fn check_merge_eligibility(
372        &mut self,
373        repo_path: &str,
374        source_thread: &str,
375        target_thread: &str,
376        source_state: &str,
377        gated_action: &str,
378        changed_paths: Vec<String>,
379        author_user_id: Option<&str>,
380    ) -> Result<CheckMergeEligibilityResponse, ProtocolError> {
381        Ok(authed_call!(
382            self,
383            check_merge_eligibility,
384            CheckMergeEligibilityRequest {
385                repo_path: repo_path.to_string(),
386                source_thread: source_thread.to_string(),
387                target_thread: target_thread.to_string(),
388                source_state: objects::object::ChangeId::parse(source_state)
389                    .map(|id| id.as_bytes().to_vec())
390                    .unwrap_or_default(),
391                gated_action: gated_action.to_string(),
392                changed_paths,
393                author_user_id: author_user_id.unwrap_or_default().to_string(),
394            }
395        ))
396    }
397
398    /// Phase C: grant a Heddle staff member temporary admin on a
399    /// namespace or repo. Exactly one of `namespace_path` or
400    /// `repo_path` should be set.
401    pub async fn grant_support_access(
402        &mut self,
403        operator_email: &str,
404        namespace_path: Option<&str>,
405        repo_path: Option<&str>,
406        ttl_seconds: u32,
407        reason: &str,
408        client_operation_id: String,
409    ) -> Result<SupportAccessGrant, ProtocolError> {
410        let target = build_target_ref(namespace_path, repo_path)?;
411        Ok(authed_call!(
412            self,
413            grant_support_access,
414            GrantSupportAccessRequest {
415                operator_email: operator_email.to_string(),
416                target,
417                ttl_seconds: Some(prost_types::Duration {
418                    seconds: i64::from(ttl_seconds),
419                    nanos: 0,
420                }),
421                reason: reason.to_string(),
422                client_operation_id,
423            }
424        ))
425    }
426
427    pub async fn list_support_access_grants(
428        &mut self,
429        namespace_path: Option<&str>,
430        repo_path: Option<&str>,
431        include_inactive: bool,
432    ) -> Result<Vec<SupportAccessGrant>, ProtocolError> {
433        let target = build_target_ref(namespace_path, repo_path)?;
434        Ok(authed_call!(
435            self,
436            list_support_access_grants,
437            ListSupportAccessGrantsRequest {
438                target,
439                include_inactive,
440            }
441        )
442        .grants)
443    }
444
445    pub async fn revoke_support_access(
446        &mut self,
447        id: &str,
448        client_operation_id: String,
449    ) -> Result<(), ProtocolError> {
450        authed_call!(
451            self,
452            revoke_support_access,
453            RevokeSupportAccessRequest {
454                id: id.to_string(),
455                client_operation_id,
456            }
457        );
458        Ok(())
459    }
460}
461
462/// Build a `GrantTargetRef` oneof from CLI-style optional path args.
463/// Caller layer enforces that at most one of `namespace_path` /
464/// `repo_path` is set; this helper is just the wire-format adapter.
465fn build_target_ref(
466    namespace_path: Option<&str>,
467    repo_path: Option<&str>,
468) -> Result<Option<GrantTargetRef>, ProtocolError> {
469    match (
470        namespace_path.filter(|s| !s.is_empty()),
471        repo_path.filter(|s| !s.is_empty()),
472    ) {
473        (Some(ns), None) => Ok(Some(GrantTargetRef {
474            target: Some(GrantTargetKind::NamespacePath(ns.to_string())),
475        })),
476        (None, Some(rp)) => Ok(Some(GrantTargetRef {
477            target: Some(GrantTargetKind::RepoPath(rp.to_string())),
478        })),
479        _ => Err(ProtocolError::InvalidState(
480            "exactly one of namespace_path or repo_path must be set".into(),
481        )),
482    }
483}
484
485/// Parse a CLI-supplied namespace kind string ("user" / "namespace" /
486/// "team", with "org" accepted as an alias for "namespace") into the
487/// proto `NamespaceKind` enum.
488fn parse_namespace_kind_arg(value: &str) -> Result<grpc::heddle::v1::NamespaceKind, ProtocolError> {
489    use grpc::heddle::v1::NamespaceKind;
490    match value.trim().to_ascii_lowercase().as_str() {
491        "user" => Ok(NamespaceKind::User),
492        "namespace" | "org" => Ok(NamespaceKind::Org),
493        "team" => Ok(NamespaceKind::Team),
494        other => Err(ProtocolError::InvalidState(format!(
495            "invalid namespace kind '{other}': expected user|namespace|team"
496        ))),
497    }
498}
499
500/// Parse a CLI-supplied role name into the proto `HostedRole` enum.
501fn parse_hosted_role_arg(value: &str) -> Result<grpc::heddle::v1::HostedRole, ProtocolError> {
502    use grpc::heddle::v1::HostedRole;
503    match value.trim().to_ascii_lowercase().as_str() {
504        "reader" => Ok(HostedRole::Reader),
505        "developer" => Ok(HostedRole::Developer),
506        "maintainer" => Ok(HostedRole::Maintainer),
507        "admin" => Ok(HostedRole::Admin),
508        "owner" => Ok(HostedRole::Owner),
509        other => Err(ProtocolError::InvalidState(format!(
510            "invalid role '{other}': expected reader|developer|maintainer|admin|owner"
511        ))),
512    }
513}