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 fn check_publish_rate_limit(
512 &self,
513 agent_id: &acdp_types::primitives::AgentDid,
514 ) -> Result<(), AcdpError> {
515 match self.rate_limiter.check_publish(agent_id) {
516 Ok(()) => Ok(()),
517 Err(e) => {
518 #[cfg(feature = "tracing")]
519 tracing::warn!(
520 agent_id = agent_id.as_str(),
521 "publish rejected by rate limiter"
522 );
523 Err(e)
524 }
525 }
526 }
527
528 fn commit_via_store(
533 &self,
534 req: &PublishRequest,
535 idempotency_key: Option<&str>,
536 tenant: Option<&str>,
537 producer_key_fingerprint: Option<String>,
538 ) -> Result<PublishResponse, AcdpError> {
539 let idempotency = if self.caps.supports_idempotency_key {
540 idempotency_key.map(|key| crate::registry::store::PendingIdempotencyCommit {
541 key,
542 ttl: chrono::Duration::seconds(
543 self.caps
544 .limits
545 .idempotency_key_ttl_seconds
546 .unwrap_or(86_400) as i64,
547 ),
548 })
549 } else {
550 None
551 };
552 #[allow(clippy::type_complexity)]
555 let minter: Option<
556 Box<dyn Fn(&Body) -> Result<serde_json::Value, AcdpError> + Send + Sync>,
557 > = match (&self.receipt_signer, producer_key_fingerprint) {
558 (Some(signer), Some(fp)) => Some(Box::new(move |body: &Body| {
559 let receipt = signer.mint(
560 &body.ctx_id,
561 &body.lineage_id,
562 &body.origin_registry,
563 body.created_at,
564 &body.content_hash,
565 &fp,
566 )?;
567 serde_json::to_value(receipt).map_err(AcdpError::from)
568 })),
569 _ => None,
570 };
571 let minted_expected = minter.is_some();
572 let outcome = self
573 .store
574 .commit_publish(crate::registry::store::PublishCommit {
575 req,
576 authority: &self.authority,
577 idempotency,
578 tenant,
579 receipt_minter: minter.as_deref(),
580 })?;
581 let (response, replayed) = match outcome {
582 crate::registry::store::PublishCommitOutcome::Inserted(r) => (r, false),
583 crate::registry::store::PublishCommitOutcome::IdempotentReplay(r) => (r, true),
584 };
585 #[cfg(feature = "tracing")]
586 tracing::debug!(
587 ctx_id = %response.ctx_id.0,
588 lineage_id = %response.lineage_id.0,
589 version = response.version,
590 replayed,
591 "publish committed"
592 );
593 if minted_expected && !replayed && response.registry_receipt.is_none() {
607 return Err(AcdpError::RegistryInternal(
608 "receipt signer is configured but the store returned no receipt — \
609 the RegistryStore implementation must invoke PublishCommit::receipt_minter \
610 inside its commit (RFC-ACDP-0010 §7: no degraded mode)"
611 .into(),
612 ));
613 }
614 Ok(response)
615 }
616
617 pub fn retrieve(
630 &self,
631 ctx_id: &CtxId,
632 requester: Option<&AgentDid>,
633 ) -> Result<Option<FullContext>, AcdpError> {
634 let Some(ctx) = self.store.get(ctx_id)? else {
635 return Ok(None);
636 };
637 if !can_retrieve(&ctx.body, requester, &self.caps) {
638 return Ok(None);
639 }
640 Ok(Some(ctx))
641 }
642
643 pub fn retrieve_body(
645 &self,
646 ctx_id: &CtxId,
647 requester: Option<&AgentDid>,
648 ) -> Result<Option<Body>, AcdpError> {
649 Ok(self.retrieve(ctx_id, requester)?.map(|c| c.body))
650 }
651
652 pub fn lineage(
659 &self,
660 lineage_id: &LineageId,
661 requester: Option<&AgentDid>,
662 ) -> Result<Vec<FullContext>, AcdpError> {
663 let all = self.store.lineage(lineage_id)?;
664 Ok(all
665 .into_iter()
666 .filter(|ctx| can_retrieve(&ctx.body, requester, &self.caps))
667 .collect())
668 }
669
670 pub fn current(
690 &self,
691 lineage_id: &LineageId,
692 requester: Option<&AgentDid>,
693 ) -> Result<Option<FullContext>, AcdpError> {
694 let all = self.store.lineage(lineage_id)?;
695 for mut ctx in all.into_iter().rev() {
701 if !matches!(
702 ctx.registry_state.status,
703 Status::Superseded | Status::Retracted
704 ) && can_retrieve(&ctx.body, requester, &self.caps)
705 {
706 if self.mint_head_receipts {
707 let signer = self.receipt_signer.as_ref().ok_or_else(|| {
712 AcdpError::RegistryInternal(
713 "head-receipt minting enabled without a receipt signer \
714 (RFC-ACDP-0011 §9 prerequisite violated)"
715 .into(),
716 )
717 })?;
718 let receipt = signer.mint_lineage_head(
719 lineage_id,
720 &ctx.body.ctx_id,
721 ctx.body.version,
722 &ctx.registry_state.status,
723 chrono::Utc::now(),
724 )?;
725 ctx.lineage_head_receipt = Some(serde_json::to_value(receipt)?);
726 }
727 return Ok(Some(ctx));
728 }
729 }
730 Ok(None)
731 }
732
733 pub fn search(
746 &self,
747 params: &SearchParams,
748 requester: Option<&AgentDid>,
749 ) -> Result<SearchResponse, AcdpError> {
750 if requester.is_none() && !self.caps.anonymous_public_reads {
755 return Err(AcdpError::NotAuthorized(
756 "anonymous search requires authentication \
757 (registry caps: anonymous_public_reads=false)"
758 .into(),
759 ));
760 }
761 self.store
766 .search(params, requester, self.caps.anonymous_public_reads)
767 }
768
769 fn lifecycle_precheck(
801 &self,
802 event: &acdp_types::lifecycle::LifecycleEvent,
803 expected_type: &acdp_types::lifecycle::LifecycleEventType,
804 requester: Option<&AgentDid>,
805 ) -> Result<FullContext, AcdpError> {
806 if !self.lifecycle_enabled {
807 return Err(AcdpError::NotImplemented(
808 "this registry does not advertise acdp-registry-lifecycle \
809 (RFC-ACDP-0013 §6: lifecycle endpoints are not implemented)"
810 .into(),
811 ));
812 }
813 let ctx = self
815 .retrieve(&event.ctx_id, requester)?
816 .ok_or_else(|| AcdpError::NotFound(format!("context '{}' not found", event.ctx_id)))?;
817 event.validate()?;
819 if &event.event_type != expected_type {
820 return Err(AcdpError::SchemaViolation(format!(
821 "event_type '{}' does not match this endpoint (expected '{}', \
822 RFC-ACDP-0013 §6 step 2)",
823 event.event_type, expected_type
824 )));
825 }
826 let now = chrono::Utc::now();
827 if event.occurred_at > now + chrono::Duration::seconds(120) {
828 return Err(AcdpError::SchemaViolation(format!(
829 "event occurred_at '{}' is in the future beyond the 120s skew allowance \
830 (RFC-ACDP-0013 §4)",
831 event.occurred_at.format("%Y-%m-%dT%H:%M:%S%.3fZ")
832 )));
833 }
834 if event.actor != ctx.body.agent_id {
836 return Err(AcdpError::NotAuthorized(format!(
837 "event actor '{}' is not the context's producer — only the producer \
838 (agent_id) may use the lifecycle endpoints (RFC-ACDP-0013 §6 step 3)",
839 event.actor
840 )));
841 }
842 event.actor_bound_signature()?;
846 Ok(ctx)
847 }
848
849 fn lifecycle_commit(
853 &self,
854 event: &acdp_types::lifecycle::LifecycleEvent,
855 ) -> Result<FullContext, AcdpError> {
856 Ok(self.store.commit_lifecycle_event(event)?.into_context())
857 }
858
859 #[cfg(feature = "client")]
861 async fn lifecycle_transition_verified(
862 &self,
863 event: &acdp_types::lifecycle::LifecycleEvent,
864 expected_type: acdp_types::lifecycle::LifecycleEventType,
865 requester: Option<&AgentDid>,
866 resolver: &acdp_did::WebResolver,
867 ) -> Result<FullContext, AcdpError> {
868 let ctx = self.lifecycle_precheck(event, &expected_type, requester)?;
869 acdp_verify::verify_lifecycle_event(
873 &serde_json::to_value(event)?,
874 &event.ctx_id,
875 &ctx.body.agent_id,
876 None, resolver,
878 )
879 .await?;
880 self.lifecycle_commit(event)
881 }
882
883 fn lifecycle_transition_verified_did_key(
885 &self,
886 event: &acdp_types::lifecycle::LifecycleEvent,
887 expected_type: acdp_types::lifecycle::LifecycleEventType,
888 requester: Option<&AgentDid>,
889 ) -> Result<FullContext, AcdpError> {
890 let ctx = self.lifecycle_precheck(event, &expected_type, requester)?;
891 acdp_verify::verify_lifecycle_event_offline(
892 &serde_json::to_value(event)?,
893 &event.ctx_id,
894 &ctx.body.agent_id,
895 None,
896 )?;
897 self.lifecycle_commit(event)
898 }
899
900 #[cfg(feature = "client")]
913 pub async fn retract_verified(
914 &self,
915 event: &acdp_types::lifecycle::LifecycleEvent,
916 requester: Option<&AgentDid>,
917 resolver: &acdp_did::WebResolver,
918 ) -> Result<FullContext, AcdpError> {
919 self.lifecycle_transition_verified(
920 event,
921 acdp_types::lifecycle::LifecycleEventType::Retracted,
922 requester,
923 resolver,
924 )
925 .await
926 }
927
928 #[cfg(feature = "client")]
935 pub async fn republish_verified(
936 &self,
937 event: &acdp_types::lifecycle::LifecycleEvent,
938 requester: Option<&AgentDid>,
939 resolver: &acdp_did::WebResolver,
940 ) -> Result<FullContext, AcdpError> {
941 self.lifecycle_transition_verified(
942 event,
943 acdp_types::lifecycle::LifecycleEventType::Republished,
944 requester,
945 resolver,
946 )
947 .await
948 }
949
950 pub fn retract_verified_did_key(
955 &self,
956 event: &acdp_types::lifecycle::LifecycleEvent,
957 requester: Option<&AgentDid>,
958 ) -> Result<FullContext, AcdpError> {
959 self.lifecycle_transition_verified_did_key(
960 event,
961 acdp_types::lifecycle::LifecycleEventType::Retracted,
962 requester,
963 )
964 }
965
966 pub fn republish_verified_did_key(
968 &self,
969 event: &acdp_types::lifecycle::LifecycleEvent,
970 requester: Option<&AgentDid>,
971 ) -> Result<FullContext, AcdpError> {
972 self.lifecycle_transition_verified_did_key(
973 event,
974 acdp_types::lifecycle::LifecycleEventType::Republished,
975 requester,
976 )
977 }
978
979 #[doc(hidden)]
984 pub fn retract_unverified_for_tests(
985 &self,
986 event: &acdp_types::lifecycle::LifecycleEvent,
987 requester: Option<&AgentDid>,
988 ) -> Result<FullContext, AcdpError> {
989 self.lifecycle_precheck(
990 event,
991 &acdp_types::lifecycle::LifecycleEventType::Retracted,
992 requester,
993 )?;
994 self.lifecycle_commit(event)
995 }
996
997 #[doc(hidden)]
999 pub fn republish_unverified_for_tests(
1000 &self,
1001 event: &acdp_types::lifecycle::LifecycleEvent,
1002 requester: Option<&AgentDid>,
1003 ) -> Result<FullContext, AcdpError> {
1004 self.lifecycle_precheck(
1005 event,
1006 &acdp_types::lifecycle::LifecycleEventType::Republished,
1007 requester,
1008 )?;
1009 self.lifecycle_commit(event)
1010 }
1011
1012 pub fn record_registry_lifecycle_event(
1024 &self,
1025 event: &acdp_types::lifecycle::LifecycleEvent,
1026 ) -> Result<FullContext, AcdpError> {
1027 if !self.lifecycle_enabled {
1028 return Err(AcdpError::NotImplemented(
1029 "this registry does not advertise acdp-registry-lifecycle \
1030 (RFC-ACDP-0013 §6)"
1031 .into(),
1032 ));
1033 }
1034 event.validate()?;
1035 if !event.event_type.is_registered() {
1036 return Err(AcdpError::SchemaViolation(format!(
1037 "event_type '{}' is not registered for acceptance in 0.3.0 \
1038 (RFC-ACDP-0013 §7.3)",
1039 event.event_type
1040 )));
1041 }
1042 if event.actor.as_str() != self.caps.registry_did {
1043 return Err(AcdpError::NotAuthorized(format!(
1044 "registry-initiated event actor '{}' ≠ this registry's DID '{}' \
1045 (RFC-ACDP-0013 §6)",
1046 event.actor, self.caps.registry_did
1047 )));
1048 }
1049 if self.receipt_signer.is_some() && !event.is_signed() {
1050 return Err(AcdpError::SchemaViolation(
1051 "a registry advertising acdp-registry-receipts MUST sign its lifecycle \
1052 events (RFC-ACDP-0013 §5)"
1053 .into(),
1054 ));
1055 }
1056 if event.is_signed() {
1057 event.actor_bound_signature()?;
1059 }
1060 self.lifecycle_commit(event)
1061 }
1062}
1063
1064pub(crate) fn can_retrieve(
1066 body: &Body,
1067 requester: Option<&AgentDid>,
1068 caps: &CapabilitiesDocument,
1069) -> bool {
1070 match body.visibility {
1071 Visibility::Public => caps.anonymous_public_reads || requester.is_some(),
1072 Visibility::Restricted | Visibility::Private => match requester {
1073 None => false,
1074 Some(r) => {
1075 r == &body.agent_id
1076 || body
1077 .audience
1078 .as_deref()
1079 .is_some_and(|a| a.iter().any(|d| d == r))
1080 }
1081 },
1082 }
1083}
1084
1085#[cfg(feature = "client")]
1095async fn producer_key_fingerprint(
1096 req: &PublishRequest,
1097 resolver: &acdp_did::WebResolver,
1098) -> Result<String, AcdpError> {
1099 acdp_crypto::fingerprint::fingerprint_for_key_id(
1100 &req.signature.key_id,
1101 &req.signature.algorithm,
1102 resolver,
1103 )
1104 .await
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109 use super::*;
1110 use crate::registry::store::InMemoryStore;
1111 use acdp_crypto::SigningKey;
1112 use acdp_producer::Producer;
1113 use acdp_types::capabilities::Limits;
1114 use acdp_types::primitives::{AgentDid, ContextType, Visibility};
1115
1116 fn caps() -> CapabilitiesDocument {
1117 CapabilitiesDocument {
1118 acdp_version: "0.1.0".into(),
1119 registry_did: "did:web:registry.example.com".into(),
1120 supported_signature_algorithms: vec!["ed25519".into()],
1121 supported_did_methods: vec!["did:web".into()],
1122 profiles: vec!["acdp-registry-core".into()],
1123 limits: Limits {
1124 max_payload_bytes: 1_048_576,
1125 max_embedded_bytes: 65_536,
1126 idempotency_key_ttl_seconds: None,
1127 max_publish_per_minute: None,
1128 },
1129 read_authentication_methods: vec![],
1130 anonymous_public_reads: true,
1131 supports_idempotency_key: false,
1132 extensions: Default::default(),
1133 }
1134 }
1135
1136 fn producer() -> Producer {
1137 Producer::new(
1138 SigningKey::from_bytes(&[1u8; 32]),
1139 AgentDid::new("did:web:agents.example.com:test"),
1140 "did:web:agents.example.com:test#key-1",
1141 )
1142 }
1143
1144 #[test]
1145 fn publish_v1_then_retrieve() {
1146 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1147 let p = producer();
1148 let req = p
1149 .publish_request()
1150 .title("v1")
1151 .context_type(ContextType::DataSnapshot)
1152 .visibility(Visibility::Public)
1153 .build()
1154 .unwrap();
1155 let resp = server.publish_unverified_for_tests(&req).unwrap();
1156 assert_eq!(resp.version, 1);
1157 let ctx = server.retrieve(&resp.ctx_id, None).unwrap().unwrap();
1158 assert_eq!(ctx.body.title, "v1");
1159 let lineage = server.lineage(&resp.lineage_id, None).unwrap();
1161 assert_eq!(lineage.len(), 1);
1162 let cur = server.current(&resp.lineage_id, None).unwrap().unwrap();
1164 assert_eq!(cur.body.ctx_id, resp.ctx_id);
1165 }
1166
1167 #[test]
1168 fn supersession_marks_predecessor_and_returns_v2() {
1169 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1170 let p = producer();
1171 let v1_req = p
1172 .publish_request()
1173 .title("v1")
1174 .context_type(ContextType::DataSnapshot)
1175 .visibility(Visibility::Public)
1176 .build()
1177 .unwrap();
1178 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1179
1180 let v2_req = p
1181 .supersede(v1.ctx_id.clone())
1182 .version(2)
1183 .title("v2")
1184 .context_type(ContextType::DataSnapshot)
1185 .visibility(Visibility::Public)
1186 .build()
1187 .unwrap();
1188 let v2 = server.publish_unverified_for_tests(&v2_req).unwrap();
1189 assert_eq!(v2.version, 2);
1190 let v1_ctx = server.retrieve(&v1.ctx_id, None).unwrap().unwrap();
1192 assert!(matches!(
1193 v1_ctx.registry_state.status,
1194 acdp_types::Status::Superseded
1195 ));
1196 assert_eq!(v1.lineage_id, v2.lineage_id);
1198 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1200 assert_eq!(cur.body.ctx_id, v2.ctx_id);
1201 }
1202
1203 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1211 async fn concurrent_supersession_exactly_one_succeeds() {
1212 use std::sync::Arc;
1213 let server = Arc::new(RegistryServer::new(
1214 InMemoryStore::new(),
1215 caps(),
1216 "registry.example.com",
1217 ));
1218 let p = producer();
1219 let v1_req = p
1220 .publish_request()
1221 .title("v1")
1222 .context_type(ContextType::DataSnapshot)
1223 .visibility(Visibility::Public)
1224 .build()
1225 .unwrap();
1226 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1227
1228 let v2a_req = p
1233 .supersede(v1.ctx_id.clone())
1234 .version(2)
1235 .title("v2-A")
1236 .context_type(ContextType::DataSnapshot)
1237 .visibility(Visibility::Public)
1238 .build()
1239 .unwrap();
1240 let v2b_req = p
1241 .supersede(v1.ctx_id.clone())
1242 .version(2)
1243 .title("v2-B")
1244 .context_type(ContextType::DataSnapshot)
1245 .visibility(Visibility::Public)
1246 .build()
1247 .unwrap();
1248
1249 let s1 = Arc::clone(&server);
1250 let s2 = Arc::clone(&server);
1251 let h1 = tokio::task::spawn_blocking(move || s1.publish_unverified_for_tests(&v2a_req));
1252 let h2 = tokio::task::spawn_blocking(move || s2.publish_unverified_for_tests(&v2b_req));
1253 let (r1, r2) = (h1.await.unwrap(), h2.await.unwrap());
1254
1255 let outcomes = [r1, r2];
1256 let successes = outcomes.iter().filter(|r| r.is_ok()).count();
1257 let failures = outcomes.iter().filter(|r| r.is_err()).count();
1258 assert_eq!(
1259 successes, 1,
1260 "exactly one concurrent supersession MUST succeed; got {successes} successes / {failures} failures"
1261 );
1262 assert_eq!(failures, 1);
1263 for r in &outcomes {
1266 if let Err(e) = r {
1267 match e {
1268 AcdpError::SupersededTarget { reason, .. } => assert_eq!(
1269 *reason,
1270 acdp_primitives::error::SupersessionReason::AlreadySuperseded,
1271 "concurrent loser MUST be AlreadySuperseded"
1272 ),
1273 other => panic!("concurrent loser had wrong error: {other:?}"),
1274 }
1275 }
1276 }
1277 }
1278
1279 #[test]
1280 fn hostile_supersession_by_non_owner_rejected_predecessor_unchanged() {
1281 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1286 let victim = producer_for(7, "did:web:agents.example.com:victim");
1287 let v1_req = victim
1288 .publish_request()
1289 .title("v1")
1290 .context_type(ContextType::DataSnapshot)
1291 .visibility(Visibility::Public)
1292 .build()
1293 .unwrap();
1294 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1295
1296 let attacker = producer_for(9, "did:web:evil.example.com:attacker");
1299 let v2_req = attacker
1300 .supersede(v1.ctx_id.clone())
1301 .version(2)
1302 .title("hijacked")
1303 .context_type(ContextType::DataSnapshot)
1304 .visibility(Visibility::Public)
1305 .build()
1306 .unwrap();
1307 let err = server.publish_unverified_for_tests(&v2_req).unwrap_err();
1308 match err {
1310 AcdpError::SupersededTarget { reason, .. } => {
1311 assert_eq!(reason, acdp_primitives::error::SupersessionReason::NotFound);
1312 }
1313 other => panic!("expected uniform SupersededTarget::NotFound, got {other:?}"),
1314 }
1315 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1317 assert_eq!(cur.body.ctx_id, v1.ctx_id);
1318 assert_eq!(cur.body.title, "v1");
1319 assert_eq!(
1320 cur.registry_state.status,
1321 acdp_types::primitives::Status::Active
1322 );
1323 }
1324
1325 #[test]
1326 fn owner_supersession_still_succeeds_after_ownership_check() {
1327 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1328 let p = producer();
1329 let v1_req = p
1330 .publish_request()
1331 .title("v1")
1332 .context_type(ContextType::DataSnapshot)
1333 .visibility(Visibility::Public)
1334 .build()
1335 .unwrap();
1336 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1337 let v2_req = p
1338 .supersede(v1.ctx_id.clone())
1339 .version(2)
1340 .title("v2")
1341 .context_type(ContextType::DataSnapshot)
1342 .visibility(Visibility::Public)
1343 .build()
1344 .unwrap();
1345 let v2 = server.publish_unverified_for_tests(&v2_req).unwrap();
1346 assert_eq!(v2.version, 2);
1347 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1348 assert_eq!(cur.body.ctx_id, v2.ctx_id);
1349 }
1350
1351 #[test]
1352 fn supersession_with_unknown_target_rejected_as_not_found() {
1353 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1354 let p = producer();
1355 let phantom =
1356 CtxId("acdp://registry.example.com/12345678-1234-4321-8123-deadbeefcafe".into());
1357 let req = p
1358 .supersede(phantom)
1359 .version(2)
1360 .title("v2-orphan")
1361 .context_type(ContextType::DataSnapshot)
1362 .visibility(Visibility::Public)
1363 .build()
1364 .unwrap();
1365 let err = server.publish_unverified_for_tests(&req).unwrap_err();
1366 match err {
1367 AcdpError::SupersededTarget { reason, .. } => {
1368 assert_eq!(reason, acdp_primitives::error::SupersessionReason::NotFound);
1369 }
1370 other => panic!("expected SupersededTarget::NotFound, got {other:?}"),
1371 }
1372 }
1373
1374 #[test]
1375 fn version_mismatch_rejected() {
1376 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1377 let p = producer();
1378 let v1_req = p
1379 .publish_request()
1380 .title("v1")
1381 .context_type(ContextType::DataSnapshot)
1382 .visibility(Visibility::Public)
1383 .build()
1384 .unwrap();
1385 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1386 let v3_req = p
1388 .supersede(v1.ctx_id.clone())
1389 .version(3)
1390 .title("v3-skipped")
1391 .context_type(ContextType::DataSnapshot)
1392 .visibility(Visibility::Public)
1393 .build()
1394 .unwrap();
1395 let err = server.publish_unverified_for_tests(&v3_req).unwrap_err();
1396 match err {
1397 AcdpError::SupersededTarget { reason, .. } => {
1398 assert_eq!(
1399 reason,
1400 acdp_primitives::error::SupersessionReason::VersionMismatch
1401 );
1402 }
1403 other => panic!("expected VersionMismatch, got {other:?}"),
1404 }
1405 }
1406
1407 #[test]
1408 fn search_finds_published_context() {
1409 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1410 let p = producer();
1411 let req = p
1412 .publish_request()
1413 .title("Q1 portfolio risk")
1414 .context_type(ContextType::DataSnapshot)
1415 .visibility(Visibility::Public)
1416 .build()
1417 .unwrap();
1418 server.publish_unverified_for_tests(&req).unwrap();
1419 let resp = server
1420 .search(
1421 &SearchParams {
1422 q: Some("portfolio".into()),
1423 ..Default::default()
1424 },
1425 None,
1426 )
1427 .unwrap();
1428 assert_eq!(resp.matches.len(), 1);
1429 assert_eq!(resp.matches[0].title, "Q1 portfolio risk");
1430 }
1431
1432 #[test]
1438 fn lineage_filters_restricted_for_stranger() {
1439 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1440 let p = producer();
1441 let audience = AgentDid::new("did:web:audience.example.com:reader");
1442 let req = p
1443 .publish_request()
1444 .title("restricted v1")
1445 .context_type(ContextType::DataSnapshot)
1446 .visibility(Visibility::Restricted)
1447 .audience(vec![audience.clone()])
1448 .build()
1449 .unwrap();
1450 let resp = server.publish_unverified_for_tests(&req).unwrap();
1451
1452 let stranger = AgentDid::new("did:web:other.example.com:reader");
1453 let stranger_view = server.lineage(&resp.lineage_id, Some(&stranger)).unwrap();
1454 assert!(
1455 stranger_view.is_empty(),
1456 "stranger MUST NOT see restricted bodies via lineage(); got {} entries",
1457 stranger_view.len()
1458 );
1459
1460 let audience_view = server.lineage(&resp.lineage_id, Some(&audience)).unwrap();
1461 assert_eq!(
1462 audience_view.len(),
1463 1,
1464 "audience member MUST see the restricted body via lineage()"
1465 );
1466 }
1467
1468 #[test]
1471 fn current_filters_private_for_stranger() {
1472 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1473 let p = producer();
1474 let req = p
1475 .publish_request()
1476 .title("private v1")
1477 .context_type(ContextType::DataSnapshot)
1478 .visibility(Visibility::Private)
1479 .build()
1480 .unwrap();
1481 let resp = server.publish_unverified_for_tests(&req).unwrap();
1482
1483 let stranger = AgentDid::new("did:web:other.example.com:reader");
1484 assert!(
1485 server
1486 .current(&resp.lineage_id, Some(&stranger))
1487 .unwrap()
1488 .is_none(),
1489 "stranger MUST NOT see private contexts via current()"
1490 );
1491
1492 let producer_did = AgentDid::new("did:web:agents.example.com:test");
1493 assert!(
1494 server
1495 .current(&resp.lineage_id, Some(&producer_did))
1496 .unwrap()
1497 .is_some(),
1498 "producer MUST see private contexts via current()"
1499 );
1500 }
1501
1502 #[test]
1513 fn current_returns_none_when_all_superseded() {
1514 use crate::registry::store::RegistryStore;
1515 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1516 let p = producer();
1517 let req = p
1518 .publish_request()
1519 .title("v1")
1520 .context_type(ContextType::DataSnapshot)
1521 .visibility(Visibility::Public)
1522 .build()
1523 .unwrap();
1524 let resp = server.publish_unverified_for_tests(&req).unwrap();
1525 server.store().mark_superseded(&resp.ctx_id).unwrap();
1527
1528 let cur = server.current(&resp.lineage_id, None).unwrap();
1529 assert!(
1530 cur.is_none(),
1531 "all-superseded lineage MUST resolve to None per RFC-ACDP-0004 §5; got {cur:?}"
1532 );
1533 }
1534
1535 #[test]
1543 fn search_suppresses_public_when_anonymous_public_reads_false() {
1544 let mut c = caps();
1545 c.anonymous_public_reads = false;
1546 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
1547 let p = producer();
1548 let req = p
1549 .publish_request()
1550 .title("public-but-flag-off")
1551 .context_type(ContextType::DataSnapshot)
1552 .visibility(Visibility::Public)
1553 .build()
1554 .unwrap();
1555 server.publish_unverified_for_tests(&req).unwrap();
1556
1557 let err = server
1559 .search(
1560 &SearchParams {
1561 q: Some("public-but-flag-off".into()),
1562 ..Default::default()
1563 },
1564 None,
1565 )
1566 .unwrap_err();
1567 assert!(
1568 matches!(err, AcdpError::NotAuthorized(_)),
1569 "vis-009: anonymous search MUST be NotAuthorized when \
1570 anonymous_public_reads=false; got {err:?}"
1571 );
1572
1573 let stranger = AgentDid::new("did:web:other.example.com:reader");
1576 let authed = server
1577 .search(
1578 &SearchParams {
1579 q: Some("public-but-flag-off".into()),
1580 ..Default::default()
1581 },
1582 Some(&stranger),
1583 )
1584 .unwrap();
1585 assert_eq!(
1586 authed.matches.len(),
1587 1,
1588 "authenticated search MUST see public contexts regardless of anonymous_public_reads"
1589 );
1590 }
1591
1592 #[test]
1595 fn try_new_rejects_did_authority_mismatch() {
1596 let mut c = caps();
1597 c.registry_did = "did:web:other.example.com".into(); let res = RegistryServer::try_new(InMemoryStore::new(), c, "registry.example.com");
1599 match res {
1600 Err(AcdpError::SchemaViolation(msg)) => {
1601 assert!(msg.contains("does not match expected"))
1602 }
1603 Err(other) => panic!("expected SchemaViolation, got {other:?}"),
1604 Ok(_) => panic!("expected Err"),
1605 }
1606 }
1607
1608 #[test]
1609 fn try_new_rejects_caps_missing_ed25519() {
1610 let mut c = caps();
1611 c.supported_signature_algorithms = vec!["ecdsa-p256".into()]; let res = RegistryServer::try_new(InMemoryStore::new(), c, "registry.example.com");
1613 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1614 }
1615
1616 #[test]
1617 fn try_new_accepts_valid_caps() {
1618 RegistryServer::try_new(InMemoryStore::new(), caps(), "registry.example.com").unwrap();
1619 }
1620
1621 #[test]
1624 fn try_new_accepts_valid_dns_authority() {
1625 RegistryServer::try_new(InMemoryStore::new(), caps(), "registry.example.com").unwrap();
1626 }
1627
1628 #[test]
1629 fn try_new_rejects_host_port_authority() {
1630 let res = RegistryServer::try_new(InMemoryStore::new(), caps(), "localhost:8443");
1633 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1634 }
1635
1636 #[test]
1637 fn try_new_rejects_uppercase_authority() {
1638 let res = RegistryServer::try_new(InMemoryStore::new(), caps(), "Registry.Example.Com");
1639 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1640 }
1641
1642 #[test]
1643 fn try_new_rejects_url_form_authority() {
1644 let res =
1645 RegistryServer::try_new(InMemoryStore::new(), caps(), "https://registry.example.com");
1646 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1647 }
1648
1649 #[test]
1650 fn try_new_for_test_accepts_host_port() {
1651 let mut c = caps();
1654 c.registry_did = acdp_did::authority_to_did_web("localhost:8443");
1655 RegistryServer::try_new_for_test_authority(InMemoryStore::new(), c, "localhost:8443")
1656 .unwrap();
1657 }
1658
1659 fn producer_for(seed: u8, did: &str) -> Producer {
1662 Producer::new(
1663 SigningKey::from_bytes(&[seed; 32]),
1664 AgentDid::new(did),
1665 format!("{did}#key-1"),
1666 )
1667 }
1668
1669 #[test]
1670 fn retrieve_restricted_blocks_stranger_returns_none() {
1671 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1672 let owner = AgentDid::new("did:web:agents.example.com:owner");
1673 let audience_member = AgentDid::new("did:web:agents.example.com:friend");
1674 let p = producer_for(2, owner.as_str());
1675 let req = p
1676 .publish_request()
1677 .title("restricted")
1678 .context_type(ContextType::DataSnapshot)
1679 .visibility(Visibility::Restricted)
1680 .audience(vec![audience_member.clone()])
1681 .build()
1682 .unwrap();
1683 let resp = server.publish_unverified_for_tests(&req).unwrap();
1684 let stranger = AgentDid::new("did:web:agents.example.com:stranger");
1685
1686 assert!(server.retrieve(&resp.ctx_id, None).unwrap().is_none());
1687 assert!(server
1688 .retrieve(&resp.ctx_id, Some(&stranger))
1689 .unwrap()
1690 .is_none());
1691 assert!(server
1692 .retrieve(&resp.ctx_id, Some(&owner))
1693 .unwrap()
1694 .is_some());
1695 assert!(server
1696 .retrieve(&resp.ctx_id, Some(&audience_member))
1697 .unwrap()
1698 .is_some());
1699 }
1700
1701 #[test]
1702 fn search_restricted_filters_strangers() {
1703 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1704 let owner = AgentDid::new("did:web:agents.example.com:owner");
1705 let p = producer_for(3, owner.as_str());
1706 let req = p
1707 .publish_request()
1708 .title("hush hush")
1709 .context_type(ContextType::DataSnapshot)
1710 .visibility(Visibility::Restricted)
1711 .audience(vec![AgentDid::new("did:web:agents.example.com:friend")])
1712 .build()
1713 .unwrap();
1714 server.publish_unverified_for_tests(&req).unwrap();
1715
1716 let stranger = AgentDid::new("did:web:agents.example.com:stranger");
1717 let r_anon = server.search(&SearchParams::default(), None).unwrap();
1718 assert!(
1719 r_anon.matches.is_empty(),
1720 "anonymous must not see restricted"
1721 );
1722 let r_stranger = server
1723 .search(&SearchParams::default(), Some(&stranger))
1724 .unwrap();
1725 assert!(r_stranger.matches.is_empty());
1726 let r_owner = server
1727 .search(&SearchParams::default(), Some(&owner))
1728 .unwrap();
1729 assert_eq!(r_owner.matches.len(), 1);
1730 }
1731
1732 #[test]
1736 fn search_private_visible_only_to_producer() {
1737 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1738 let owner = AgentDid::new("did:web:agents.example.com:owner");
1739 let audience_member = AgentDid::new("did:web:agents.example.com:friend");
1740 let p = producer_for(4, owner.as_str());
1741 let req = p
1742 .publish_request()
1743 .title("private note")
1744 .context_type(ContextType::DataSnapshot)
1745 .visibility(Visibility::Private)
1746 .audience(vec![audience_member.clone()])
1747 .build()
1748 .unwrap();
1749 let resp = server.publish_unverified_for_tests(&req).unwrap();
1750
1751 let r_audience = server
1752 .search(&SearchParams::default(), Some(&audience_member))
1753 .unwrap();
1754 assert!(
1755 r_audience.matches.is_empty(),
1756 "audience must NOT see private in search"
1757 );
1758 let r_owner = server
1759 .search(&SearchParams::default(), Some(&owner))
1760 .unwrap();
1761 assert_eq!(
1762 r_owner.matches.len(),
1763 1,
1764 "owner sees their own private context"
1765 );
1766
1767 assert!(server
1769 .retrieve(&resp.ctx_id, Some(&audience_member))
1770 .unwrap()
1771 .is_some());
1772 }
1773
1774 #[cfg(feature = "client")]
1786 #[tokio::test]
1787 async fn publish_verified_rejects_non_did_web_key_id() {
1788 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1789 let p = producer();
1790 let mut req = p
1791 .publish_request()
1792 .title("v1")
1793 .context_type(ContextType::DataSnapshot)
1794 .visibility(Visibility::Public)
1795 .build()
1796 .unwrap();
1797 let did_key = acdp_did::key::did_key_from_ed25519(
1804 &SigningKey::from_bytes(&[9u8; 32]).verifying_key_bytes(),
1805 );
1806 req.signature.key_id = acdp_did::key::did_key_url(&did_key).unwrap();
1807 let resolver = acdp_did::WebResolver::new();
1808 let err = server
1809 .publish_verified(&req, None, &resolver)
1810 .await
1811 .unwrap_err();
1812 match err {
1813 AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("did:web")),
1814 other => panic!("expected KeyNotAuthorized for non-did:web, got {other:?}"),
1815 }
1816 }
1817
1818 #[cfg(feature = "client")]
1819 #[tokio::test]
1820 async fn publish_verified_rejects_agent_id_keyid_mismatch() {
1821 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1822 let p = producer();
1823 let mut req = p
1824 .publish_request()
1825 .title("v1")
1826 .context_type(ContextType::DataSnapshot)
1827 .visibility(Visibility::Public)
1828 .build()
1829 .unwrap();
1830 req.signature.key_id = "did:web:other.example.com:agent#key-1".into();
1831 let resolver = acdp_did::WebResolver::new();
1832 let err = server
1833 .publish_verified(&req, None, &resolver)
1834 .await
1835 .unwrap_err();
1836 match err {
1837 AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("agent_id")),
1838 other => panic!("expected KeyNotAuthorized for agent_id mismatch, got {other:?}"),
1839 }
1840 }
1841
1842 #[cfg(feature = "client")]
1843 #[tokio::test]
1844 async fn publish_verified_rejects_keyid_without_fragment() {
1845 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1846 let p = producer();
1847 let mut req = p
1848 .publish_request()
1849 .title("v1")
1850 .context_type(ContextType::DataSnapshot)
1851 .visibility(Visibility::Public)
1852 .build()
1853 .unwrap();
1854 req.signature.key_id = "did:web:agents.example.com:test".into(); let resolver = acdp_did::WebResolver::new();
1856 let err = server
1857 .publish_verified(&req, None, &resolver)
1858 .await
1859 .unwrap_err();
1860 assert!(
1863 matches!(
1864 err,
1865 AcdpError::SchemaViolation(_) | AcdpError::KeyResolution(_)
1866 ),
1867 "expected fragment-rejection error, got {err:?}"
1868 );
1869 }
1870
1871 fn caps_with_idempotency() -> CapabilitiesDocument {
1874 let mut c = caps();
1875 c.supports_idempotency_key = true;
1876 c.limits.idempotency_key_ttl_seconds = Some(86_400);
1877 c
1878 }
1879
1880 #[test]
1881 fn idempotency_same_hash_returns_original_response() {
1882 let server = RegistryServer::new(
1883 InMemoryStore::new(),
1884 caps_with_idempotency(),
1885 "registry.example.com",
1886 );
1887 let p = producer();
1888 let req = p
1889 .publish_request()
1890 .title("once")
1891 .context_type(ContextType::DataSnapshot)
1892 .visibility(Visibility::Public)
1893 .build()
1894 .unwrap();
1895 let first = server.publish_unverified_for_tests(&req).unwrap();
1897 let ttl = caps_with_idempotency()
1901 .limits
1902 .idempotency_key_ttl_seconds
1903 .unwrap() as i64;
1904 server
1905 .store()
1906 .idempotency_record(
1907 &req.agent_id,
1908 "k-001",
1909 &req.content_hash,
1910 &first,
1911 chrono::Utc::now() + chrono::Duration::seconds(ttl),
1912 )
1913 .unwrap();
1914 let prior = server
1915 .store()
1916 .idempotency_lookup(&req.agent_id, "k-001")
1917 .unwrap()
1918 .unwrap();
1919 assert_eq!(prior.content_hash, req.content_hash);
1920 assert_eq!(prior.response.ctx_id, first.ctx_id);
1921 }
1922
1923 #[test]
1924 fn idempotency_evicts_after_ttl() {
1925 let store = InMemoryStore::new();
1926 let agent = AgentDid::new("did:web:agents.example.com:test");
1927 let resp = PublishResponse {
1928 registry_receipt: None,
1929 ctx_id: acdp_types::CtxId("acdp://r/12345678-1234-4321-8123-000000000099".into()),
1930 lineage_id: acdp_types::LineageId(
1931 "lin:sha256:9999999999999999999999999999999999999999999999999999999999999999"
1932 .into(),
1933 ),
1934 version: 1,
1935 created_at: chrono::Utc::now(),
1936 status: Status::Active,
1937 };
1938 let past = chrono::Utc::now() - chrono::Duration::seconds(1);
1940 store
1941 .idempotency_record(
1942 &agent,
1943 "expired",
1944 &acdp_types::ContentHash("sha256:0".into()),
1945 &resp,
1946 past,
1947 )
1948 .unwrap();
1949 let prior = store.idempotency_lookup(&agent, "expired").unwrap();
1951 assert!(
1952 prior.is_none(),
1953 "lazy TTL eviction should drop expired record"
1954 );
1955 }
1956
1957 struct AlwaysDeny;
1960 impl crate::registry::RateLimiter for AlwaysDeny {
1961 fn check_publish(&self, agent_id: &AgentDid) -> Result<(), AcdpError> {
1962 Err(AcdpError::RateLimited(format!("blocked: {agent_id}")))
1963 }
1964 }
1965
1966 #[test]
1967 fn rate_limiter_blocks_publish_before_persist() {
1968 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com")
1969 .with_rate_limiter(AlwaysDeny);
1970 let p = producer();
1971 let req = p
1972 .publish_request()
1973 .title("blocked")
1974 .context_type(ContextType::DataSnapshot)
1975 .visibility(Visibility::Public)
1976 .build()
1977 .unwrap();
1978 let err = server.publish_unverified_for_tests(&req).unwrap_err();
1979 assert!(matches!(err, AcdpError::RateLimited(_)));
1980 let resp = server.search(&SearchParams::default(), None).unwrap();
1982 assert!(
1983 resp.matches.is_empty(),
1984 "rate-limited publish must not persist"
1985 );
1986 }
1987
1988 #[test]
1989 fn created_at_is_ms_truncated() {
1990 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1991 let p = producer();
1992 let req = p
1993 .publish_request()
1994 .title("ms")
1995 .context_type(ContextType::DataSnapshot)
1996 .visibility(Visibility::Public)
1997 .build()
1998 .unwrap();
1999 let resp = server.publish_unverified_for_tests(&req).unwrap();
2000 assert_eq!(
2002 resp.created_at.timestamp_subsec_nanos() % 1_000_000,
2003 0,
2004 "created_at must be millisecond-truncated per RFC-ACDP-0001 §5.3"
2005 );
2006 }
2007
2008 fn did_key_request() -> acdp_types::publish::PublishRequest {
2011 let p = Producer::new_did_key(SigningKey::from_bytes(&[7u8; 32]));
2012 p.publish_request()
2013 .title("did:key publish")
2014 .context_type(ContextType::DataSnapshot)
2015 .visibility(Visibility::Public)
2016 .build()
2017 .unwrap()
2018 }
2019
2020 #[test]
2024 fn did_key_publish_rejected_when_not_advertised() {
2025 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2026 let err = server
2027 .publish_verified_did_key(&did_key_request(), None)
2028 .unwrap_err();
2029 assert!(
2030 matches!(err, AcdpError::KeyResolution(ref m) if m.contains("supported_did_methods")),
2031 "got {err:?}"
2032 );
2033 }
2034
2035 #[test]
2039 fn did_key_publish_verified_end_to_end() {
2040 let mut c = caps();
2041 c.supported_did_methods.push("did:key".into());
2042 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
2043 let req = did_key_request();
2044 let resp = server.publish_verified_did_key(&req, None).unwrap();
2045 assert_eq!(resp.ctx_id.authority(), "registry.example.com");
2046
2047 let mut tampered = did_key_request();
2049 tampered.title = "tampered".into();
2050 let err = server
2051 .publish_verified_did_key(&tampered, None)
2052 .unwrap_err();
2053 assert!(matches!(err, AcdpError::HashMismatch { .. }), "got {err:?}");
2054 }
2055
2056 #[test]
2062 fn receiptless_idempotent_replay_survives_enabling_receipts() {
2063 let mut c = caps();
2064 c.acdp_version = "0.2.0".into();
2065 c.supported_did_methods.push("did:key".into());
2066 c.supports_idempotency_key = true;
2067 c.limits.idempotency_key_ttl_seconds = Some(86_400);
2068 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
2069 .with_receipt_signer(
2070 acdp_types::receipt::ReceiptSigner::new(
2071 SigningKey::from_bytes(&[0x11u8; 32]),
2072 "did:web:registry.example.com",
2073 "did:web:registry.example.com#receipt-key-1",
2074 )
2075 .unwrap(),
2076 )
2077 .unwrap();
2078
2079 let req = did_key_request();
2082 let pre_receipts_response = acdp_types::publish::PublishResponse {
2083 ctx_id: CtxId(format!(
2084 "acdp://registry.example.com/{}",
2085 uuid::Uuid::new_v4()
2086 )),
2087 lineage_id: acdp_crypto::derive_lineage_id(&CtxId(
2088 "acdp://registry.example.com/v1".into(),
2089 )),
2090 version: 1,
2091 created_at: acdp_primitives::time::trunc_ms(chrono::Utc::now()),
2092 status: Status::Active,
2093 registry_receipt: None,
2094 };
2095 server
2096 .store()
2097 .idempotency_record(
2098 &req.agent_id,
2099 "pre-receipts-key",
2100 &req.content_hash,
2101 &pre_receipts_response,
2102 chrono::Utc::now() + chrono::Duration::hours(1),
2103 )
2104 .unwrap();
2105
2106 let resp = server
2109 .publish_verified_did_key(&req, Some("pre-receipts-key"))
2110 .expect("replay of a pre-receipts record must succeed");
2111 assert_eq!(resp.ctx_id, pre_receipts_response.ctx_id);
2112 assert!(
2113 resp.registry_receipt.is_none(),
2114 "replay returns the original response verbatim"
2115 );
2116
2117 let p2 = Producer::new_did_key(SigningKey::from_bytes(&[8u8; 32]));
2119 let fresh = p2
2120 .publish_request()
2121 .title("fresh after enabling receipts")
2122 .context_type(ContextType::DataSnapshot)
2123 .visibility(Visibility::Public)
2124 .build()
2125 .unwrap();
2126 let fresh_resp = server.publish_verified_did_key(&fresh, None).unwrap();
2127 assert!(
2128 fresh_resp.registry_receipt.is_some(),
2129 "new inserts on a receipts registry must mint"
2130 );
2131 }
2132
2133 #[test]
2136 fn did_key_publish_path_refuses_did_web() {
2137 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2138 let p = producer();
2139 let req = p
2140 .publish_request()
2141 .title("did:web on the offline path")
2142 .context_type(ContextType::DataSnapshot)
2143 .visibility(Visibility::Public)
2144 .build()
2145 .unwrap();
2146 let err = server.publish_verified_did_key(&req, None).unwrap_err();
2147 assert!(
2148 matches!(err, AcdpError::KeyResolution(_)),
2149 "did:web on the offline path must be refused, got {err:?}"
2150 );
2151 }
2152}