1use crate::registry::rate_limit::{NoopRateLimiter, RateLimiter};
31use crate::registry::store::RegistryStore;
32use crate::registry::validator::PublishValidator;
33use acdp_primitives::error::AcdpError;
34use acdp_types::{
35 body::{Body, FullContext},
36 capabilities::CapabilitiesDocument,
37 primitives::{AgentDid, CtxId, LineageId, Status, Visibility},
38 publish::{PublishRequest, PublishResponse},
39 search::{SearchParams, SearchResponse},
40};
41
42pub struct RegistryServer<S: RegistryStore, L: RateLimiter = NoopRateLimiter> {
48 store: S,
49 caps: CapabilitiesDocument,
50 authority: String,
51 rate_limiter: L,
52 receipt_signer: Option<acdp_types::receipt::ReceiptSigner>,
57 mint_head_receipts: bool,
64 lifecycle_enabled: bool,
71}
72
73impl<S: RegistryStore> RegistryServer<S, NoopRateLimiter> {
74 #[doc(hidden)]
78 pub fn new(store: S, caps: CapabilitiesDocument, authority: impl Into<String>) -> Self {
79 Self {
80 store,
81 caps,
82 authority: authority.into(),
83 rate_limiter: NoopRateLimiter,
84 receipt_signer: None,
85 mint_head_receipts: false,
86 lifecycle_enabled: false,
87 }
88 }
89
90 pub fn try_new(
104 store: S,
105 caps: CapabilitiesDocument,
106 authority: impl Into<String>,
107 ) -> Result<Self, AcdpError> {
108 let authority = authority.into();
109 if !acdp_types::primitives::is_valid_dns_authority(&authority) {
112 return Err(AcdpError::SchemaViolation(format!(
113 "registry authority '{authority}' is not a valid DNS hostname \
114 (must be lowercase labels, e.g. 'registry.example.com'); \
115 use RegistryServer::try_new_for_test_authority for host:port test setups"
116 )));
117 }
118 acdp_validation::validate_capabilities(&caps)?;
119 let expected_did = acdp_did::authority_to_did_web(&authority);
122 if caps.registry_did != expected_did {
123 return Err(AcdpError::SchemaViolation(format!(
124 "capabilities.registry_did '{}' does not match expected '{expected_did}' \
125 for authority '{authority}'",
126 caps.registry_did
127 )));
128 }
129 Ok(Self {
130 store,
131 caps,
132 authority,
133 rate_limiter: NoopRateLimiter,
134 receipt_signer: None,
135 mint_head_receipts: false,
136 lifecycle_enabled: false,
137 })
138 }
139
140 #[doc(hidden)]
149 pub fn try_new_for_test_authority(
150 store: S,
151 caps: CapabilitiesDocument,
152 authority: impl Into<String>,
153 ) -> Result<Self, AcdpError> {
154 let authority = authority.into();
155 acdp_validation::validate_capabilities(&caps)?;
156 let expected_did = acdp_did::authority_to_did_web(&authority);
157 if caps.registry_did != expected_did {
158 return Err(AcdpError::SchemaViolation(format!(
159 "capabilities.registry_did '{}' does not match expected '{expected_did}' \
160 for authority '{authority}'",
161 caps.registry_did
162 )));
163 }
164 Ok(Self {
165 store,
166 caps,
167 authority,
168 rate_limiter: NoopRateLimiter,
169 receipt_signer: None,
170 mint_head_receipts: false,
171 lifecycle_enabled: false,
172 })
173 }
174}
175
176impl<S: RegistryStore, L: RateLimiter> RegistryServer<S, L> {
177 pub fn with_rate_limiter<L2: RateLimiter>(self, limiter: L2) -> RegistryServer<S, L2> {
179 RegistryServer {
180 store: self.store,
181 caps: self.caps,
182 authority: self.authority,
183 rate_limiter: limiter,
184 receipt_signer: self.receipt_signer,
185 mint_head_receipts: self.mint_head_receipts,
186 lifecycle_enabled: self.lifecycle_enabled,
187 }
188 }
189
190 pub fn with_receipt_signer(
206 mut self,
207 signer: acdp_types::receipt::ReceiptSigner,
208 ) -> Result<Self, AcdpError> {
209 if signer.registry_did() != self.caps.registry_did {
210 return Err(AcdpError::SchemaViolation(format!(
211 "receipt signer registry_did '{}' ≠ capabilities.registry_did '{}'",
212 signer.registry_did(),
213 self.caps.registry_did
214 )));
215 }
216 self.require_min_acdp_version((0, 2, 0), "acdp-registry-receipts")?;
219 let profile = acdp_types::profile::Profile::RegistryReceipts.as_str();
220 if !self.caps.profiles.iter().any(|p| p == profile) {
221 self.caps.profiles.push(profile.to_string());
222 }
223 self.receipt_signer = Some(signer);
224 Ok(self)
225 }
226
227 pub fn with_lineage_head_receipts(mut self) -> Result<Self, AcdpError> {
241 if self.receipt_signer.is_none() {
242 return Err(AcdpError::SchemaViolation(
243 "acdp-registry-head-receipts requires the acdp-registry-receipts profile \
244 (RFC-ACDP-0011 §9): call with_receipt_signer first"
245 .into(),
246 ));
247 }
248 self.require_min_acdp_version((0, 3, 0), "acdp-registry-head-receipts")?;
249 let profile = acdp_types::profile::Profile::RegistryHeadReceipts.as_str();
250 if !self.caps.profiles.iter().any(|p| p == profile) {
251 self.caps.profiles.push(profile.to_string());
252 }
253 self.mint_head_receipts = true;
254 Ok(self)
255 }
256
257 pub fn with_lifecycle(mut self) -> Result<Self, AcdpError> {
272 self.require_min_acdp_version((0, 3, 0), "acdp-registry-lifecycle")?;
273 let profile = acdp_types::profile::Profile::RegistryLifecycle.as_str();
274 if !self.caps.profiles.iter().any(|p| p == profile) {
275 self.caps.profiles.push(profile.to_string());
276 }
277 self.lifecycle_enabled = true;
278 Ok(self)
279 }
280
281 fn require_min_acdp_version(&self, min: (u64, u64, u64), what: &str) -> Result<(), AcdpError> {
286 let parts: Vec<u64> = self
287 .caps
288 .acdp_version
289 .split('.')
290 .map(|p| p.parse::<u64>())
291 .collect::<Result<_, _>>()
292 .map_err(|_| {
293 AcdpError::SchemaViolation(format!(
294 "capabilities.acdp_version '{}' is not a plain MAJOR.MINOR.PATCH version",
295 self.caps.acdp_version
296 ))
297 })?;
298 let [major, minor, patch] = parts.as_slice() else {
299 return Err(AcdpError::SchemaViolation(format!(
300 "capabilities.acdp_version '{}' is not a plain MAJOR.MINOR.PATCH version",
301 self.caps.acdp_version
302 )));
303 };
304 if (*major, *minor, *patch) < min {
305 return Err(AcdpError::SchemaViolation(format!(
306 "{what} requires capabilities.acdp_version >= {}.{}.{}, got '{}'",
307 min.0, min.1, min.2, self.caps.acdp_version
308 )));
309 }
310 Ok(())
311 }
312
313 pub fn store(&self) -> &S {
316 &self.store
317 }
318
319 pub fn capabilities(&self) -> &CapabilitiesDocument {
321 &self.caps
322 }
323
324 #[cfg(feature = "client")]
340 #[cfg_attr(
341 feature = "tracing",
342 tracing::instrument(
343 name = "acdp.publish_verified",
344 skip_all,
345 fields(
346 agent_id = req.agent_id.as_str(),
347 version = req.version,
348 idempotency_key = idempotency_key.is_some(),
349 ),
350 err(Display)
351 )
352 )]
353 pub async fn publish_verified(
354 &self,
355 req: &PublishRequest,
356 idempotency_key: Option<&str>,
357 resolver: &acdp_did::WebResolver,
358 ) -> Result<PublishResponse, AcdpError> {
359 self.publish_verified_in_tenant(req, idempotency_key, resolver, None)
360 .await
361 }
362
363 #[cfg(feature = "client")]
369 pub async fn publish_verified_in_tenant(
370 &self,
371 req: &PublishRequest,
372 idempotency_key: Option<&str>,
373 resolver: &acdp_did::WebResolver,
374 tenant: Option<&str>,
375 ) -> Result<PublishResponse, AcdpError> {
376 self.check_publish_rate_limit(&req.agent_id)?;
378
379 let raw_bytes = serde_json::to_vec(req)?.len();
380 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
381 let _validated = validator.validate_post_schema(req, raw_bytes)?;
382
383 acdp_verify::verify_publish_request_signature(req, resolver).await?;
385
386 let fingerprint = if self.receipt_signer.is_some() {
390 Some(producer_key_fingerprint(req, resolver).await?)
391 } else {
392 None
393 };
394
395 self.commit_via_store(req, idempotency_key, tenant, fingerprint)
403 }
404
405 pub fn publish_verified_did_key(
419 &self,
420 req: &PublishRequest,
421 idempotency_key: Option<&str>,
422 ) -> Result<PublishResponse, AcdpError> {
423 self.publish_verified_did_key_in_tenant(req, idempotency_key, None)
424 }
425
426 #[cfg_attr(
432 feature = "tracing",
433 tracing::instrument(
434 name = "acdp.publish_verified_did_key",
435 skip_all,
436 fields(
437 agent_id = req.agent_id.as_str(),
438 version = req.version,
439 idempotency_key = idempotency_key.is_some(),
440 ),
441 err(Display)
442 )
443 )]
444 pub fn publish_verified_did_key_in_tenant(
445 &self,
446 req: &PublishRequest,
447 idempotency_key: Option<&str>,
448 tenant: Option<&str>,
449 ) -> Result<PublishResponse, AcdpError> {
450 self.check_publish_rate_limit(&req.agent_id)?;
451
452 let raw_bytes = serde_json::to_vec(req)?.len();
453 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
454 let _validated = validator.validate_post_schema(req, raw_bytes)?;
455
456 acdp_verify::verify_publish_request_signature_offline(req)?;
458
459 let fingerprint = if self.receipt_signer.is_some() {
462 let material = acdp_did::key::resolve_did_key(req.agent_id.as_str())?;
463 Some(acdp_crypto::fingerprint::fingerprint_did_key_material(
464 &material,
465 )?)
466 } else {
467 None
468 };
469
470 self.commit_via_store(req, idempotency_key, tenant, fingerprint)
471 }
472
473 #[doc(hidden)]
480 pub fn publish_unverified_for_tests(
481 &self,
482 req: &PublishRequest,
483 ) -> Result<PublishResponse, AcdpError> {
484 self.check_publish_rate_limit(&req.agent_id)?;
488
489 if self.receipt_signer.is_some() {
495 return Err(AcdpError::SchemaViolation(
496 "publish_unverified_for_tests is unavailable on a receipts-advertising \
497 registry (RFC-ACDP-0010 §7: no degraded mode); use publish_verified or \
498 publish_verified_did_key"
499 .into(),
500 ));
501 }
502 let raw_bytes = serde_json::to_vec(req)?.len();
503 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
504 let _validated = validator.validate_post_schema(req, raw_bytes)?;
505 self.commit_via_store(req, None, None, None)
506 }
507
508 #[doc(hidden)]
530 pub fn publish_pinned_verified_in_tenant(
531 &self,
532 req: &PublishRequest,
533 idempotency_key: Option<&str>,
534 tenant: Option<&str>,
535 verified_public_key_b64: &str,
536 verified_algorithm: &str,
537 ) -> Result<PublishResponse, AcdpError> {
538 self.check_publish_rate_limit(&req.agent_id)?;
539
540 let raw_bytes = serde_json::to_vec(req)?.len();
541 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
542 let _validated = validator.validate_post_schema(req, raw_bytes)?;
543
544 let fingerprint = if self.receipt_signer.is_some() {
545 Some(fingerprint_pinned_key(
546 verified_public_key_b64,
547 verified_algorithm,
548 )?)
549 } else {
550 None
551 };
552
553 self.commit_via_store(req, idempotency_key, tenant, fingerprint)
554 }
555
556 fn check_publish_rate_limit(
560 &self,
561 agent_id: &acdp_types::primitives::AgentDid,
562 ) -> Result<(), AcdpError> {
563 match self.rate_limiter.check_publish(agent_id) {
564 Ok(()) => Ok(()),
565 Err(e) => {
566 #[cfg(feature = "tracing")]
567 tracing::warn!(
568 agent_id = agent_id.as_str(),
569 "publish rejected by rate limiter"
570 );
571 Err(e)
572 }
573 }
574 }
575
576 fn commit_via_store(
581 &self,
582 req: &PublishRequest,
583 idempotency_key: Option<&str>,
584 tenant: Option<&str>,
585 producer_key_fingerprint: Option<String>,
586 ) -> Result<PublishResponse, AcdpError> {
587 let idempotency = if self.caps.supports_idempotency_key {
588 idempotency_key.map(|key| crate::registry::store::PendingIdempotencyCommit {
589 key,
590 ttl: chrono::Duration::seconds(
591 self.caps
592 .limits
593 .idempotency_key_ttl_seconds
594 .unwrap_or(86_400) as i64,
595 ),
596 })
597 } else {
598 None
599 };
600 #[allow(clippy::type_complexity)]
603 let minter: Option<
604 Box<dyn Fn(&Body) -> Result<serde_json::Value, AcdpError> + Send + Sync>,
605 > = match (&self.receipt_signer, producer_key_fingerprint) {
606 (Some(signer), Some(fp)) => Some(Box::new(move |body: &Body| {
607 let receipt = signer.mint(
608 &body.ctx_id,
609 &body.lineage_id,
610 &body.origin_registry,
611 body.created_at,
612 &body.content_hash,
613 &fp,
614 )?;
615 serde_json::to_value(receipt).map_err(AcdpError::from)
616 })),
617 _ => None,
618 };
619 let minted_expected = minter.is_some();
620 let outcome = self
621 .store
622 .commit_publish(crate::registry::store::PublishCommit {
623 req,
624 authority: &self.authority,
625 idempotency,
626 tenant,
627 receipt_minter: minter.as_deref(),
628 })?;
629 let (response, replayed) = match outcome {
630 crate::registry::store::PublishCommitOutcome::Inserted(r) => (r, false),
631 crate::registry::store::PublishCommitOutcome::IdempotentReplay(r) => (r, true),
632 };
633 #[cfg(feature = "tracing")]
634 tracing::debug!(
635 ctx_id = %response.ctx_id.0,
636 lineage_id = %response.lineage_id.0,
637 version = response.version,
638 replayed,
639 "publish committed"
640 );
641 if minted_expected && !replayed && response.registry_receipt.is_none() {
655 return Err(AcdpError::RegistryInternal(
656 "receipt signer is configured but the store returned no receipt — \
657 the RegistryStore implementation must invoke PublishCommit::receipt_minter \
658 inside its commit (RFC-ACDP-0010 §7: no degraded mode)"
659 .into(),
660 ));
661 }
662 Ok(response)
663 }
664
665 pub fn retrieve(
678 &self,
679 ctx_id: &CtxId,
680 requester: Option<&AgentDid>,
681 ) -> Result<Option<FullContext>, AcdpError> {
682 let Some(ctx) = self.store.get(ctx_id)? else {
683 return Ok(None);
684 };
685 if !can_retrieve(&ctx.body, requester, &self.caps) {
686 return Ok(None);
687 }
688 Ok(Some(ctx))
689 }
690
691 pub fn retrieve_body(
693 &self,
694 ctx_id: &CtxId,
695 requester: Option<&AgentDid>,
696 ) -> Result<Option<Body>, AcdpError> {
697 Ok(self.retrieve(ctx_id, requester)?.map(|c| c.body))
698 }
699
700 pub fn lineage(
707 &self,
708 lineage_id: &LineageId,
709 requester: Option<&AgentDid>,
710 ) -> Result<Vec<FullContext>, AcdpError> {
711 let all = self.store.lineage(lineage_id)?;
712 Ok(all
713 .into_iter()
714 .filter(|ctx| can_retrieve(&ctx.body, requester, &self.caps))
715 .collect())
716 }
717
718 pub fn current(
738 &self,
739 lineage_id: &LineageId,
740 requester: Option<&AgentDid>,
741 ) -> Result<Option<FullContext>, AcdpError> {
742 let all = self.store.lineage(lineage_id)?;
743 for mut ctx in all.into_iter().rev() {
749 if !matches!(
750 ctx.registry_state.status,
751 Status::Superseded | Status::Retracted
752 ) && can_retrieve(&ctx.body, requester, &self.caps)
753 {
754 if self.mint_head_receipts {
755 let signer = self.receipt_signer.as_ref().ok_or_else(|| {
760 AcdpError::RegistryInternal(
761 "head-receipt minting enabled without a receipt signer \
762 (RFC-ACDP-0011 §9 prerequisite violated)"
763 .into(),
764 )
765 })?;
766 let receipt = signer.mint_lineage_head(
767 lineage_id,
768 &ctx.body.ctx_id,
769 ctx.body.version,
770 &ctx.registry_state.status,
771 chrono::Utc::now(),
772 )?;
773 ctx.lineage_head_receipt = Some(serde_json::to_value(receipt)?);
774 }
775 return Ok(Some(ctx));
776 }
777 }
778 Ok(None)
779 }
780
781 pub fn search(
794 &self,
795 params: &SearchParams,
796 requester: Option<&AgentDid>,
797 ) -> Result<SearchResponse, AcdpError> {
798 if requester.is_none() && !self.caps.anonymous_public_reads {
803 return Err(AcdpError::NotAuthorized(
804 "anonymous search requires authentication \
805 (registry caps: anonymous_public_reads=false)"
806 .into(),
807 ));
808 }
809 self.store
814 .search(params, requester, self.caps.anonymous_public_reads)
815 }
816
817 fn lifecycle_precheck(
849 &self,
850 event: &acdp_types::lifecycle::LifecycleEvent,
851 expected_type: &acdp_types::lifecycle::LifecycleEventType,
852 requester: Option<&AgentDid>,
853 ) -> Result<FullContext, AcdpError> {
854 if !self.lifecycle_enabled {
855 return Err(AcdpError::NotImplemented(
856 "this registry does not advertise acdp-registry-lifecycle \
857 (RFC-ACDP-0013 §6: lifecycle endpoints are not implemented)"
858 .into(),
859 ));
860 }
861 let ctx = self
863 .retrieve(&event.ctx_id, requester)?
864 .ok_or_else(|| AcdpError::NotFound(format!("context '{}' not found", event.ctx_id)))?;
865 event.validate()?;
867 if &event.event_type != expected_type {
868 return Err(AcdpError::SchemaViolation(format!(
869 "event_type '{}' does not match this endpoint (expected '{}', \
870 RFC-ACDP-0013 §6 step 2)",
871 event.event_type, expected_type
872 )));
873 }
874 let now = chrono::Utc::now();
875 if event.occurred_at > now + chrono::Duration::seconds(120) {
876 return Err(AcdpError::SchemaViolation(format!(
877 "event occurred_at '{}' is in the future beyond the 120s skew allowance \
878 (RFC-ACDP-0013 §4)",
879 event.occurred_at.format("%Y-%m-%dT%H:%M:%S%.3fZ")
880 )));
881 }
882 if event.actor != ctx.body.agent_id {
884 return Err(AcdpError::NotAuthorized(format!(
885 "event actor '{}' is not the context's producer — only the producer \
886 (agent_id) may use the lifecycle endpoints (RFC-ACDP-0013 §6 step 3)",
887 event.actor
888 )));
889 }
890 event.actor_bound_signature()?;
894 Ok(ctx)
895 }
896
897 fn lifecycle_commit(
901 &self,
902 event: &acdp_types::lifecycle::LifecycleEvent,
903 ) -> Result<FullContext, AcdpError> {
904 Ok(self.store.commit_lifecycle_event(event)?.into_context())
905 }
906
907 #[cfg(feature = "client")]
909 async fn lifecycle_transition_verified(
910 &self,
911 event: &acdp_types::lifecycle::LifecycleEvent,
912 expected_type: acdp_types::lifecycle::LifecycleEventType,
913 requester: Option<&AgentDid>,
914 resolver: &acdp_did::WebResolver,
915 ) -> Result<FullContext, AcdpError> {
916 let ctx = self.lifecycle_precheck(event, &expected_type, requester)?;
917 acdp_verify::verify_lifecycle_event(
921 &serde_json::to_value(event)?,
922 &event.ctx_id,
923 &ctx.body.agent_id,
924 None, resolver,
926 )
927 .await?;
928 self.lifecycle_commit(event)
929 }
930
931 fn lifecycle_transition_verified_did_key(
933 &self,
934 event: &acdp_types::lifecycle::LifecycleEvent,
935 expected_type: acdp_types::lifecycle::LifecycleEventType,
936 requester: Option<&AgentDid>,
937 ) -> Result<FullContext, AcdpError> {
938 let ctx = self.lifecycle_precheck(event, &expected_type, requester)?;
939 acdp_verify::verify_lifecycle_event_offline(
940 &serde_json::to_value(event)?,
941 &event.ctx_id,
942 &ctx.body.agent_id,
943 None,
944 )?;
945 self.lifecycle_commit(event)
946 }
947
948 #[cfg(feature = "client")]
961 pub async fn retract_verified(
962 &self,
963 event: &acdp_types::lifecycle::LifecycleEvent,
964 requester: Option<&AgentDid>,
965 resolver: &acdp_did::WebResolver,
966 ) -> Result<FullContext, AcdpError> {
967 self.lifecycle_transition_verified(
968 event,
969 acdp_types::lifecycle::LifecycleEventType::Retracted,
970 requester,
971 resolver,
972 )
973 .await
974 }
975
976 #[cfg(feature = "client")]
983 pub async fn republish_verified(
984 &self,
985 event: &acdp_types::lifecycle::LifecycleEvent,
986 requester: Option<&AgentDid>,
987 resolver: &acdp_did::WebResolver,
988 ) -> Result<FullContext, AcdpError> {
989 self.lifecycle_transition_verified(
990 event,
991 acdp_types::lifecycle::LifecycleEventType::Republished,
992 requester,
993 resolver,
994 )
995 .await
996 }
997
998 pub fn retract_verified_did_key(
1003 &self,
1004 event: &acdp_types::lifecycle::LifecycleEvent,
1005 requester: Option<&AgentDid>,
1006 ) -> Result<FullContext, AcdpError> {
1007 self.lifecycle_transition_verified_did_key(
1008 event,
1009 acdp_types::lifecycle::LifecycleEventType::Retracted,
1010 requester,
1011 )
1012 }
1013
1014 pub fn republish_verified_did_key(
1016 &self,
1017 event: &acdp_types::lifecycle::LifecycleEvent,
1018 requester: Option<&AgentDid>,
1019 ) -> Result<FullContext, AcdpError> {
1020 self.lifecycle_transition_verified_did_key(
1021 event,
1022 acdp_types::lifecycle::LifecycleEventType::Republished,
1023 requester,
1024 )
1025 }
1026
1027 #[doc(hidden)]
1032 pub fn retract_unverified_for_tests(
1033 &self,
1034 event: &acdp_types::lifecycle::LifecycleEvent,
1035 requester: Option<&AgentDid>,
1036 ) -> Result<FullContext, AcdpError> {
1037 self.lifecycle_precheck(
1038 event,
1039 &acdp_types::lifecycle::LifecycleEventType::Retracted,
1040 requester,
1041 )?;
1042 self.lifecycle_commit(event)
1043 }
1044
1045 #[doc(hidden)]
1047 pub fn republish_unverified_for_tests(
1048 &self,
1049 event: &acdp_types::lifecycle::LifecycleEvent,
1050 requester: Option<&AgentDid>,
1051 ) -> Result<FullContext, AcdpError> {
1052 self.lifecycle_precheck(
1053 event,
1054 &acdp_types::lifecycle::LifecycleEventType::Republished,
1055 requester,
1056 )?;
1057 self.lifecycle_commit(event)
1058 }
1059
1060 pub fn record_registry_lifecycle_event(
1072 &self,
1073 event: &acdp_types::lifecycle::LifecycleEvent,
1074 ) -> Result<FullContext, AcdpError> {
1075 if !self.lifecycle_enabled {
1076 return Err(AcdpError::NotImplemented(
1077 "this registry does not advertise acdp-registry-lifecycle \
1078 (RFC-ACDP-0013 §6)"
1079 .into(),
1080 ));
1081 }
1082 event.validate()?;
1083 if !event.event_type.is_registered() {
1084 return Err(AcdpError::SchemaViolation(format!(
1085 "event_type '{}' is not registered for acceptance in 0.3.0 \
1086 (RFC-ACDP-0013 §7.3)",
1087 event.event_type
1088 )));
1089 }
1090 if event.actor.as_str() != self.caps.registry_did {
1091 return Err(AcdpError::NotAuthorized(format!(
1092 "registry-initiated event actor '{}' ≠ this registry's DID '{}' \
1093 (RFC-ACDP-0013 §6)",
1094 event.actor, self.caps.registry_did
1095 )));
1096 }
1097 if self.receipt_signer.is_some() && !event.is_signed() {
1098 return Err(AcdpError::SchemaViolation(
1099 "a registry advertising acdp-registry-receipts MUST sign its lifecycle \
1100 events (RFC-ACDP-0013 §5)"
1101 .into(),
1102 ));
1103 }
1104 if event.is_signed() {
1105 event.actor_bound_signature()?;
1107 }
1108 self.lifecycle_commit(event)
1109 }
1110}
1111
1112pub(crate) fn can_retrieve(
1114 body: &Body,
1115 requester: Option<&AgentDid>,
1116 caps: &CapabilitiesDocument,
1117) -> bool {
1118 match body.visibility {
1119 Visibility::Public => caps.anonymous_public_reads || requester.is_some(),
1120 Visibility::Restricted | Visibility::Private => match requester {
1121 None => false,
1122 Some(r) => {
1123 r == &body.agent_id
1124 || body
1125 .audience
1126 .as_deref()
1127 .is_some_and(|a| a.iter().any(|d| d == r))
1128 }
1129 },
1130 }
1131}
1132
1133#[cfg(feature = "client")]
1143async fn producer_key_fingerprint(
1144 req: &PublishRequest,
1145 resolver: &acdp_did::WebResolver,
1146) -> Result<String, AcdpError> {
1147 acdp_crypto::fingerprint::fingerprint_for_key_id(
1148 &req.signature.key_id,
1149 &req.signature.algorithm,
1150 resolver,
1151 )
1152 .await
1153}
1154
1155fn fingerprint_pinned_key(public_key_b64: &str, algorithm: &str) -> Result<String, AcdpError> {
1160 use base64::{engine::general_purpose::STANDARD, Engine};
1161
1162 let raw = STANDARD
1163 .decode(public_key_b64)
1164 .map_err(|e| AcdpError::KeyResolution(format!("pinned key is not valid base64: {e}")))?;
1165 match algorithm {
1166 "ed25519" => {
1167 let arr: [u8; 32] = raw.as_slice().try_into().map_err(|_| {
1168 AcdpError::KeyResolution(format!(
1169 "pinned ed25519 key must be 32 bytes, got {}",
1170 raw.len()
1171 ))
1172 })?;
1173 Ok(acdp_crypto::fingerprint::fingerprint_ed25519(&arr))
1174 }
1175 "ecdsa-p256" => acdp_crypto::fingerprint::fingerprint_p256_sec1(&raw),
1176 other => Err(AcdpError::UnsupportedAlgorithm(format!(
1177 "cannot fingerprint a pinned key for algorithm '{other}'"
1178 ))),
1179 }
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184 use super::*;
1185 use crate::registry::store::InMemoryStore;
1186 use acdp_crypto::SigningKey;
1187 use acdp_producer::Producer;
1188 use acdp_types::capabilities::Limits;
1189 use acdp_types::primitives::{AgentDid, ContextType, Visibility};
1190
1191 fn caps() -> CapabilitiesDocument {
1192 CapabilitiesDocument {
1193 acdp_version: "0.1.0".into(),
1194 registry_did: "did:web:registry.example.com".into(),
1195 supported_signature_algorithms: vec!["ed25519".into()],
1196 supported_did_methods: vec!["did:web".into()],
1197 profiles: vec!["acdp-registry-core".into()],
1198 limits: Limits {
1199 max_payload_bytes: 1_048_576,
1200 max_embedded_bytes: 65_536,
1201 idempotency_key_ttl_seconds: None,
1202 max_publish_per_minute: None,
1203 },
1204 read_authentication_methods: vec![],
1205 anonymous_public_reads: true,
1206 supports_idempotency_key: false,
1207 extensions: Default::default(),
1208 }
1209 }
1210
1211 fn producer() -> Producer {
1212 Producer::new(
1213 SigningKey::from_bytes(&[1u8; 32]),
1214 AgentDid::new("did:web:agents.example.com:test"),
1215 "did:web:agents.example.com:test#key-1",
1216 )
1217 }
1218
1219 #[test]
1220 fn publish_v1_then_retrieve() {
1221 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1222 let p = producer();
1223 let req = p
1224 .publish_request()
1225 .title("v1")
1226 .context_type(ContextType::DataSnapshot)
1227 .visibility(Visibility::Public)
1228 .build()
1229 .unwrap();
1230 let resp = server.publish_unverified_for_tests(&req).unwrap();
1231 assert_eq!(resp.version, 1);
1232 let ctx = server.retrieve(&resp.ctx_id, None).unwrap().unwrap();
1233 assert_eq!(ctx.body.title, "v1");
1234 let lineage = server.lineage(&resp.lineage_id, None).unwrap();
1236 assert_eq!(lineage.len(), 1);
1237 let cur = server.current(&resp.lineage_id, None).unwrap().unwrap();
1239 assert_eq!(cur.body.ctx_id, resp.ctx_id);
1240 }
1241
1242 #[test]
1243 fn supersession_marks_predecessor_and_returns_v2() {
1244 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1245 let p = producer();
1246 let v1_req = p
1247 .publish_request()
1248 .title("v1")
1249 .context_type(ContextType::DataSnapshot)
1250 .visibility(Visibility::Public)
1251 .build()
1252 .unwrap();
1253 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1254
1255 let v2_req = p
1256 .supersede(v1.ctx_id.clone())
1257 .version(2)
1258 .title("v2")
1259 .context_type(ContextType::DataSnapshot)
1260 .visibility(Visibility::Public)
1261 .build()
1262 .unwrap();
1263 let v2 = server.publish_unverified_for_tests(&v2_req).unwrap();
1264 assert_eq!(v2.version, 2);
1265 let v1_ctx = server.retrieve(&v1.ctx_id, None).unwrap().unwrap();
1267 assert!(matches!(
1268 v1_ctx.registry_state.status,
1269 acdp_types::Status::Superseded
1270 ));
1271 assert_eq!(v1.lineage_id, v2.lineage_id);
1273 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1275 assert_eq!(cur.body.ctx_id, v2.ctx_id);
1276 }
1277
1278 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1286 async fn concurrent_supersession_exactly_one_succeeds() {
1287 use std::sync::Arc;
1288 let server = Arc::new(RegistryServer::new(
1289 InMemoryStore::new(),
1290 caps(),
1291 "registry.example.com",
1292 ));
1293 let p = producer();
1294 let v1_req = p
1295 .publish_request()
1296 .title("v1")
1297 .context_type(ContextType::DataSnapshot)
1298 .visibility(Visibility::Public)
1299 .build()
1300 .unwrap();
1301 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1302
1303 let v2a_req = p
1308 .supersede(v1.ctx_id.clone())
1309 .version(2)
1310 .title("v2-A")
1311 .context_type(ContextType::DataSnapshot)
1312 .visibility(Visibility::Public)
1313 .build()
1314 .unwrap();
1315 let v2b_req = p
1316 .supersede(v1.ctx_id.clone())
1317 .version(2)
1318 .title("v2-B")
1319 .context_type(ContextType::DataSnapshot)
1320 .visibility(Visibility::Public)
1321 .build()
1322 .unwrap();
1323
1324 let s1 = Arc::clone(&server);
1325 let s2 = Arc::clone(&server);
1326 let h1 = tokio::task::spawn_blocking(move || s1.publish_unverified_for_tests(&v2a_req));
1327 let h2 = tokio::task::spawn_blocking(move || s2.publish_unverified_for_tests(&v2b_req));
1328 let (r1, r2) = (h1.await.unwrap(), h2.await.unwrap());
1329
1330 let outcomes = [r1, r2];
1331 let successes = outcomes.iter().filter(|r| r.is_ok()).count();
1332 let failures = outcomes.iter().filter(|r| r.is_err()).count();
1333 assert_eq!(
1334 successes, 1,
1335 "exactly one concurrent supersession MUST succeed; got {successes} successes / {failures} failures"
1336 );
1337 assert_eq!(failures, 1);
1338 for r in &outcomes {
1341 if let Err(e) = r {
1342 match e {
1343 AcdpError::SupersededTarget { reason, .. } => assert_eq!(
1344 *reason,
1345 acdp_primitives::error::SupersessionReason::AlreadySuperseded,
1346 "concurrent loser MUST be AlreadySuperseded"
1347 ),
1348 other => panic!("concurrent loser had wrong error: {other:?}"),
1349 }
1350 }
1351 }
1352 }
1353
1354 #[test]
1355 fn hostile_supersession_by_non_owner_rejected_predecessor_unchanged() {
1356 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1361 let victim = producer_for(7, "did:web:agents.example.com:victim");
1362 let v1_req = victim
1363 .publish_request()
1364 .title("v1")
1365 .context_type(ContextType::DataSnapshot)
1366 .visibility(Visibility::Public)
1367 .build()
1368 .unwrap();
1369 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1370
1371 let attacker = producer_for(9, "did:web:evil.example.com:attacker");
1374 let v2_req = attacker
1375 .supersede(v1.ctx_id.clone())
1376 .version(2)
1377 .title("hijacked")
1378 .context_type(ContextType::DataSnapshot)
1379 .visibility(Visibility::Public)
1380 .build()
1381 .unwrap();
1382 let err = server.publish_unverified_for_tests(&v2_req).unwrap_err();
1383 match err {
1385 AcdpError::SupersededTarget { reason, .. } => {
1386 assert_eq!(reason, acdp_primitives::error::SupersessionReason::NotFound);
1387 }
1388 other => panic!("expected uniform SupersededTarget::NotFound, got {other:?}"),
1389 }
1390 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1392 assert_eq!(cur.body.ctx_id, v1.ctx_id);
1393 assert_eq!(cur.body.title, "v1");
1394 assert_eq!(
1395 cur.registry_state.status,
1396 acdp_types::primitives::Status::Active
1397 );
1398 }
1399
1400 #[test]
1401 fn owner_supersession_still_succeeds_after_ownership_check() {
1402 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1403 let p = producer();
1404 let v1_req = p
1405 .publish_request()
1406 .title("v1")
1407 .context_type(ContextType::DataSnapshot)
1408 .visibility(Visibility::Public)
1409 .build()
1410 .unwrap();
1411 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1412 let v2_req = p
1413 .supersede(v1.ctx_id.clone())
1414 .version(2)
1415 .title("v2")
1416 .context_type(ContextType::DataSnapshot)
1417 .visibility(Visibility::Public)
1418 .build()
1419 .unwrap();
1420 let v2 = server.publish_unverified_for_tests(&v2_req).unwrap();
1421 assert_eq!(v2.version, 2);
1422 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1423 assert_eq!(cur.body.ctx_id, v2.ctx_id);
1424 }
1425
1426 #[test]
1427 fn supersession_with_unknown_target_rejected_as_not_found() {
1428 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1429 let p = producer();
1430 let phantom =
1431 CtxId("acdp://registry.example.com/12345678-1234-4321-8123-deadbeefcafe".into());
1432 let req = p
1433 .supersede(phantom)
1434 .version(2)
1435 .title("v2-orphan")
1436 .context_type(ContextType::DataSnapshot)
1437 .visibility(Visibility::Public)
1438 .build()
1439 .unwrap();
1440 let err = server.publish_unverified_for_tests(&req).unwrap_err();
1441 match err {
1442 AcdpError::SupersededTarget { reason, .. } => {
1443 assert_eq!(reason, acdp_primitives::error::SupersessionReason::NotFound);
1444 }
1445 other => panic!("expected SupersededTarget::NotFound, got {other:?}"),
1446 }
1447 }
1448
1449 #[test]
1450 fn version_mismatch_rejected() {
1451 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1452 let p = producer();
1453 let v1_req = p
1454 .publish_request()
1455 .title("v1")
1456 .context_type(ContextType::DataSnapshot)
1457 .visibility(Visibility::Public)
1458 .build()
1459 .unwrap();
1460 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1461 let v3_req = p
1463 .supersede(v1.ctx_id.clone())
1464 .version(3)
1465 .title("v3-skipped")
1466 .context_type(ContextType::DataSnapshot)
1467 .visibility(Visibility::Public)
1468 .build()
1469 .unwrap();
1470 let err = server.publish_unverified_for_tests(&v3_req).unwrap_err();
1471 match err {
1472 AcdpError::SupersededTarget { reason, .. } => {
1473 assert_eq!(
1474 reason,
1475 acdp_primitives::error::SupersessionReason::VersionMismatch
1476 );
1477 }
1478 other => panic!("expected VersionMismatch, got {other:?}"),
1479 }
1480 }
1481
1482 #[test]
1483 fn search_finds_published_context() {
1484 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1485 let p = producer();
1486 let req = p
1487 .publish_request()
1488 .title("Q1 portfolio risk")
1489 .context_type(ContextType::DataSnapshot)
1490 .visibility(Visibility::Public)
1491 .build()
1492 .unwrap();
1493 server.publish_unverified_for_tests(&req).unwrap();
1494 let resp = server
1495 .search(
1496 &SearchParams {
1497 q: Some("portfolio".into()),
1498 ..Default::default()
1499 },
1500 None,
1501 )
1502 .unwrap();
1503 assert_eq!(resp.matches.len(), 1);
1504 assert_eq!(resp.matches[0].title, "Q1 portfolio risk");
1505 }
1506
1507 #[test]
1513 fn lineage_filters_restricted_for_stranger() {
1514 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1515 let p = producer();
1516 let audience = AgentDid::new("did:web:audience.example.com:reader");
1517 let req = p
1518 .publish_request()
1519 .title("restricted v1")
1520 .context_type(ContextType::DataSnapshot)
1521 .visibility(Visibility::Restricted)
1522 .audience(vec![audience.clone()])
1523 .build()
1524 .unwrap();
1525 let resp = server.publish_unverified_for_tests(&req).unwrap();
1526
1527 let stranger = AgentDid::new("did:web:other.example.com:reader");
1528 let stranger_view = server.lineage(&resp.lineage_id, Some(&stranger)).unwrap();
1529 assert!(
1530 stranger_view.is_empty(),
1531 "stranger MUST NOT see restricted bodies via lineage(); got {} entries",
1532 stranger_view.len()
1533 );
1534
1535 let audience_view = server.lineage(&resp.lineage_id, Some(&audience)).unwrap();
1536 assert_eq!(
1537 audience_view.len(),
1538 1,
1539 "audience member MUST see the restricted body via lineage()"
1540 );
1541 }
1542
1543 #[test]
1546 fn current_filters_private_for_stranger() {
1547 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1548 let p = producer();
1549 let req = p
1550 .publish_request()
1551 .title("private v1")
1552 .context_type(ContextType::DataSnapshot)
1553 .visibility(Visibility::Private)
1554 .build()
1555 .unwrap();
1556 let resp = server.publish_unverified_for_tests(&req).unwrap();
1557
1558 let stranger = AgentDid::new("did:web:other.example.com:reader");
1559 assert!(
1560 server
1561 .current(&resp.lineage_id, Some(&stranger))
1562 .unwrap()
1563 .is_none(),
1564 "stranger MUST NOT see private contexts via current()"
1565 );
1566
1567 let producer_did = AgentDid::new("did:web:agents.example.com:test");
1568 assert!(
1569 server
1570 .current(&resp.lineage_id, Some(&producer_did))
1571 .unwrap()
1572 .is_some(),
1573 "producer MUST see private contexts via current()"
1574 );
1575 }
1576
1577 #[test]
1588 fn current_returns_none_when_all_superseded() {
1589 use crate::registry::store::RegistryStore;
1590 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1591 let p = producer();
1592 let req = p
1593 .publish_request()
1594 .title("v1")
1595 .context_type(ContextType::DataSnapshot)
1596 .visibility(Visibility::Public)
1597 .build()
1598 .unwrap();
1599 let resp = server.publish_unverified_for_tests(&req).unwrap();
1600 server.store().mark_superseded(&resp.ctx_id).unwrap();
1602
1603 let cur = server.current(&resp.lineage_id, None).unwrap();
1604 assert!(
1605 cur.is_none(),
1606 "all-superseded lineage MUST resolve to None per RFC-ACDP-0004 §5; got {cur:?}"
1607 );
1608 }
1609
1610 #[test]
1618 fn search_suppresses_public_when_anonymous_public_reads_false() {
1619 let mut c = caps();
1620 c.anonymous_public_reads = false;
1621 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
1622 let p = producer();
1623 let req = p
1624 .publish_request()
1625 .title("public-but-flag-off")
1626 .context_type(ContextType::DataSnapshot)
1627 .visibility(Visibility::Public)
1628 .build()
1629 .unwrap();
1630 server.publish_unverified_for_tests(&req).unwrap();
1631
1632 let err = server
1634 .search(
1635 &SearchParams {
1636 q: Some("public-but-flag-off".into()),
1637 ..Default::default()
1638 },
1639 None,
1640 )
1641 .unwrap_err();
1642 assert!(
1643 matches!(err, AcdpError::NotAuthorized(_)),
1644 "vis-009: anonymous search MUST be NotAuthorized when \
1645 anonymous_public_reads=false; got {err:?}"
1646 );
1647
1648 let stranger = AgentDid::new("did:web:other.example.com:reader");
1651 let authed = server
1652 .search(
1653 &SearchParams {
1654 q: Some("public-but-flag-off".into()),
1655 ..Default::default()
1656 },
1657 Some(&stranger),
1658 )
1659 .unwrap();
1660 assert_eq!(
1661 authed.matches.len(),
1662 1,
1663 "authenticated search MUST see public contexts regardless of anonymous_public_reads"
1664 );
1665 }
1666
1667 #[test]
1670 fn try_new_rejects_did_authority_mismatch() {
1671 let mut c = caps();
1672 c.registry_did = "did:web:other.example.com".into(); let res = RegistryServer::try_new(InMemoryStore::new(), c, "registry.example.com");
1674 match res {
1675 Err(AcdpError::SchemaViolation(msg)) => {
1676 assert!(msg.contains("does not match expected"))
1677 }
1678 Err(other) => panic!("expected SchemaViolation, got {other:?}"),
1679 Ok(_) => panic!("expected Err"),
1680 }
1681 }
1682
1683 #[test]
1684 fn try_new_rejects_caps_missing_ed25519() {
1685 let mut c = caps();
1686 c.supported_signature_algorithms = vec!["ecdsa-p256".into()]; let res = RegistryServer::try_new(InMemoryStore::new(), c, "registry.example.com");
1688 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1689 }
1690
1691 #[test]
1692 fn try_new_accepts_valid_caps() {
1693 RegistryServer::try_new(InMemoryStore::new(), caps(), "registry.example.com").unwrap();
1694 }
1695
1696 #[test]
1699 fn try_new_accepts_valid_dns_authority() {
1700 RegistryServer::try_new(InMemoryStore::new(), caps(), "registry.example.com").unwrap();
1701 }
1702
1703 #[test]
1704 fn try_new_rejects_host_port_authority() {
1705 let res = RegistryServer::try_new(InMemoryStore::new(), caps(), "localhost:8443");
1708 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1709 }
1710
1711 #[test]
1712 fn try_new_rejects_uppercase_authority() {
1713 let res = RegistryServer::try_new(InMemoryStore::new(), caps(), "Registry.Example.Com");
1714 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1715 }
1716
1717 #[test]
1718 fn try_new_rejects_url_form_authority() {
1719 let res =
1720 RegistryServer::try_new(InMemoryStore::new(), caps(), "https://registry.example.com");
1721 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1722 }
1723
1724 #[test]
1725 fn try_new_for_test_accepts_host_port() {
1726 let mut c = caps();
1729 c.registry_did = acdp_did::authority_to_did_web("localhost:8443");
1730 RegistryServer::try_new_for_test_authority(InMemoryStore::new(), c, "localhost:8443")
1731 .unwrap();
1732 }
1733
1734 fn producer_for(seed: u8, did: &str) -> Producer {
1737 Producer::new(
1738 SigningKey::from_bytes(&[seed; 32]),
1739 AgentDid::new(did),
1740 format!("{did}#key-1"),
1741 )
1742 }
1743
1744 #[test]
1745 fn retrieve_restricted_blocks_stranger_returns_none() {
1746 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1747 let owner = AgentDid::new("did:web:agents.example.com:owner");
1748 let audience_member = AgentDid::new("did:web:agents.example.com:friend");
1749 let p = producer_for(2, owner.as_str());
1750 let req = p
1751 .publish_request()
1752 .title("restricted")
1753 .context_type(ContextType::DataSnapshot)
1754 .visibility(Visibility::Restricted)
1755 .audience(vec![audience_member.clone()])
1756 .build()
1757 .unwrap();
1758 let resp = server.publish_unverified_for_tests(&req).unwrap();
1759 let stranger = AgentDid::new("did:web:agents.example.com:stranger");
1760
1761 assert!(server.retrieve(&resp.ctx_id, None).unwrap().is_none());
1762 assert!(server
1763 .retrieve(&resp.ctx_id, Some(&stranger))
1764 .unwrap()
1765 .is_none());
1766 assert!(server
1767 .retrieve(&resp.ctx_id, Some(&owner))
1768 .unwrap()
1769 .is_some());
1770 assert!(server
1771 .retrieve(&resp.ctx_id, Some(&audience_member))
1772 .unwrap()
1773 .is_some());
1774 }
1775
1776 #[test]
1777 fn search_restricted_filters_strangers() {
1778 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1779 let owner = AgentDid::new("did:web:agents.example.com:owner");
1780 let p = producer_for(3, owner.as_str());
1781 let req = p
1782 .publish_request()
1783 .title("hush hush")
1784 .context_type(ContextType::DataSnapshot)
1785 .visibility(Visibility::Restricted)
1786 .audience(vec![AgentDid::new("did:web:agents.example.com:friend")])
1787 .build()
1788 .unwrap();
1789 server.publish_unverified_for_tests(&req).unwrap();
1790
1791 let stranger = AgentDid::new("did:web:agents.example.com:stranger");
1792 let r_anon = server.search(&SearchParams::default(), None).unwrap();
1793 assert!(
1794 r_anon.matches.is_empty(),
1795 "anonymous must not see restricted"
1796 );
1797 let r_stranger = server
1798 .search(&SearchParams::default(), Some(&stranger))
1799 .unwrap();
1800 assert!(r_stranger.matches.is_empty());
1801 let r_owner = server
1802 .search(&SearchParams::default(), Some(&owner))
1803 .unwrap();
1804 assert_eq!(r_owner.matches.len(), 1);
1805 }
1806
1807 #[test]
1811 fn search_private_visible_only_to_producer() {
1812 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1813 let owner = AgentDid::new("did:web:agents.example.com:owner");
1814 let audience_member = AgentDid::new("did:web:agents.example.com:friend");
1815 let p = producer_for(4, owner.as_str());
1816 let req = p
1817 .publish_request()
1818 .title("private note")
1819 .context_type(ContextType::DataSnapshot)
1820 .visibility(Visibility::Private)
1821 .audience(vec![audience_member.clone()])
1822 .build()
1823 .unwrap();
1824 let resp = server.publish_unverified_for_tests(&req).unwrap();
1825
1826 let r_audience = server
1827 .search(&SearchParams::default(), Some(&audience_member))
1828 .unwrap();
1829 assert!(
1830 r_audience.matches.is_empty(),
1831 "audience must NOT see private in search"
1832 );
1833 let r_owner = server
1834 .search(&SearchParams::default(), Some(&owner))
1835 .unwrap();
1836 assert_eq!(
1837 r_owner.matches.len(),
1838 1,
1839 "owner sees their own private context"
1840 );
1841
1842 assert!(server
1844 .retrieve(&resp.ctx_id, Some(&audience_member))
1845 .unwrap()
1846 .is_some());
1847 }
1848
1849 #[cfg(feature = "client")]
1861 #[tokio::test]
1862 async fn publish_verified_rejects_non_did_web_key_id() {
1863 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1864 let p = producer();
1865 let mut req = p
1866 .publish_request()
1867 .title("v1")
1868 .context_type(ContextType::DataSnapshot)
1869 .visibility(Visibility::Public)
1870 .build()
1871 .unwrap();
1872 let did_key = acdp_did::key::did_key_from_ed25519(
1879 &SigningKey::from_bytes(&[9u8; 32]).verifying_key_bytes(),
1880 );
1881 req.signature.key_id = acdp_did::key::did_key_url(&did_key).unwrap();
1882 let resolver = acdp_did::WebResolver::new();
1883 let err = server
1884 .publish_verified(&req, None, &resolver)
1885 .await
1886 .unwrap_err();
1887 match err {
1888 AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("did:web")),
1889 other => panic!("expected KeyNotAuthorized for non-did:web, got {other:?}"),
1890 }
1891 }
1892
1893 #[cfg(feature = "client")]
1894 #[tokio::test]
1895 async fn publish_verified_rejects_agent_id_keyid_mismatch() {
1896 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1897 let p = producer();
1898 let mut req = p
1899 .publish_request()
1900 .title("v1")
1901 .context_type(ContextType::DataSnapshot)
1902 .visibility(Visibility::Public)
1903 .build()
1904 .unwrap();
1905 req.signature.key_id = "did:web:other.example.com:agent#key-1".into();
1906 let resolver = acdp_did::WebResolver::new();
1907 let err = server
1908 .publish_verified(&req, None, &resolver)
1909 .await
1910 .unwrap_err();
1911 match err {
1912 AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("agent_id")),
1913 other => panic!("expected KeyNotAuthorized for agent_id mismatch, got {other:?}"),
1914 }
1915 }
1916
1917 #[cfg(feature = "client")]
1918 #[tokio::test]
1919 async fn publish_verified_rejects_keyid_without_fragment() {
1920 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1921 let p = producer();
1922 let mut req = p
1923 .publish_request()
1924 .title("v1")
1925 .context_type(ContextType::DataSnapshot)
1926 .visibility(Visibility::Public)
1927 .build()
1928 .unwrap();
1929 req.signature.key_id = "did:web:agents.example.com:test".into(); let resolver = acdp_did::WebResolver::new();
1931 let err = server
1932 .publish_verified(&req, None, &resolver)
1933 .await
1934 .unwrap_err();
1935 assert!(
1938 matches!(
1939 err,
1940 AcdpError::SchemaViolation(_) | AcdpError::KeyResolution(_)
1941 ),
1942 "expected fragment-rejection error, got {err:?}"
1943 );
1944 }
1945
1946 fn caps_with_idempotency() -> CapabilitiesDocument {
1949 let mut c = caps();
1950 c.supports_idempotency_key = true;
1951 c.limits.idempotency_key_ttl_seconds = Some(86_400);
1952 c
1953 }
1954
1955 #[test]
1956 fn idempotency_same_hash_returns_original_response() {
1957 let server = RegistryServer::new(
1958 InMemoryStore::new(),
1959 caps_with_idempotency(),
1960 "registry.example.com",
1961 );
1962 let p = producer();
1963 let req = p
1964 .publish_request()
1965 .title("once")
1966 .context_type(ContextType::DataSnapshot)
1967 .visibility(Visibility::Public)
1968 .build()
1969 .unwrap();
1970 let first = server.publish_unverified_for_tests(&req).unwrap();
1972 let ttl = caps_with_idempotency()
1976 .limits
1977 .idempotency_key_ttl_seconds
1978 .unwrap() as i64;
1979 server
1980 .store()
1981 .idempotency_record(
1982 &req.agent_id,
1983 "k-001",
1984 &req.content_hash,
1985 &first,
1986 chrono::Utc::now() + chrono::Duration::seconds(ttl),
1987 )
1988 .unwrap();
1989 let prior = server
1990 .store()
1991 .idempotency_lookup(&req.agent_id, "k-001")
1992 .unwrap()
1993 .unwrap();
1994 assert_eq!(prior.content_hash, req.content_hash);
1995 assert_eq!(prior.response.ctx_id, first.ctx_id);
1996 }
1997
1998 #[test]
1999 fn idempotency_evicts_after_ttl() {
2000 let store = InMemoryStore::new();
2001 let agent = AgentDid::new("did:web:agents.example.com:test");
2002 let resp = PublishResponse {
2003 registry_receipt: None,
2004 ctx_id: acdp_types::CtxId("acdp://r/12345678-1234-4321-8123-000000000099".into()),
2005 lineage_id: acdp_types::LineageId(
2006 "lin:sha256:9999999999999999999999999999999999999999999999999999999999999999"
2007 .into(),
2008 ),
2009 version: 1,
2010 created_at: chrono::Utc::now(),
2011 status: Status::Active,
2012 };
2013 let past = chrono::Utc::now() - chrono::Duration::seconds(1);
2015 store
2016 .idempotency_record(
2017 &agent,
2018 "expired",
2019 &acdp_types::ContentHash("sha256:0".into()),
2020 &resp,
2021 past,
2022 )
2023 .unwrap();
2024 let prior = store.idempotency_lookup(&agent, "expired").unwrap();
2026 assert!(
2027 prior.is_none(),
2028 "lazy TTL eviction should drop expired record"
2029 );
2030 }
2031
2032 struct AlwaysDeny;
2035 impl crate::registry::RateLimiter for AlwaysDeny {
2036 fn check_publish(&self, agent_id: &AgentDid) -> Result<(), AcdpError> {
2037 Err(AcdpError::RateLimited(format!("blocked: {agent_id}")))
2038 }
2039 }
2040
2041 #[test]
2042 fn rate_limiter_blocks_publish_before_persist() {
2043 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com")
2044 .with_rate_limiter(AlwaysDeny);
2045 let p = producer();
2046 let req = p
2047 .publish_request()
2048 .title("blocked")
2049 .context_type(ContextType::DataSnapshot)
2050 .visibility(Visibility::Public)
2051 .build()
2052 .unwrap();
2053 let err = server.publish_unverified_for_tests(&req).unwrap_err();
2054 assert!(matches!(err, AcdpError::RateLimited(_)));
2055 let resp = server.search(&SearchParams::default(), None).unwrap();
2057 assert!(
2058 resp.matches.is_empty(),
2059 "rate-limited publish must not persist"
2060 );
2061 }
2062
2063 #[test]
2064 fn created_at_is_ms_truncated() {
2065 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2066 let p = producer();
2067 let req = p
2068 .publish_request()
2069 .title("ms")
2070 .context_type(ContextType::DataSnapshot)
2071 .visibility(Visibility::Public)
2072 .build()
2073 .unwrap();
2074 let resp = server.publish_unverified_for_tests(&req).unwrap();
2075 assert_eq!(
2077 resp.created_at.timestamp_subsec_nanos() % 1_000_000,
2078 0,
2079 "created_at must be millisecond-truncated per RFC-ACDP-0001 §5.3"
2080 );
2081 }
2082
2083 fn did_key_request() -> acdp_types::publish::PublishRequest {
2086 let p = Producer::new_did_key(SigningKey::from_bytes(&[7u8; 32]));
2087 p.publish_request()
2088 .title("did:key publish")
2089 .context_type(ContextType::DataSnapshot)
2090 .visibility(Visibility::Public)
2091 .build()
2092 .unwrap()
2093 }
2094
2095 #[test]
2099 fn did_key_publish_rejected_when_not_advertised() {
2100 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2101 let err = server
2102 .publish_verified_did_key(&did_key_request(), None)
2103 .unwrap_err();
2104 assert!(
2105 matches!(err, AcdpError::KeyResolution(ref m) if m.contains("supported_did_methods")),
2106 "got {err:?}"
2107 );
2108 }
2109
2110 #[test]
2114 fn did_key_publish_verified_end_to_end() {
2115 let mut c = caps();
2116 c.supported_did_methods.push("did:key".into());
2117 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
2118 let req = did_key_request();
2119 let resp = server.publish_verified_did_key(&req, None).unwrap();
2120 assert_eq!(resp.ctx_id.authority(), "registry.example.com");
2121
2122 let mut tampered = did_key_request();
2124 tampered.title = "tampered".into();
2125 let err = server
2126 .publish_verified_did_key(&tampered, None)
2127 .unwrap_err();
2128 assert!(matches!(err, AcdpError::HashMismatch { .. }), "got {err:?}");
2129 }
2130
2131 #[test]
2137 fn receiptless_idempotent_replay_survives_enabling_receipts() {
2138 let mut c = caps();
2139 c.acdp_version = "0.2.0".into();
2140 c.supported_did_methods.push("did:key".into());
2141 c.supports_idempotency_key = true;
2142 c.limits.idempotency_key_ttl_seconds = Some(86_400);
2143 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
2144 .with_receipt_signer(
2145 acdp_types::receipt::ReceiptSigner::new(
2146 SigningKey::from_bytes(&[0x11u8; 32]),
2147 "did:web:registry.example.com",
2148 "did:web:registry.example.com#receipt-key-1",
2149 )
2150 .unwrap(),
2151 )
2152 .unwrap();
2153
2154 let req = did_key_request();
2157 let pre_receipts_response = acdp_types::publish::PublishResponse {
2158 ctx_id: CtxId(format!(
2159 "acdp://registry.example.com/{}",
2160 uuid::Uuid::new_v4()
2161 )),
2162 lineage_id: acdp_crypto::derive_lineage_id(&CtxId(
2163 "acdp://registry.example.com/v1".into(),
2164 )),
2165 version: 1,
2166 created_at: acdp_primitives::time::trunc_ms(chrono::Utc::now()),
2167 status: Status::Active,
2168 registry_receipt: None,
2169 };
2170 server
2171 .store()
2172 .idempotency_record(
2173 &req.agent_id,
2174 "pre-receipts-key",
2175 &req.content_hash,
2176 &pre_receipts_response,
2177 chrono::Utc::now() + chrono::Duration::hours(1),
2178 )
2179 .unwrap();
2180
2181 let resp = server
2184 .publish_verified_did_key(&req, Some("pre-receipts-key"))
2185 .expect("replay of a pre-receipts record must succeed");
2186 assert_eq!(resp.ctx_id, pre_receipts_response.ctx_id);
2187 assert!(
2188 resp.registry_receipt.is_none(),
2189 "replay returns the original response verbatim"
2190 );
2191
2192 let p2 = Producer::new_did_key(SigningKey::from_bytes(&[8u8; 32]));
2194 let fresh = p2
2195 .publish_request()
2196 .title("fresh after enabling receipts")
2197 .context_type(ContextType::DataSnapshot)
2198 .visibility(Visibility::Public)
2199 .build()
2200 .unwrap();
2201 let fresh_resp = server.publish_verified_did_key(&fresh, None).unwrap();
2202 assert!(
2203 fresh_resp.registry_receipt.is_some(),
2204 "new inserts on a receipts registry must mint"
2205 );
2206 }
2207
2208 #[test]
2215 fn pinned_verified_publish_mints_receipt_with_correct_fingerprint() {
2216 use base64::{engine::general_purpose::STANDARD, Engine};
2217
2218 let key = SigningKey::from_bytes(&[3u8; 32]);
2219 let verifying_key_bytes = key.verifying_key_bytes();
2220 let pub_b64 = STANDARD.encode(verifying_key_bytes);
2221 let did = "did:web:agents.example.com:pinned-agent";
2222 let p = Producer::new(key, AgentDid::new(did), format!("{did}#key-1"));
2223 let req = p
2224 .publish_request()
2225 .title("pinned publish")
2226 .context_type(ContextType::DataSnapshot)
2227 .visibility(Visibility::Public)
2228 .build()
2229 .unwrap();
2230
2231 let mut c = caps();
2232 c.acdp_version = "0.2.0".into();
2233 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
2234 .with_receipt_signer(
2235 acdp_types::receipt::ReceiptSigner::new(
2236 SigningKey::from_bytes(&[0x22u8; 32]),
2237 "did:web:registry.example.com",
2238 "did:web:registry.example.com#receipt-key-1",
2239 )
2240 .unwrap(),
2241 )
2242 .unwrap();
2243
2244 let resp = server
2245 .publish_pinned_verified_in_tenant(&req, None, None, &pub_b64, "ed25519")
2246 .expect("pinned-verified publish must succeed on a receipts registry");
2247 let receipt = resp
2248 .registry_receipt
2249 .expect("a receipts-advertising registry must mint a receipt");
2250 assert_eq!(
2251 receipt["key_fingerprint"].as_str().unwrap(),
2252 acdp_crypto::fingerprint::fingerprint_ed25519(&verifying_key_bytes)
2253 );
2254 }
2255
2256 #[test]
2260 fn pinned_verified_publish_without_receipt_signer_succeeds_with_no_receipt() {
2261 use base64::{engine::general_purpose::STANDARD, Engine};
2262
2263 let key = SigningKey::from_bytes(&[4u8; 32]);
2264 let pub_b64 = STANDARD.encode(key.verifying_key_bytes());
2265 let did = "did:web:agents.example.com:pinned-agent-2";
2266 let p = Producer::new(key, AgentDid::new(did), format!("{did}#key-1"));
2267 let req = p
2268 .publish_request()
2269 .title("pinned publish, no receipts")
2270 .context_type(ContextType::DataSnapshot)
2271 .visibility(Visibility::Public)
2272 .build()
2273 .unwrap();
2274
2275 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2276 let resp = server
2277 .publish_pinned_verified_in_tenant(&req, None, None, &pub_b64, "ed25519")
2278 .unwrap();
2279 assert!(resp.registry_receipt.is_none());
2280 }
2281
2282 #[test]
2285 fn did_key_publish_path_refuses_did_web() {
2286 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2287 let p = producer();
2288 let req = p
2289 .publish_request()
2290 .title("did:web on the offline path")
2291 .context_type(ContextType::DataSnapshot)
2292 .visibility(Visibility::Public)
2293 .build()
2294 .unwrap();
2295 let err = server.publish_verified_did_key(&req, None).unwrap_err();
2296 assert!(
2297 matches!(err, AcdpError::KeyResolution(_)),
2298 "did:web on the offline path must be refused, got {err:?}"
2299 );
2300 }
2301}