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 the message in a
23/// `tonic::Request`, stamp bearer auth AND the Tier-1 PoP request signature via
24/// `apply_signed_auth`, await the call, and — if the server rejects with
25/// `x-weft-sig-required: human` — invoke the app-registered human-signature
26/// callback over the SAME action and retry ONCE. Maps a transport `Status` to a
27/// `ProtocolError` and unwraps the response.
28///
29/// `$rpc` is the snake_case tonic client method; `$grpc_method` is the PascalCase
30/// proto RPC name (used to build the signed `:path`). The message is bound once
31/// and cloned for the potential human retry (all hosted request protos derive
32/// `Clone`). The macro is the one chokepoint for the auth/sign/retry sequence;
33/// it must be invoked inside an `async fn` returning `Result<_, ProtocolError>`.
34macro_rules! signed_call {
35    ($self:ident, $client:ident, $rpc:ident, $path:expr, $msg:expr) => {{
36        let path = $path;
37        let message = $msg;
38        let mut request = Request::new(message.clone());
39        let sig_ctx = $self.apply_signed_auth(&mut request, path)?;
40        match $self.$client.$rpc(request).await {
41            Ok(response) => response.into_inner(),
42            Err(status)
43                if $crate::grpc_hosted::request_signing::requires_human_signature(&status) =>
44            {
45                // The human assertion must cover the SAME action (ts + nonce +
46                // body-hash) the challenge was derived from, so we reuse the
47                // original `sig_ctx` rather than re-signing with a fresh nonce.
48                // `attach_human` re-stamps `x-weft-sig-ts`/`-nonce-bin` from that
49                // context; we only need bearer auth (not a fresh PoP) on retry.
50                let ctx = $self.require_human_sig_context(sig_ctx)?;
51                // The server may include a deep-link (weft#338) on the rejection pointing at a
52                // surface that can complete the WebAuthn ceremony; forward it to the callback.
53                let action_url =
54                    $crate::grpc_hosted::request_signing::action_url_from_status(&status);
55                let assertion = $self.request_human_signature(path, &ctx, action_url)?;
56                let mut retry = Request::new(message);
57                $self.apply_auth(&mut retry)?;
58                $crate::grpc_hosted::request_signing::attach_human(&mut retry, &ctx, &assertion)?;
59                $self
60                    .$client
61                    .$rpc(retry)
62                    .await
63                    .map_err(status_to_protocol_error)?
64                    .into_inner()
65            }
66            Err(status) => return Err(status_to_protocol_error(status)),
67        }
68    }};
69}
70
71/// Dispatch an authenticated unary RPC on `self.user`: wrap the message in a
72/// `tonic::Request`, stamp bearer auth AND the Tier-1 PoP request signature via
73/// `apply_signed_auth`, await the call, and — if the server rejects with
74/// `x-weft-sig-required: human` — invoke the app-registered human-signature
75/// callback over the SAME action and retry ONCE. Maps a transport `Status` to a
76/// `ProtocolError` and unwraps the response.
77///
78/// `$rpc` is the snake_case tonic client method; `$grpc_method` is the PascalCase
79/// proto RPC name (used to build the signed `:path`). The message is bound once
80/// and cloned for the potential human retry (all hosted request protos derive
81/// `Clone`). Delegates to [`signed_call!`], the one chokepoint for the
82/// auth/sign/retry sequence; must be invoked inside an `async fn` returning
83/// `Result<_, ProtocolError>`.
84macro_rules! authed_call {
85    ($self:ident, $rpc:ident, $grpc_method:literal, $msg:expr) => {{
86        signed_call!(
87            $self,
88            user,
89            $rpc,
90            concat!("/heddle.v1.HostedUserService/", $grpc_method),
91            $msg
92        )
93    }};
94}
95
96fn default_spool_settings_request() -> grpc::heddle::v1::SpoolSettings {
97    use grpc::heddle::v1::{
98        SpoolBootstrapKind, SpoolBootstrapSyncDirection, SpoolChildPolicy, SpoolInitialTooling,
99        SpoolSettings, SpoolStateVisibility, SpoolSyncBehavior, SpoolVisibility, SpoolWritePolicy,
100    };
101
102    SpoolSettings {
103        visibility: SpoolVisibility::Private as i32,
104        default_state_visibility: SpoolStateVisibility::Internal as i32,
105        bootstrap_kind: SpoolBootstrapKind::Empty as i32,
106        bootstrap_source: String::new(),
107        write_policy: SpoolWritePolicy::Developers as i32,
108        child_policy: SpoolChildPolicy::Maintainers as i32,
109        initial_tooling: Some(SpoolInitialTooling::default()),
110        sync_behavior: SpoolSyncBehavior::Manual as i32,
111        bootstrap_sync_direction: SpoolBootstrapSyncDirection::Pull as i32,
112        description: String::new(),
113    }
114}
115
116impl HostedGrpcClient {
117    pub async fn begin_login(
118        &mut self,
119        username: &str,
120    ) -> Result<(String, String, u64), ProtocolError> {
121        let request = Request::new(BeginWebAuthnAuthenticationRequest {
122            username: username.to_string(),
123        });
124        let response = self
125            .auth
126            .begin_web_authn_authentication(request)
127            .await
128            .map_err(status_to_protocol_error)?
129            .into_inner();
130        let expires_at_secs = response
131            .expires_at
132            .as_ref()
133            .map(|t| t.seconds.max(0) as u64)
134            .unwrap_or(0);
135        Ok((response.challenge_id, response.challenge, expires_at_secs))
136    }
137
138    pub async fn get_current_user_namespace(
139        &mut self,
140    ) -> Result<wire::HostedNamespaceInfo, ProtocolError> {
141        let namespace = authed_call!(
142            self,
143            get_current_user_namespace,
144            "GetCurrentUserNamespace",
145            GetCurrentUserNamespaceRequest {}
146        );
147        Ok(to_protocol_namespace(namespace))
148    }
149
150    pub async fn list_namespaces(
151        &mut self,
152    ) -> Result<Vec<wire::HostedNamespaceInfo>, ProtocolError> {
153        let response = authed_call!(self, list_namespaces, "ListNamespaces", ListNamespacesRequest {});
154        Ok(response
155            .namespaces
156            .into_iter()
157            .map(to_protocol_namespace)
158            .collect())
159    }
160
161    pub async fn create_namespace(
162        &mut self,
163        kind: &str,
164        slug: &str,
165        parent_path: Option<&str>,
166        display_name: Option<String>,
167    ) -> Result<wire::HostedNamespaceInfo, ProtocolError> {
168        let namespace = authed_call!(
169            self,
170            create_namespace,
171            "CreateNamespace",
172            grpc::heddle::v1::CreateNamespaceRequest {
173                kind: parse_namespace_kind_arg(kind)? as i32,
174                slug: slug.to_string(),
175                parent_path: parent_path.unwrap_or_default().to_string(),
176                display_name: display_name.unwrap_or_default(),
177                settings: Some(default_spool_settings_request()),
178                client_operation_id: String::new(),
179            }
180        );
181        Ok(to_protocol_namespace(namespace))
182    }
183
184    pub async fn create_repository(
185        &mut self,
186        namespace_path: &str,
187        slug: &str,
188    ) -> Result<wire::HostedRepositoryInfo, ProtocolError> {
189        let repo = authed_call!(
190            self,
191            create_repository,
192            "CreateRepository",
193            CreateRepositoryRequest {
194                namespace_path: namespace_path.to_string(),
195                slug: slug.to_string(),
196                client_operation_id: String::new(),
197            }
198        );
199        Ok(to_protocol_repository(repo))
200    }
201
202    pub async fn list_repositories(
203        &mut self,
204        namespace_path: Option<&str>,
205    ) -> Result<Vec<wire::HostedRepositoryInfo>, ProtocolError> {
206        let response = authed_call!(
207            self,
208            list_repositories,
209            "ListRepositories",
210            ListRepositoriesRequest {
211                namespace_path: namespace_path.unwrap_or_default().to_string(),
212            }
213        );
214        Ok(response
215            .repositories
216            .into_iter()
217            .map(to_protocol_repository)
218            .collect())
219    }
220
221    pub async fn update_namespace(
222        &mut self,
223        full_path: &str,
224        new_slug: Option<&str>,
225        display_name: Option<Option<String>>,
226    ) -> Result<wire::HostedNamespaceInfo, ProtocolError> {
227        let (display_name, clear_display_name) = match display_name {
228            Some(Some(value)) => (value, false),
229            Some(None) => (String::new(), true),
230            None => (String::new(), false),
231        };
232        let namespace = authed_call!(
233            self,
234            update_namespace,
235            "UpdateNamespace",
236            UpdateNamespaceRequest {
237                full_path: full_path.to_string(),
238                new_slug: new_slug.unwrap_or_default().to_string(),
239                display_name,
240                clear_display_name,
241                client_operation_id: String::new(),
242            }
243        );
244        Ok(to_protocol_namespace(namespace))
245    }
246
247    pub async fn delete_namespace(&mut self, full_path: &str) -> Result<(), ProtocolError> {
248        authed_call!(
249            self,
250            delete_namespace,
251            "DeleteNamespace",
252            DeleteNamespaceRequest {
253                full_path: full_path.to_string(),
254                client_operation_id: String::new(),
255            }
256        );
257        Ok(())
258    }
259
260    pub async fn update_repository(
261        &mut self,
262        full_path: &str,
263        new_slug: &str,
264    ) -> Result<wire::HostedRepositoryInfo, ProtocolError> {
265        let repo = authed_call!(
266            self,
267            update_repository,
268            "UpdateRepository",
269            UpdateRepositoryRequest {
270                full_path: full_path.to_string(),
271                new_slug: new_slug.to_string(),
272                client_operation_id: String::new(),
273            }
274        );
275        Ok(to_protocol_repository(repo))
276    }
277
278    pub async fn delete_repository(&mut self, full_path: &str) -> Result<(), ProtocolError> {
279        authed_call!(
280            self,
281            delete_repository,
282            "DeleteRepository",
283            DeleteRepositoryRequest {
284                full_path: full_path.to_string(),
285                client_operation_id: String::new(),
286            }
287        );
288        Ok(())
289    }
290
291    pub async fn create_grant(
292        &mut self,
293        subject: &str,
294        role: &str,
295        namespace_path: Option<&str>,
296        repo_path: Option<&str>,
297    ) -> Result<wire::HostedGrantInfo, ProtocolError> {
298        let target = build_target_ref(namespace_path, repo_path)?;
299        let grant = authed_call!(
300            self,
301            create_grant,
302            "CreateGrant",
303            CreateGrantRequest {
304                subject: subject.to_string(),
305                role: parse_hosted_role_arg(role)? as i32,
306                target,
307                client_operation_id: String::new(),
308            }
309        );
310        Ok(to_protocol_grant(grant))
311    }
312
313    pub async fn list_grants(
314        &mut self,
315        resource: Option<&str>,
316    ) -> Result<Vec<wire::HostedGrantInfo>, ProtocolError> {
317        let response = authed_call!(
318            self,
319            list_grants,
320            "ListGrants",
321            ListGrantsRequest {
322                resource: resource.unwrap_or_default().to_string(),
323            }
324        );
325        Ok(response.grants.into_iter().map(to_protocol_grant).collect())
326    }
327
328    pub async fn update_grant(
329        &mut self,
330        subject: &str,
331        role: &str,
332        namespace_path: Option<&str>,
333        repo_path: Option<&str>,
334    ) -> Result<wire::HostedGrantInfo, ProtocolError> {
335        let target = build_target_ref(namespace_path, repo_path)?;
336        let grant = authed_call!(
337            self,
338            update_grant,
339            "UpdateGrant",
340            UpdateGrantRequest {
341                subject: subject.to_string(),
342                role: parse_hosted_role_arg(role)? as i32,
343                target,
344                client_operation_id: String::new(),
345            }
346        );
347        Ok(to_protocol_grant(grant))
348    }
349
350    pub async fn delete_grant(
351        &mut self,
352        subject: &str,
353        namespace_path: Option<&str>,
354        repo_path: Option<&str>,
355    ) -> Result<(), ProtocolError> {
356        let target = build_target_ref(namespace_path, repo_path)?;
357        authed_call!(
358            self,
359            delete_grant,
360            "DeleteGrant",
361            DeleteGrantRequest {
362                subject: subject.to_string(),
363                target,
364                client_operation_id: String::new(),
365            }
366        );
367        Ok(())
368    }
369
370    /// Track D — create a pending invitation. Returns the raw proto type
371    /// to keep the surface narrow until we settle on a domain shape.
372    pub async fn create_invitation(
373        &mut self,
374        email: &str,
375        namespace_path: &str,
376        role: &str,
377    ) -> Result<ProtoInvitation, ProtocolError> {
378        let invitation = authed_call!(
379            self,
380            create_invitation,
381            "CreateInvitation",
382            CreateInvitationRequest {
383                email: email.to_string(),
384                namespace_path: namespace_path.to_string(),
385                role: parse_hosted_role_arg(role)? as i32,
386                expires_at: None,
387                metadata: String::new(),
388                client_operation_id: String::new(),
389            }
390        );
391        Ok(invitation)
392    }
393
394    /// Record an approval for `(source_thread → target_thread)` at
395    /// the source's current `source_state`. The server's gate decides
396    /// later whether this approval *counts* against any matching
397    /// policy's requirements.
398    pub async fn approve_thread(
399        &mut self,
400        repo_path: &str,
401        source_thread: &str,
402        target_thread: &str,
403        source_state: &str,
404        note: Option<&str>,
405    ) -> Result<ThreadApproval, ProtocolError> {
406        Ok(authed_call!(
407            self,
408            approve_thread,
409            "ApproveThread",
410            ApproveThreadRequest {
411                repo_path: repo_path.to_string(),
412                source_thread: source_thread.to_string(),
413                target_thread: target_thread.to_string(),
414                source_state: objects::object::ChangeId::parse(source_state)
415                    .map(|id| id.as_bytes().to_vec())
416                    .unwrap_or_default(),
417                note: note.unwrap_or_default().to_string(),
418                client_operation_id: String::new(),
419            }
420        ))
421    }
422
423    pub async fn revoke_approval(&mut self, id: &str) -> Result<(), ProtocolError> {
424        authed_call!(
425            self,
426            revoke_approval,
427            "RevokeApproval",
428            RevokeApprovalRequest {
429                id: id.to_string(),
430                client_operation_id: String::new(),
431            }
432        );
433        Ok(())
434    }
435
436    pub async fn list_thread_approvals(
437        &mut self,
438        repo_path: &str,
439        source_thread: &str,
440        target_thread: &str,
441    ) -> Result<Vec<ThreadApproval>, ProtocolError> {
442        Ok(authed_call!(
443            self,
444            list_thread_approvals,
445            "ListThreadApprovals",
446            ListThreadApprovalsRequest {
447                repo_path: repo_path.to_string(),
448                source_thread: source_thread.to_string(),
449                target_thread: target_thread.to_string(),
450            }
451        )
452        .approvals)
453    }
454
455    /// Ask the server "can <source> merge into <target> at
456    /// <source_state>, given the diff touches `changed_paths`?" The
457    /// reply lists every unmet requirement and the approvals that
458    /// counted as valid.
459    #[allow(clippy::too_many_arguments)]
460    pub async fn check_merge_eligibility(
461        &mut self,
462        repo_path: &str,
463        source_thread: &str,
464        target_thread: &str,
465        source_state: &str,
466        gated_action: &str,
467        changed_paths: Vec<String>,
468        author_user_id: Option<&str>,
469    ) -> Result<CheckMergeEligibilityResponse, ProtocolError> {
470        Ok(authed_call!(
471            self,
472            check_merge_eligibility,
473            "CheckMergeEligibility",
474            CheckMergeEligibilityRequest {
475                repo_path: repo_path.to_string(),
476                source_thread: source_thread.to_string(),
477                target_thread: target_thread.to_string(),
478                source_state: objects::object::ChangeId::parse(source_state)
479                    .map(|id| id.as_bytes().to_vec())
480                    .unwrap_or_default(),
481                gated_action: gated_action.to_string(),
482                changed_paths,
483                author_user_id: author_user_id.unwrap_or_default().to_string(),
484            }
485        ))
486    }
487
488    /// Phase C: grant a Heddle staff member temporary admin on a
489    /// namespace or repo. Exactly one of `namespace_path` or
490    /// `repo_path` should be set.
491    pub async fn grant_support_access(
492        &mut self,
493        operator_email: &str,
494        namespace_path: Option<&str>,
495        repo_path: Option<&str>,
496        ttl_seconds: u32,
497        reason: &str,
498        client_operation_id: String,
499    ) -> Result<SupportAccessGrant, ProtocolError> {
500        let target = build_target_ref(namespace_path, repo_path)?;
501        Ok(authed_call!(
502            self,
503            grant_support_access,
504            "GrantSupportAccess",
505            GrantSupportAccessRequest {
506                operator_email: operator_email.to_string(),
507                target,
508                ttl_seconds: Some(prost_types::Duration {
509                    seconds: i64::from(ttl_seconds),
510                    nanos: 0,
511                }),
512                reason: reason.to_string(),
513                client_operation_id,
514            }
515        ))
516    }
517
518    pub async fn list_support_access_grants(
519        &mut self,
520        namespace_path: Option<&str>,
521        repo_path: Option<&str>,
522        include_inactive: bool,
523    ) -> Result<Vec<SupportAccessGrant>, ProtocolError> {
524        let target = build_target_ref(namespace_path, repo_path)?;
525        Ok(authed_call!(
526            self,
527            list_support_access_grants,
528            "ListSupportAccessGrants",
529            ListSupportAccessGrantsRequest {
530                target,
531                include_inactive,
532            }
533        )
534        .grants)
535    }
536
537    pub async fn revoke_support_access(
538        &mut self,
539        id: &str,
540        client_operation_id: String,
541    ) -> Result<(), ProtocolError> {
542        authed_call!(
543            self,
544            revoke_support_access,
545            "RevokeSupportAccess",
546            RevokeSupportAccessRequest {
547                id: id.to_string(),
548                client_operation_id,
549            }
550        );
551        Ok(())
552    }
553
554    /// Test-only: exercise the exact `signed_call!` orchestration (PoP sign →
555    /// human-required rejection → callback → retry with WebAuthn headers) over
556    /// the 3-method `TreeEditService` mock, so the retry path is covered
557    /// end-to-end without a 41-method `HostedUserService` mock. Uses
558    /// `StatusForThread` purely as a carrier RPC.
559    #[cfg(test)]
560    async fn signed_status_for_thread_with_retry(
561        &mut self,
562        thread: &str,
563    ) -> Result<grpc::heddle::v1::StatusForThreadResponse, ProtocolError> {
564        Ok(signed_call!(
565            self,
566            tree_edit,
567            status_for_thread,
568            "/heddle.v1.TreeEditService/StatusForThread",
569            grpc::heddle::v1::StatusForThreadRequest {
570                repo_path: "owner/repo".to_string(),
571                thread: thread.to_string(),
572                compare_tree: None,
573            }
574        ))
575    }
576}
577
578/// Build a `GrantTargetRef` oneof from CLI-style optional path args.
579/// Caller layer enforces that at most one of `namespace_path` /
580/// `repo_path` is set; this helper is just the wire-format adapter.
581fn build_target_ref(
582    namespace_path: Option<&str>,
583    repo_path: Option<&str>,
584) -> Result<Option<GrantTargetRef>, ProtocolError> {
585    match (
586        namespace_path.filter(|s| !s.is_empty()),
587        repo_path.filter(|s| !s.is_empty()),
588    ) {
589        (Some(ns), None) => Ok(Some(GrantTargetRef {
590            target: Some(GrantTargetKind::NamespacePath(ns.to_string())),
591        })),
592        (None, Some(rp)) => Ok(Some(GrantTargetRef {
593            target: Some(GrantTargetKind::RepoPath(rp.to_string())),
594        })),
595        _ => Err(ProtocolError::InvalidState(
596            "exactly one of namespace_path or repo_path must be set".into(),
597        )),
598    }
599}
600
601/// Parse a CLI-supplied namespace kind string ("user" / "namespace" /
602/// "team", with "org" accepted as an alias for "namespace") into the
603/// proto `NamespaceKind` enum.
604fn parse_namespace_kind_arg(value: &str) -> Result<grpc::heddle::v1::NamespaceKind, ProtocolError> {
605    use grpc::heddle::v1::NamespaceKind;
606    match value.trim().to_ascii_lowercase().as_str() {
607        "user" => Ok(NamespaceKind::User),
608        "namespace" | "org" => Ok(NamespaceKind::Org),
609        "team" => Ok(NamespaceKind::Team),
610        other => Err(ProtocolError::InvalidState(format!(
611            "invalid namespace kind '{other}': expected user|namespace|team"
612        ))),
613    }
614}
615
616/// Parse a CLI-supplied role name into the proto `HostedRole` enum.
617fn parse_hosted_role_arg(value: &str) -> Result<grpc::heddle::v1::HostedRole, ProtocolError> {
618    use grpc::heddle::v1::HostedRole;
619    match value.trim().to_ascii_lowercase().as_str() {
620        "reader" => Ok(HostedRole::Reader),
621        "developer" => Ok(HostedRole::Developer),
622        "maintainer" => Ok(HostedRole::Maintainer),
623        "admin" => Ok(HostedRole::Admin),
624        "owner" => Ok(HostedRole::Owner),
625        other => Err(ProtocolError::InvalidState(format!(
626            "invalid role '{other}': expected reader|developer|maintainer|admin|owner"
627        ))),
628    }
629}
630
631#[cfg(test)]
632mod human_retry_tests {
633    //! End-to-end coverage of the `signed_call!` orchestration: proactive PoP
634    //! signing, the human-required rejection → app callback → single retry with
635    //! WebAuthn headers, and the no-callback typed-error (no-loop) case.
636
637    use std::sync::Arc;
638    use std::sync::atomic::{AtomicUsize, Ordering};
639
640    use cli_shared::ClientConfig;
641    use crypto::Ed25519Signer;
642    use grpc::heddle::v1::{
643        StatusForThreadRequest, StatusForThreadResponse,
644        tree_edit_service_server::{TreeEditService, TreeEditServiceServer},
645        DiffForThreadRequest, DiffForThreadResponse, LogForThreadRequest, LogForThreadResponse,
646    };
647    use tonic::{Request, Response, Status, transport::Server};
648    use wire::ProtocolError;
649
650    use super::super::request_signing::{
651        HDR_SIG_ACTION_URL, HDR_SIG_ALG, HDR_SIG_BIN, HDR_SIG_KEY_BIN, HDR_SIG_REQUIRED,
652        HDR_SIG_WEBAUTHN_AUTH_DATA_BIN, HDR_SIG_WEBAUTHN_CLIENT_DATA_BIN, WebAuthnAssertion,
653    };
654    use super::HostedGrpcClient;
655
656    /// The deep-link the human-tier mock returns on its rejection (weft#338), so the retry test
657    /// can assert the client threads it into `HumanSignatureRequest.action_url`.
658    const MOCK_ACTION_URL: &str =
659        "https://app.heddle.sh/verify-action?method=%2Fheddle.v1.TreeEditService%2FStatusForThread&challenge=CHAL";
660
661    /// A `TreeEditService` mock for `StatusForThread` that models a `human`-tier
662    /// endpoint: the first request (no WebAuthn assertion) is rejected with
663    /// `x-weft-sig-required: human`; a request carrying the WebAuthn alg + client
664    /// data succeeds. It records how many times it was hit.
665    #[derive(Clone, Default)]
666    struct HumanTierMock {
667        hits: Arc<AtomicUsize>,
668    }
669
670    #[tonic::async_trait]
671    impl TreeEditService for HumanTierMock {
672        async fn status_for_thread(
673            &self,
674            request: Request<StatusForThreadRequest>,
675        ) -> Result<Response<StatusForThreadResponse>, Status> {
676            self.hits.fetch_add(1, Ordering::SeqCst);
677            let md = request.metadata();
678            let is_human = md
679                .get(HDR_SIG_ALG)
680                .and_then(|v| v.to_str().ok())
681                .map(|v| v == "webauthn")
682                .unwrap_or(false);
683            if !is_human {
684                // A keyed client PoP-signs the first attempt; a keyless
685                // (anonymous) client sends no signature. Record which so the
686                // signed test can assert PoP headers were present.
687                if md.get(HDR_SIG_ALG).is_some() {
688                    assert_eq!(
689                        md.get(HDR_SIG_ALG).and_then(|v| v.to_str().ok()),
690                        Some("ed25519"),
691                        "a signed first attempt must be PoP (ed25519), not webauthn"
692                    );
693                    assert!(md.get_bin(HDR_SIG_KEY_BIN).is_some(), "PoP key header present");
694                    assert!(md.get_bin(HDR_SIG_BIN).is_some(), "PoP signature present");
695                }
696                let mut trailer = tonic::metadata::MetadataMap::new();
697                trailer.insert(HDR_SIG_REQUIRED, "human".parse().unwrap());
698                // Emit the weft#338 deep-link trailer so the client-side plumbing that reads it
699                // into `HumanSignatureRequest.action_url` is exercised end-to-end.
700                trailer.insert(
701                    HDR_SIG_ACTION_URL,
702                    MOCK_ACTION_URL.parse().unwrap(),
703                );
704                return Err(Status::with_metadata(
705                    tonic::Code::Unauthenticated,
706                    "user verification required",
707                    trailer,
708                ));
709            }
710            // Retry: WebAuthn headers must be present.
711            assert!(
712                md.get_bin(HDR_SIG_WEBAUTHN_CLIENT_DATA_BIN).is_some(),
713                "retry carries clientDataJSON"
714            );
715            assert!(
716                md.get_bin(HDR_SIG_WEBAUTHN_AUTH_DATA_BIN).is_some(),
717                "retry carries authenticatorData"
718            );
719            Ok(Response::new(StatusForThreadResponse {
720                thread: request.into_inner().thread,
721                head_state: "hd".into(),
722                base_state: "bd".into(),
723                target_thread: "main".into(),
724                coordination_status: "ahead".into(),
725                changes: None,
726                compared_to_supplied_tree: false,
727            }))
728        }
729
730        async fn diff_for_thread(
731            &self,
732            _request: Request<DiffForThreadRequest>,
733        ) -> Result<Response<DiffForThreadResponse>, Status> {
734            Err(Status::unimplemented("unused"))
735        }
736
737        async fn log_for_thread(
738            &self,
739            _request: Request<LogForThreadRequest>,
740        ) -> Result<Response<LogForThreadResponse>, Status> {
741            Err(Status::unimplemented("unused"))
742        }
743    }
744
745    /// A software Ed25519 seed usable as the client device key (`auth_proof_key_pem`).
746    fn device_key_pem() -> String {
747        Ed25519Signer::generate()
748            .expect("gen device key")
749            .to_pem()
750            .expect("pem")
751    }
752
753    async fn connect_mock(
754        callback: Option<super::super::request_signing::HumanSignatureCallback>,
755    ) -> Option<(HostedGrpcClient, Arc<AtomicUsize>, tokio::task::JoinHandle<()>)> {
756        let mock = HumanTierMock::default();
757        let hits = mock.hits.clone();
758        let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await {
759            Ok(l) => l,
760            Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
761                eprintln!("skipping human-retry test: TCP bind denied: {err}");
762                return None;
763            }
764            Err(err) => panic!("bind: {err}"),
765        };
766        let addr = listener.local_addr().expect("addr");
767        let incoming = futures::stream::unfold(listener, |listener| async {
768            match listener.accept().await {
769                Ok((stream, _)) => Some((Ok::<_, std::io::Error>(stream), listener)),
770                Err(err) => Some((Err(err), listener)),
771            }
772        });
773        let handle = tokio::spawn(async move {
774            Server::builder()
775                .add_service(TreeEditServiceServer::new(mock))
776                .serve_with_incoming(incoming)
777                .await
778                .expect("serve");
779        });
780
781        let config = ClientConfig::default().with_auth_proof_key_pem(device_key_pem());
782        let mut client = HostedGrpcClient::connect(addr, &config)
783            .await
784            .expect("connect");
785        if let Some(cb) = callback {
786            client = client.with_human_signature_callback(cb);
787        }
788        Some((client, hits, handle))
789    }
790
791    #[tokio::test]
792    async fn human_tier_rejection_invokes_callback_and_retries_once() {
793        let callback_calls = Arc::new(AtomicUsize::new(0));
794        let cc = callback_calls.clone();
795        let callback: super::super::request_signing::HumanSignatureCallback =
796            Arc::new(move |req: super::super::request_signing::HumanSignatureRequest| {
797                cc.fetch_add(1, Ordering::SeqCst);
798                // The challenge must be the client-derived SHA256(canonical).
799                let expected =
800                    super::super::request_signing::human_challenge(&req.canonical);
801                assert_eq!(req.challenge, expected);
802                assert!(req.method_path.ends_with("/StatusForThread"));
803                // The server's deep-link trailer (weft#338) reaches the callback verbatim.
804                assert_eq!(req.action_url.as_deref(), Some(MOCK_ACTION_URL));
805                Ok(WebAuthnAssertion {
806                    credential_id: b"cred-id".to_vec(),
807                    signature: b"assertion-sig".to_vec(),
808                    client_data_json: b"{\"type\":\"webauthn.get\"}".to_vec(),
809                    authenticator_data: vec![0u8; 37],
810                    user_handle: None,
811                })
812            });
813
814        let Some((mut client, hits, server)) = connect_mock(Some(callback)).await else {
815            return;
816        };
817        let resp = client
818            .signed_status_for_thread_with_retry("feat/x")
819            .await
820            .expect("call succeeds after human retry");
821        server.abort();
822
823        assert_eq!(resp.thread, "feat/x");
824        assert_eq!(callback_calls.load(Ordering::SeqCst), 1, "callback invoked once");
825        assert_eq!(hits.load(Ordering::SeqCst), 2, "server hit exactly twice (reject + retry)");
826    }
827
828    #[tokio::test]
829    async fn human_tier_rejection_without_callback_is_typed_error_no_loop() {
830        let Some((mut client, hits, server)) = connect_mock(None).await else {
831            return;
832        };
833        let err = client
834            .signed_status_for_thread_with_retry("feat/x")
835            .await
836            .expect_err("no callback => typed error");
837        server.abort();
838
839        match err {
840            ProtocolError::AuthorizationFailed(msg) => {
841                assert!(
842                    msg.contains("user verification"),
843                    "typed error names user verification: {msg}"
844                );
845            }
846            other => panic!("expected AuthorizationFailed, got {other:?}"),
847        }
848        // Exactly one server hit — the rejection — with NO retry loop.
849        assert_eq!(hits.load(Ordering::SeqCst), 1, "no retry without a callback");
850    }
851
852    #[tokio::test]
853    async fn anonymous_client_without_device_key_skips_signing() {
854        // No device key + a mock that rejects only unsigned-tier-agnostic: here we
855        // just assert signing is skipped (no PoP headers) and no panic. Reuse the
856        // echo mock indirectly by asserting the request is not human-rejected on a
857        // fresh call — an anonymous client sends no signature and the server's
858        // human gate would 401, but with no callback and no context we get the
859        // typed error. The key assertion is that `apply_signed_auth` returns
860        // `Ok(None)` for a keyless client (covered here by not panicking).
861        let mock = HumanTierMock::default();
862        let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await {
863            Ok(l) => l,
864            Err(_) => return,
865        };
866        let addr = listener.local_addr().expect("addr");
867        let incoming = futures::stream::unfold(listener, |listener| async {
868            match listener.accept().await {
869                Ok((stream, _)) => Some((Ok::<_, std::io::Error>(stream), listener)),
870                Err(err) => Some((Err(err), listener)),
871            }
872        });
873        let server = tokio::spawn(async move {
874            Server::builder()
875                .add_service(TreeEditServiceServer::new(mock))
876                .serve_with_incoming(incoming)
877                .await
878                .expect("serve");
879        });
880
881        // Anonymous: no auth_proof_key_pem.
882        let mut client = HostedGrpcClient::connect(addr, &ClientConfig::default())
883            .await
884            .expect("connect");
885        // Should not panic; signing is simply skipped. The mock rejects because
886        // no ed25519 alg header is present, which maps to a typed error — the
887        // point is the client did not crash and sent no signature.
888        let result = client.signed_status_for_thread_with_retry("feat/x").await;
889        server.abort();
890        assert!(result.is_err(), "keyless client hits the human gate but does not panic");
891    }
892}