Skip to main content

heddle_client/grpc_hosted/
user.rs

1use grpc::heddle::v1::{
2    ApproveThreadRequest, AttachChildRequest, BeginWebAuthnAuthenticationRequest,
3    CheckMergeEligibilityRequest, CheckMergeEligibilityResponse, ChildEdge, CreateGrantRequest,
4    CreateInvitationRequest, CreateRepositoryRequest, DeleteGrantRequest, DeleteNamespaceRequest,
5    DeleteRepositoryRequest, DetachChildRequest, GetCurrentUserNamespaceRequest,
6    GetGovernanceHistoryRequest, GetMembershipHistoryRequest, GovernanceHistoryEntry,
7    GrantSupportAccessRequest, GrantTargetRef, Invitation as ProtoInvitation, ListChildrenRequest,
8    ListGrantsRequest, ListSpoolsRequest, ListSupportAccessGrantsRequest,
9    ListThreadApprovalsRequest, MembershipHistoryEntry, MonorepoNode, ResolveMonorepoRequest,
10    RevokeApprovalRequest, RevokeSupportAccessRequest, SpoolSummary, SupportAccessGrant,
11    ThreadApproval, UpdateGrantRequest, UpdateNamespaceRequest, UpdateRepositoryRequest,
12    grant_target_ref::Target as GrantTargetKind,
13};
14use tonic::Request;
15use wire::ProtocolError;
16
17use super::{
18    HostedGrpcClient,
19    helpers::{
20        status_to_protocol_error, to_protocol_grant, to_protocol_namespace, to_protocol_repository,
21    },
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-weft-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>`.
36macro_rules! signed_call {
37    ($self:ident, $client:ident, $rpc:ident, $path:expr, $msg:expr) => {{
38        let path = $path;
39        let message = $msg;
40        let mut request = Request::new(message.clone());
41        let sig_ctx = $self.apply_signed_auth(&mut request, path)?;
42        match $self.$client.$rpc(request).await {
43            Ok(response) => response.into_inner(),
44            Err(status)
45                if $crate::grpc_hosted::request_signing::requires_human_signature(&status) =>
46            {
47                // The human assertion must cover the SAME action (ts + nonce +
48                // body-hash) the challenge was derived from, so we reuse the
49                // original `sig_ctx` rather than re-signing with a fresh nonce.
50                // `attach_human` re-stamps `x-weft-sig-ts`/`-nonce-bin` from that
51                // context; we only need bearer auth (not a fresh PoP) on retry.
52                let ctx = $self.require_human_sig_context(sig_ctx)?;
53                // The server may include a deep-link (weft#338) on the rejection pointing at a
54                // surface that can complete the WebAuthn ceremony; forward it to the callback.
55                let action_url =
56                    $crate::grpc_hosted::request_signing::action_url_from_status(&status);
57                let assertion = $self.request_human_signature(path, &ctx, action_url)?;
58                let mut retry = Request::new(message);
59                $self.apply_auth(&mut retry)?;
60                $crate::grpc_hosted::request_signing::attach_human(&mut retry, &ctx, &assertion)?;
61                $self
62                    .$client
63                    .$rpc(retry)
64                    .await
65                    .map_err(status_to_protocol_error)?
66                    .into_inner()
67            }
68            Err(status) => return Err(status_to_protocol_error(status)),
69        }
70    }};
71}
72
73/// Dispatch an authenticated unary RPC on `self.user`: wrap the message in a
74/// `tonic::Request`, stamp bearer auth AND the Tier-1 PoP request signature via
75/// `apply_signed_auth`, await the call, and — if the server rejects with
76/// `x-weft-sig-required: human` — invoke the app-registered human-signature
77/// callback over the SAME action and retry ONCE. Maps a transport `Status` to a
78/// `ProtocolError` and unwraps the response.
79///
80/// `$rpc` is the snake_case tonic client method; `$grpc_method` is the PascalCase
81/// proto RPC name (used to build the signed `:path`). The message is bound once
82/// and cloned for the potential human retry (all hosted request protos derive
83/// `Clone`). Delegates to [`signed_call!`], the one chokepoint for the
84/// auth/sign/retry sequence; must be invoked inside an `async fn` returning
85/// `Result<_, ProtocolError>`.
86macro_rules! authed_call {
87    ($self:ident, $rpc:ident, $grpc_method:literal, $msg:expr) => {{
88        signed_call!(
89            $self,
90            user,
91            $rpc,
92            concat!("/heddle.v1.HostedUserService/", $grpc_method),
93            $msg
94        )
95    }};
96}
97
98fn default_spool_settings_request() -> grpc::heddle::v1::SpoolSettings {
99    use grpc::heddle::v1::{
100        SpoolBootstrapKind, SpoolBootstrapSyncDirection, SpoolChildPolicy, SpoolInitialTooling,
101        SpoolSettings, SpoolStateVisibility, SpoolSyncBehavior, SpoolVisibility, SpoolWritePolicy,
102    };
103
104    SpoolSettings {
105        visibility: SpoolVisibility::Private as i32,
106        default_state_visibility: SpoolStateVisibility::Internal as i32,
107        bootstrap_kind: SpoolBootstrapKind::Empty as i32,
108        bootstrap_source: String::new(),
109        write_policy: SpoolWritePolicy::Developers as i32,
110        child_policy: SpoolChildPolicy::Maintainers as i32,
111        initial_tooling: Some(SpoolInitialTooling::default()),
112        sync_behavior: SpoolSyncBehavior::Manual as i32,
113        bootstrap_sync_direction: SpoolBootstrapSyncDirection::Pull as i32,
114        description: String::new(),
115    }
116}
117
118impl HostedGrpcClient {
119    pub async fn begin_login(
120        &mut self,
121        username: &str,
122    ) -> Result<(String, String, u64), ProtocolError> {
123        let request = Request::new(BeginWebAuthnAuthenticationRequest {
124            username: username.to_string(),
125        });
126        let response = self
127            .auth
128            .begin_web_authn_authentication(request)
129            .await
130            .map_err(status_to_protocol_error)?
131            .into_inner();
132        let expires_at_secs = response
133            .expires_at
134            .as_ref()
135            .map(|t| t.seconds.max(0) as u64)
136            .unwrap_or(0);
137        Ok((response.challenge_id, response.challenge, expires_at_secs))
138    }
139
140    pub async fn get_current_user_namespace(
141        &mut self,
142    ) -> Result<wire::HostedNamespaceInfo, ProtocolError> {
143        let namespace = authed_call!(
144            self,
145            get_current_user_namespace,
146            "GetCurrentUserNamespace",
147            GetCurrentUserNamespaceRequest {}
148        );
149        Ok(to_protocol_namespace(namespace))
150    }
151
152    pub async fn list_spools(
153        &mut self,
154        repos_only: bool,
155    ) -> Result<Vec<SpoolSummary>, ProtocolError> {
156        let response = authed_call!(
157            self,
158            list_spools,
159            "ListSpools",
160            ListSpoolsRequest { repos_only }
161        );
162        Ok(response.spools)
163    }
164
165    pub async fn create_namespace(
166        &mut self,
167        kind: &str,
168        slug: &str,
169        parent_path: Option<&str>,
170        display_name: Option<String>,
171    ) -> Result<wire::HostedNamespaceInfo, ProtocolError> {
172        let namespace = authed_call!(
173            self,
174            create_namespace,
175            "CreateNamespace",
176            grpc::heddle::v1::CreateNamespaceRequest {
177                kind: parse_namespace_kind_arg(kind)? as i32,
178                slug: slug.to_string(),
179                parent_path: parent_path.unwrap_or_default().to_string(),
180                display_name: display_name.unwrap_or_default(),
181                settings: Some(default_spool_settings_request()),
182                client_operation_id: String::new(),
183            }
184        );
185        Ok(to_protocol_namespace(namespace))
186    }
187
188    pub async fn create_repository(
189        &mut self,
190        namespace_path: &str,
191        slug: &str,
192    ) -> Result<wire::HostedRepositoryInfo, ProtocolError> {
193        let repo = authed_call!(
194            self,
195            create_repository,
196            "CreateRepository",
197            CreateRepositoryRequest {
198                namespace_path: namespace_path.to_string(),
199                slug: slug.to_string(),
200                client_operation_id: String::new(),
201            }
202        );
203        Ok(to_protocol_repository(repo))
204    }
205
206    pub async fn update_namespace(
207        &mut self,
208        full_path: &str,
209        new_slug: Option<&str>,
210        display_name: Option<Option<String>>,
211    ) -> Result<wire::HostedNamespaceInfo, ProtocolError> {
212        let (display_name, clear_display_name) = match display_name {
213            Some(Some(value)) => (value, false),
214            Some(None) => (String::new(), true),
215            None => (String::new(), false),
216        };
217        let namespace = authed_call!(
218            self,
219            update_namespace,
220            "UpdateNamespace",
221            UpdateNamespaceRequest {
222                full_path: full_path.to_string(),
223                new_slug: new_slug.unwrap_or_default().to_string(),
224                display_name,
225                clear_display_name,
226                client_operation_id: String::new(),
227            }
228        );
229        Ok(to_protocol_namespace(namespace))
230    }
231
232    pub async fn delete_namespace(&mut self, full_path: &str) -> Result<(), ProtocolError> {
233        authed_call!(
234            self,
235            delete_namespace,
236            "DeleteNamespace",
237            DeleteNamespaceRequest {
238                full_path: full_path.to_string(),
239                client_operation_id: String::new(),
240            }
241        );
242        Ok(())
243    }
244
245    pub async fn update_repository(
246        &mut self,
247        full_path: &str,
248        new_slug: &str,
249    ) -> Result<wire::HostedRepositoryInfo, ProtocolError> {
250        let repo = authed_call!(
251            self,
252            update_repository,
253            "UpdateRepository",
254            UpdateRepositoryRequest {
255                full_path: full_path.to_string(),
256                new_slug: new_slug.to_string(),
257                client_operation_id: String::new(),
258            }
259        );
260        Ok(to_protocol_repository(repo))
261    }
262
263    pub async fn delete_repository(&mut self, full_path: &str) -> Result<(), ProtocolError> {
264        authed_call!(
265            self,
266            delete_repository,
267            "DeleteRepository",
268            DeleteRepositoryRequest {
269                full_path: full_path.to_string(),
270                client_operation_id: String::new(),
271            }
272        );
273        Ok(())
274    }
275
276    pub async fn create_grant(
277        &mut self,
278        subject: &str,
279        role: &str,
280        namespace_path: Option<&str>,
281        repo_path: Option<&str>,
282    ) -> Result<wire::HostedGrantInfo, ProtocolError> {
283        let target = build_target_ref(namespace_path, repo_path)?;
284        let grant = authed_call!(
285            self,
286            create_grant,
287            "CreateGrant",
288            CreateGrantRequest {
289                subject: subject.to_string(),
290                role: parse_hosted_role_arg(role)? as i32,
291                target,
292                client_operation_id: String::new(),
293            }
294        );
295        Ok(to_protocol_grant(grant))
296    }
297
298    pub async fn list_grants(
299        &mut self,
300        resource: Option<&str>,
301    ) -> Result<Vec<wire::HostedGrantInfo>, ProtocolError> {
302        let response = authed_call!(
303            self,
304            list_grants,
305            "ListGrants",
306            ListGrantsRequest {
307                resource: resource.unwrap_or_default().to_string(),
308            }
309        );
310        Ok(response.grants.into_iter().map(to_protocol_grant).collect())
311    }
312
313    pub async fn update_grant(
314        &mut self,
315        subject: &str,
316        role: &str,
317        namespace_path: Option<&str>,
318        repo_path: Option<&str>,
319    ) -> Result<wire::HostedGrantInfo, ProtocolError> {
320        let target = build_target_ref(namespace_path, repo_path)?;
321        let grant = authed_call!(
322            self,
323            update_grant,
324            "UpdateGrant",
325            UpdateGrantRequest {
326                subject: subject.to_string(),
327                role: parse_hosted_role_arg(role)? as i32,
328                target,
329                client_operation_id: String::new(),
330            }
331        );
332        Ok(to_protocol_grant(grant))
333    }
334
335    pub async fn delete_grant(
336        &mut self,
337        subject: &str,
338        namespace_path: Option<&str>,
339        repo_path: Option<&str>,
340    ) -> Result<(), ProtocolError> {
341        let target = build_target_ref(namespace_path, repo_path)?;
342        authed_call!(
343            self,
344            delete_grant,
345            "DeleteGrant",
346            DeleteGrantRequest {
347                subject: subject.to_string(),
348                target,
349                client_operation_id: String::new(),
350            }
351        );
352        Ok(())
353    }
354
355    /// Track D — create a pending invitation. Returns the raw proto type
356    /// to keep the surface narrow until we settle on a domain shape.
357    pub async fn create_invitation(
358        &mut self,
359        email: &str,
360        namespace_path: &str,
361        role: &str,
362    ) -> Result<ProtoInvitation, ProtocolError> {
363        let invitation = authed_call!(
364            self,
365            create_invitation,
366            "CreateInvitation",
367            CreateInvitationRequest {
368                email: email.to_string(),
369                namespace_path: namespace_path.to_string(),
370                role: parse_hosted_role_arg(role)? as i32,
371                expires_at: None,
372                metadata: String::new(),
373                client_operation_id: String::new(),
374            }
375        );
376        Ok(invitation)
377    }
378
379    /// Record an approval for `(source_thread → target_thread)` at
380    /// the source's current `source_state`. The server's gate decides
381    /// later whether this approval *counts* against any matching
382    /// policy's requirements.
383    pub async fn approve_thread(
384        &mut self,
385        repo_path: &str,
386        source_thread: &str,
387        target_thread: &str,
388        source_state: &str,
389        note: Option<&str>,
390    ) -> Result<ThreadApproval, ProtocolError> {
391        Ok(authed_call!(
392            self,
393            approve_thread,
394            "ApproveThread",
395            ApproveThreadRequest {
396                repo_path: repo_path.to_string(),
397                source_thread: source_thread.to_string(),
398                target_thread: target_thread.to_string(),
399                source_state: objects::object::ChangeId::parse(source_state)
400                    .map(|id| id.as_bytes().to_vec())
401                    .unwrap_or_default(),
402                note: note.unwrap_or_default().to_string(),
403                client_operation_id: String::new(),
404            }
405        ))
406    }
407
408    pub async fn revoke_approval(&mut self, id: &str) -> Result<(), ProtocolError> {
409        authed_call!(
410            self,
411            revoke_approval,
412            "RevokeApproval",
413            RevokeApprovalRequest {
414                id: id.to_string(),
415                client_operation_id: String::new(),
416            }
417        );
418        Ok(())
419    }
420
421    pub async fn list_thread_approvals(
422        &mut self,
423        repo_path: &str,
424        source_thread: &str,
425        target_thread: &str,
426    ) -> Result<Vec<ThreadApproval>, ProtocolError> {
427        Ok(authed_call!(
428            self,
429            list_thread_approvals,
430            "ListThreadApprovals",
431            ListThreadApprovalsRequest {
432                repo_path: repo_path.to_string(),
433                source_thread: source_thread.to_string(),
434                target_thread: target_thread.to_string(),
435            }
436        )
437        .approvals)
438    }
439
440    /// Ask the server "can <source> merge into <target> at
441    /// <source_state>, given the diff touches `changed_paths`?" The
442    /// reply lists every unmet requirement and the approvals that
443    /// counted as valid.
444    #[allow(clippy::too_many_arguments)]
445    pub async fn check_merge_eligibility(
446        &mut self,
447        repo_path: &str,
448        source_thread: &str,
449        target_thread: &str,
450        source_state: &str,
451        gated_action: &str,
452        changed_paths: Vec<String>,
453        author_user_id: Option<&str>,
454    ) -> Result<CheckMergeEligibilityResponse, ProtocolError> {
455        Ok(authed_call!(
456            self,
457            check_merge_eligibility,
458            "CheckMergeEligibility",
459            CheckMergeEligibilityRequest {
460                repo_path: repo_path.to_string(),
461                source_thread: source_thread.to_string(),
462                target_thread: target_thread.to_string(),
463                source_state: objects::object::ChangeId::parse(source_state)
464                    .map(|id| id.as_bytes().to_vec())
465                    .unwrap_or_default(),
466                gated_action: gated_action.to_string(),
467                changed_paths,
468                author_user_id: author_user_id.unwrap_or_default().to_string(),
469            }
470        ))
471    }
472
473    /// Phase C: grant a Heddle staff member temporary admin on a
474    /// namespace or repo. Exactly one of `namespace_path` or
475    /// `repo_path` should be set.
476    pub async fn grant_support_access(
477        &mut self,
478        operator_email: &str,
479        namespace_path: Option<&str>,
480        repo_path: Option<&str>,
481        ttl_seconds: u32,
482        reason: &str,
483        client_operation_id: String,
484    ) -> Result<SupportAccessGrant, ProtocolError> {
485        let target = build_target_ref(namespace_path, repo_path)?;
486        Ok(authed_call!(
487            self,
488            grant_support_access,
489            "GrantSupportAccess",
490            GrantSupportAccessRequest {
491                operator_email: operator_email.to_string(),
492                target,
493                ttl_seconds: Some(prost_types::Duration {
494                    seconds: i64::from(ttl_seconds),
495                    nanos: 0,
496                }),
497                reason: reason.to_string(),
498                client_operation_id,
499            }
500        ))
501    }
502
503    pub async fn list_support_access_grants(
504        &mut self,
505        namespace_path: Option<&str>,
506        repo_path: Option<&str>,
507        include_inactive: bool,
508    ) -> Result<Vec<SupportAccessGrant>, ProtocolError> {
509        let target = build_target_ref(namespace_path, repo_path)?;
510        Ok(authed_call!(
511            self,
512            list_support_access_grants,
513            "ListSupportAccessGrants",
514            ListSupportAccessGrantsRequest {
515                target,
516                include_inactive,
517            }
518        )
519        .grants)
520    }
521
522    pub async fn revoke_support_access(
523        &mut self,
524        id: &str,
525        client_operation_id: String,
526    ) -> Result<(), ProtocolError> {
527        authed_call!(
528            self,
529            revoke_support_access,
530            "RevokeSupportAccess",
531            RevokeSupportAccessRequest {
532                id: id.to_string(),
533                client_operation_id,
534            }
535        );
536        Ok(())
537    }
538
539    // --- Spool child edges + monorepo resolution + facet history ---
540    // (Spool epic P9, weft#358). Thin unary wrappers over the P8a
541    // HostedUserService Spool RPCs. AttachChild/DetachChild are pop-tier
542    // mutations — they route through `authed_call!` (PoP-signed via
543    // `apply_signed_auth`; the human retry from #346 handles any human-tier
544    // escalation). The three read RPCs are unsigned-tier but go through the
545    // same chokepoint for uniform auth + error mapping. Each returns the raw
546    // proto type to keep the surface narrow — callers (CLI/tapestry) render.
547
548    /// Attach `child_path` under `parent_path` at `mount_name`, anchored at the
549    /// child's current content head. Mutates only the parent's children
550    /// edge-set (never its Content facet). Returns the resolved edge.
551    pub async fn attach_child(
552        &mut self,
553        parent_path: &str,
554        child_path: &str,
555        mount_name: &str,
556    ) -> Result<ChildEdge, ProtocolError> {
557        Ok(authed_call!(
558            self,
559            attach_child,
560            "AttachChild",
561            AttachChildRequest {
562                parent_path: parent_path.to_string(),
563                child_path: child_path.to_string(),
564                mount_name: mount_name.to_string(),
565            }
566        ))
567    }
568
569    /// Detach the child mounted at `mount_name` under `parent_path`. Returns
570    /// `true` when an edge was present and removed, `false` when no such mount
571    /// existed.
572    pub async fn detach_child(
573        &mut self,
574        parent_path: &str,
575        mount_name: &str,
576    ) -> Result<bool, ProtocolError> {
577        Ok(authed_call!(
578            self,
579            detach_child,
580            "DetachChild",
581            DetachChildRequest {
582                parent_path: parent_path.to_string(),
583                mount_name: mount_name.to_string(),
584            }
585        )
586        .removed)
587    }
588
589    /// List the child edges of `parent_path`, each resolved with its anchored
590    /// state + moving-anchored-ff status. Read-only; moves no edge.
591    pub async fn list_children(
592        &mut self,
593        parent_path: &str,
594    ) -> Result<Vec<ChildEdge>, ProtocolError> {
595        Ok(authed_call!(
596            self,
597            list_children,
598            "ListChildren",
599            ListChildrenRequest {
600                parent_path: parent_path.to_string(),
601            }
602        )
603        .children)
604    }
605
606    /// Recursively resolve the monorepo rooted at `root_path` into the caller's
607    /// coherent visible slice (per-child visibility, cycle guard, depth bound).
608    /// `max_depth` is an optional recursion bound (server clamps to
609    /// `MONOREPO_MAX_DEPTH`). Returns the root `MonorepoNode` — the whole tree
610    /// the monorepo-clone planner walks.
611    pub async fn resolve_monorepo(
612        &mut self,
613        root_path: &str,
614        max_depth: Option<u32>,
615    ) -> Result<MonorepoNode, ProtocolError> {
616        Ok(authed_call!(
617            self,
618            resolve_monorepo,
619            "ResolveMonorepo",
620            ResolveMonorepoRequest {
621                root_path: root_path.to_string(),
622                max_depth,
623            }
624        ))
625    }
626
627    /// Walk `spool_path`'s governance-facet history newest-first. `limit` caps
628    /// the entries walked (server default when `None`).
629    pub async fn get_governance_history(
630        &mut self,
631        spool_path: &str,
632        limit: Option<u32>,
633    ) -> Result<Vec<GovernanceHistoryEntry>, ProtocolError> {
634        Ok(authed_call!(
635            self,
636            get_governance_history,
637            "GetGovernanceHistory",
638            GetGovernanceHistoryRequest {
639                spool_path: spool_path.to_string(),
640                limit,
641            }
642        )
643        .entries)
644    }
645
646    /// Walk `spool_path`'s membership-facet history newest-first. `limit` caps
647    /// the entries walked (server default when `None`).
648    pub async fn get_membership_history(
649        &mut self,
650        spool_path: &str,
651        limit: Option<u32>,
652    ) -> Result<Vec<MembershipHistoryEntry>, ProtocolError> {
653        Ok(authed_call!(
654            self,
655            get_membership_history,
656            "GetMembershipHistory",
657            GetMembershipHistoryRequest {
658                spool_path: spool_path.to_string(),
659                limit,
660            }
661        )
662        .entries)
663    }
664
665    /// Test-only: exercise the exact `signed_call!` orchestration (PoP sign →
666    /// human-required rejection → callback → retry with WebAuthn headers) over
667    /// the 3-method `TreeEditService` mock, so the retry path is covered
668    /// end-to-end without a 41-method `HostedUserService` mock. Uses
669    /// `StatusForThread` purely as a carrier RPC.
670    #[cfg(test)]
671    async fn signed_status_for_thread_with_retry(
672        &mut self,
673        thread: &str,
674    ) -> Result<grpc::heddle::v1::StatusForThreadResponse, ProtocolError> {
675        Ok(signed_call!(
676            self,
677            tree_edit,
678            status_for_thread,
679            "/heddle.v1.TreeEditService/StatusForThread",
680            grpc::heddle::v1::StatusForThreadRequest {
681                repo_path: "owner/repo".to_string(),
682                thread: thread.to_string(),
683                compare_tree: None,
684            }
685        ))
686    }
687}
688
689/// Build a `GrantTargetRef` oneof from CLI-style optional path args.
690/// Caller layer enforces that at most one of `namespace_path` /
691/// `repo_path` is set; this helper is just the wire-format adapter.
692fn build_target_ref(
693    namespace_path: Option<&str>,
694    repo_path: Option<&str>,
695) -> Result<Option<GrantTargetRef>, ProtocolError> {
696    match (
697        namespace_path.filter(|s| !s.is_empty()),
698        repo_path.filter(|s| !s.is_empty()),
699    ) {
700        (Some(ns), None) => Ok(Some(GrantTargetRef {
701            target: Some(GrantTargetKind::NamespacePath(ns.to_string())),
702        })),
703        (None, Some(rp)) => Ok(Some(GrantTargetRef {
704            target: Some(GrantTargetKind::RepoPath(rp.to_string())),
705        })),
706        _ => Err(ProtocolError::InvalidState(
707            "exactly one of namespace_path or repo_path must be set".into(),
708        )),
709    }
710}
711
712/// Parse a CLI-supplied namespace kind string ("user" / "namespace" /
713/// "team", with "org" accepted as an alias for "namespace") into the
714/// proto `NamespaceKind` enum.
715fn parse_namespace_kind_arg(value: &str) -> Result<grpc::heddle::v1::NamespaceKind, ProtocolError> {
716    use grpc::heddle::v1::NamespaceKind;
717    match value.trim().to_ascii_lowercase().as_str() {
718        "user" => Ok(NamespaceKind::User),
719        "namespace" | "org" => Ok(NamespaceKind::Org),
720        "team" => Ok(NamespaceKind::Team),
721        other => Err(ProtocolError::InvalidState(format!(
722            "invalid namespace kind '{other}': expected user|namespace|team"
723        ))),
724    }
725}
726
727/// Parse a CLI-supplied role name into the proto `HostedRole` enum.
728fn parse_hosted_role_arg(value: &str) -> Result<grpc::heddle::v1::HostedRole, ProtocolError> {
729    use grpc::heddle::v1::HostedRole;
730    match value.trim().to_ascii_lowercase().as_str() {
731        "reader" => Ok(HostedRole::Reader),
732        "developer" => Ok(HostedRole::Developer),
733        "maintainer" => Ok(HostedRole::Maintainer),
734        "admin" => Ok(HostedRole::Admin),
735        "owner" => Ok(HostedRole::Owner),
736        other => Err(ProtocolError::InvalidState(format!(
737            "invalid role '{other}': expected reader|developer|maintainer|admin|owner"
738        ))),
739    }
740}
741
742#[cfg(test)]
743mod spool_request_shape_tests {
744    //! Request-shape coverage for the P9 Spool client methods. A full
745    //! `HostedUserService` mock is impractical (47 RPCs), and the auth/sign/
746    //! retry dispatch is already covered end-to-end by `human_retry_tests` over
747    //! the 3-method `TreeEditService`. What remains method-specific here is the
748    //! field mapping each method feeds into its request proto and the way it
749    //! unwraps the response — those are asserted directly against the proto
750    //! types so a wire-contract drift (renamed/reordered field) fails a test
751    //! rather than silently mis-populating a request.
752
753    use grpc::heddle::v1::{
754        AttachChildRequest, DetachChildRequest, DetachChildResponse, GetGovernanceHistoryRequest,
755        GetMembershipHistoryRequest, ListChildrenRequest, ListChildrenResponse,
756        ResolveMonorepoRequest,
757    };
758
759    #[test]
760    fn attach_child_request_maps_parent_child_mount_in_order() {
761        // Mirrors the `attach_child` method body: the three &str args map to
762        // parent_path / child_path / mount_name respectively.
763        let req = AttachChildRequest {
764            parent_path: "acme/root".to_string(),
765            child_path: "acme/lib".to_string(),
766            mount_name: "libs".to_string(),
767        };
768        assert_eq!(req.parent_path, "acme/root");
769        assert_eq!(req.child_path, "acme/lib");
770        assert_eq!(req.mount_name, "libs");
771    }
772
773    #[test]
774    fn detach_child_request_and_response_unwrap() {
775        let req = DetachChildRequest {
776            parent_path: "acme/root".to_string(),
777            mount_name: "libs".to_string(),
778        };
779        assert_eq!(req.parent_path, "acme/root");
780        assert_eq!(req.mount_name, "libs");
781        // The method returns `.removed`.
782        assert!(DetachChildResponse { removed: true }.removed);
783        assert!(!DetachChildResponse { removed: false }.removed);
784    }
785
786    #[test]
787    fn list_children_request_and_response_unwrap() {
788        let req = ListChildrenRequest {
789            parent_path: "acme/root".to_string(),
790        };
791        assert_eq!(req.parent_path, "acme/root");
792        // The method returns `.children`.
793        assert_eq!(ListChildrenResponse { children: vec![] }.children.len(), 0);
794    }
795
796    #[test]
797    fn resolve_monorepo_request_threads_optional_max_depth() {
798        let bounded = ResolveMonorepoRequest {
799            root_path: "acme/root".to_string(),
800            max_depth: Some(3),
801        };
802        assert_eq!(bounded.root_path, "acme/root");
803        assert_eq!(bounded.max_depth, Some(3));
804
805        let unbounded = ResolveMonorepoRequest {
806            root_path: "acme/root".to_string(),
807            max_depth: None,
808        };
809        assert_eq!(unbounded.max_depth, None);
810    }
811
812    #[test]
813    fn history_requests_thread_optional_limit() {
814        let gov = GetGovernanceHistoryRequest {
815            spool_path: "acme/root".to_string(),
816            limit: Some(10),
817        };
818        assert_eq!(gov.spool_path, "acme/root");
819        assert_eq!(gov.limit, Some(10));
820
821        let mem = GetMembershipHistoryRequest {
822            spool_path: "acme/root".to_string(),
823            limit: None,
824        };
825        assert_eq!(mem.spool_path, "acme/root");
826        assert_eq!(mem.limit, None);
827    }
828}
829
830#[cfg(test)]
831mod human_retry_tests {
832    //! End-to-end coverage of the `signed_call!` orchestration: proactive PoP
833    //! signing, the human-required rejection → app callback → single retry with
834    //! WebAuthn headers, and the no-callback typed-error (no-loop) case.
835
836    use std::sync::{
837        Arc,
838        atomic::{AtomicUsize, Ordering},
839    };
840
841    use cli_shared::ClientConfig;
842    use crypto::Ed25519Signer;
843    use grpc::heddle::v1::{
844        DiffForThreadRequest, DiffForThreadResponse, LogForThreadRequest, LogForThreadResponse,
845        StatusForThreadRequest, StatusForThreadResponse,
846        tree_edit_service_server::{TreeEditService, TreeEditServiceServer},
847    };
848    use tonic::{Request, Response, Status, transport::Server};
849    use wire::ProtocolError;
850
851    use super::{
852        super::request_signing::{
853            HDR_SIG_ACTION_URL, HDR_SIG_ALG, HDR_SIG_BIN, HDR_SIG_KEY_BIN, HDR_SIG_REQUIRED,
854            HDR_SIG_WEBAUTHN_AUTH_DATA_BIN, HDR_SIG_WEBAUTHN_CLIENT_DATA_BIN, WebAuthnAssertion,
855        },
856        HostedGrpcClient,
857    };
858
859    /// The deep-link the human-tier mock returns on its rejection (weft#338), so the retry test
860    /// can assert the client threads it into `HumanSignatureRequest.action_url`.
861    const MOCK_ACTION_URL: &str = "https://app.heddle.sh/verify-action?method=%2Fheddle.v1.TreeEditService%2FStatusForThread&challenge=CHAL";
862
863    /// A `TreeEditService` mock for `StatusForThread` that models a `human`-tier
864    /// endpoint: the first request (no WebAuthn assertion) is rejected with
865    /// `x-weft-sig-required: human`; a request carrying the WebAuthn alg + client
866    /// data succeeds. It records how many times it was hit.
867    #[derive(Clone, Default)]
868    struct HumanTierMock {
869        hits: Arc<AtomicUsize>,
870    }
871
872    #[tonic::async_trait]
873    impl TreeEditService for HumanTierMock {
874        async fn status_for_thread(
875            &self,
876            request: Request<StatusForThreadRequest>,
877        ) -> Result<Response<StatusForThreadResponse>, Status> {
878            self.hits.fetch_add(1, Ordering::SeqCst);
879            let md = request.metadata();
880            let is_human = md
881                .get(HDR_SIG_ALG)
882                .and_then(|v| v.to_str().ok())
883                .map(|v| v == "webauthn")
884                .unwrap_or(false);
885            if !is_human {
886                // A keyed client PoP-signs the first attempt; a keyless
887                // (anonymous) client sends no signature. Record which so the
888                // signed test can assert PoP headers were present.
889                if md.get(HDR_SIG_ALG).is_some() {
890                    assert_eq!(
891                        md.get(HDR_SIG_ALG).and_then(|v| v.to_str().ok()),
892                        Some("ed25519"),
893                        "a signed first attempt must be PoP (ed25519), not webauthn"
894                    );
895                    assert!(
896                        md.get_bin(HDR_SIG_KEY_BIN).is_some(),
897                        "PoP key header present"
898                    );
899                    assert!(md.get_bin(HDR_SIG_BIN).is_some(), "PoP signature present");
900                }
901                let mut trailer = tonic::metadata::MetadataMap::new();
902                trailer.insert(HDR_SIG_REQUIRED, "human".parse().unwrap());
903                // Emit the weft#338 deep-link trailer so the client-side plumbing that reads it
904                // into `HumanSignatureRequest.action_url` is exercised end-to-end.
905                trailer.insert(HDR_SIG_ACTION_URL, MOCK_ACTION_URL.parse().unwrap());
906                return Err(Status::with_metadata(
907                    tonic::Code::Unauthenticated,
908                    "user verification required",
909                    trailer,
910                ));
911            }
912            // Retry: WebAuthn headers must be present.
913            assert!(
914                md.get_bin(HDR_SIG_WEBAUTHN_CLIENT_DATA_BIN).is_some(),
915                "retry carries clientDataJSON"
916            );
917            assert!(
918                md.get_bin(HDR_SIG_WEBAUTHN_AUTH_DATA_BIN).is_some(),
919                "retry carries authenticatorData"
920            );
921            Ok(Response::new(StatusForThreadResponse {
922                thread: request.into_inner().thread,
923                head_state: "hd".into(),
924                base_state: "bd".into(),
925                target_thread: "main".into(),
926                coordination_status: "ahead".into(),
927                changes: None,
928                compared_to_supplied_tree: false,
929            }))
930        }
931
932        async fn diff_for_thread(
933            &self,
934            _request: Request<DiffForThreadRequest>,
935        ) -> Result<Response<DiffForThreadResponse>, Status> {
936            Err(Status::unimplemented("unused"))
937        }
938
939        async fn log_for_thread(
940            &self,
941            _request: Request<LogForThreadRequest>,
942        ) -> Result<Response<LogForThreadResponse>, Status> {
943            Err(Status::unimplemented("unused"))
944        }
945    }
946
947    /// A software Ed25519 seed usable as the client device key (`auth_proof_key_pem`).
948    fn device_key_pem() -> String {
949        Ed25519Signer::generate()
950            .expect("gen device key")
951            .to_pem()
952            .expect("pem")
953    }
954
955    async fn connect_mock(
956        callback: Option<super::super::request_signing::HumanSignatureCallback>,
957    ) -> Option<(
958        HostedGrpcClient,
959        Arc<AtomicUsize>,
960        tokio::task::JoinHandle<()>,
961    )> {
962        let mock = HumanTierMock::default();
963        let hits = mock.hits.clone();
964        let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await {
965            Ok(l) => l,
966            Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
967                eprintln!("skipping human-retry test: TCP bind denied: {err}");
968                return None;
969            }
970            Err(err) => panic!("bind: {err}"),
971        };
972        let addr = listener.local_addr().expect("addr");
973        let incoming = futures::stream::unfold(listener, |listener| async {
974            match listener.accept().await {
975                Ok((stream, _)) => Some((Ok::<_, std::io::Error>(stream), listener)),
976                Err(err) => Some((Err(err), listener)),
977            }
978        });
979        let handle = tokio::spawn(async move {
980            Server::builder()
981                .add_service(TreeEditServiceServer::new(mock))
982                .serve_with_incoming(incoming)
983                .await
984                .expect("serve");
985        });
986
987        let config = ClientConfig::default().with_auth_proof_key_pem(device_key_pem());
988        let mut client = HostedGrpcClient::connect(addr, &config)
989            .await
990            .expect("connect");
991        if let Some(cb) = callback {
992            client = client.with_human_signature_callback(cb);
993        }
994        Some((client, hits, handle))
995    }
996
997    #[tokio::test]
998    async fn human_tier_rejection_invokes_callback_and_retries_once() {
999        let callback_calls = Arc::new(AtomicUsize::new(0));
1000        let cc = callback_calls.clone();
1001        let callback: super::super::request_signing::HumanSignatureCallback = Arc::new(
1002            move |req: super::super::request_signing::HumanSignatureRequest| {
1003                cc.fetch_add(1, Ordering::SeqCst);
1004                // The challenge must be the client-derived SHA256(canonical).
1005                let expected = super::super::request_signing::human_challenge(&req.canonical);
1006                assert_eq!(req.challenge, expected);
1007                assert!(req.method_path.ends_with("/StatusForThread"));
1008                // The server's deep-link trailer (weft#338) reaches the callback verbatim.
1009                assert_eq!(req.action_url.as_deref(), Some(MOCK_ACTION_URL));
1010                Ok(WebAuthnAssertion {
1011                    credential_id: b"cred-id".to_vec(),
1012                    signature: b"assertion-sig".to_vec(),
1013                    client_data_json: b"{\"type\":\"webauthn.get\"}".to_vec(),
1014                    authenticator_data: vec![0u8; 37],
1015                    user_handle: None,
1016                })
1017            },
1018        );
1019
1020        let Some((mut client, hits, server)) = connect_mock(Some(callback)).await else {
1021            return;
1022        };
1023        let resp = client
1024            .signed_status_for_thread_with_retry("feat/x")
1025            .await
1026            .expect("call succeeds after human retry");
1027        server.abort();
1028
1029        assert_eq!(resp.thread, "feat/x");
1030        assert_eq!(
1031            callback_calls.load(Ordering::SeqCst),
1032            1,
1033            "callback invoked once"
1034        );
1035        assert_eq!(
1036            hits.load(Ordering::SeqCst),
1037            2,
1038            "server hit exactly twice (reject + retry)"
1039        );
1040    }
1041
1042    #[tokio::test]
1043    async fn human_tier_rejection_without_callback_is_typed_error_no_loop() {
1044        let Some((mut client, hits, server)) = connect_mock(None).await else {
1045            return;
1046        };
1047        let err = client
1048            .signed_status_for_thread_with_retry("feat/x")
1049            .await
1050            .expect_err("no callback => typed error");
1051        server.abort();
1052
1053        match err {
1054            ProtocolError::AuthorizationFailed(msg) => {
1055                assert!(
1056                    msg.contains("user verification"),
1057                    "typed error names user verification: {msg}"
1058                );
1059            }
1060            other => panic!("expected AuthorizationFailed, got {other:?}"),
1061        }
1062        // Exactly one server hit — the rejection — with NO retry loop.
1063        assert_eq!(
1064            hits.load(Ordering::SeqCst),
1065            1,
1066            "no retry without a callback"
1067        );
1068    }
1069
1070    #[tokio::test]
1071    async fn anonymous_client_without_device_key_skips_signing() {
1072        // No device key + a mock that rejects only unsigned-tier-agnostic: here we
1073        // just assert signing is skipped (no PoP headers) and no panic. Reuse the
1074        // echo mock indirectly by asserting the request is not human-rejected on a
1075        // fresh call — an anonymous client sends no signature and the server's
1076        // human gate would 401, but with no callback and no context we get the
1077        // typed error. The key assertion is that `apply_signed_auth` returns
1078        // `Ok(None)` for a keyless client (covered here by not panicking).
1079        let mock = HumanTierMock::default();
1080        let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await {
1081            Ok(l) => l,
1082            Err(_) => return,
1083        };
1084        let addr = listener.local_addr().expect("addr");
1085        let incoming = futures::stream::unfold(listener, |listener| async {
1086            match listener.accept().await {
1087                Ok((stream, _)) => Some((Ok::<_, std::io::Error>(stream), listener)),
1088                Err(err) => Some((Err(err), listener)),
1089            }
1090        });
1091        let server = tokio::spawn(async move {
1092            Server::builder()
1093                .add_service(TreeEditServiceServer::new(mock))
1094                .serve_with_incoming(incoming)
1095                .await
1096                .expect("serve");
1097        });
1098
1099        // Anonymous: no auth_proof_key_pem.
1100        let mut client = HostedGrpcClient::connect(addr, &ClientConfig::default())
1101            .await
1102            .expect("connect");
1103        // Should not panic; signing is simply skipped. The mock rejects because
1104        // no ed25519 alg header is present, which maps to a typed error — the
1105        // point is the client did not crash and sent no signature.
1106        let result = client.signed_status_for_thread_with_retry("feat/x").await;
1107        server.abort();
1108        assert!(
1109            result.is_err(),
1110            "keyless client hits the human gate but does not panic"
1111        );
1112    }
1113}