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