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
24macro_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 let ctx = $self.require_human_sig_context(sig_ctx)?;
53 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
73macro_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 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 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 #[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 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 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 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 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 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 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 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 #[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
689fn 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
712fn 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
727fn 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 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 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 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 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 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 const MOCK_ACTION_URL: &str = "https://app.heddle.sh/verify-action?method=%2Fheddle.v1.TreeEditService%2FStatusForThread&challenge=CHAL";
862
863 #[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 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 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 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 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 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 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 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 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 let mut client = HostedGrpcClient::connect(addr, &ClientConfig::default())
1101 .await
1102 .expect("connect");
1103 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}