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