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}
65
66impl<S: RegistryStore> RegistryServer<S, NoopRateLimiter> {
67 #[doc(hidden)]
71 pub fn new(store: S, caps: CapabilitiesDocument, authority: impl Into<String>) -> Self {
72 Self {
73 store,
74 caps,
75 authority: authority.into(),
76 rate_limiter: NoopRateLimiter,
77 receipt_signer: None,
78 mint_head_receipts: false,
79 }
80 }
81
82 pub fn try_new(
96 store: S,
97 caps: CapabilitiesDocument,
98 authority: impl Into<String>,
99 ) -> Result<Self, AcdpError> {
100 let authority = authority.into();
101 if !acdp_types::primitives::is_valid_dns_authority(&authority) {
104 return Err(AcdpError::SchemaViolation(format!(
105 "registry authority '{authority}' is not a valid DNS hostname \
106 (must be lowercase labels, e.g. 'registry.example.com'); \
107 use RegistryServer::try_new_for_test_authority for host:port test setups"
108 )));
109 }
110 acdp_validation::validate_capabilities(&caps)?;
111 let expected_did = acdp_did::authority_to_did_web(&authority);
114 if caps.registry_did != expected_did {
115 return Err(AcdpError::SchemaViolation(format!(
116 "capabilities.registry_did '{}' does not match expected '{expected_did}' \
117 for authority '{authority}'",
118 caps.registry_did
119 )));
120 }
121 Ok(Self {
122 store,
123 caps,
124 authority,
125 rate_limiter: NoopRateLimiter,
126 receipt_signer: None,
127 mint_head_receipts: false,
128 })
129 }
130
131 #[doc(hidden)]
140 pub fn try_new_for_test_authority(
141 store: S,
142 caps: CapabilitiesDocument,
143 authority: impl Into<String>,
144 ) -> Result<Self, AcdpError> {
145 let authority = authority.into();
146 acdp_validation::validate_capabilities(&caps)?;
147 let expected_did = acdp_did::authority_to_did_web(&authority);
148 if caps.registry_did != expected_did {
149 return Err(AcdpError::SchemaViolation(format!(
150 "capabilities.registry_did '{}' does not match expected '{expected_did}' \
151 for authority '{authority}'",
152 caps.registry_did
153 )));
154 }
155 Ok(Self {
156 store,
157 caps,
158 authority,
159 rate_limiter: NoopRateLimiter,
160 receipt_signer: None,
161 mint_head_receipts: false,
162 })
163 }
164}
165
166impl<S: RegistryStore, L: RateLimiter> RegistryServer<S, L> {
167 pub fn with_rate_limiter<L2: RateLimiter>(self, limiter: L2) -> RegistryServer<S, L2> {
169 RegistryServer {
170 store: self.store,
171 caps: self.caps,
172 authority: self.authority,
173 rate_limiter: limiter,
174 receipt_signer: self.receipt_signer,
175 mint_head_receipts: self.mint_head_receipts,
176 }
177 }
178
179 pub fn with_receipt_signer(
195 mut self,
196 signer: acdp_types::receipt::ReceiptSigner,
197 ) -> Result<Self, AcdpError> {
198 if signer.registry_did() != self.caps.registry_did {
199 return Err(AcdpError::SchemaViolation(format!(
200 "receipt signer registry_did '{}' ≠ capabilities.registry_did '{}'",
201 signer.registry_did(),
202 self.caps.registry_did
203 )));
204 }
205 self.require_min_acdp_version((0, 2, 0), "acdp-registry-receipts")?;
208 let profile = acdp_types::profile::Profile::RegistryReceipts.as_str();
209 if !self.caps.profiles.iter().any(|p| p == profile) {
210 self.caps.profiles.push(profile.to_string());
211 }
212 self.receipt_signer = Some(signer);
213 Ok(self)
214 }
215
216 pub fn with_lineage_head_receipts(mut self) -> Result<Self, AcdpError> {
230 if self.receipt_signer.is_none() {
231 return Err(AcdpError::SchemaViolation(
232 "acdp-registry-head-receipts requires the acdp-registry-receipts profile \
233 (RFC-ACDP-0011 §9): call with_receipt_signer first"
234 .into(),
235 ));
236 }
237 self.require_min_acdp_version((0, 3, 0), "acdp-registry-head-receipts")?;
238 let profile = acdp_types::profile::Profile::RegistryHeadReceipts.as_str();
239 if !self.caps.profiles.iter().any(|p| p == profile) {
240 self.caps.profiles.push(profile.to_string());
241 }
242 self.mint_head_receipts = true;
243 Ok(self)
244 }
245
246 fn require_min_acdp_version(&self, min: (u64, u64, u64), what: &str) -> Result<(), AcdpError> {
251 let parts: Vec<u64> = self
252 .caps
253 .acdp_version
254 .split('.')
255 .map(|p| p.parse::<u64>())
256 .collect::<Result<_, _>>()
257 .map_err(|_| {
258 AcdpError::SchemaViolation(format!(
259 "capabilities.acdp_version '{}' is not a plain MAJOR.MINOR.PATCH version",
260 self.caps.acdp_version
261 ))
262 })?;
263 let [major, minor, patch] = parts.as_slice() else {
264 return Err(AcdpError::SchemaViolation(format!(
265 "capabilities.acdp_version '{}' is not a plain MAJOR.MINOR.PATCH version",
266 self.caps.acdp_version
267 )));
268 };
269 if (*major, *minor, *patch) < min {
270 return Err(AcdpError::SchemaViolation(format!(
271 "{what} requires capabilities.acdp_version >= {}.{}.{}, got '{}'",
272 min.0, min.1, min.2, self.caps.acdp_version
273 )));
274 }
275 Ok(())
276 }
277
278 pub fn store(&self) -> &S {
281 &self.store
282 }
283
284 pub fn capabilities(&self) -> &CapabilitiesDocument {
286 &self.caps
287 }
288
289 #[cfg(feature = "client")]
305 #[cfg_attr(
306 feature = "tracing",
307 tracing::instrument(
308 name = "acdp.publish_verified",
309 skip_all,
310 fields(
311 agent_id = req.agent_id.as_str(),
312 version = req.version,
313 idempotency_key = idempotency_key.is_some(),
314 ),
315 err(Display)
316 )
317 )]
318 pub async fn publish_verified(
319 &self,
320 req: &PublishRequest,
321 idempotency_key: Option<&str>,
322 resolver: &acdp_did::WebResolver,
323 ) -> Result<PublishResponse, AcdpError> {
324 self.publish_verified_in_tenant(req, idempotency_key, resolver, None)
325 .await
326 }
327
328 #[cfg(feature = "client")]
334 pub async fn publish_verified_in_tenant(
335 &self,
336 req: &PublishRequest,
337 idempotency_key: Option<&str>,
338 resolver: &acdp_did::WebResolver,
339 tenant: Option<&str>,
340 ) -> Result<PublishResponse, AcdpError> {
341 self.check_publish_rate_limit(&req.agent_id)?;
343
344 let raw_bytes = serde_json::to_vec(req)?.len();
345 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
346 let _validated = validator.validate_post_schema(req, raw_bytes)?;
347
348 acdp_verify::verify_publish_request_signature(req, resolver).await?;
350
351 let fingerprint = if self.receipt_signer.is_some() {
355 Some(producer_key_fingerprint(req, resolver).await?)
356 } else {
357 None
358 };
359
360 self.commit_via_store(req, idempotency_key, tenant, fingerprint)
368 }
369
370 pub fn publish_verified_did_key(
384 &self,
385 req: &PublishRequest,
386 idempotency_key: Option<&str>,
387 ) -> Result<PublishResponse, AcdpError> {
388 self.publish_verified_did_key_in_tenant(req, idempotency_key, None)
389 }
390
391 #[cfg_attr(
397 feature = "tracing",
398 tracing::instrument(
399 name = "acdp.publish_verified_did_key",
400 skip_all,
401 fields(
402 agent_id = req.agent_id.as_str(),
403 version = req.version,
404 idempotency_key = idempotency_key.is_some(),
405 ),
406 err(Display)
407 )
408 )]
409 pub fn publish_verified_did_key_in_tenant(
410 &self,
411 req: &PublishRequest,
412 idempotency_key: Option<&str>,
413 tenant: Option<&str>,
414 ) -> Result<PublishResponse, AcdpError> {
415 self.check_publish_rate_limit(&req.agent_id)?;
416
417 let raw_bytes = serde_json::to_vec(req)?.len();
418 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
419 let _validated = validator.validate_post_schema(req, raw_bytes)?;
420
421 acdp_verify::verify_publish_request_signature_offline(req)?;
423
424 let fingerprint = if self.receipt_signer.is_some() {
427 let material = acdp_did::key::resolve_did_key(req.agent_id.as_str())?;
428 Some(acdp_crypto::fingerprint::fingerprint_did_key_material(
429 &material,
430 )?)
431 } else {
432 None
433 };
434
435 self.commit_via_store(req, idempotency_key, tenant, fingerprint)
436 }
437
438 #[doc(hidden)]
445 pub fn publish_unverified_for_tests(
446 &self,
447 req: &PublishRequest,
448 ) -> Result<PublishResponse, AcdpError> {
449 self.check_publish_rate_limit(&req.agent_id)?;
453
454 if self.receipt_signer.is_some() {
460 return Err(AcdpError::SchemaViolation(
461 "publish_unverified_for_tests is unavailable on a receipts-advertising \
462 registry (RFC-ACDP-0010 §7: no degraded mode); use publish_verified or \
463 publish_verified_did_key"
464 .into(),
465 ));
466 }
467 let raw_bytes = serde_json::to_vec(req)?.len();
468 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
469 let _validated = validator.validate_post_schema(req, raw_bytes)?;
470 self.commit_via_store(req, None, None, None)
471 }
472
473 fn check_publish_rate_limit(
477 &self,
478 agent_id: &acdp_types::primitives::AgentDid,
479 ) -> Result<(), AcdpError> {
480 match self.rate_limiter.check_publish(agent_id) {
481 Ok(()) => Ok(()),
482 Err(e) => {
483 #[cfg(feature = "tracing")]
484 tracing::warn!(
485 agent_id = agent_id.as_str(),
486 "publish rejected by rate limiter"
487 );
488 Err(e)
489 }
490 }
491 }
492
493 fn commit_via_store(
498 &self,
499 req: &PublishRequest,
500 idempotency_key: Option<&str>,
501 tenant: Option<&str>,
502 producer_key_fingerprint: Option<String>,
503 ) -> Result<PublishResponse, AcdpError> {
504 let idempotency = if self.caps.supports_idempotency_key {
505 idempotency_key.map(|key| crate::registry::store::PendingIdempotencyCommit {
506 key,
507 ttl: chrono::Duration::seconds(
508 self.caps
509 .limits
510 .idempotency_key_ttl_seconds
511 .unwrap_or(86_400) as i64,
512 ),
513 })
514 } else {
515 None
516 };
517 #[allow(clippy::type_complexity)]
520 let minter: Option<
521 Box<dyn Fn(&Body) -> Result<serde_json::Value, AcdpError> + Send + Sync>,
522 > = match (&self.receipt_signer, producer_key_fingerprint) {
523 (Some(signer), Some(fp)) => Some(Box::new(move |body: &Body| {
524 let receipt = signer.mint(
525 &body.ctx_id,
526 &body.lineage_id,
527 &body.origin_registry,
528 body.created_at,
529 &body.content_hash,
530 &fp,
531 )?;
532 serde_json::to_value(receipt).map_err(AcdpError::from)
533 })),
534 _ => None,
535 };
536 let minted_expected = minter.is_some();
537 let outcome = self
538 .store
539 .commit_publish(crate::registry::store::PublishCommit {
540 req,
541 authority: &self.authority,
542 idempotency,
543 tenant,
544 receipt_minter: minter.as_deref(),
545 })?;
546 let (response, replayed) = match outcome {
547 crate::registry::store::PublishCommitOutcome::Inserted(r) => (r, false),
548 crate::registry::store::PublishCommitOutcome::IdempotentReplay(r) => (r, true),
549 };
550 #[cfg(feature = "tracing")]
551 tracing::debug!(
552 ctx_id = %response.ctx_id.0,
553 lineage_id = %response.lineage_id.0,
554 version = response.version,
555 replayed,
556 "publish committed"
557 );
558 if minted_expected && !replayed && response.registry_receipt.is_none() {
572 return Err(AcdpError::RegistryInternal(
573 "receipt signer is configured but the store returned no receipt — \
574 the RegistryStore implementation must invoke PublishCommit::receipt_minter \
575 inside its commit (RFC-ACDP-0010 §7: no degraded mode)"
576 .into(),
577 ));
578 }
579 Ok(response)
580 }
581
582 pub fn retrieve(
595 &self,
596 ctx_id: &CtxId,
597 requester: Option<&AgentDid>,
598 ) -> Result<Option<FullContext>, AcdpError> {
599 let Some(ctx) = self.store.get(ctx_id)? else {
600 return Ok(None);
601 };
602 if !can_retrieve(&ctx.body, requester, &self.caps) {
603 return Ok(None);
604 }
605 Ok(Some(ctx))
606 }
607
608 pub fn retrieve_body(
610 &self,
611 ctx_id: &CtxId,
612 requester: Option<&AgentDid>,
613 ) -> Result<Option<Body>, AcdpError> {
614 Ok(self.retrieve(ctx_id, requester)?.map(|c| c.body))
615 }
616
617 pub fn lineage(
624 &self,
625 lineage_id: &LineageId,
626 requester: Option<&AgentDid>,
627 ) -> Result<Vec<FullContext>, AcdpError> {
628 let all = self.store.lineage(lineage_id)?;
629 Ok(all
630 .into_iter()
631 .filter(|ctx| can_retrieve(&ctx.body, requester, &self.caps))
632 .collect())
633 }
634
635 pub fn current(
649 &self,
650 lineage_id: &LineageId,
651 requester: Option<&AgentDid>,
652 ) -> Result<Option<FullContext>, AcdpError> {
653 let all = self.store.lineage(lineage_id)?;
654 for mut ctx in all.into_iter().rev() {
660 if !matches!(ctx.registry_state.status, Status::Superseded)
661 && can_retrieve(&ctx.body, requester, &self.caps)
662 {
663 if self.mint_head_receipts {
664 let signer = self.receipt_signer.as_ref().ok_or_else(|| {
669 AcdpError::RegistryInternal(
670 "head-receipt minting enabled without a receipt signer \
671 (RFC-ACDP-0011 §9 prerequisite violated)"
672 .into(),
673 )
674 })?;
675 let receipt = signer.mint_lineage_head(
676 lineage_id,
677 &ctx.body.ctx_id,
678 ctx.body.version,
679 &ctx.registry_state.status,
680 chrono::Utc::now(),
681 )?;
682 ctx.lineage_head_receipt = Some(serde_json::to_value(receipt)?);
683 }
684 return Ok(Some(ctx));
685 }
686 }
687 Ok(None)
688 }
689
690 pub fn search(
703 &self,
704 params: &SearchParams,
705 requester: Option<&AgentDid>,
706 ) -> Result<SearchResponse, AcdpError> {
707 if requester.is_none() && !self.caps.anonymous_public_reads {
712 return Err(AcdpError::NotAuthorized(
713 "anonymous search requires authentication \
714 (registry caps: anonymous_public_reads=false)"
715 .into(),
716 ));
717 }
718 self.store
723 .search(params, requester, self.caps.anonymous_public_reads)
724 }
725}
726
727pub(crate) fn can_retrieve(
729 body: &Body,
730 requester: Option<&AgentDid>,
731 caps: &CapabilitiesDocument,
732) -> bool {
733 match body.visibility {
734 Visibility::Public => caps.anonymous_public_reads || requester.is_some(),
735 Visibility::Restricted | Visibility::Private => match requester {
736 None => false,
737 Some(r) => {
738 r == &body.agent_id
739 || body
740 .audience
741 .as_deref()
742 .is_some_and(|a| a.iter().any(|d| d == r))
743 }
744 },
745 }
746}
747
748#[cfg(feature = "client")]
758async fn producer_key_fingerprint(
759 req: &PublishRequest,
760 resolver: &acdp_did::WebResolver,
761) -> Result<String, AcdpError> {
762 acdp_crypto::fingerprint::fingerprint_for_key_id(
763 &req.signature.key_id,
764 &req.signature.algorithm,
765 resolver,
766 )
767 .await
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773 use crate::registry::store::InMemoryStore;
774 use acdp_crypto::SigningKey;
775 use acdp_producer::Producer;
776 use acdp_types::capabilities::Limits;
777 use acdp_types::primitives::{AgentDid, ContextType, Visibility};
778
779 fn caps() -> CapabilitiesDocument {
780 CapabilitiesDocument {
781 acdp_version: "0.1.0".into(),
782 registry_did: "did:web:registry.example.com".into(),
783 supported_signature_algorithms: vec!["ed25519".into()],
784 supported_did_methods: vec!["did:web".into()],
785 profiles: vec!["acdp-registry-core".into()],
786 limits: Limits {
787 max_payload_bytes: 1_048_576,
788 max_embedded_bytes: 65_536,
789 idempotency_key_ttl_seconds: None,
790 max_publish_per_minute: None,
791 },
792 read_authentication_methods: vec![],
793 anonymous_public_reads: true,
794 supports_idempotency_key: false,
795 extensions: Default::default(),
796 }
797 }
798
799 fn producer() -> Producer {
800 Producer::new(
801 SigningKey::from_bytes(&[1u8; 32]),
802 AgentDid::new("did:web:agents.example.com:test"),
803 "did:web:agents.example.com:test#key-1",
804 )
805 }
806
807 #[test]
808 fn publish_v1_then_retrieve() {
809 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
810 let p = producer();
811 let req = p
812 .publish_request()
813 .title("v1")
814 .context_type(ContextType::DataSnapshot)
815 .visibility(Visibility::Public)
816 .build()
817 .unwrap();
818 let resp = server.publish_unverified_for_tests(&req).unwrap();
819 assert_eq!(resp.version, 1);
820 let ctx = server.retrieve(&resp.ctx_id, None).unwrap().unwrap();
821 assert_eq!(ctx.body.title, "v1");
822 let lineage = server.lineage(&resp.lineage_id, None).unwrap();
824 assert_eq!(lineage.len(), 1);
825 let cur = server.current(&resp.lineage_id, None).unwrap().unwrap();
827 assert_eq!(cur.body.ctx_id, resp.ctx_id);
828 }
829
830 #[test]
831 fn supersession_marks_predecessor_and_returns_v2() {
832 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
833 let p = producer();
834 let v1_req = p
835 .publish_request()
836 .title("v1")
837 .context_type(ContextType::DataSnapshot)
838 .visibility(Visibility::Public)
839 .build()
840 .unwrap();
841 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
842
843 let v2_req = p
844 .supersede(v1.ctx_id.clone())
845 .version(2)
846 .title("v2")
847 .context_type(ContextType::DataSnapshot)
848 .visibility(Visibility::Public)
849 .build()
850 .unwrap();
851 let v2 = server.publish_unverified_for_tests(&v2_req).unwrap();
852 assert_eq!(v2.version, 2);
853 let v1_ctx = server.retrieve(&v1.ctx_id, None).unwrap().unwrap();
855 assert!(matches!(
856 v1_ctx.registry_state.status,
857 acdp_types::Status::Superseded
858 ));
859 assert_eq!(v1.lineage_id, v2.lineage_id);
861 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
863 assert_eq!(cur.body.ctx_id, v2.ctx_id);
864 }
865
866 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
874 async fn concurrent_supersession_exactly_one_succeeds() {
875 use std::sync::Arc;
876 let server = Arc::new(RegistryServer::new(
877 InMemoryStore::new(),
878 caps(),
879 "registry.example.com",
880 ));
881 let p = producer();
882 let v1_req = p
883 .publish_request()
884 .title("v1")
885 .context_type(ContextType::DataSnapshot)
886 .visibility(Visibility::Public)
887 .build()
888 .unwrap();
889 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
890
891 let v2a_req = p
896 .supersede(v1.ctx_id.clone())
897 .version(2)
898 .title("v2-A")
899 .context_type(ContextType::DataSnapshot)
900 .visibility(Visibility::Public)
901 .build()
902 .unwrap();
903 let v2b_req = p
904 .supersede(v1.ctx_id.clone())
905 .version(2)
906 .title("v2-B")
907 .context_type(ContextType::DataSnapshot)
908 .visibility(Visibility::Public)
909 .build()
910 .unwrap();
911
912 let s1 = Arc::clone(&server);
913 let s2 = Arc::clone(&server);
914 let h1 = tokio::task::spawn_blocking(move || s1.publish_unverified_for_tests(&v2a_req));
915 let h2 = tokio::task::spawn_blocking(move || s2.publish_unverified_for_tests(&v2b_req));
916 let (r1, r2) = (h1.await.unwrap(), h2.await.unwrap());
917
918 let outcomes = [r1, r2];
919 let successes = outcomes.iter().filter(|r| r.is_ok()).count();
920 let failures = outcomes.iter().filter(|r| r.is_err()).count();
921 assert_eq!(
922 successes, 1,
923 "exactly one concurrent supersession MUST succeed; got {successes} successes / {failures} failures"
924 );
925 assert_eq!(failures, 1);
926 for r in &outcomes {
929 if let Err(e) = r {
930 match e {
931 AcdpError::SupersededTarget { reason, .. } => assert_eq!(
932 *reason,
933 acdp_primitives::error::SupersessionReason::AlreadySuperseded,
934 "concurrent loser MUST be AlreadySuperseded"
935 ),
936 other => panic!("concurrent loser had wrong error: {other:?}"),
937 }
938 }
939 }
940 }
941
942 #[test]
943 fn hostile_supersession_by_non_owner_rejected_predecessor_unchanged() {
944 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
949 let victim = producer_for(7, "did:web:agents.example.com:victim");
950 let v1_req = victim
951 .publish_request()
952 .title("v1")
953 .context_type(ContextType::DataSnapshot)
954 .visibility(Visibility::Public)
955 .build()
956 .unwrap();
957 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
958
959 let attacker = producer_for(9, "did:web:evil.example.com:attacker");
962 let v2_req = attacker
963 .supersede(v1.ctx_id.clone())
964 .version(2)
965 .title("hijacked")
966 .context_type(ContextType::DataSnapshot)
967 .visibility(Visibility::Public)
968 .build()
969 .unwrap();
970 let err = server.publish_unverified_for_tests(&v2_req).unwrap_err();
971 match err {
973 AcdpError::SupersededTarget { reason, .. } => {
974 assert_eq!(reason, acdp_primitives::error::SupersessionReason::NotFound);
975 }
976 other => panic!("expected uniform SupersededTarget::NotFound, got {other:?}"),
977 }
978 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
980 assert_eq!(cur.body.ctx_id, v1.ctx_id);
981 assert_eq!(cur.body.title, "v1");
982 assert_eq!(
983 cur.registry_state.status,
984 acdp_types::primitives::Status::Active
985 );
986 }
987
988 #[test]
989 fn owner_supersession_still_succeeds_after_ownership_check() {
990 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
991 let p = producer();
992 let v1_req = p
993 .publish_request()
994 .title("v1")
995 .context_type(ContextType::DataSnapshot)
996 .visibility(Visibility::Public)
997 .build()
998 .unwrap();
999 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1000 let v2_req = p
1001 .supersede(v1.ctx_id.clone())
1002 .version(2)
1003 .title("v2")
1004 .context_type(ContextType::DataSnapshot)
1005 .visibility(Visibility::Public)
1006 .build()
1007 .unwrap();
1008 let v2 = server.publish_unverified_for_tests(&v2_req).unwrap();
1009 assert_eq!(v2.version, 2);
1010 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1011 assert_eq!(cur.body.ctx_id, v2.ctx_id);
1012 }
1013
1014 #[test]
1015 fn supersession_with_unknown_target_rejected_as_not_found() {
1016 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1017 let p = producer();
1018 let phantom =
1019 CtxId("acdp://registry.example.com/12345678-1234-4321-8123-deadbeefcafe".into());
1020 let req = p
1021 .supersede(phantom)
1022 .version(2)
1023 .title("v2-orphan")
1024 .context_type(ContextType::DataSnapshot)
1025 .visibility(Visibility::Public)
1026 .build()
1027 .unwrap();
1028 let err = server.publish_unverified_for_tests(&req).unwrap_err();
1029 match err {
1030 AcdpError::SupersededTarget { reason, .. } => {
1031 assert_eq!(reason, acdp_primitives::error::SupersessionReason::NotFound);
1032 }
1033 other => panic!("expected SupersededTarget::NotFound, got {other:?}"),
1034 }
1035 }
1036
1037 #[test]
1038 fn version_mismatch_rejected() {
1039 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1040 let p = producer();
1041 let v1_req = p
1042 .publish_request()
1043 .title("v1")
1044 .context_type(ContextType::DataSnapshot)
1045 .visibility(Visibility::Public)
1046 .build()
1047 .unwrap();
1048 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1049 let v3_req = p
1051 .supersede(v1.ctx_id.clone())
1052 .version(3)
1053 .title("v3-skipped")
1054 .context_type(ContextType::DataSnapshot)
1055 .visibility(Visibility::Public)
1056 .build()
1057 .unwrap();
1058 let err = server.publish_unverified_for_tests(&v3_req).unwrap_err();
1059 match err {
1060 AcdpError::SupersededTarget { reason, .. } => {
1061 assert_eq!(
1062 reason,
1063 acdp_primitives::error::SupersessionReason::VersionMismatch
1064 );
1065 }
1066 other => panic!("expected VersionMismatch, got {other:?}"),
1067 }
1068 }
1069
1070 #[test]
1071 fn search_finds_published_context() {
1072 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1073 let p = producer();
1074 let req = p
1075 .publish_request()
1076 .title("Q1 portfolio risk")
1077 .context_type(ContextType::DataSnapshot)
1078 .visibility(Visibility::Public)
1079 .build()
1080 .unwrap();
1081 server.publish_unverified_for_tests(&req).unwrap();
1082 let resp = server
1083 .search(
1084 &SearchParams {
1085 q: Some("portfolio".into()),
1086 ..Default::default()
1087 },
1088 None,
1089 )
1090 .unwrap();
1091 assert_eq!(resp.matches.len(), 1);
1092 assert_eq!(resp.matches[0].title, "Q1 portfolio risk");
1093 }
1094
1095 #[test]
1101 fn lineage_filters_restricted_for_stranger() {
1102 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1103 let p = producer();
1104 let audience = AgentDid::new("did:web:audience.example.com:reader");
1105 let req = p
1106 .publish_request()
1107 .title("restricted v1")
1108 .context_type(ContextType::DataSnapshot)
1109 .visibility(Visibility::Restricted)
1110 .audience(vec![audience.clone()])
1111 .build()
1112 .unwrap();
1113 let resp = server.publish_unverified_for_tests(&req).unwrap();
1114
1115 let stranger = AgentDid::new("did:web:other.example.com:reader");
1116 let stranger_view = server.lineage(&resp.lineage_id, Some(&stranger)).unwrap();
1117 assert!(
1118 stranger_view.is_empty(),
1119 "stranger MUST NOT see restricted bodies via lineage(); got {} entries",
1120 stranger_view.len()
1121 );
1122
1123 let audience_view = server.lineage(&resp.lineage_id, Some(&audience)).unwrap();
1124 assert_eq!(
1125 audience_view.len(),
1126 1,
1127 "audience member MUST see the restricted body via lineage()"
1128 );
1129 }
1130
1131 #[test]
1134 fn current_filters_private_for_stranger() {
1135 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1136 let p = producer();
1137 let req = p
1138 .publish_request()
1139 .title("private v1")
1140 .context_type(ContextType::DataSnapshot)
1141 .visibility(Visibility::Private)
1142 .build()
1143 .unwrap();
1144 let resp = server.publish_unverified_for_tests(&req).unwrap();
1145
1146 let stranger = AgentDid::new("did:web:other.example.com:reader");
1147 assert!(
1148 server
1149 .current(&resp.lineage_id, Some(&stranger))
1150 .unwrap()
1151 .is_none(),
1152 "stranger MUST NOT see private contexts via current()"
1153 );
1154
1155 let producer_did = AgentDid::new("did:web:agents.example.com:test");
1156 assert!(
1157 server
1158 .current(&resp.lineage_id, Some(&producer_did))
1159 .unwrap()
1160 .is_some(),
1161 "producer MUST see private contexts via current()"
1162 );
1163 }
1164
1165 #[test]
1176 fn current_returns_none_when_all_superseded() {
1177 use crate::registry::store::RegistryStore;
1178 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1179 let p = producer();
1180 let req = p
1181 .publish_request()
1182 .title("v1")
1183 .context_type(ContextType::DataSnapshot)
1184 .visibility(Visibility::Public)
1185 .build()
1186 .unwrap();
1187 let resp = server.publish_unverified_for_tests(&req).unwrap();
1188 server.store().mark_superseded(&resp.ctx_id).unwrap();
1190
1191 let cur = server.current(&resp.lineage_id, None).unwrap();
1192 assert!(
1193 cur.is_none(),
1194 "all-superseded lineage MUST resolve to None per RFC-ACDP-0004 §5; got {cur:?}"
1195 );
1196 }
1197
1198 #[test]
1206 fn search_suppresses_public_when_anonymous_public_reads_false() {
1207 let mut c = caps();
1208 c.anonymous_public_reads = false;
1209 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
1210 let p = producer();
1211 let req = p
1212 .publish_request()
1213 .title("public-but-flag-off")
1214 .context_type(ContextType::DataSnapshot)
1215 .visibility(Visibility::Public)
1216 .build()
1217 .unwrap();
1218 server.publish_unverified_for_tests(&req).unwrap();
1219
1220 let err = server
1222 .search(
1223 &SearchParams {
1224 q: Some("public-but-flag-off".into()),
1225 ..Default::default()
1226 },
1227 None,
1228 )
1229 .unwrap_err();
1230 assert!(
1231 matches!(err, AcdpError::NotAuthorized(_)),
1232 "vis-009: anonymous search MUST be NotAuthorized when \
1233 anonymous_public_reads=false; got {err:?}"
1234 );
1235
1236 let stranger = AgentDid::new("did:web:other.example.com:reader");
1239 let authed = server
1240 .search(
1241 &SearchParams {
1242 q: Some("public-but-flag-off".into()),
1243 ..Default::default()
1244 },
1245 Some(&stranger),
1246 )
1247 .unwrap();
1248 assert_eq!(
1249 authed.matches.len(),
1250 1,
1251 "authenticated search MUST see public contexts regardless of anonymous_public_reads"
1252 );
1253 }
1254
1255 #[test]
1258 fn try_new_rejects_did_authority_mismatch() {
1259 let mut c = caps();
1260 c.registry_did = "did:web:other.example.com".into(); let res = RegistryServer::try_new(InMemoryStore::new(), c, "registry.example.com");
1262 match res {
1263 Err(AcdpError::SchemaViolation(msg)) => {
1264 assert!(msg.contains("does not match expected"))
1265 }
1266 Err(other) => panic!("expected SchemaViolation, got {other:?}"),
1267 Ok(_) => panic!("expected Err"),
1268 }
1269 }
1270
1271 #[test]
1272 fn try_new_rejects_caps_missing_ed25519() {
1273 let mut c = caps();
1274 c.supported_signature_algorithms = vec!["ecdsa-p256".into()]; let res = RegistryServer::try_new(InMemoryStore::new(), c, "registry.example.com");
1276 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1277 }
1278
1279 #[test]
1280 fn try_new_accepts_valid_caps() {
1281 RegistryServer::try_new(InMemoryStore::new(), caps(), "registry.example.com").unwrap();
1282 }
1283
1284 #[test]
1287 fn try_new_accepts_valid_dns_authority() {
1288 RegistryServer::try_new(InMemoryStore::new(), caps(), "registry.example.com").unwrap();
1289 }
1290
1291 #[test]
1292 fn try_new_rejects_host_port_authority() {
1293 let res = RegistryServer::try_new(InMemoryStore::new(), caps(), "localhost:8443");
1296 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1297 }
1298
1299 #[test]
1300 fn try_new_rejects_uppercase_authority() {
1301 let res = RegistryServer::try_new(InMemoryStore::new(), caps(), "Registry.Example.Com");
1302 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1303 }
1304
1305 #[test]
1306 fn try_new_rejects_url_form_authority() {
1307 let res =
1308 RegistryServer::try_new(InMemoryStore::new(), caps(), "https://registry.example.com");
1309 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1310 }
1311
1312 #[test]
1313 fn try_new_for_test_accepts_host_port() {
1314 let mut c = caps();
1317 c.registry_did = acdp_did::authority_to_did_web("localhost:8443");
1318 RegistryServer::try_new_for_test_authority(InMemoryStore::new(), c, "localhost:8443")
1319 .unwrap();
1320 }
1321
1322 fn producer_for(seed: u8, did: &str) -> Producer {
1325 Producer::new(
1326 SigningKey::from_bytes(&[seed; 32]),
1327 AgentDid::new(did),
1328 format!("{did}#key-1"),
1329 )
1330 }
1331
1332 #[test]
1333 fn retrieve_restricted_blocks_stranger_returns_none() {
1334 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1335 let owner = AgentDid::new("did:web:agents.example.com:owner");
1336 let audience_member = AgentDid::new("did:web:agents.example.com:friend");
1337 let p = producer_for(2, owner.as_str());
1338 let req = p
1339 .publish_request()
1340 .title("restricted")
1341 .context_type(ContextType::DataSnapshot)
1342 .visibility(Visibility::Restricted)
1343 .audience(vec![audience_member.clone()])
1344 .build()
1345 .unwrap();
1346 let resp = server.publish_unverified_for_tests(&req).unwrap();
1347 let stranger = AgentDid::new("did:web:agents.example.com:stranger");
1348
1349 assert!(server.retrieve(&resp.ctx_id, None).unwrap().is_none());
1350 assert!(server
1351 .retrieve(&resp.ctx_id, Some(&stranger))
1352 .unwrap()
1353 .is_none());
1354 assert!(server
1355 .retrieve(&resp.ctx_id, Some(&owner))
1356 .unwrap()
1357 .is_some());
1358 assert!(server
1359 .retrieve(&resp.ctx_id, Some(&audience_member))
1360 .unwrap()
1361 .is_some());
1362 }
1363
1364 #[test]
1365 fn search_restricted_filters_strangers() {
1366 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1367 let owner = AgentDid::new("did:web:agents.example.com:owner");
1368 let p = producer_for(3, owner.as_str());
1369 let req = p
1370 .publish_request()
1371 .title("hush hush")
1372 .context_type(ContextType::DataSnapshot)
1373 .visibility(Visibility::Restricted)
1374 .audience(vec![AgentDid::new("did:web:agents.example.com:friend")])
1375 .build()
1376 .unwrap();
1377 server.publish_unverified_for_tests(&req).unwrap();
1378
1379 let stranger = AgentDid::new("did:web:agents.example.com:stranger");
1380 let r_anon = server.search(&SearchParams::default(), None).unwrap();
1381 assert!(
1382 r_anon.matches.is_empty(),
1383 "anonymous must not see restricted"
1384 );
1385 let r_stranger = server
1386 .search(&SearchParams::default(), Some(&stranger))
1387 .unwrap();
1388 assert!(r_stranger.matches.is_empty());
1389 let r_owner = server
1390 .search(&SearchParams::default(), Some(&owner))
1391 .unwrap();
1392 assert_eq!(r_owner.matches.len(), 1);
1393 }
1394
1395 #[test]
1399 fn search_private_visible_only_to_producer() {
1400 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1401 let owner = AgentDid::new("did:web:agents.example.com:owner");
1402 let audience_member = AgentDid::new("did:web:agents.example.com:friend");
1403 let p = producer_for(4, owner.as_str());
1404 let req = p
1405 .publish_request()
1406 .title("private note")
1407 .context_type(ContextType::DataSnapshot)
1408 .visibility(Visibility::Private)
1409 .audience(vec![audience_member.clone()])
1410 .build()
1411 .unwrap();
1412 let resp = server.publish_unverified_for_tests(&req).unwrap();
1413
1414 let r_audience = server
1415 .search(&SearchParams::default(), Some(&audience_member))
1416 .unwrap();
1417 assert!(
1418 r_audience.matches.is_empty(),
1419 "audience must NOT see private in search"
1420 );
1421 let r_owner = server
1422 .search(&SearchParams::default(), Some(&owner))
1423 .unwrap();
1424 assert_eq!(
1425 r_owner.matches.len(),
1426 1,
1427 "owner sees their own private context"
1428 );
1429
1430 assert!(server
1432 .retrieve(&resp.ctx_id, Some(&audience_member))
1433 .unwrap()
1434 .is_some());
1435 }
1436
1437 #[cfg(feature = "client")]
1449 #[tokio::test]
1450 async fn publish_verified_rejects_non_did_web_key_id() {
1451 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1452 let p = producer();
1453 let mut req = p
1454 .publish_request()
1455 .title("v1")
1456 .context_type(ContextType::DataSnapshot)
1457 .visibility(Visibility::Public)
1458 .build()
1459 .unwrap();
1460 let did_key = acdp_did::key::did_key_from_ed25519(
1467 &SigningKey::from_bytes(&[9u8; 32]).verifying_key_bytes(),
1468 );
1469 req.signature.key_id = acdp_did::key::did_key_url(&did_key).unwrap();
1470 let resolver = acdp_did::WebResolver::new();
1471 let err = server
1472 .publish_verified(&req, None, &resolver)
1473 .await
1474 .unwrap_err();
1475 match err {
1476 AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("did:web")),
1477 other => panic!("expected KeyNotAuthorized for non-did:web, got {other:?}"),
1478 }
1479 }
1480
1481 #[cfg(feature = "client")]
1482 #[tokio::test]
1483 async fn publish_verified_rejects_agent_id_keyid_mismatch() {
1484 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1485 let p = producer();
1486 let mut req = p
1487 .publish_request()
1488 .title("v1")
1489 .context_type(ContextType::DataSnapshot)
1490 .visibility(Visibility::Public)
1491 .build()
1492 .unwrap();
1493 req.signature.key_id = "did:web:other.example.com:agent#key-1".into();
1494 let resolver = acdp_did::WebResolver::new();
1495 let err = server
1496 .publish_verified(&req, None, &resolver)
1497 .await
1498 .unwrap_err();
1499 match err {
1500 AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("agent_id")),
1501 other => panic!("expected KeyNotAuthorized for agent_id mismatch, got {other:?}"),
1502 }
1503 }
1504
1505 #[cfg(feature = "client")]
1506 #[tokio::test]
1507 async fn publish_verified_rejects_keyid_without_fragment() {
1508 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1509 let p = producer();
1510 let mut req = p
1511 .publish_request()
1512 .title("v1")
1513 .context_type(ContextType::DataSnapshot)
1514 .visibility(Visibility::Public)
1515 .build()
1516 .unwrap();
1517 req.signature.key_id = "did:web:agents.example.com:test".into(); let resolver = acdp_did::WebResolver::new();
1519 let err = server
1520 .publish_verified(&req, None, &resolver)
1521 .await
1522 .unwrap_err();
1523 assert!(
1526 matches!(
1527 err,
1528 AcdpError::SchemaViolation(_) | AcdpError::KeyResolution(_)
1529 ),
1530 "expected fragment-rejection error, got {err:?}"
1531 );
1532 }
1533
1534 fn caps_with_idempotency() -> CapabilitiesDocument {
1537 let mut c = caps();
1538 c.supports_idempotency_key = true;
1539 c.limits.idempotency_key_ttl_seconds = Some(86_400);
1540 c
1541 }
1542
1543 #[test]
1544 fn idempotency_same_hash_returns_original_response() {
1545 let server = RegistryServer::new(
1546 InMemoryStore::new(),
1547 caps_with_idempotency(),
1548 "registry.example.com",
1549 );
1550 let p = producer();
1551 let req = p
1552 .publish_request()
1553 .title("once")
1554 .context_type(ContextType::DataSnapshot)
1555 .visibility(Visibility::Public)
1556 .build()
1557 .unwrap();
1558 let first = server.publish_unverified_for_tests(&req).unwrap();
1560 let ttl = caps_with_idempotency()
1564 .limits
1565 .idempotency_key_ttl_seconds
1566 .unwrap() as i64;
1567 server
1568 .store()
1569 .idempotency_record(
1570 &req.agent_id,
1571 "k-001",
1572 &req.content_hash,
1573 &first,
1574 chrono::Utc::now() + chrono::Duration::seconds(ttl),
1575 )
1576 .unwrap();
1577 let prior = server
1578 .store()
1579 .idempotency_lookup(&req.agent_id, "k-001")
1580 .unwrap()
1581 .unwrap();
1582 assert_eq!(prior.content_hash, req.content_hash);
1583 assert_eq!(prior.response.ctx_id, first.ctx_id);
1584 }
1585
1586 #[test]
1587 fn idempotency_evicts_after_ttl() {
1588 let store = InMemoryStore::new();
1589 let agent = AgentDid::new("did:web:agents.example.com:test");
1590 let resp = PublishResponse {
1591 registry_receipt: None,
1592 ctx_id: acdp_types::CtxId("acdp://r/12345678-1234-4321-8123-000000000099".into()),
1593 lineage_id: acdp_types::LineageId(
1594 "lin:sha256:9999999999999999999999999999999999999999999999999999999999999999"
1595 .into(),
1596 ),
1597 version: 1,
1598 created_at: chrono::Utc::now(),
1599 status: Status::Active,
1600 };
1601 let past = chrono::Utc::now() - chrono::Duration::seconds(1);
1603 store
1604 .idempotency_record(
1605 &agent,
1606 "expired",
1607 &acdp_types::ContentHash("sha256:0".into()),
1608 &resp,
1609 past,
1610 )
1611 .unwrap();
1612 let prior = store.idempotency_lookup(&agent, "expired").unwrap();
1614 assert!(
1615 prior.is_none(),
1616 "lazy TTL eviction should drop expired record"
1617 );
1618 }
1619
1620 struct AlwaysDeny;
1623 impl crate::registry::RateLimiter for AlwaysDeny {
1624 fn check_publish(&self, agent_id: &AgentDid) -> Result<(), AcdpError> {
1625 Err(AcdpError::RateLimited(format!("blocked: {agent_id}")))
1626 }
1627 }
1628
1629 #[test]
1630 fn rate_limiter_blocks_publish_before_persist() {
1631 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com")
1632 .with_rate_limiter(AlwaysDeny);
1633 let p = producer();
1634 let req = p
1635 .publish_request()
1636 .title("blocked")
1637 .context_type(ContextType::DataSnapshot)
1638 .visibility(Visibility::Public)
1639 .build()
1640 .unwrap();
1641 let err = server.publish_unverified_for_tests(&req).unwrap_err();
1642 assert!(matches!(err, AcdpError::RateLimited(_)));
1643 let resp = server.search(&SearchParams::default(), None).unwrap();
1645 assert!(
1646 resp.matches.is_empty(),
1647 "rate-limited publish must not persist"
1648 );
1649 }
1650
1651 #[test]
1652 fn created_at_is_ms_truncated() {
1653 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1654 let p = producer();
1655 let req = p
1656 .publish_request()
1657 .title("ms")
1658 .context_type(ContextType::DataSnapshot)
1659 .visibility(Visibility::Public)
1660 .build()
1661 .unwrap();
1662 let resp = server.publish_unverified_for_tests(&req).unwrap();
1663 assert_eq!(
1665 resp.created_at.timestamp_subsec_nanos() % 1_000_000,
1666 0,
1667 "created_at must be millisecond-truncated per RFC-ACDP-0001 §5.3"
1668 );
1669 }
1670
1671 fn did_key_request() -> acdp_types::publish::PublishRequest {
1674 let p = Producer::new_did_key(SigningKey::from_bytes(&[7u8; 32]));
1675 p.publish_request()
1676 .title("did:key publish")
1677 .context_type(ContextType::DataSnapshot)
1678 .visibility(Visibility::Public)
1679 .build()
1680 .unwrap()
1681 }
1682
1683 #[test]
1687 fn did_key_publish_rejected_when_not_advertised() {
1688 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1689 let err = server
1690 .publish_verified_did_key(&did_key_request(), None)
1691 .unwrap_err();
1692 assert!(
1693 matches!(err, AcdpError::KeyResolution(ref m) if m.contains("supported_did_methods")),
1694 "got {err:?}"
1695 );
1696 }
1697
1698 #[test]
1702 fn did_key_publish_verified_end_to_end() {
1703 let mut c = caps();
1704 c.supported_did_methods.push("did:key".into());
1705 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
1706 let req = did_key_request();
1707 let resp = server.publish_verified_did_key(&req, None).unwrap();
1708 assert_eq!(resp.ctx_id.authority(), "registry.example.com");
1709
1710 let mut tampered = did_key_request();
1712 tampered.title = "tampered".into();
1713 let err = server
1714 .publish_verified_did_key(&tampered, None)
1715 .unwrap_err();
1716 assert!(matches!(err, AcdpError::HashMismatch { .. }), "got {err:?}");
1717 }
1718
1719 #[test]
1725 fn receiptless_idempotent_replay_survives_enabling_receipts() {
1726 let mut c = caps();
1727 c.acdp_version = "0.2.0".into();
1728 c.supported_did_methods.push("did:key".into());
1729 c.supports_idempotency_key = true;
1730 c.limits.idempotency_key_ttl_seconds = Some(86_400);
1731 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
1732 .with_receipt_signer(
1733 acdp_types::receipt::ReceiptSigner::new(
1734 SigningKey::from_bytes(&[0x11u8; 32]),
1735 "did:web:registry.example.com",
1736 "did:web:registry.example.com#receipt-key-1",
1737 )
1738 .unwrap(),
1739 )
1740 .unwrap();
1741
1742 let req = did_key_request();
1745 let pre_receipts_response = acdp_types::publish::PublishResponse {
1746 ctx_id: CtxId(format!(
1747 "acdp://registry.example.com/{}",
1748 uuid::Uuid::new_v4()
1749 )),
1750 lineage_id: acdp_crypto::derive_lineage_id(&CtxId(
1751 "acdp://registry.example.com/v1".into(),
1752 )),
1753 version: 1,
1754 created_at: acdp_primitives::time::trunc_ms(chrono::Utc::now()),
1755 status: Status::Active,
1756 registry_receipt: None,
1757 };
1758 server
1759 .store()
1760 .idempotency_record(
1761 &req.agent_id,
1762 "pre-receipts-key",
1763 &req.content_hash,
1764 &pre_receipts_response,
1765 chrono::Utc::now() + chrono::Duration::hours(1),
1766 )
1767 .unwrap();
1768
1769 let resp = server
1772 .publish_verified_did_key(&req, Some("pre-receipts-key"))
1773 .expect("replay of a pre-receipts record must succeed");
1774 assert_eq!(resp.ctx_id, pre_receipts_response.ctx_id);
1775 assert!(
1776 resp.registry_receipt.is_none(),
1777 "replay returns the original response verbatim"
1778 );
1779
1780 let p2 = Producer::new_did_key(SigningKey::from_bytes(&[8u8; 32]));
1782 let fresh = p2
1783 .publish_request()
1784 .title("fresh after enabling receipts")
1785 .context_type(ContextType::DataSnapshot)
1786 .visibility(Visibility::Public)
1787 .build()
1788 .unwrap();
1789 let fresh_resp = server.publish_verified_did_key(&fresh, None).unwrap();
1790 assert!(
1791 fresh_resp.registry_receipt.is_some(),
1792 "new inserts on a receipts registry must mint"
1793 );
1794 }
1795
1796 #[test]
1799 fn did_key_publish_path_refuses_did_web() {
1800 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1801 let p = producer();
1802 let req = p
1803 .publish_request()
1804 .title("did:web on the offline path")
1805 .context_type(ContextType::DataSnapshot)
1806 .visibility(Visibility::Public)
1807 .build()
1808 .unwrap();
1809 let err = server.publish_verified_did_key(&req, None).unwrap_err();
1810 assert!(
1811 matches!(err, AcdpError::KeyResolution(_)),
1812 "did:web on the offline path must be refused, got {err:?}"
1813 );
1814 }
1815}