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
22macro_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 let ctx = $self.require_human_sig_context(sig_ctx)?;
51 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
71macro_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 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 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 #[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 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 #[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
578fn 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
601fn 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
616fn 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 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 const MOCK_ACTION_URL: &str =
659 "https://app.heddle.sh/verify-action?method=%2Fheddle.v1.TreeEditService%2FStatusForThread&challenge=CHAL";
660
661 #[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 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 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 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 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 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 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 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 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 let mut client = HostedGrpcClient::connect(addr, &ClientConfig::default())
883 .await
884 .expect("connect");
885 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}