1use crate::registry::rate_limit::{NoopRateLimiter, RateLimiter};
31use crate::registry::store::RegistryStore;
32use crate::registry::validator::{
33 check_revocation_supersession, key_revocation_gate_applies, PublishValidator,
34};
35use acdp_primitives::error::AcdpError;
36use acdp_types::{
37 body::{Body, FullContext},
38 capabilities::CapabilitiesDocument,
39 primitives::{AgentDid, CtxId, LineageId, Status, Visibility},
40 publish::{PublishRequest, PublishResponse},
41 revocation::KeyRevocation,
42 search::{SearchParams, SearchResponse},
43};
44
45pub struct RegistryServer<S: RegistryStore, L: RateLimiter = NoopRateLimiter> {
51 store: S,
52 caps: CapabilitiesDocument,
53 authority: String,
54 rate_limiter: L,
55 receipt_signer: Option<acdp_types::receipt::ReceiptSigner>,
60 mint_head_receipts: bool,
67 lifecycle_enabled: bool,
74}
75
76impl<S: RegistryStore> RegistryServer<S, NoopRateLimiter> {
77 #[doc(hidden)]
81 pub fn new(store: S, caps: CapabilitiesDocument, authority: impl Into<String>) -> Self {
82 Self {
83 store,
84 caps,
85 authority: authority.into(),
86 rate_limiter: NoopRateLimiter,
87 receipt_signer: None,
88 mint_head_receipts: false,
89 lifecycle_enabled: false,
90 }
91 }
92
93 pub fn try_new(
107 store: S,
108 caps: CapabilitiesDocument,
109 authority: impl Into<String>,
110 ) -> Result<Self, AcdpError> {
111 let authority = authority.into();
112 if !acdp_types::primitives::is_valid_dns_authority(&authority) {
115 return Err(AcdpError::SchemaViolation(format!(
116 "registry authority '{authority}' is not a valid DNS hostname \
117 (must be lowercase labels, e.g. 'registry.example.com'); \
118 use RegistryServer::try_new_for_test_authority for host:port test setups"
119 )));
120 }
121 acdp_validation::validate_capabilities(&caps)?;
122 let expected_did = acdp_did::authority_to_did_web(&authority);
125 if caps.registry_did != expected_did {
126 return Err(AcdpError::SchemaViolation(format!(
127 "capabilities.registry_did '{}' does not match expected '{expected_did}' \
128 for authority '{authority}'",
129 caps.registry_did
130 )));
131 }
132 Ok(Self {
133 store,
134 caps,
135 authority,
136 rate_limiter: NoopRateLimiter,
137 receipt_signer: None,
138 mint_head_receipts: false,
139 lifecycle_enabled: false,
140 })
141 }
142
143 #[doc(hidden)]
152 pub fn try_new_for_test_authority(
153 store: S,
154 caps: CapabilitiesDocument,
155 authority: impl Into<String>,
156 ) -> Result<Self, AcdpError> {
157 let authority = authority.into();
158 acdp_validation::validate_capabilities(&caps)?;
159 let expected_did = acdp_did::authority_to_did_web(&authority);
160 if caps.registry_did != expected_did {
161 return Err(AcdpError::SchemaViolation(format!(
162 "capabilities.registry_did '{}' does not match expected '{expected_did}' \
163 for authority '{authority}'",
164 caps.registry_did
165 )));
166 }
167 Ok(Self {
168 store,
169 caps,
170 authority,
171 rate_limiter: NoopRateLimiter,
172 receipt_signer: None,
173 mint_head_receipts: false,
174 lifecycle_enabled: false,
175 })
176 }
177}
178
179impl<S: RegistryStore, L: RateLimiter> RegistryServer<S, L> {
180 pub fn with_rate_limiter<L2: RateLimiter>(self, limiter: L2) -> RegistryServer<S, L2> {
182 RegistryServer {
183 store: self.store,
184 caps: self.caps,
185 authority: self.authority,
186 rate_limiter: limiter,
187 receipt_signer: self.receipt_signer,
188 mint_head_receipts: self.mint_head_receipts,
189 lifecycle_enabled: self.lifecycle_enabled,
190 }
191 }
192
193 pub fn with_receipt_signer(
209 mut self,
210 signer: acdp_types::receipt::ReceiptSigner,
211 ) -> Result<Self, AcdpError> {
212 if signer.registry_did() != self.caps.registry_did {
213 return Err(AcdpError::SchemaViolation(format!(
214 "receipt signer registry_did '{}' ≠ capabilities.registry_did '{}'",
215 signer.registry_did(),
216 self.caps.registry_did
217 )));
218 }
219 self.require_min_acdp_version((0, 2, 0), "acdp-registry-receipts")?;
222 let profile = acdp_types::profile::Profile::RegistryReceipts.as_str();
223 if !self.caps.profiles.iter().any(|p| p == profile) {
224 self.caps.profiles.push(profile.to_string());
225 }
226 self.receipt_signer = Some(signer);
227 Ok(self)
228 }
229
230 pub fn with_lineage_head_receipts(mut self) -> Result<Self, AcdpError> {
244 if self.receipt_signer.is_none() {
245 return Err(AcdpError::SchemaViolation(
246 "acdp-registry-head-receipts requires the acdp-registry-receipts profile \
247 (RFC-ACDP-0011 §9): call with_receipt_signer first"
248 .into(),
249 ));
250 }
251 self.require_min_acdp_version((0, 3, 0), "acdp-registry-head-receipts")?;
252 let profile = acdp_types::profile::Profile::RegistryHeadReceipts.as_str();
253 if !self.caps.profiles.iter().any(|p| p == profile) {
254 self.caps.profiles.push(profile.to_string());
255 }
256 self.mint_head_receipts = true;
257 Ok(self)
258 }
259
260 pub fn with_lifecycle(mut self) -> Result<Self, AcdpError> {
275 self.require_min_acdp_version((0, 3, 0), "acdp-registry-lifecycle")?;
276 let profile = acdp_types::profile::Profile::RegistryLifecycle.as_str();
277 if !self.caps.profiles.iter().any(|p| p == profile) {
278 self.caps.profiles.push(profile.to_string());
279 }
280 self.lifecycle_enabled = true;
281 Ok(self)
282 }
283
284 fn require_min_acdp_version(&self, min: (u64, u64, u64), what: &str) -> Result<(), AcdpError> {
289 let parts: Vec<u64> = self
290 .caps
291 .acdp_version
292 .split('.')
293 .map(|p| p.parse::<u64>())
294 .collect::<Result<_, _>>()
295 .map_err(|_| {
296 AcdpError::SchemaViolation(format!(
297 "capabilities.acdp_version '{}' is not a plain MAJOR.MINOR.PATCH version",
298 self.caps.acdp_version
299 ))
300 })?;
301 let [major, minor, patch] = parts.as_slice() else {
302 return Err(AcdpError::SchemaViolation(format!(
303 "capabilities.acdp_version '{}' is not a plain MAJOR.MINOR.PATCH version",
304 self.caps.acdp_version
305 )));
306 };
307 if (*major, *minor, *patch) < min {
308 return Err(AcdpError::SchemaViolation(format!(
309 "{what} requires capabilities.acdp_version >= {}.{}.{}, got '{}'",
310 min.0, min.1, min.2, self.caps.acdp_version
311 )));
312 }
313 Ok(())
314 }
315
316 pub fn store(&self) -> &S {
319 &self.store
320 }
321
322 pub fn capabilities(&self) -> &CapabilitiesDocument {
324 &self.caps
325 }
326
327 #[cfg(feature = "client")]
343 #[cfg_attr(
344 feature = "tracing",
345 tracing::instrument(
346 name = "acdp.publish_verified",
347 skip_all,
348 fields(
349 agent_id = req.agent_id.as_str(),
350 version = req.version,
351 idempotency_key = idempotency_key.is_some(),
352 ),
353 err(Display)
354 )
355 )]
356 pub async fn publish_verified(
357 &self,
358 req: &PublishRequest,
359 idempotency_key: Option<&str>,
360 resolver: &acdp_did::WebResolver,
361 ) -> Result<PublishResponse, AcdpError> {
362 self.publish_verified_in_tenant(req, idempotency_key, resolver, None)
363 .await
364 }
365
366 #[cfg(feature = "client")]
372 pub async fn publish_verified_in_tenant(
373 &self,
374 req: &PublishRequest,
375 idempotency_key: Option<&str>,
376 resolver: &acdp_did::WebResolver,
377 tenant: Option<&str>,
378 ) -> Result<PublishResponse, AcdpError> {
379 self.check_publish_rate_limit(&req.agent_id)?;
381
382 let raw_bytes = serde_json::to_vec(req)?.len();
383 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
384 let _validated = validator.validate_post_schema(req, raw_bytes)?;
385
386 acdp_verify::verify_publish_request_signature(req, resolver).await?;
388
389 let revocation_check_needed = req.context_type.is_key_revocation()
408 && key_revocation_gate_applies(&self.caps.acdp_version);
409
410 let fingerprint = if self.receipt_signer.is_some() || revocation_check_needed {
416 Some(producer_key_fingerprint(req, resolver).await?)
417 } else {
418 None
419 };
420
421 if revocation_check_needed {
422 let fp = fingerprint.as_deref().ok_or_else(|| {
431 AcdpError::RegistryInternal(
432 "key-revocation fingerprint missing despite revocation_check_needed \
433 — this is an internal invariant violation, not a caller error"
434 .into(),
435 )
436 })?;
437 KeyRevocation::from_publish_request(req)?.check_not_self_signed(fp)?;
438 }
439
440 self.commit_via_store(req, idempotency_key, tenant, fingerprint)
448 }
449
450 pub fn publish_verified_did_key(
464 &self,
465 req: &PublishRequest,
466 idempotency_key: Option<&str>,
467 ) -> Result<PublishResponse, AcdpError> {
468 self.publish_verified_did_key_in_tenant(req, idempotency_key, None)
469 }
470
471 #[cfg_attr(
477 feature = "tracing",
478 tracing::instrument(
479 name = "acdp.publish_verified_did_key",
480 skip_all,
481 fields(
482 agent_id = req.agent_id.as_str(),
483 version = req.version,
484 idempotency_key = idempotency_key.is_some(),
485 ),
486 err(Display)
487 )
488 )]
489 pub fn publish_verified_did_key_in_tenant(
490 &self,
491 req: &PublishRequest,
492 idempotency_key: Option<&str>,
493 tenant: Option<&str>,
494 ) -> Result<PublishResponse, AcdpError> {
495 self.check_publish_rate_limit(&req.agent_id)?;
496
497 let raw_bytes = serde_json::to_vec(req)?.len();
498 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
499 let _validated = validator.validate_post_schema(req, raw_bytes)?;
500
501 acdp_verify::verify_publish_request_signature_offline(req)?;
503
504 let fingerprint = if self.receipt_signer.is_some() {
507 let material = acdp_did::key::resolve_did_key(req.agent_id.as_str())?;
508 Some(acdp_crypto::fingerprint::fingerprint_did_key_material(
509 &material,
510 )?)
511 } else {
512 None
513 };
514
515 self.commit_via_store(req, idempotency_key, tenant, fingerprint)
516 }
517
518 #[doc(hidden)]
525 pub fn publish_unverified_for_tests(
526 &self,
527 req: &PublishRequest,
528 ) -> Result<PublishResponse, AcdpError> {
529 self.check_publish_rate_limit(&req.agent_id)?;
533
534 if self.receipt_signer.is_some() {
540 return Err(AcdpError::SchemaViolation(
541 "publish_unverified_for_tests is unavailable on a receipts-advertising \
542 registry (RFC-ACDP-0010 §7: no degraded mode); use publish_verified or \
543 publish_verified_did_key"
544 .into(),
545 ));
546 }
547 let raw_bytes = serde_json::to_vec(req)?.len();
557 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
558 let _validated = validator.validate_post_schema(req, raw_bytes)?;
559 self.commit_via_store(req, None, None, None)
560 }
561
562 #[doc(hidden)]
584 pub fn publish_pinned_verified_in_tenant(
585 &self,
586 req: &PublishRequest,
587 idempotency_key: Option<&str>,
588 tenant: Option<&str>,
589 verified_public_key_b64: &str,
590 verified_algorithm: &str,
591 ) -> Result<PublishResponse, AcdpError> {
592 self.check_publish_rate_limit(&req.agent_id)?;
593
594 let raw_bytes = serde_json::to_vec(req)?.len();
595 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
596 let _validated = validator.validate_post_schema(req, raw_bytes)?;
597
598 let revocation_check_needed = req.context_type.is_key_revocation()
606 && key_revocation_gate_applies(&self.caps.acdp_version);
607
608 let fingerprint = if self.receipt_signer.is_some() || revocation_check_needed {
609 Some(fingerprint_pinned_key(
610 verified_public_key_b64,
611 verified_algorithm,
612 )?)
613 } else {
614 None
615 };
616
617 if revocation_check_needed {
618 let fp = fingerprint.as_deref().ok_or_else(|| {
625 AcdpError::RegistryInternal(
626 "key-revocation fingerprint missing despite revocation_check_needed \
627 — this is an internal invariant violation, not a caller error"
628 .into(),
629 )
630 })?;
631 KeyRevocation::from_publish_request(req)?.check_not_self_signed(fp)?;
632 }
633
634 self.commit_via_store(req, idempotency_key, tenant, fingerprint)
635 }
636
637 fn check_publish_rate_limit(
641 &self,
642 agent_id: &acdp_types::primitives::AgentDid,
643 ) -> Result<(), AcdpError> {
644 match self.rate_limiter.check_publish(agent_id) {
645 Ok(()) => Ok(()),
646 Err(e) => {
647 #[cfg(feature = "tracing")]
648 tracing::warn!(
649 agent_id = agent_id.as_str(),
650 "publish rejected by rate limiter"
651 );
652 Err(e)
653 }
654 }
655 }
656
657 fn commit_via_store(
662 &self,
663 req: &PublishRequest,
664 idempotency_key: Option<&str>,
665 tenant: Option<&str>,
666 producer_key_fingerprint: Option<String>,
667 ) -> Result<PublishResponse, AcdpError> {
668 let idempotency = if self.caps.supports_idempotency_key {
669 idempotency_key.map(|key| crate::registry::store::PendingIdempotencyCommit {
670 key,
671 ttl: chrono::Duration::seconds(
672 self.caps
673 .limits
674 .idempotency_key_ttl_seconds
675 .unwrap_or(86_400) as i64,
676 ),
677 })
678 } else {
679 None
680 };
681 #[allow(clippy::type_complexity)]
684 let minter: Option<
685 Box<dyn Fn(&Body) -> Result<serde_json::Value, AcdpError> + Send + Sync>,
686 > = match (&self.receipt_signer, producer_key_fingerprint) {
687 (Some(signer), Some(fp)) => Some(Box::new(move |body: &Body| {
688 let receipt = signer.mint(
689 &body.ctx_id,
690 &body.lineage_id,
691 &body.origin_registry,
692 body.created_at,
693 &body.content_hash,
694 &fp,
695 )?;
696 serde_json::to_value(receipt).map_err(AcdpError::from)
697 })),
698 _ => None,
699 };
700 let minted_expected = minter.is_some();
701
702 let admission_closure =
717 if key_revocation_gate_applies(&self.caps.acdp_version) && req.supersedes.is_some() {
718 Some(move |prev: &Body| check_revocation_supersession(prev, req))
719 } else {
720 None
721 };
722 #[allow(clippy::type_complexity)]
723 let predecessor_admission: Option<
724 &(dyn Fn(&Body) -> Result<(), AcdpError> + Send + Sync),
725 > = admission_closure
726 .as_ref()
727 .map(|f| f as &(dyn Fn(&Body) -> Result<(), AcdpError> + Send + Sync));
728
729 let outcome = self
730 .store
731 .commit_publish(crate::registry::store::PublishCommit {
732 req,
733 authority: &self.authority,
734 idempotency,
735 tenant,
736 receipt_minter: minter.as_deref(),
737 predecessor_admission,
738 })?;
739 let (response, replayed) = match outcome {
740 crate::registry::store::PublishCommitOutcome::Inserted(r) => (r, false),
741 crate::registry::store::PublishCommitOutcome::IdempotentReplay(r) => (r, true),
742 };
743 #[cfg(feature = "tracing")]
744 tracing::debug!(
745 ctx_id = %response.ctx_id.0,
746 lineage_id = %response.lineage_id.0,
747 version = response.version,
748 replayed,
749 "publish committed"
750 );
751 if minted_expected && !replayed && response.registry_receipt.is_none() {
765 return Err(AcdpError::RegistryInternal(
766 "receipt signer is configured but the store returned no receipt — \
767 the RegistryStore implementation must invoke PublishCommit::receipt_minter \
768 inside its commit (RFC-ACDP-0010 §7: no degraded mode)"
769 .into(),
770 ));
771 }
772 Ok(response)
773 }
774
775 pub fn retrieve(
788 &self,
789 ctx_id: &CtxId,
790 requester: Option<&AgentDid>,
791 ) -> Result<Option<FullContext>, AcdpError> {
792 let Some(ctx) = self.store.get(ctx_id)? else {
793 return Ok(None);
794 };
795 if !can_retrieve(&ctx.body, requester, &self.caps) {
796 return Ok(None);
797 }
798 Ok(Some(ctx))
799 }
800
801 pub fn retrieve_body(
803 &self,
804 ctx_id: &CtxId,
805 requester: Option<&AgentDid>,
806 ) -> Result<Option<Body>, AcdpError> {
807 Ok(self.retrieve(ctx_id, requester)?.map(|c| c.body))
808 }
809
810 pub fn lineage(
817 &self,
818 lineage_id: &LineageId,
819 requester: Option<&AgentDid>,
820 ) -> Result<Vec<FullContext>, AcdpError> {
821 let all = self.store.lineage(lineage_id)?;
822 Ok(all
823 .into_iter()
824 .filter(|ctx| can_retrieve(&ctx.body, requester, &self.caps))
825 .collect())
826 }
827
828 pub fn current(
848 &self,
849 lineage_id: &LineageId,
850 requester: Option<&AgentDid>,
851 ) -> Result<Option<FullContext>, AcdpError> {
852 let all = self.store.lineage(lineage_id)?;
853 for mut ctx in all.into_iter().rev() {
859 if !matches!(
860 ctx.registry_state.status,
861 Status::Superseded | Status::Retracted
862 ) && can_retrieve(&ctx.body, requester, &self.caps)
863 {
864 if self.mint_head_receipts {
865 let signer = self.receipt_signer.as_ref().ok_or_else(|| {
870 AcdpError::RegistryInternal(
871 "head-receipt minting enabled without a receipt signer \
872 (RFC-ACDP-0011 §9 prerequisite violated)"
873 .into(),
874 )
875 })?;
876 let receipt = signer.mint_lineage_head(
877 lineage_id,
878 &ctx.body.ctx_id,
879 ctx.body.version,
880 &ctx.registry_state.status,
881 chrono::Utc::now(),
882 )?;
883 ctx.lineage_head_receipt = Some(serde_json::to_value(receipt)?);
884 }
885 return Ok(Some(ctx));
886 }
887 }
888 Ok(None)
889 }
890
891 pub fn search(
904 &self,
905 params: &SearchParams,
906 requester: Option<&AgentDid>,
907 ) -> Result<SearchResponse, AcdpError> {
908 if requester.is_none() && !self.caps.anonymous_public_reads {
913 return Err(AcdpError::NotAuthorized(
914 "anonymous search requires authentication \
915 (registry caps: anonymous_public_reads=false)"
916 .into(),
917 ));
918 }
919 self.store
924 .search(params, requester, self.caps.anonymous_public_reads)
925 }
926
927 fn lifecycle_precheck(
959 &self,
960 event: &acdp_types::lifecycle::LifecycleEvent,
961 expected_type: &acdp_types::lifecycle::LifecycleEventType,
962 requester: Option<&AgentDid>,
963 ) -> Result<FullContext, AcdpError> {
964 if !self.lifecycle_enabled {
965 return Err(AcdpError::NotImplemented(
966 "this registry does not advertise acdp-registry-lifecycle \
967 (RFC-ACDP-0013 §6: lifecycle endpoints are not implemented)"
968 .into(),
969 ));
970 }
971 let ctx = self
973 .retrieve(&event.ctx_id, requester)?
974 .ok_or_else(|| AcdpError::NotFound(format!("context '{}' not found", event.ctx_id)))?;
975 event.validate()?;
977 if &event.event_type != expected_type {
978 return Err(AcdpError::SchemaViolation(format!(
979 "event_type '{}' does not match this endpoint (expected '{}', \
980 RFC-ACDP-0013 §6 step 2)",
981 event.event_type, expected_type
982 )));
983 }
984 let now = chrono::Utc::now();
985 if event.occurred_at > now + chrono::Duration::seconds(120) {
986 return Err(AcdpError::SchemaViolation(format!(
987 "event occurred_at '{}' is in the future beyond the 120s skew allowance \
988 (RFC-ACDP-0013 §4)",
989 event.occurred_at.format("%Y-%m-%dT%H:%M:%S%.3fZ")
990 )));
991 }
992 if event.actor != ctx.body.agent_id {
994 return Err(AcdpError::NotAuthorized(format!(
995 "event actor '{}' is not the context's producer — only the producer \
996 (agent_id) may use the lifecycle endpoints (RFC-ACDP-0013 §6 step 3)",
997 event.actor
998 )));
999 }
1000 event.actor_bound_signature()?;
1004 Ok(ctx)
1005 }
1006
1007 fn lifecycle_commit(
1011 &self,
1012 event: &acdp_types::lifecycle::LifecycleEvent,
1013 ) -> Result<FullContext, AcdpError> {
1014 Ok(self.store.commit_lifecycle_event(event)?.into_context())
1015 }
1016
1017 #[cfg(feature = "client")]
1019 async fn lifecycle_transition_verified(
1020 &self,
1021 event: &acdp_types::lifecycle::LifecycleEvent,
1022 expected_type: acdp_types::lifecycle::LifecycleEventType,
1023 requester: Option<&AgentDid>,
1024 resolver: &acdp_did::WebResolver,
1025 ) -> Result<FullContext, AcdpError> {
1026 let ctx = self.lifecycle_precheck(event, &expected_type, requester)?;
1027 acdp_verify::verify_lifecycle_event(
1031 &serde_json::to_value(event)?,
1032 &event.ctx_id,
1033 &ctx.body.agent_id,
1034 None, resolver,
1036 )
1037 .await?;
1038 self.lifecycle_commit(event)
1039 }
1040
1041 fn lifecycle_transition_verified_did_key(
1043 &self,
1044 event: &acdp_types::lifecycle::LifecycleEvent,
1045 expected_type: acdp_types::lifecycle::LifecycleEventType,
1046 requester: Option<&AgentDid>,
1047 ) -> Result<FullContext, AcdpError> {
1048 let ctx = self.lifecycle_precheck(event, &expected_type, requester)?;
1049 acdp_verify::verify_lifecycle_event_offline(
1050 &serde_json::to_value(event)?,
1051 &event.ctx_id,
1052 &ctx.body.agent_id,
1053 None,
1054 )?;
1055 self.lifecycle_commit(event)
1056 }
1057
1058 #[cfg(feature = "client")]
1071 pub async fn retract_verified(
1072 &self,
1073 event: &acdp_types::lifecycle::LifecycleEvent,
1074 requester: Option<&AgentDid>,
1075 resolver: &acdp_did::WebResolver,
1076 ) -> Result<FullContext, AcdpError> {
1077 self.lifecycle_transition_verified(
1078 event,
1079 acdp_types::lifecycle::LifecycleEventType::Retracted,
1080 requester,
1081 resolver,
1082 )
1083 .await
1084 }
1085
1086 #[cfg(feature = "client")]
1093 pub async fn republish_verified(
1094 &self,
1095 event: &acdp_types::lifecycle::LifecycleEvent,
1096 requester: Option<&AgentDid>,
1097 resolver: &acdp_did::WebResolver,
1098 ) -> Result<FullContext, AcdpError> {
1099 self.lifecycle_transition_verified(
1100 event,
1101 acdp_types::lifecycle::LifecycleEventType::Republished,
1102 requester,
1103 resolver,
1104 )
1105 .await
1106 }
1107
1108 pub fn retract_verified_did_key(
1113 &self,
1114 event: &acdp_types::lifecycle::LifecycleEvent,
1115 requester: Option<&AgentDid>,
1116 ) -> Result<FullContext, AcdpError> {
1117 self.lifecycle_transition_verified_did_key(
1118 event,
1119 acdp_types::lifecycle::LifecycleEventType::Retracted,
1120 requester,
1121 )
1122 }
1123
1124 pub fn republish_verified_did_key(
1126 &self,
1127 event: &acdp_types::lifecycle::LifecycleEvent,
1128 requester: Option<&AgentDid>,
1129 ) -> Result<FullContext, AcdpError> {
1130 self.lifecycle_transition_verified_did_key(
1131 event,
1132 acdp_types::lifecycle::LifecycleEventType::Republished,
1133 requester,
1134 )
1135 }
1136
1137 #[doc(hidden)]
1142 pub fn retract_unverified_for_tests(
1143 &self,
1144 event: &acdp_types::lifecycle::LifecycleEvent,
1145 requester: Option<&AgentDid>,
1146 ) -> Result<FullContext, AcdpError> {
1147 self.lifecycle_precheck(
1148 event,
1149 &acdp_types::lifecycle::LifecycleEventType::Retracted,
1150 requester,
1151 )?;
1152 self.lifecycle_commit(event)
1153 }
1154
1155 #[doc(hidden)]
1157 pub fn republish_unverified_for_tests(
1158 &self,
1159 event: &acdp_types::lifecycle::LifecycleEvent,
1160 requester: Option<&AgentDid>,
1161 ) -> Result<FullContext, AcdpError> {
1162 self.lifecycle_precheck(
1163 event,
1164 &acdp_types::lifecycle::LifecycleEventType::Republished,
1165 requester,
1166 )?;
1167 self.lifecycle_commit(event)
1168 }
1169
1170 pub fn record_registry_lifecycle_event(
1182 &self,
1183 event: &acdp_types::lifecycle::LifecycleEvent,
1184 ) -> Result<FullContext, AcdpError> {
1185 if !self.lifecycle_enabled {
1186 return Err(AcdpError::NotImplemented(
1187 "this registry does not advertise acdp-registry-lifecycle \
1188 (RFC-ACDP-0013 §6)"
1189 .into(),
1190 ));
1191 }
1192 event.validate()?;
1193 if !event.event_type.is_registered() {
1194 return Err(AcdpError::SchemaViolation(format!(
1195 "event_type '{}' is not registered for acceptance in 0.3.0 \
1196 (RFC-ACDP-0013 §7.3)",
1197 event.event_type
1198 )));
1199 }
1200 if event.actor.as_str() != self.caps.registry_did {
1201 return Err(AcdpError::NotAuthorized(format!(
1202 "registry-initiated event actor '{}' ≠ this registry's DID '{}' \
1203 (RFC-ACDP-0013 §6)",
1204 event.actor, self.caps.registry_did
1205 )));
1206 }
1207 if self.receipt_signer.is_some() && !event.is_signed() {
1208 return Err(AcdpError::SchemaViolation(
1209 "a registry advertising acdp-registry-receipts MUST sign its lifecycle \
1210 events (RFC-ACDP-0013 §5)"
1211 .into(),
1212 ));
1213 }
1214 if event.is_signed() {
1215 event.actor_bound_signature()?;
1217 }
1218 self.lifecycle_commit(event)
1219 }
1220}
1221
1222pub(crate) fn can_retrieve(
1224 body: &Body,
1225 requester: Option<&AgentDid>,
1226 caps: &CapabilitiesDocument,
1227) -> bool {
1228 match body.visibility {
1229 Visibility::Public => caps.anonymous_public_reads || requester.is_some(),
1230 Visibility::Restricted | Visibility::Private => match requester {
1231 None => false,
1232 Some(r) => {
1233 r == &body.agent_id
1234 || body
1235 .audience
1236 .as_deref()
1237 .is_some_and(|a| a.iter().any(|d| d == r))
1238 }
1239 },
1240 }
1241}
1242
1243#[cfg(feature = "client")]
1258async fn producer_key_fingerprint(
1259 req: &PublishRequest,
1260 resolver: &acdp_did::WebResolver,
1261) -> Result<String, AcdpError> {
1262 acdp_crypto::fingerprint::fingerprint_for_key_id(
1263 &req.signature.key_id,
1264 &req.signature.algorithm,
1265 resolver,
1266 )
1267 .await
1268}
1269
1270fn fingerprint_pinned_key(public_key_b64: &str, algorithm: &str) -> Result<String, AcdpError> {
1275 use base64::{engine::general_purpose::STANDARD, Engine};
1276
1277 let raw = STANDARD
1278 .decode(public_key_b64)
1279 .map_err(|e| AcdpError::KeyResolution(format!("pinned key is not valid base64: {e}")))?;
1280 match algorithm {
1281 "ed25519" => {
1282 let arr: [u8; 32] = raw.as_slice().try_into().map_err(|_| {
1283 AcdpError::KeyResolution(format!(
1284 "pinned ed25519 key must be 32 bytes, got {}",
1285 raw.len()
1286 ))
1287 })?;
1288 Ok(acdp_crypto::fingerprint::fingerprint_ed25519(&arr))
1289 }
1290 "ecdsa-p256" => acdp_crypto::fingerprint::fingerprint_p256_sec1(&raw),
1291 other => Err(AcdpError::UnsupportedAlgorithm(format!(
1292 "cannot fingerprint a pinned key for algorithm '{other}'"
1293 ))),
1294 }
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299 use super::*;
1300 use crate::registry::store::InMemoryStore;
1301 use acdp_crypto::SigningKey;
1302 use acdp_producer::Producer;
1303 use acdp_types::capabilities::Limits;
1304 use acdp_types::primitives::{AgentDid, ContextType, Visibility};
1305
1306 fn caps() -> CapabilitiesDocument {
1307 CapabilitiesDocument {
1308 acdp_version: "0.1.0".into(),
1309 registry_did: "did:web:registry.example.com".into(),
1310 supported_signature_algorithms: vec!["ed25519".into()],
1311 supported_did_methods: vec!["did:web".into()],
1312 profiles: vec!["acdp-registry-core".into()],
1313 limits: Limits {
1314 max_payload_bytes: 1_048_576,
1315 max_embedded_bytes: 65_536,
1316 idempotency_key_ttl_seconds: None,
1317 max_publish_per_minute: None,
1318 },
1319 read_authentication_methods: vec![],
1320 anonymous_public_reads: true,
1321 supports_idempotency_key: false,
1322 extensions: Default::default(),
1323 }
1324 }
1325
1326 fn producer() -> Producer {
1327 Producer::new(
1328 SigningKey::from_bytes(&[1u8; 32]),
1329 AgentDid::new("did:web:agents.example.com:test"),
1330 "did:web:agents.example.com:test#key-1",
1331 )
1332 }
1333
1334 #[test]
1335 fn publish_v1_then_retrieve() {
1336 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1337 let p = producer();
1338 let req = p
1339 .publish_request()
1340 .title("v1")
1341 .context_type(ContextType::DataSnapshot)
1342 .visibility(Visibility::Public)
1343 .build()
1344 .unwrap();
1345 let resp = server.publish_unverified_for_tests(&req).unwrap();
1346 assert_eq!(resp.version, 1);
1347 let ctx = server.retrieve(&resp.ctx_id, None).unwrap().unwrap();
1348 assert_eq!(ctx.body.title, "v1");
1349 let lineage = server.lineage(&resp.lineage_id, None).unwrap();
1351 assert_eq!(lineage.len(), 1);
1352 let cur = server.current(&resp.lineage_id, None).unwrap().unwrap();
1354 assert_eq!(cur.body.ctx_id, resp.ctx_id);
1355 }
1356
1357 #[test]
1358 fn supersession_marks_predecessor_and_returns_v2() {
1359 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1360 let p = producer();
1361 let v1_req = p
1362 .publish_request()
1363 .title("v1")
1364 .context_type(ContextType::DataSnapshot)
1365 .visibility(Visibility::Public)
1366 .build()
1367 .unwrap();
1368 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1369
1370 let v2_req = p
1371 .supersede(v1.ctx_id.clone())
1372 .version(2)
1373 .title("v2")
1374 .context_type(ContextType::DataSnapshot)
1375 .visibility(Visibility::Public)
1376 .build()
1377 .unwrap();
1378 let v2 = server.publish_unverified_for_tests(&v2_req).unwrap();
1379 assert_eq!(v2.version, 2);
1380 let v1_ctx = server.retrieve(&v1.ctx_id, None).unwrap().unwrap();
1382 assert!(matches!(
1383 v1_ctx.registry_state.status,
1384 acdp_types::Status::Superseded
1385 ));
1386 assert_eq!(v1.lineage_id, v2.lineage_id);
1388 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1390 assert_eq!(cur.body.ctx_id, v2.ctx_id);
1391 }
1392
1393 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1401 async fn concurrent_supersession_exactly_one_succeeds() {
1402 use std::sync::Arc;
1403 let server = Arc::new(RegistryServer::new(
1404 InMemoryStore::new(),
1405 caps(),
1406 "registry.example.com",
1407 ));
1408 let p = producer();
1409 let v1_req = p
1410 .publish_request()
1411 .title("v1")
1412 .context_type(ContextType::DataSnapshot)
1413 .visibility(Visibility::Public)
1414 .build()
1415 .unwrap();
1416 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1417
1418 let v2a_req = p
1423 .supersede(v1.ctx_id.clone())
1424 .version(2)
1425 .title("v2-A")
1426 .context_type(ContextType::DataSnapshot)
1427 .visibility(Visibility::Public)
1428 .build()
1429 .unwrap();
1430 let v2b_req = p
1431 .supersede(v1.ctx_id.clone())
1432 .version(2)
1433 .title("v2-B")
1434 .context_type(ContextType::DataSnapshot)
1435 .visibility(Visibility::Public)
1436 .build()
1437 .unwrap();
1438
1439 let s1 = Arc::clone(&server);
1440 let s2 = Arc::clone(&server);
1441 let h1 = tokio::task::spawn_blocking(move || s1.publish_unverified_for_tests(&v2a_req));
1442 let h2 = tokio::task::spawn_blocking(move || s2.publish_unverified_for_tests(&v2b_req));
1443 let (r1, r2) = (h1.await.unwrap(), h2.await.unwrap());
1444
1445 let outcomes = [r1, r2];
1446 let successes = outcomes.iter().filter(|r| r.is_ok()).count();
1447 let failures = outcomes.iter().filter(|r| r.is_err()).count();
1448 assert_eq!(
1449 successes, 1,
1450 "exactly one concurrent supersession MUST succeed; got {successes} successes / {failures} failures"
1451 );
1452 assert_eq!(failures, 1);
1453 for r in &outcomes {
1456 if let Err(e) = r {
1457 match e {
1458 AcdpError::SupersededTarget { reason, .. } => assert_eq!(
1459 *reason,
1460 acdp_primitives::error::SupersessionReason::AlreadySuperseded,
1461 "concurrent loser MUST be AlreadySuperseded"
1462 ),
1463 other => panic!("concurrent loser had wrong error: {other:?}"),
1464 }
1465 }
1466 }
1467 }
1468
1469 #[test]
1470 fn hostile_supersession_by_non_owner_rejected_predecessor_unchanged() {
1471 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1476 let victim = producer_for(7, "did:web:agents.example.com:victim");
1477 let v1_req = victim
1478 .publish_request()
1479 .title("v1")
1480 .context_type(ContextType::DataSnapshot)
1481 .visibility(Visibility::Public)
1482 .build()
1483 .unwrap();
1484 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1485
1486 let attacker = producer_for(9, "did:web:evil.example.com:attacker");
1489 let v2_req = attacker
1490 .supersede(v1.ctx_id.clone())
1491 .version(2)
1492 .title("hijacked")
1493 .context_type(ContextType::DataSnapshot)
1494 .visibility(Visibility::Public)
1495 .build()
1496 .unwrap();
1497 let err = server.publish_unverified_for_tests(&v2_req).unwrap_err();
1498 match err {
1500 AcdpError::SupersededTarget { reason, .. } => {
1501 assert_eq!(reason, acdp_primitives::error::SupersessionReason::NotFound);
1502 }
1503 other => panic!("expected uniform SupersededTarget::NotFound, got {other:?}"),
1504 }
1505 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1507 assert_eq!(cur.body.ctx_id, v1.ctx_id);
1508 assert_eq!(cur.body.title, "v1");
1509 assert_eq!(
1510 cur.registry_state.status,
1511 acdp_types::primitives::Status::Active
1512 );
1513 }
1514
1515 #[test]
1516 fn owner_supersession_still_succeeds_after_ownership_check() {
1517 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1518 let p = producer();
1519 let v1_req = p
1520 .publish_request()
1521 .title("v1")
1522 .context_type(ContextType::DataSnapshot)
1523 .visibility(Visibility::Public)
1524 .build()
1525 .unwrap();
1526 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1527 let v2_req = p
1528 .supersede(v1.ctx_id.clone())
1529 .version(2)
1530 .title("v2")
1531 .context_type(ContextType::DataSnapshot)
1532 .visibility(Visibility::Public)
1533 .build()
1534 .unwrap();
1535 let v2 = server.publish_unverified_for_tests(&v2_req).unwrap();
1536 assert_eq!(v2.version, 2);
1537 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1538 assert_eq!(cur.body.ctx_id, v2.ctx_id);
1539 }
1540
1541 #[test]
1542 fn supersession_with_unknown_target_rejected_as_not_found() {
1543 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1544 let p = producer();
1545 let phantom =
1546 CtxId("acdp://registry.example.com/12345678-1234-4321-8123-deadbeefcafe".into());
1547 let req = p
1548 .supersede(phantom)
1549 .version(2)
1550 .title("v2-orphan")
1551 .context_type(ContextType::DataSnapshot)
1552 .visibility(Visibility::Public)
1553 .build()
1554 .unwrap();
1555 let err = server.publish_unverified_for_tests(&req).unwrap_err();
1556 match err {
1557 AcdpError::SupersededTarget { reason, .. } => {
1558 assert_eq!(reason, acdp_primitives::error::SupersessionReason::NotFound);
1559 }
1560 other => panic!("expected SupersededTarget::NotFound, got {other:?}"),
1561 }
1562 }
1563
1564 #[test]
1565 fn version_mismatch_rejected() {
1566 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1567 let p = producer();
1568 let v1_req = p
1569 .publish_request()
1570 .title("v1")
1571 .context_type(ContextType::DataSnapshot)
1572 .visibility(Visibility::Public)
1573 .build()
1574 .unwrap();
1575 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1576 let v3_req = p
1578 .supersede(v1.ctx_id.clone())
1579 .version(3)
1580 .title("v3-skipped")
1581 .context_type(ContextType::DataSnapshot)
1582 .visibility(Visibility::Public)
1583 .build()
1584 .unwrap();
1585 let err = server.publish_unverified_for_tests(&v3_req).unwrap_err();
1586 match err {
1587 AcdpError::SupersededTarget { reason, .. } => {
1588 assert_eq!(
1589 reason,
1590 acdp_primitives::error::SupersessionReason::VersionMismatch
1591 );
1592 }
1593 other => panic!("expected VersionMismatch, got {other:?}"),
1594 }
1595 }
1596
1597 #[test]
1598 fn search_finds_published_context() {
1599 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1600 let p = producer();
1601 let req = p
1602 .publish_request()
1603 .title("Q1 portfolio risk")
1604 .context_type(ContextType::DataSnapshot)
1605 .visibility(Visibility::Public)
1606 .build()
1607 .unwrap();
1608 server.publish_unverified_for_tests(&req).unwrap();
1609 let resp = server
1610 .search(
1611 &SearchParams {
1612 q: Some("portfolio".into()),
1613 ..Default::default()
1614 },
1615 None,
1616 )
1617 .unwrap();
1618 assert_eq!(resp.matches.len(), 1);
1619 assert_eq!(resp.matches[0].title, "Q1 portfolio risk");
1620 }
1621
1622 #[test]
1628 fn lineage_filters_restricted_for_stranger() {
1629 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1630 let p = producer();
1631 let audience = AgentDid::new("did:web:audience.example.com:reader");
1632 let req = p
1633 .publish_request()
1634 .title("restricted v1")
1635 .context_type(ContextType::DataSnapshot)
1636 .visibility(Visibility::Restricted)
1637 .audience(vec![audience.clone()])
1638 .build()
1639 .unwrap();
1640 let resp = server.publish_unverified_for_tests(&req).unwrap();
1641
1642 let stranger = AgentDid::new("did:web:other.example.com:reader");
1643 let stranger_view = server.lineage(&resp.lineage_id, Some(&stranger)).unwrap();
1644 assert!(
1645 stranger_view.is_empty(),
1646 "stranger MUST NOT see restricted bodies via lineage(); got {} entries",
1647 stranger_view.len()
1648 );
1649
1650 let audience_view = server.lineage(&resp.lineage_id, Some(&audience)).unwrap();
1651 assert_eq!(
1652 audience_view.len(),
1653 1,
1654 "audience member MUST see the restricted body via lineage()"
1655 );
1656 }
1657
1658 #[test]
1661 fn current_filters_private_for_stranger() {
1662 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1663 let p = producer();
1664 let req = p
1665 .publish_request()
1666 .title("private v1")
1667 .context_type(ContextType::DataSnapshot)
1668 .visibility(Visibility::Private)
1669 .build()
1670 .unwrap();
1671 let resp = server.publish_unverified_for_tests(&req).unwrap();
1672
1673 let stranger = AgentDid::new("did:web:other.example.com:reader");
1674 assert!(
1675 server
1676 .current(&resp.lineage_id, Some(&stranger))
1677 .unwrap()
1678 .is_none(),
1679 "stranger MUST NOT see private contexts via current()"
1680 );
1681
1682 let producer_did = AgentDid::new("did:web:agents.example.com:test");
1683 assert!(
1684 server
1685 .current(&resp.lineage_id, Some(&producer_did))
1686 .unwrap()
1687 .is_some(),
1688 "producer MUST see private contexts via current()"
1689 );
1690 }
1691
1692 #[test]
1703 fn current_returns_none_when_all_superseded() {
1704 use crate::registry::store::RegistryStore;
1705 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1706 let p = producer();
1707 let req = p
1708 .publish_request()
1709 .title("v1")
1710 .context_type(ContextType::DataSnapshot)
1711 .visibility(Visibility::Public)
1712 .build()
1713 .unwrap();
1714 let resp = server.publish_unverified_for_tests(&req).unwrap();
1715 server.store().mark_superseded(&resp.ctx_id).unwrap();
1717
1718 let cur = server.current(&resp.lineage_id, None).unwrap();
1719 assert!(
1720 cur.is_none(),
1721 "all-superseded lineage MUST resolve to None per RFC-ACDP-0004 §5; got {cur:?}"
1722 );
1723 }
1724
1725 #[test]
1733 fn search_suppresses_public_when_anonymous_public_reads_false() {
1734 let mut c = caps();
1735 c.anonymous_public_reads = false;
1736 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
1737 let p = producer();
1738 let req = p
1739 .publish_request()
1740 .title("public-but-flag-off")
1741 .context_type(ContextType::DataSnapshot)
1742 .visibility(Visibility::Public)
1743 .build()
1744 .unwrap();
1745 server.publish_unverified_for_tests(&req).unwrap();
1746
1747 let err = server
1749 .search(
1750 &SearchParams {
1751 q: Some("public-but-flag-off".into()),
1752 ..Default::default()
1753 },
1754 None,
1755 )
1756 .unwrap_err();
1757 assert!(
1758 matches!(err, AcdpError::NotAuthorized(_)),
1759 "vis-009: anonymous search MUST be NotAuthorized when \
1760 anonymous_public_reads=false; got {err:?}"
1761 );
1762
1763 let stranger = AgentDid::new("did:web:other.example.com:reader");
1766 let authed = server
1767 .search(
1768 &SearchParams {
1769 q: Some("public-but-flag-off".into()),
1770 ..Default::default()
1771 },
1772 Some(&stranger),
1773 )
1774 .unwrap();
1775 assert_eq!(
1776 authed.matches.len(),
1777 1,
1778 "authenticated search MUST see public contexts regardless of anonymous_public_reads"
1779 );
1780 }
1781
1782 #[test]
1785 fn try_new_rejects_did_authority_mismatch() {
1786 let mut c = caps();
1787 c.registry_did = "did:web:other.example.com".into(); let res = RegistryServer::try_new(InMemoryStore::new(), c, "registry.example.com");
1789 match res {
1790 Err(AcdpError::SchemaViolation(msg)) => {
1791 assert!(msg.contains("does not match expected"))
1792 }
1793 Err(other) => panic!("expected SchemaViolation, got {other:?}"),
1794 Ok(_) => panic!("expected Err"),
1795 }
1796 }
1797
1798 #[test]
1799 fn try_new_rejects_caps_missing_ed25519() {
1800 let mut c = caps();
1801 c.supported_signature_algorithms = vec!["ecdsa-p256".into()]; let res = RegistryServer::try_new(InMemoryStore::new(), c, "registry.example.com");
1803 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1804 }
1805
1806 #[test]
1807 fn try_new_accepts_valid_caps() {
1808 RegistryServer::try_new(InMemoryStore::new(), caps(), "registry.example.com").unwrap();
1809 }
1810
1811 #[test]
1814 fn try_new_accepts_valid_dns_authority() {
1815 RegistryServer::try_new(InMemoryStore::new(), caps(), "registry.example.com").unwrap();
1816 }
1817
1818 #[test]
1819 fn try_new_rejects_host_port_authority() {
1820 let res = RegistryServer::try_new(InMemoryStore::new(), caps(), "localhost:8443");
1823 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1824 }
1825
1826 #[test]
1827 fn try_new_rejects_uppercase_authority() {
1828 let res = RegistryServer::try_new(InMemoryStore::new(), caps(), "Registry.Example.Com");
1829 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1830 }
1831
1832 #[test]
1833 fn try_new_rejects_url_form_authority() {
1834 let res =
1835 RegistryServer::try_new(InMemoryStore::new(), caps(), "https://registry.example.com");
1836 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1837 }
1838
1839 #[test]
1840 fn try_new_for_test_accepts_host_port() {
1841 let mut c = caps();
1844 c.registry_did = acdp_did::authority_to_did_web("localhost:8443");
1845 RegistryServer::try_new_for_test_authority(InMemoryStore::new(), c, "localhost:8443")
1846 .unwrap();
1847 }
1848
1849 fn producer_for(seed: u8, did: &str) -> Producer {
1852 Producer::new(
1853 SigningKey::from_bytes(&[seed; 32]),
1854 AgentDid::new(did),
1855 format!("{did}#key-1"),
1856 )
1857 }
1858
1859 #[test]
1860 fn retrieve_restricted_blocks_stranger_returns_none() {
1861 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1862 let owner = AgentDid::new("did:web:agents.example.com:owner");
1863 let audience_member = AgentDid::new("did:web:agents.example.com:friend");
1864 let p = producer_for(2, owner.as_str());
1865 let req = p
1866 .publish_request()
1867 .title("restricted")
1868 .context_type(ContextType::DataSnapshot)
1869 .visibility(Visibility::Restricted)
1870 .audience(vec![audience_member.clone()])
1871 .build()
1872 .unwrap();
1873 let resp = server.publish_unverified_for_tests(&req).unwrap();
1874 let stranger = AgentDid::new("did:web:agents.example.com:stranger");
1875
1876 assert!(server.retrieve(&resp.ctx_id, None).unwrap().is_none());
1877 assert!(server
1878 .retrieve(&resp.ctx_id, Some(&stranger))
1879 .unwrap()
1880 .is_none());
1881 assert!(server
1882 .retrieve(&resp.ctx_id, Some(&owner))
1883 .unwrap()
1884 .is_some());
1885 assert!(server
1886 .retrieve(&resp.ctx_id, Some(&audience_member))
1887 .unwrap()
1888 .is_some());
1889 }
1890
1891 #[test]
1892 fn search_restricted_filters_strangers() {
1893 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1894 let owner = AgentDid::new("did:web:agents.example.com:owner");
1895 let p = producer_for(3, owner.as_str());
1896 let req = p
1897 .publish_request()
1898 .title("hush hush")
1899 .context_type(ContextType::DataSnapshot)
1900 .visibility(Visibility::Restricted)
1901 .audience(vec![AgentDid::new("did:web:agents.example.com:friend")])
1902 .build()
1903 .unwrap();
1904 server.publish_unverified_for_tests(&req).unwrap();
1905
1906 let stranger = AgentDid::new("did:web:agents.example.com:stranger");
1907 let r_anon = server.search(&SearchParams::default(), None).unwrap();
1908 assert!(
1909 r_anon.matches.is_empty(),
1910 "anonymous must not see restricted"
1911 );
1912 let r_stranger = server
1913 .search(&SearchParams::default(), Some(&stranger))
1914 .unwrap();
1915 assert!(r_stranger.matches.is_empty());
1916 let r_owner = server
1917 .search(&SearchParams::default(), Some(&owner))
1918 .unwrap();
1919 assert_eq!(r_owner.matches.len(), 1);
1920 }
1921
1922 #[test]
1926 fn search_private_visible_only_to_producer() {
1927 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1928 let owner = AgentDid::new("did:web:agents.example.com:owner");
1929 let audience_member = AgentDid::new("did:web:agents.example.com:friend");
1930 let p = producer_for(4, owner.as_str());
1931 let req = p
1932 .publish_request()
1933 .title("private note")
1934 .context_type(ContextType::DataSnapshot)
1935 .visibility(Visibility::Private)
1936 .audience(vec![audience_member.clone()])
1937 .build()
1938 .unwrap();
1939 let resp = server.publish_unverified_for_tests(&req).unwrap();
1940
1941 let r_audience = server
1942 .search(&SearchParams::default(), Some(&audience_member))
1943 .unwrap();
1944 assert!(
1945 r_audience.matches.is_empty(),
1946 "audience must NOT see private in search"
1947 );
1948 let r_owner = server
1949 .search(&SearchParams::default(), Some(&owner))
1950 .unwrap();
1951 assert_eq!(
1952 r_owner.matches.len(),
1953 1,
1954 "owner sees their own private context"
1955 );
1956
1957 assert!(server
1959 .retrieve(&resp.ctx_id, Some(&audience_member))
1960 .unwrap()
1961 .is_some());
1962 }
1963
1964 #[cfg(feature = "client")]
1976 #[tokio::test]
1977 async fn publish_verified_rejects_non_did_web_key_id() {
1978 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1979 let p = producer();
1980 let mut req = p
1981 .publish_request()
1982 .title("v1")
1983 .context_type(ContextType::DataSnapshot)
1984 .visibility(Visibility::Public)
1985 .build()
1986 .unwrap();
1987 let did_key = acdp_did::key::did_key_from_ed25519(
1994 &SigningKey::from_bytes(&[9u8; 32]).verifying_key_bytes(),
1995 );
1996 req.signature.key_id = acdp_did::key::did_key_url(&did_key).unwrap();
1997 let resolver = acdp_did::WebResolver::new();
1998 let err = server
1999 .publish_verified(&req, None, &resolver)
2000 .await
2001 .unwrap_err();
2002 match err {
2003 AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("did:web")),
2004 other => panic!("expected KeyNotAuthorized for non-did:web, got {other:?}"),
2005 }
2006 }
2007
2008 #[cfg(feature = "client")]
2009 #[tokio::test]
2010 async fn publish_verified_rejects_agent_id_keyid_mismatch() {
2011 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2012 let p = producer();
2013 let mut req = p
2014 .publish_request()
2015 .title("v1")
2016 .context_type(ContextType::DataSnapshot)
2017 .visibility(Visibility::Public)
2018 .build()
2019 .unwrap();
2020 req.signature.key_id = "did:web:other.example.com:agent#key-1".into();
2021 let resolver = acdp_did::WebResolver::new();
2022 let err = server
2023 .publish_verified(&req, None, &resolver)
2024 .await
2025 .unwrap_err();
2026 match err {
2027 AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("agent_id")),
2028 other => panic!("expected KeyNotAuthorized for agent_id mismatch, got {other:?}"),
2029 }
2030 }
2031
2032 #[cfg(feature = "client")]
2033 #[tokio::test]
2034 async fn publish_verified_rejects_keyid_without_fragment() {
2035 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2036 let p = producer();
2037 let mut req = p
2038 .publish_request()
2039 .title("v1")
2040 .context_type(ContextType::DataSnapshot)
2041 .visibility(Visibility::Public)
2042 .build()
2043 .unwrap();
2044 req.signature.key_id = "did:web:agents.example.com:test".into(); let resolver = acdp_did::WebResolver::new();
2046 let err = server
2047 .publish_verified(&req, None, &resolver)
2048 .await
2049 .unwrap_err();
2050 assert!(
2053 matches!(
2054 err,
2055 AcdpError::SchemaViolation(_) | AcdpError::KeyResolution(_)
2056 ),
2057 "expected fragment-rejection error, got {err:?}"
2058 );
2059 }
2060
2061 fn caps_with_idempotency() -> CapabilitiesDocument {
2064 let mut c = caps();
2065 c.supports_idempotency_key = true;
2066 c.limits.idempotency_key_ttl_seconds = Some(86_400);
2067 c
2068 }
2069
2070 #[test]
2071 fn idempotency_same_hash_returns_original_response() {
2072 let server = RegistryServer::new(
2073 InMemoryStore::new(),
2074 caps_with_idempotency(),
2075 "registry.example.com",
2076 );
2077 let p = producer();
2078 let req = p
2079 .publish_request()
2080 .title("once")
2081 .context_type(ContextType::DataSnapshot)
2082 .visibility(Visibility::Public)
2083 .build()
2084 .unwrap();
2085 let first = server.publish_unverified_for_tests(&req).unwrap();
2087 let ttl = caps_with_idempotency()
2091 .limits
2092 .idempotency_key_ttl_seconds
2093 .unwrap() as i64;
2094 server
2095 .store()
2096 .idempotency_record(
2097 &req.agent_id,
2098 "k-001",
2099 &req.content_hash,
2100 &first,
2101 chrono::Utc::now() + chrono::Duration::seconds(ttl),
2102 )
2103 .unwrap();
2104 let prior = server
2105 .store()
2106 .idempotency_lookup(&req.agent_id, "k-001")
2107 .unwrap()
2108 .unwrap();
2109 assert_eq!(prior.content_hash, req.content_hash);
2110 assert_eq!(prior.response.ctx_id, first.ctx_id);
2111 }
2112
2113 #[test]
2114 fn idempotency_evicts_after_ttl() {
2115 let store = InMemoryStore::new();
2116 let agent = AgentDid::new("did:web:agents.example.com:test");
2117 let resp = PublishResponse {
2118 registry_receipt: None,
2119 ctx_id: acdp_types::CtxId("acdp://r/12345678-1234-4321-8123-000000000099".into()),
2120 lineage_id: acdp_types::LineageId(
2121 "lin:sha256:9999999999999999999999999999999999999999999999999999999999999999"
2122 .into(),
2123 ),
2124 version: 1,
2125 created_at: chrono::Utc::now(),
2126 status: Status::Active,
2127 };
2128 let past = chrono::Utc::now() - chrono::Duration::seconds(1);
2130 store
2131 .idempotency_record(
2132 &agent,
2133 "expired",
2134 &acdp_types::ContentHash("sha256:0".into()),
2135 &resp,
2136 past,
2137 )
2138 .unwrap();
2139 let prior = store.idempotency_lookup(&agent, "expired").unwrap();
2141 assert!(
2142 prior.is_none(),
2143 "lazy TTL eviction should drop expired record"
2144 );
2145 }
2146
2147 struct AlwaysDeny;
2150 impl crate::registry::RateLimiter for AlwaysDeny {
2151 fn check_publish(&self, agent_id: &AgentDid) -> Result<(), AcdpError> {
2152 Err(AcdpError::RateLimited(format!("blocked: {agent_id}")))
2153 }
2154 }
2155
2156 #[test]
2157 fn rate_limiter_blocks_publish_before_persist() {
2158 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com")
2159 .with_rate_limiter(AlwaysDeny);
2160 let p = producer();
2161 let req = p
2162 .publish_request()
2163 .title("blocked")
2164 .context_type(ContextType::DataSnapshot)
2165 .visibility(Visibility::Public)
2166 .build()
2167 .unwrap();
2168 let err = server.publish_unverified_for_tests(&req).unwrap_err();
2169 assert!(matches!(err, AcdpError::RateLimited(_)));
2170 let resp = server.search(&SearchParams::default(), None).unwrap();
2172 assert!(
2173 resp.matches.is_empty(),
2174 "rate-limited publish must not persist"
2175 );
2176 }
2177
2178 #[test]
2179 fn created_at_is_ms_truncated() {
2180 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2181 let p = producer();
2182 let req = p
2183 .publish_request()
2184 .title("ms")
2185 .context_type(ContextType::DataSnapshot)
2186 .visibility(Visibility::Public)
2187 .build()
2188 .unwrap();
2189 let resp = server.publish_unverified_for_tests(&req).unwrap();
2190 assert_eq!(
2192 resp.created_at.timestamp_subsec_nanos() % 1_000_000,
2193 0,
2194 "created_at must be millisecond-truncated per RFC-ACDP-0001 §5.3"
2195 );
2196 }
2197
2198 fn did_key_request() -> acdp_types::publish::PublishRequest {
2201 let p = Producer::new_did_key(SigningKey::from_bytes(&[7u8; 32]));
2202 p.publish_request()
2203 .title("did:key publish")
2204 .context_type(ContextType::DataSnapshot)
2205 .visibility(Visibility::Public)
2206 .build()
2207 .unwrap()
2208 }
2209
2210 #[test]
2214 fn did_key_publish_rejected_when_not_advertised() {
2215 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2216 let err = server
2217 .publish_verified_did_key(&did_key_request(), None)
2218 .unwrap_err();
2219 assert!(
2220 matches!(err, AcdpError::KeyResolution(ref m) if m.contains("supported_did_methods")),
2221 "got {err:?}"
2222 );
2223 }
2224
2225 #[test]
2229 fn did_key_publish_verified_end_to_end() {
2230 let mut c = caps();
2231 c.supported_did_methods.push("did:key".into());
2232 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
2233 let req = did_key_request();
2234 let resp = server.publish_verified_did_key(&req, None).unwrap();
2235 assert_eq!(resp.ctx_id.authority(), "registry.example.com");
2236
2237 let mut tampered = did_key_request();
2239 tampered.title = "tampered".into();
2240 let err = server
2241 .publish_verified_did_key(&tampered, None)
2242 .unwrap_err();
2243 assert!(matches!(err, AcdpError::HashMismatch { .. }), "got {err:?}");
2244 }
2245
2246 #[test]
2252 fn receiptless_idempotent_replay_survives_enabling_receipts() {
2253 let mut c = caps();
2254 c.acdp_version = "0.2.0".into();
2255 c.supported_did_methods.push("did:key".into());
2256 c.supports_idempotency_key = true;
2257 c.limits.idempotency_key_ttl_seconds = Some(86_400);
2258 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
2259 .with_receipt_signer(
2260 acdp_types::receipt::ReceiptSigner::new(
2261 SigningKey::from_bytes(&[0x11u8; 32]),
2262 "did:web:registry.example.com",
2263 "did:web:registry.example.com#receipt-key-1",
2264 )
2265 .unwrap(),
2266 )
2267 .unwrap();
2268
2269 let req = did_key_request();
2272 let pre_receipts_response = acdp_types::publish::PublishResponse {
2273 ctx_id: CtxId(format!(
2274 "acdp://registry.example.com/{}",
2275 uuid::Uuid::new_v4()
2276 )),
2277 lineage_id: acdp_crypto::derive_lineage_id(&CtxId(
2278 "acdp://registry.example.com/v1".into(),
2279 )),
2280 version: 1,
2281 created_at: acdp_primitives::time::trunc_ms(chrono::Utc::now()),
2282 status: Status::Active,
2283 registry_receipt: None,
2284 };
2285 server
2286 .store()
2287 .idempotency_record(
2288 &req.agent_id,
2289 "pre-receipts-key",
2290 &req.content_hash,
2291 &pre_receipts_response,
2292 chrono::Utc::now() + chrono::Duration::hours(1),
2293 )
2294 .unwrap();
2295
2296 let resp = server
2299 .publish_verified_did_key(&req, Some("pre-receipts-key"))
2300 .expect("replay of a pre-receipts record must succeed");
2301 assert_eq!(resp.ctx_id, pre_receipts_response.ctx_id);
2302 assert!(
2303 resp.registry_receipt.is_none(),
2304 "replay returns the original response verbatim"
2305 );
2306
2307 let p2 = Producer::new_did_key(SigningKey::from_bytes(&[8u8; 32]));
2309 let fresh = p2
2310 .publish_request()
2311 .title("fresh after enabling receipts")
2312 .context_type(ContextType::DataSnapshot)
2313 .visibility(Visibility::Public)
2314 .build()
2315 .unwrap();
2316 let fresh_resp = server.publish_verified_did_key(&fresh, None).unwrap();
2317 assert!(
2318 fresh_resp.registry_receipt.is_some(),
2319 "new inserts on a receipts registry must mint"
2320 );
2321 }
2322
2323 #[test]
2330 fn pinned_verified_publish_mints_receipt_with_correct_fingerprint() {
2331 use base64::{engine::general_purpose::STANDARD, Engine};
2332
2333 let key = SigningKey::from_bytes(&[3u8; 32]);
2334 let verifying_key_bytes = key.verifying_key_bytes();
2335 let pub_b64 = STANDARD.encode(verifying_key_bytes);
2336 let did = "did:web:agents.example.com:pinned-agent";
2337 let p = Producer::new(key, AgentDid::new(did), format!("{did}#key-1"));
2338 let req = p
2339 .publish_request()
2340 .title("pinned publish")
2341 .context_type(ContextType::DataSnapshot)
2342 .visibility(Visibility::Public)
2343 .build()
2344 .unwrap();
2345
2346 let mut c = caps();
2347 c.acdp_version = "0.2.0".into();
2348 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
2349 .with_receipt_signer(
2350 acdp_types::receipt::ReceiptSigner::new(
2351 SigningKey::from_bytes(&[0x22u8; 32]),
2352 "did:web:registry.example.com",
2353 "did:web:registry.example.com#receipt-key-1",
2354 )
2355 .unwrap(),
2356 )
2357 .unwrap();
2358
2359 let resp = server
2360 .publish_pinned_verified_in_tenant(&req, None, None, &pub_b64, "ed25519")
2361 .expect("pinned-verified publish must succeed on a receipts registry");
2362 let receipt = resp
2363 .registry_receipt
2364 .expect("a receipts-advertising registry must mint a receipt");
2365 assert_eq!(
2366 receipt["key_fingerprint"].as_str().unwrap(),
2367 acdp_crypto::fingerprint::fingerprint_ed25519(&verifying_key_bytes)
2368 );
2369 }
2370
2371 #[test]
2375 fn pinned_verified_publish_without_receipt_signer_succeeds_with_no_receipt() {
2376 use base64::{engine::general_purpose::STANDARD, Engine};
2377
2378 let key = SigningKey::from_bytes(&[4u8; 32]);
2379 let pub_b64 = STANDARD.encode(key.verifying_key_bytes());
2380 let did = "did:web:agents.example.com:pinned-agent-2";
2381 let p = Producer::new(key, AgentDid::new(did), format!("{did}#key-1"));
2382 let req = p
2383 .publish_request()
2384 .title("pinned publish, no receipts")
2385 .context_type(ContextType::DataSnapshot)
2386 .visibility(Visibility::Public)
2387 .build()
2388 .unwrap();
2389
2390 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2391 let resp = server
2392 .publish_pinned_verified_in_tenant(&req, None, None, &pub_b64, "ed25519")
2393 .unwrap();
2394 assert!(resp.registry_receipt.is_none());
2395 }
2396
2397 #[test]
2400 fn did_key_publish_path_refuses_did_web() {
2401 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2402 let p = producer();
2403 let req = p
2404 .publish_request()
2405 .title("did:web on the offline path")
2406 .context_type(ContextType::DataSnapshot)
2407 .visibility(Visibility::Public)
2408 .build()
2409 .unwrap();
2410 let err = server.publish_verified_did_key(&req, None).unwrap_err();
2411 assert!(
2412 matches!(err, AcdpError::KeyResolution(_)),
2413 "did:web on the offline path must be refused, got {err:?}"
2414 );
2415 }
2416}