Skip to main content

heddle_client/grpc_hosted/
user.rs

1use grpc::heddle::api::v1alpha1::{
2    ApproveThreadRequest, BeginWebAuthnAuthenticationRequest, CheckMergeEligibilityRequest,
3    CheckMergeEligibilityResponse, CreateGrantRequest, CreateInvitationRequest,
4    CreateRepositoryRequest, CreateServiceAccountRequest, DeleteGrantRequest,
5    DeleteNamespaceRequest, DeleteRepositoryRequest, GetCurrentUserNamespaceRequest,
6    GrantSupportAccessRequest, GrantTargetRef, Invitation as ProtoInvitation,
7    IssueServiceAccountCredentialRequest, IssuedCredentialResponse, ListGrantsRequest,
8    ListSpoolsRequest, ListSupportAccessGrantsRequest, ListThreadApprovalsRequest, MonorepoNode,
9    ResolveMonorepoRequest, RevokeApprovalRequest, RevokeSupportAccessRequest,
10    ServiceAccountResponse, SpoolSummary, SupportAccessGrant, ThreadApproval, UpdateGrantRequest,
11    UpdateNamespaceRequest, UpdateRepositoryRequest, grant_target_ref::Target as GrantTargetKind,
12};
13use tonic::Request;
14use wire::ProtocolError;
15
16use super::{
17    HostedGrpcClient,
18    helpers::{
19        status_to_protocol_error, to_protocol_grant, to_protocol_namespace, to_protocol_repository,
20    },
21    operation_id::ClientOperationId,
22};
23
24/// Dispatch an authenticated unary RPC on `self.user`: wrap the message in a
25/// `tonic::Request`, stamp bearer auth AND the Tier-1 PoP request signature via
26/// `apply_signed_auth`, await the call, and — if the server rejects with
27/// `x-heddle-sig-required: human` — invoke the app-registered human-signature
28/// callback over the SAME action and retry ONCE. Maps a transport `Status` to a
29/// `ProtocolError` and unwraps the response.
30///
31/// `$rpc` is the snake_case tonic client method; `$grpc_method` is the PascalCase
32/// proto RPC name (used to build the signed `:path`). The message is bound once
33/// and cloned for the potential human retry (all hosted request protos derive
34/// `Clone`). The macro is the one chokepoint for the auth/sign/retry sequence;
35/// it must be invoked inside an `async fn` returning `Result<_, ProtocolError>`.
36fn clone_request_for_retry<T: Clone>(request: &Request<T>) -> Request<T> {
37    let mut retry = Request::new(request.get_ref().clone());
38    *retry.metadata_mut() = request.metadata().clone();
39    retry
40}
41
42/// Shared signed-call boundary for callers that already carry custom request
43/// metadata. The untouched body and metadata template is retained before PoP
44/// headers are applied, then reused for the one permitted human-tier retry.
45macro_rules! signed_request_call {
46    ($self:ident, $client:ident, $rpc:ident, $path:expr, $request:expr) => {{
47        let path = $path;
48        let mut request = $request;
49        let retry_template = clone_request_for_retry(&request);
50        let sig_ctx = $self.apply_signed_auth(&mut request, path)?;
51        match $self.$client.$rpc(request).await {
52            Ok(response) => response.into_inner(),
53            Err(status)
54                if $crate::grpc_hosted::request_signing::requires_human_signature(&status) =>
55            {
56                // The human assertion must cover the SAME action (ts + nonce +
57                // body-hash) the challenge was derived from, so we reuse the
58                // original `sig_ctx` rather than re-signing with a fresh nonce.
59                // `attach_human` re-stamps `x-heddle-sig-ts`/`-nonce-bin` from that
60                // context; we only need bearer auth (not a fresh PoP) on retry.
61                let ctx = $self.require_human_sig_context(sig_ctx)?;
62                // The server may include a deep-link (weft#338) on the rejection pointing at a
63                // surface that can complete the WebAuthn ceremony; forward it to the callback.
64                let action_url =
65                    $crate::grpc_hosted::request_signing::action_url_from_status(&status);
66                let assertion = $self.request_human_signature(path, &ctx, action_url)?;
67                let mut retry = retry_template;
68                $self.apply_auth(&mut retry, path)?;
69                $crate::grpc_hosted::request_signing::attach_human(&mut retry, &ctx, &assertion)?;
70                $self
71                    .$client
72                    .$rpc(retry)
73                    .await
74                    .map_err(status_to_protocol_error)?
75                    .into_inner()
76            }
77            Err(status) => return Err(status_to_protocol_error(status)),
78        }
79    }};
80}
81
82macro_rules! signed_call {
83    ($self:ident, $client:ident, $rpc:ident, $path:expr, $msg:expr) => {{ signed_request_call!($self, $client, $rpc, $path, Request::new($msg)) }};
84}
85
86/// Dispatch an authenticated unary RPC on `self.user`: wrap the message in a
87/// `tonic::Request`, stamp bearer auth AND the Tier-1 PoP request signature via
88/// `apply_signed_auth`, await the call, and — if the server rejects with
89/// `x-heddle-sig-required: human` — invoke the app-registered human-signature
90/// callback over the SAME action and retry ONCE. Maps a transport `Status` to a
91/// `ProtocolError` and unwraps the response.
92///
93/// `$rpc` is the snake_case tonic client method; `$grpc_method` is the PascalCase
94/// proto RPC name (used to build the signed `:path`). The message is bound once
95/// and cloned for the potential human retry (all hosted request protos derive
96/// `Clone`). Delegates to [`signed_call!`], the one chokepoint for the
97/// auth/sign/retry sequence; must be invoked inside an `async fn` returning
98/// `Result<_, ProtocolError>`.
99macro_rules! authed_call {
100    ($self:ident, $rpc:ident, $grpc_method:literal, $msg:expr) => {{
101        signed_call!(
102            $self,
103            user,
104            $rpc,
105            concat!("/heddle.api.v1alpha1.RegistryService/", $grpc_method),
106            $msg
107        )
108    }};
109}
110
111macro_rules! workflow_call {
112    ($self:ident, $rpc:ident, $grpc_method:literal, $msg:expr) => {{
113        signed_call!(
114            $self,
115            workflow,
116            $rpc,
117            concat!("/heddle.api.v1alpha1.WorkflowService/", $grpc_method),
118            $msg
119        )
120    }};
121}
122
123fn default_spool_settings_request() -> grpc::heddle::api::v1alpha1::SpoolSettings {
124    use grpc::heddle::api::v1alpha1::{
125        SpoolBootstrapKind, SpoolBootstrapSyncDirection, SpoolChildPolicy, SpoolHoldLifecycle,
126        SpoolInitialTooling, SpoolSettings, SpoolStateVisibility, SpoolSyncBehavior,
127        SpoolVisibility, SpoolWritePolicy,
128    };
129
130    SpoolSettings {
131        visibility: SpoolVisibility::Private as i32,
132        default_state_visibility: SpoolStateVisibility::Internal as i32,
133        bootstrap_kind: SpoolBootstrapKind::Empty as i32,
134        bootstrap_source: String::new(),
135        write_policy: SpoolWritePolicy::Developers as i32,
136        child_policy: SpoolChildPolicy::Maintainers as i32,
137        initial_tooling: Some(SpoolInitialTooling::default()),
138        sync_behavior: SpoolSyncBehavior::Manual as i32,
139        bootstrap_sync_direction: SpoolBootstrapSyncDirection::Pull as i32,
140        description: String::new(),
141        // UNSPECIFIED = inherit; effective root default is EXPLICIT_SUPERSESSION.
142        hold_lifecycle: SpoolHoldLifecycle::Unspecified as i32,
143    }
144}
145
146impl HostedGrpcClient {
147    /// Resolve the acting identity for the bound bearer (subject, staff/service
148    /// markers, session, server-side scope, and directly-held resource roles).
149    /// Read-only; drives `heddle whoami`.
150    pub(crate) async fn who_am_i(
151        &mut self,
152    ) -> Result<grpc::heddle::api::v1alpha1::WhoAmIResponse, ProtocolError> {
153        Ok(signed_call!(
154            self,
155            auth,
156            who_am_i,
157            "/heddle.api.v1alpha1.IdentityService/WhoAmI",
158            grpc::heddle::api::v1alpha1::WhoAmIRequest {}
159        ))
160    }
161
162    pub(crate) async fn create_service_account(
163        &mut self,
164        request: CreateServiceAccountRequest,
165    ) -> Result<ServiceAccountResponse, ProtocolError> {
166        Ok(signed_call!(
167            self,
168            auth,
169            create_service_account,
170            "/heddle.api.v1alpha1.IdentityService/CreateServiceAccount",
171            request
172        ))
173    }
174
175    pub(crate) async fn issue_service_account_credential(
176        &mut self,
177        request: Request<IssueServiceAccountCredentialRequest>,
178    ) -> Result<IssuedCredentialResponse, ProtocolError> {
179        Ok(signed_request_call!(
180            self,
181            auth,
182            issue_service_account_credential,
183            "/heddle.api.v1alpha1.IdentityService/IssueServiceAccountCredential",
184            request
185        ))
186    }
187
188    pub async fn begin_login(
189        &mut self,
190        username: &str,
191    ) -> Result<(String, String, u64), ProtocolError> {
192        let request = Request::new(BeginWebAuthnAuthenticationRequest {
193            username: username.to_string(),
194        });
195        let response = self
196            .auth
197            .begin_web_authn_authentication(request)
198            .await
199            .map_err(status_to_protocol_error)?
200            .into_inner();
201        let expires_at_secs = response
202            .expires_at
203            .as_ref()
204            .map(|t| t.seconds.max(0) as u64)
205            .unwrap_or(0);
206        Ok((response.challenge_id, response.challenge, expires_at_secs))
207    }
208
209    pub async fn get_current_user_namespace(
210        &mut self,
211    ) -> Result<wire::HostedNamespaceInfo, ProtocolError> {
212        let namespace = authed_call!(
213            self,
214            get_current_user_namespace,
215            "GetCurrentUserNamespace",
216            GetCurrentUserNamespaceRequest {}
217        );
218        Ok(to_protocol_namespace(namespace))
219    }
220
221    pub async fn list_spools(
222        &mut self,
223        repos_only: bool,
224    ) -> Result<Vec<SpoolSummary>, ProtocolError> {
225        let response = authed_call!(
226            self,
227            list_spools,
228            "ListSpools",
229            ListSpoolsRequest { repos_only }
230        );
231        Ok(response.spools)
232    }
233
234    pub async fn create_namespace(
235        &mut self,
236        kind: &str,
237        slug: &str,
238        parent_path: Option<&str>,
239        display_name: Option<String>,
240    ) -> Result<wire::HostedNamespaceInfo, ProtocolError> {
241        let operation_id =
242            ClientOperationId::fresh("heddle.api.v1alpha1.RegistryService/CreateNamespace");
243        let namespace = authed_call!(
244            self,
245            create_namespace,
246            "CreateNamespace",
247            grpc::heddle::api::v1alpha1::CreateNamespaceRequest {
248                kind: parse_namespace_kind_arg(kind)? as i32,
249                slug: slug.to_string(),
250                parent_path: parent_path.unwrap_or_default().to_string(),
251                display_name: display_name.unwrap_or_default(),
252                settings: Some(default_spool_settings_request()),
253                client_operation_id: operation_id.to_wire(),
254            }
255        );
256        Ok(to_protocol_namespace(namespace))
257    }
258
259    pub async fn create_repository(
260        &mut self,
261        namespace_path: &str,
262        slug: &str,
263    ) -> Result<wire::HostedRepositoryInfo, ProtocolError> {
264        let operation_id =
265            ClientOperationId::fresh("heddle.api.v1alpha1.RegistryService/CreateRepository");
266        let repo = authed_call!(
267            self,
268            create_repository,
269            "CreateRepository",
270            CreateRepositoryRequest {
271                namespace_path: namespace_path.to_string(),
272                slug: slug.to_string(),
273                client_operation_id: operation_id.to_wire(),
274            }
275        );
276        Ok(to_protocol_repository(repo))
277    }
278
279    pub async fn update_namespace(
280        &mut self,
281        full_path: &str,
282        new_slug: Option<&str>,
283        display_name: Option<Option<String>>,
284    ) -> Result<wire::HostedNamespaceInfo, ProtocolError> {
285        let operation_id =
286            ClientOperationId::fresh("heddle.api.v1alpha1.RegistryService/UpdateNamespace");
287        let (display_name, clear_display_name) = match display_name {
288            Some(Some(value)) => (value, false),
289            Some(None) => (String::new(), true),
290            None => (String::new(), false),
291        };
292        let namespace = authed_call!(
293            self,
294            update_namespace,
295            "UpdateNamespace",
296            UpdateNamespaceRequest {
297                full_path: full_path.to_string(),
298                new_slug: new_slug.unwrap_or_default().to_string(),
299                display_name,
300                clear_display_name,
301                client_operation_id: operation_id.to_wire(),
302            }
303        );
304        Ok(to_protocol_namespace(namespace))
305    }
306
307    pub async fn delete_namespace(&mut self, full_path: &str) -> Result<(), ProtocolError> {
308        let operation_id =
309            ClientOperationId::fresh("heddle.api.v1alpha1.RegistryService/DeleteNamespace");
310        authed_call!(
311            self,
312            delete_namespace,
313            "DeleteNamespace",
314            DeleteNamespaceRequest {
315                full_path: full_path.to_string(),
316                client_operation_id: operation_id.to_wire(),
317            }
318        );
319        Ok(())
320    }
321
322    pub async fn update_repository(
323        &mut self,
324        full_path: &str,
325        new_slug: &str,
326    ) -> Result<wire::HostedRepositoryInfo, ProtocolError> {
327        let operation_id =
328            ClientOperationId::fresh("heddle.api.v1alpha1.RegistryService/UpdateRepository");
329        let repo = authed_call!(
330            self,
331            update_repository,
332            "UpdateRepository",
333            UpdateRepositoryRequest {
334                full_path: full_path.to_string(),
335                new_slug: new_slug.to_string(),
336                client_operation_id: operation_id.to_wire(),
337            }
338        );
339        Ok(to_protocol_repository(repo))
340    }
341
342    pub async fn delete_repository(&mut self, full_path: &str) -> Result<(), ProtocolError> {
343        let operation_id =
344            ClientOperationId::fresh("heddle.api.v1alpha1.RegistryService/DeleteRepository");
345        authed_call!(
346            self,
347            delete_repository,
348            "DeleteRepository",
349            DeleteRepositoryRequest {
350                full_path: full_path.to_string(),
351                client_operation_id: operation_id.to_wire(),
352            }
353        );
354        Ok(())
355    }
356
357    pub async fn create_grant(
358        &mut self,
359        subject: &str,
360        role: &str,
361        namespace_path: Option<&str>,
362        repo_path: Option<&str>,
363    ) -> Result<wire::HostedGrantInfo, ProtocolError> {
364        let operation_id =
365            ClientOperationId::fresh("heddle.api.v1alpha1.RegistryService/CreateGrant");
366        let target = build_target_ref(namespace_path, repo_path)?;
367        let grant = authed_call!(
368            self,
369            create_grant,
370            "CreateGrant",
371            CreateGrantRequest {
372                subject: subject.to_string(),
373                role: parse_hosted_role_arg(role)? as i32,
374                target,
375                client_operation_id: operation_id.to_wire(),
376            }
377        );
378        Ok(to_protocol_grant(grant))
379    }
380
381    pub async fn list_grants(
382        &mut self,
383        resource: Option<&str>,
384    ) -> Result<Vec<wire::HostedGrantInfo>, ProtocolError> {
385        let response = authed_call!(
386            self,
387            list_grants,
388            "ListGrants",
389            ListGrantsRequest {
390                resource: resource.unwrap_or_default().to_string(),
391            }
392        );
393        Ok(response.grants.into_iter().map(to_protocol_grant).collect())
394    }
395
396    pub async fn update_grant(
397        &mut self,
398        subject: &str,
399        role: &str,
400        namespace_path: Option<&str>,
401        repo_path: Option<&str>,
402    ) -> Result<wire::HostedGrantInfo, ProtocolError> {
403        let operation_id =
404            ClientOperationId::fresh("heddle.api.v1alpha1.RegistryService/UpdateGrant");
405        let target = build_target_ref(namespace_path, repo_path)?;
406        let grant = authed_call!(
407            self,
408            update_grant,
409            "UpdateGrant",
410            UpdateGrantRequest {
411                subject: subject.to_string(),
412                role: parse_hosted_role_arg(role)? as i32,
413                target,
414                client_operation_id: operation_id.to_wire(),
415            }
416        );
417        Ok(to_protocol_grant(grant))
418    }
419
420    pub async fn delete_grant(
421        &mut self,
422        subject: &str,
423        namespace_path: Option<&str>,
424        repo_path: Option<&str>,
425    ) -> Result<(), ProtocolError> {
426        let operation_id =
427            ClientOperationId::fresh("heddle.api.v1alpha1.RegistryService/DeleteGrant");
428        let target = build_target_ref(namespace_path, repo_path)?;
429        authed_call!(
430            self,
431            delete_grant,
432            "DeleteGrant",
433            DeleteGrantRequest {
434                subject: subject.to_string(),
435                target,
436                client_operation_id: operation_id.to_wire(),
437            }
438        );
439        Ok(())
440    }
441
442    /// Track D — create a pending invitation. Returns the raw proto type
443    /// to keep the surface narrow until we settle on a domain shape.
444    pub async fn create_invitation(
445        &mut self,
446        email: &str,
447        namespace_path: &str,
448        role: &str,
449    ) -> Result<ProtoInvitation, ProtocolError> {
450        let operation_id =
451            ClientOperationId::fresh("heddle.api.v1alpha1.RegistryService/CreateInvitation");
452        let invitation = authed_call!(
453            self,
454            create_invitation,
455            "CreateInvitation",
456            CreateInvitationRequest {
457                email: email.to_string(),
458                namespace_path: namespace_path.to_string(),
459                role: parse_hosted_role_arg(role)? as i32,
460                expires_at: None,
461                metadata: String::new(),
462                client_operation_id: operation_id.to_wire(),
463            }
464        );
465        Ok(invitation)
466    }
467
468    /// Record an approval for `(source_thread → target_thread)` at
469    /// the source's current `source_state`. The server's gate decides
470    /// later whether this approval *counts* against any matching
471    /// policy's requirements.
472    pub async fn approve_thread(
473        &mut self,
474        repo_path: &str,
475        source_thread: &str,
476        target_thread: &str,
477        source_state: &str,
478        note: Option<&str>,
479        client_operation_id: String,
480    ) -> Result<ThreadApproval, ProtocolError> {
481        let operation_id = ClientOperationId::caller_or_fresh(
482            "heddle.api.v1alpha1.WorkflowService/ApproveThread",
483            client_operation_id,
484        );
485        Ok(workflow_call!(
486            self,
487            approve_thread,
488            "ApproveThread",
489            ApproveThreadRequest {
490                repo_path: super::helpers::repository_ref(repo_path),
491                source_thread: source_thread.to_string(),
492                target_thread: target_thread.to_string(),
493                source_state: objects::object::StateId::parse(source_state)
494                    .ok()
495                    .and_then(super::helpers::proto_state_id),
496                note: note.unwrap_or_default().to_string(),
497                client_operation_id: operation_id.to_wire(),
498            }
499        ))
500    }
501
502    pub async fn revoke_approval(
503        &mut self,
504        id: &str,
505        client_operation_id: String,
506    ) -> Result<(), ProtocolError> {
507        let operation_id = ClientOperationId::caller_or_fresh(
508            "heddle.api.v1alpha1.WorkflowService/RevokeApproval",
509            client_operation_id,
510        );
511        workflow_call!(
512            self,
513            revoke_approval,
514            "RevokeApproval",
515            RevokeApprovalRequest {
516                id: id.to_string(),
517                client_operation_id: operation_id.to_wire(),
518            }
519        );
520        Ok(())
521    }
522
523    pub async fn list_thread_approvals(
524        &mut self,
525        repo_path: &str,
526        source_thread: &str,
527        target_thread: &str,
528    ) -> Result<Vec<ThreadApproval>, ProtocolError> {
529        Ok(workflow_call!(
530            self,
531            list_thread_approvals,
532            "ListThreadApprovals",
533            ListThreadApprovalsRequest {
534                repo_path: super::helpers::repository_ref(repo_path),
535                source_thread: source_thread.to_string(),
536                target_thread: target_thread.to_string(),
537            }
538        )
539        .approvals)
540    }
541
542    /// Ask the server "can <source> merge into <target> at
543    /// <source_state>, given the diff touches `changed_paths`?" The
544    /// reply lists every unmet requirement and the approvals that
545    /// counted as valid.
546    #[allow(clippy::too_many_arguments)]
547    pub async fn check_merge_eligibility(
548        &mut self,
549        repo_path: &str,
550        source_thread: &str,
551        target_thread: &str,
552        source_state: &str,
553        gated_action: &str,
554        changed_paths: Vec<String>,
555        author_user_id: Option<&str>,
556    ) -> Result<CheckMergeEligibilityResponse, ProtocolError> {
557        Ok(workflow_call!(
558            self,
559            check_merge_eligibility,
560            "CheckMergeEligibility",
561            CheckMergeEligibilityRequest {
562                repo_path: super::helpers::repository_ref(repo_path),
563                source_thread: source_thread.to_string(),
564                target_thread: target_thread.to_string(),
565                source_state: objects::object::StateId::parse(source_state)
566                    .ok()
567                    .and_then(super::helpers::proto_state_id),
568                gated_action: gated_action.to_string(),
569                changed_paths,
570                author_user_id: author_user_id.unwrap_or_default().to_string(),
571            }
572        ))
573    }
574
575    /// Phase C: grant a Heddle staff member temporary admin on a
576    /// namespace or repo. Exactly one of `namespace_path` or
577    /// `repo_path` should be set.
578    pub async fn grant_support_access(
579        &mut self,
580        operator_email: &str,
581        namespace_path: Option<&str>,
582        repo_path: Option<&str>,
583        ttl_seconds: u32,
584        reason: &str,
585        client_operation_id: String,
586    ) -> Result<SupportAccessGrant, ProtocolError> {
587        let operation_id = ClientOperationId::caller_or_fresh(
588            "heddle.api.v1alpha1.RegistryService/GrantSupportAccess",
589            client_operation_id,
590        );
591        let target = build_target_ref(namespace_path, repo_path)?;
592        Ok(authed_call!(
593            self,
594            grant_support_access,
595            "GrantSupportAccess",
596            GrantSupportAccessRequest {
597                operator_email: operator_email.to_string(),
598                target,
599                ttl_seconds: Some(prost_types::Duration {
600                    seconds: i64::from(ttl_seconds),
601                    nanos: 0,
602                }),
603                reason: reason.to_string(),
604                client_operation_id: operation_id.to_wire(),
605            }
606        ))
607    }
608
609    pub async fn list_support_access_grants(
610        &mut self,
611        namespace_path: Option<&str>,
612        repo_path: Option<&str>,
613        include_inactive: bool,
614    ) -> Result<Vec<SupportAccessGrant>, ProtocolError> {
615        let target = build_target_ref(namespace_path, repo_path)?;
616        Ok(authed_call!(
617            self,
618            list_support_access_grants,
619            "ListSupportAccessGrants",
620            ListSupportAccessGrantsRequest {
621                target,
622                include_inactive,
623            }
624        )
625        .grants)
626    }
627
628    pub async fn revoke_support_access(
629        &mut self,
630        id: &str,
631        client_operation_id: String,
632    ) -> Result<(), ProtocolError> {
633        let operation_id = ClientOperationId::caller_or_fresh(
634            "heddle.api.v1alpha1.RegistryService/RevokeSupportAccess",
635            client_operation_id,
636        );
637        authed_call!(
638            self,
639            revoke_support_access,
640            "RevokeSupportAccess",
641            RevokeSupportAccessRequest {
642                id: id.to_string(),
643                client_operation_id: operation_id.to_wire(),
644            }
645        );
646        Ok(())
647    }
648
649    /// Recursively resolve the monorepo rooted at `root_path` into the caller's
650    /// coherent visible slice (per-child visibility, cycle guard, depth bound).
651    /// `max_depth` is an optional recursion bound (server clamps to
652    /// `MONOREPO_MAX_DEPTH`). Returns the root `MonorepoNode` — the whole tree
653    /// the monorepo-clone planner walks.
654    pub async fn resolve_monorepo(
655        &mut self,
656        root_path: &str,
657        max_depth: Option<u32>,
658    ) -> Result<MonorepoNode, ProtocolError> {
659        Ok(authed_call!(
660            self,
661            resolve_monorepo,
662            "ResolveMonorepo",
663            ResolveMonorepoRequest {
664                root_path: root_path.to_string(),
665                max_depth,
666            }
667        ))
668    }
669}
670
671/// Build a `GrantTargetRef` oneof from CLI-style optional path args.
672/// Caller layer enforces that at most one of `namespace_path` /
673/// `repo_path` is set; this helper is just the wire-format adapter.
674fn build_target_ref(
675    namespace_path: Option<&str>,
676    repo_path: Option<&str>,
677) -> Result<Option<GrantTargetRef>, ProtocolError> {
678    match (
679        namespace_path.filter(|s| !s.is_empty()),
680        repo_path.filter(|s| !s.is_empty()),
681    ) {
682        (Some(ns), None) => Ok(Some(GrantTargetRef {
683            target: Some(GrantTargetKind::NamespacePath(ns.to_string())),
684        })),
685        (None, Some(rp)) => Ok(Some(GrantTargetRef {
686            target: Some(GrantTargetKind::RepoPath(
687                super::helpers::repository_ref(rp).expect("non-empty repository path"),
688            )),
689        })),
690        _ => Err(ProtocolError::InvalidState(
691            "exactly one of namespace_path or repo_path must be set".into(),
692        )),
693    }
694}
695
696/// Parse a CLI-supplied namespace kind string ("user" / "namespace" /
697/// "team", with "org" accepted as an alias for "namespace") into the
698/// proto `NamespaceKind` enum.
699fn parse_namespace_kind_arg(
700    value: &str,
701) -> Result<grpc::heddle::api::v1alpha1::NamespaceKind, ProtocolError> {
702    use grpc::heddle::api::v1alpha1::NamespaceKind;
703    match value.trim().to_ascii_lowercase().as_str() {
704        "user" => Ok(NamespaceKind::User),
705        "namespace" | "org" => Ok(NamespaceKind::Org),
706        "team" => Ok(NamespaceKind::Team),
707        other => Err(ProtocolError::InvalidState(format!(
708            "invalid namespace kind '{other}': expected user|namespace|team"
709        ))),
710    }
711}
712
713/// Parse a CLI-supplied role name into the proto `HostedRole` enum.
714fn parse_hosted_role_arg(
715    value: &str,
716) -> Result<grpc::heddle::api::v1alpha1::HostedRole, ProtocolError> {
717    use grpc::heddle::api::v1alpha1::HostedRole;
718    match value.trim().to_ascii_lowercase().as_str() {
719        "reader" => Ok(HostedRole::Reader),
720        "developer" => Ok(HostedRole::Developer),
721        "maintainer" => Ok(HostedRole::Maintainer),
722        "admin" => Ok(HostedRole::Admin),
723        "owner" => Ok(HostedRole::Owner),
724        other => Err(ProtocolError::InvalidState(format!(
725            "invalid role '{other}': expected reader|developer|maintainer|admin|owner"
726        ))),
727    }
728}
729
730#[cfg(test)]
731mod request_shape_tests {
732    use grpc::heddle::api::v1alpha1::{
733        ClaimHandleRequest, GetHandleStatusRequest, HandlePrincipal,
734        IssueServiceAccountCredentialRequest, RequestHeldNameRequest, ResolveHandleRequest,
735        ResolveMonorepoRequest, identity_service_client::IdentityServiceClient,
736    };
737    use prost::Message;
738    use tonic::{Request, transport::Channel};
739
740    use super::clone_request_for_retry;
741
742    #[test]
743    fn credential_issue_retry_preserves_custom_proof_and_operation_id() {
744        let mut original = Request::new(IssueServiceAccountCredentialRequest {
745            service_account_id: "sa-1".to_string(),
746            public_key: vec![7; 32],
747            scope: "repo:acme/*".to_string(),
748            ttl_secs: None,
749            client_operation_id: "stable-op-1".to_string(),
750        });
751        original
752            .metadata_mut()
753            .insert("x-heddle-issue-sa-proof-ts", "1700000000".parse().unwrap());
754        original.metadata_mut().insert_bin(
755            "x-heddle-issue-sa-proof-sig-bin",
756            tonic::metadata::MetadataValue::from_bytes(b"service-account-proof"),
757        );
758
759        let retry = clone_request_for_retry(&original);
760
761        assert_eq!(retry.get_ref(), original.get_ref());
762        assert_eq!(retry.get_ref().client_operation_id, "stable-op-1");
763        assert_eq!(
764            retry
765                .metadata()
766                .get("x-heddle-issue-sa-proof-ts")
767                .and_then(|value| value.to_str().ok()),
768            Some("1700000000")
769        );
770        assert!(
771            retry
772                .metadata()
773                .get_bin("x-heddle-issue-sa-proof-sig-bin")
774                .is_some()
775        );
776    }
777
778    #[test]
779    fn resolve_monorepo_request_threads_optional_max_depth() {
780        let bounded = ResolveMonorepoRequest {
781            root_path: "acme/root".to_string(),
782            max_depth: Some(3),
783        };
784        assert_eq!(bounded.root_path, "acme/root");
785        assert_eq!(bounded.max_depth, Some(3));
786
787        let unbounded = ResolveMonorepoRequest {
788            root_path: "acme/root".to_string(),
789            max_depth: None,
790        };
791        assert_eq!(unbounded.max_depth, None);
792    }
793
794    #[test]
795    fn shared_handle_client_surface_is_generated_with_retry_keys() {
796        #[allow(dead_code)]
797        async fn compile_all_handle_calls(mut client: IdentityServiceClient<Channel>) {
798            let _ = client
799                .get_handle_status(GetHandleStatusRequest {
800                    name: "octocat".to_string(),
801                })
802                .await;
803            let _ = client
804                .request_held_name(RequestHeldNameRequest {
805                    name: "octocat".to_string(),
806                    client_operation_id: "request-1".to_string(),
807                })
808                .await;
809            let _ = client
810                .claim_handle(ClaimHandleRequest {
811                    name: "octocat".to_string(),
812                    client_operation_id: "claim-1".to_string(),
813                })
814                .await;
815            let _ = client
816                .resolve_handle(ResolveHandleRequest {
817                    name: "octocat".to_string(),
818                })
819                .await;
820        }
821
822        let _ = compile_all_handle_calls;
823    }
824
825    #[test]
826    fn shared_handle_principal_drops_the_legacy_subject_field() {
827        #[derive(Clone, PartialEq, Message)]
828        struct LegacyResolvedPrincipal {
829            #[prost(string, tag = "1")]
830            subject: String,
831            #[prost(string, tag = "2")]
832            display_name: String,
833            #[prost(string, tag = "3")]
834            handle: String,
835            #[prost(bool, tag = "4")]
836            resolved: bool,
837            #[prost(string, tag = "5")]
838            primary_handle: String,
839            #[prost(string, tag = "6")]
840            kind: String,
841            #[prost(bool, tag = "7")]
842            verified: bool,
843            #[prost(string, tag = "8")]
844            discriminator: String,
845        }
846
847        let legacy = LegacyResolvedPrincipal {
848            subject: "user:private".to_string(),
849            display_name: "Octo Cat".to_string(),
850            handle: "octocat".to_string(),
851            resolved: true,
852            primary_handle: "octocat".to_string(),
853            kind: "native".to_string(),
854            verified: true,
855            discriminator: String::new(),
856        };
857        let public = HandlePrincipal::decode(legacy.encode_to_vec().as_slice())
858            .expect("legacy public tags must decode");
859
860        assert_eq!(public.display_name, "Octo Cat");
861        assert_eq!(public.handle, "octocat");
862        assert_eq!(public.primary_handle, "octocat");
863        assert!(public.resolved);
864        assert!(public.verified);
865
866        let round_trip = LegacyResolvedPrincipal::decode(public.encode_to_vec().as_slice())
867            .expect("public principal must remain wire-compatible on tags 2-8");
868        assert!(
869            round_trip.subject.is_empty(),
870            "the reserved legacy subject tag must not survive public decoding"
871        );
872
873        let descriptor = prost_types::FileDescriptorSet::decode(grpc::FILE_DESCRIPTOR_SET)
874            .expect("the shared API descriptor must decode");
875        let principal = descriptor
876            .file
877            .iter()
878            .filter(|file| file.package.as_deref() == Some("heddle.api.v1alpha1"))
879            .flat_map(|file| &file.message_type)
880            .find(|message| message.name.as_deref() == Some("HandlePrincipal"))
881            .expect("the shared descriptor must define HandlePrincipal");
882        assert!(
883            principal
884                .field
885                .iter()
886                .all(|field| field.name.as_deref() != Some("subject")),
887            "HandlePrincipal must not expose subject at any tag"
888        );
889        assert!(principal.reserved_name.iter().any(|name| name == "subject"));
890        assert!(
891            principal.reserved_range.iter().any(|range| {
892                range.start.is_some_and(|start| start <= 1) && range.end.is_some_and(|end| end > 1)
893            }),
894            "HandlePrincipal must reserve legacy subject tag 1"
895        );
896    }
897
898    #[test]
899    fn workflow_mutations_and_credential_issue_use_shared_retry_chokepoints() {
900        let source = include_str!("user.rs");
901        let approve = source
902            .split("pub async fn approve_thread")
903            .nth(1)
904            .and_then(|tail| tail.split("pub async fn revoke_approval").next())
905            .expect("approve_thread source");
906        let revoke = source
907            .split("pub async fn revoke_approval")
908            .nth(1)
909            .and_then(|tail| tail.split("pub async fn list_thread_approvals").next())
910            .expect("revoke_approval source");
911        let issue = source
912            .split("pub(crate) async fn issue_service_account_credential")
913            .nth(1)
914            .and_then(|tail| tail.split("pub async fn begin_login").next())
915            .expect("credential issue source");
916
917        assert!(approve.contains("caller_or_fresh"));
918        assert!(revoke.contains("caller_or_fresh"));
919        assert!(issue.contains("signed_request_call!"));
920    }
921}