acdp_server/registry/server.rs
1//! Logical registry handler (feature = "server").
2//!
3//! Wires [`PublishValidator`] together with a [`RegistryStore`] backend
4//! to provide the seven core registry operations enumerated in
5//! RFC-ACDP-0003 §2.1 and RFC-ACDP-0005:
6//!
7//! - capabilities — return the [`CapabilitiesDocument`].
8//! - publish — validate, verify signature, assign identifiers, persist.
9//! - retrieve — fetch a stored body + registry_state (visibility-filtered).
10//! - retrieve_body — fetch just the body (visibility-filtered).
11//! - lineage / current — lineage graph queries.
12//! - search — keyword + filter projection (visibility-filtered).
13//!
14//! This is the building block an HTTP-binding layer can sit on top of;
15//! the integration tests in this crate exercise it directly without
16//! mocking.
17//!
18//! # Conformant publish
19//!
20//! [`RegistryServer::publish_verified`] runs the full RFC-ACDP-0003 §2.1
21//! algorithm — structural validation, hash recomputation, DID resolution,
22//! signature verification — before persistence. It requires the `client`
23//! feature for [`acdp_did::WebResolver`].
24//!
25//! [`RegistryServer::publish_unverified_for_tests`] (and its
26//! idempotency/tenant-capable sibling
27//! [`RegistryServer::publish_unverified_in_tenant_for_tests`]) perform
28//! only steps 1–6 (skipping DID resolution + signature verification)
29//! and are intentionally **not** RFC-conformant; use only in tests
30//! where DID resolution would require a live network or mock server.
31//!
32//! [`RegistryServer::publish_pinned_verified_in_tenant_with_outcome`] (and
33//! [`RegistryServer::prove_publish_identity_pinned`]) are a third,
34//! distinct, RFC-conformant category — not a laxer variant of the
35//! `_unverified_for_tests` pair above. Steps 1–6 run here as usual; steps
36//! 7–8 (signature verification against a resolved key) are the *caller's*
37//! responsibility, already done before this method is reached, against an
38//! operator-pinned key rather than a live-resolved DID document. See that
39//! method's own doc comment for the full trust argument.
40
41use crate::registry::rate_limit::{NoopRateLimiter, RateLimiter};
42use crate::registry::store::RegistryStore;
43use crate::registry::validator::{
44 check_revocation_supersession, key_revocation_gate_applies, PublishValidator,
45};
46use acdp_primitives::error::AcdpError;
47use acdp_types::{
48 body::{Body, FullContext},
49 capabilities::CapabilitiesDocument,
50 primitives::{AgentDid, ContentHash, CtxId, LineageId, Status, Visibility},
51 publish::{PublishRequest, PublishResponse},
52 revocation::KeyRevocation,
53 search::{SearchParams, SearchResponse},
54};
55
56/// Logical registry handler over an arbitrary [`RegistryStore`].
57///
58/// `L` is the rate-limiting policy (RFC-ACDP-0008 §4.3). The default
59/// [`NoopRateLimiter`] accepts every publish; operators that need a
60/// real limiter construct via [`Self::with_rate_limiter`].
61pub struct RegistryServer<S: RegistryStore, L: RateLimiter = NoopRateLimiter> {
62 store: S,
63 caps: CapabilitiesDocument,
64 authority: String,
65 rate_limiter: L,
66 /// Receipt minting identity (ACDP 0.2, RFC-ACDP-0010). `None` =
67 /// 0.1.0-mode registry (no receipts). Set via
68 /// [`Self::with_receipt_signer`], which also advertises the
69 /// `acdp-registry-receipts` profile.
70 receipt_signer: Option<acdp_types::receipt::ReceiptSigner>,
71 /// Lineage-head receipt minting (ACDP 0.3, RFC-ACDP-0011). Enabled
72 /// via [`Self::with_lineage_head_receipts`], which also advertises
73 /// the `acdp-registry-head-receipts` profile. When enabled,
74 /// [`Self::current`] mints a fresh head receipt per response with
75 /// the RFC-ACDP-0010 receipt signing key. Never true without
76 /// `receipt_signer` (the profile's prerequisite).
77 mint_head_receipts: bool,
78 /// Lifecycle events & retraction (ACDP 0.3, RFC-ACDP-0013). Enabled
79 /// via [`Self::with_lifecycle`], which also advertises the
80 /// `acdp-registry-lifecycle` profile. When disabled, the lifecycle
81 /// operations return [`AcdpError::NotImplemented`] (the §6 rule for
82 /// non-advertising registries: HTTP 501) and the registry never
83 /// emits `lifecycle_events` or the `retracted` status.
84 lifecycle_enabled: bool,
85}
86
87/// Proof that a [`PublishRequest`]'s identity has been established —
88/// RFC-ACDP-0003 §2.1 steps 1–8 (schema/hash validation, DID resolution,
89/// signature verification) plus the RFC-ACDP-0014 §5 step 2 self-revocation
90/// check, whichever of those the request's `context_type` and the
91/// registry's `acdp_version` require — but nothing has been persisted yet.
92///
93/// Produced only by a successful [`RegistryServer::prove_publish_identity`] /
94/// [`RegistryServer::prove_publish_identity_did_key`] /
95/// [`RegistryServer::prove_publish_identity_pinned`] call — there is no
96/// public constructor, so a caller cannot manufacture one from an
97/// unverified request. Not [`Clone`]: a `Proven` that outlived its single
98/// intended [`RegistryServer::commit_proven`] call risks being committed
99/// twice against different tenants/idempotency keys, so this is a
100/// move-only, "prove once, commit once" value.
101///
102/// Borrows `req` rather than owning a clone — the did:web caller already
103/// holds an owned request across its `.await`, and the did:key caller
104/// already clones into its blocking closure, so an owned `Proven` would
105/// force a needless clone on the async path for no benefit on the sync
106/// one.
107#[derive(Debug)]
108pub struct Proven<'a> {
109 req: &'a PublishRequest,
110 fingerprint: Option<String>,
111 /// The `content_hash` recomputed over `req`'s `ProducerContent` during
112 /// proving (RFC-ACDP-0003 §2.1 step 4) — not exposed publicly, since
113 /// it is provably always equal to `req.content_hash` by the time a
114 /// `Proven` exists (a mismatch fails `prove_publish_identity*` with
115 /// [`AcdpError::HashMismatch`] first, and `req` — with its own `pub
116 /// content_hash` — is already reachable via [`Self::request`]).
117 /// [`RegistryServer::commit_proven`] asserts that invariant against it
118 /// in debug builds.
119 recomputed_hash: ContentHash,
120 authority: String,
121}
122
123impl<'a> Proven<'a> {
124 /// The request this proof was established for.
125 pub fn request(&self) -> &PublishRequest {
126 self.req
127 }
128
129 /// The producer's agent DID.
130 pub fn agent_id(&self) -> &AgentDid {
131 &self.req.agent_id
132 }
133
134 /// The verified producer key's fingerprint, when one was computed.
135 ///
136 /// Computed only when the registry has a receipt signer configured
137 /// (RFC-ACDP-0010) or `req` is a key-revocation subject to the
138 /// RFC-ACDP-0014 §5 step 2 self-sign check — `None` otherwise, exactly
139 /// mirroring the conditions the pre-`Proven` publish pipeline already
140 /// used to decide whether fingerprinting was worth its cost.
141 pub fn key_fingerprint(&self) -> Option<&str> {
142 self.fingerprint.as_deref()
143 }
144}
145
146impl<S: RegistryStore> RegistryServer<S, NoopRateLimiter> {
147 /// Unchecked constructor. Skips capabilities and DID-authority binding
148 /// validation; prefer [`Self::try_new`] in production. Retained for
149 /// tests that build a server from known-good fixtures.
150 #[doc(hidden)]
151 pub fn new(store: S, caps: CapabilitiesDocument, authority: impl Into<String>) -> Self {
152 Self {
153 store,
154 caps,
155 authority: authority.into(),
156 rate_limiter: NoopRateLimiter,
157 receipt_signer: None,
158 mint_head_receipts: false,
159 lifecycle_enabled: false,
160 }
161 }
162
163 /// Production constructor.
164 ///
165 /// Validates that `authority` is a bare lowercase DNS hostname,
166 /// validates capabilities against RFC-ACDP-0007 §3, and enforces that
167 /// `caps.registry_did` equals `did:web:<authority>` (per
168 /// RFC-ACDP-0006 §4.1 step 3 — the registry's DID document binds it
169 /// to the authority it claims).
170 ///
171 /// A `host:port`, scheme-prefixed, or uppercase authority is rejected:
172 /// the server uses `authority` to mint `ctx_id` (`acdp://<authority>/…`)
173 /// and `origin_registry`, and a colon or slash there violates the
174 /// `acdp://` URI authority rule (RFC-ACDP-0002 §3.1). For `host:port`
175 /// test setups use [`Self::try_new_for_test_authority`].
176 pub fn try_new(
177 store: S,
178 caps: CapabilitiesDocument,
179 authority: impl Into<String>,
180 ) -> Result<Self, AcdpError> {
181 let authority = authority.into();
182 // Production authority MUST be a bare lowercase DNS hostname — no
183 // port, no scheme, no DID prefix (RFC-ACDP-0002 §3.1).
184 if !acdp_types::primitives::is_valid_dns_authority(&authority) {
185 return Err(AcdpError::SchemaViolation(format!(
186 "registry authority '{authority}' is not a valid DNS hostname \
187 (must be lowercase labels, e.g. 'registry.example.com'); \
188 use RegistryServer::try_new_for_test_authority for host:port test setups"
189 )));
190 }
191 acdp_validation::validate_capabilities(&caps)?;
192 // BUG-06: percent-encode `:` in `host:port` authorities — the
193 // colon is a structural separator in did:web.
194 let expected_did = acdp_did::authority_to_did_web(&authority);
195 if caps.registry_did != expected_did {
196 return Err(AcdpError::SchemaViolation(format!(
197 "capabilities.registry_did '{}' does not match expected '{expected_did}' \
198 for authority '{authority}'",
199 caps.registry_did
200 )));
201 }
202 Ok(Self {
203 store,
204 caps,
205 authority,
206 rate_limiter: NoopRateLimiter,
207 receipt_signer: None,
208 mint_head_receipts: false,
209 lifecycle_enabled: false,
210 })
211 }
212
213 /// Test-only constructor that accepts a `host:port` authority such as
214 /// `"localhost:8443"`. The authority is **not** validated as a DNS
215 /// hostname; capabilities and the DID binding are still checked.
216 ///
217 /// **Non-production only.** A server built with this constructor will
218 /// mint `ctx_id` and `origin_registry` values that do not conform to
219 /// the `acdp://` URI syntax rules (a colon in the authority segment).
220 /// Use [`Self::try_new`] for production registries.
221 #[doc(hidden)]
222 pub fn try_new_for_test_authority(
223 store: S,
224 caps: CapabilitiesDocument,
225 authority: impl Into<String>,
226 ) -> Result<Self, AcdpError> {
227 let authority = authority.into();
228 acdp_validation::validate_capabilities(&caps)?;
229 let expected_did = acdp_did::authority_to_did_web(&authority);
230 if caps.registry_did != expected_did {
231 return Err(AcdpError::SchemaViolation(format!(
232 "capabilities.registry_did '{}' does not match expected '{expected_did}' \
233 for authority '{authority}'",
234 caps.registry_did
235 )));
236 }
237 Ok(Self {
238 store,
239 caps,
240 authority,
241 rate_limiter: NoopRateLimiter,
242 receipt_signer: None,
243 mint_head_receipts: false,
244 lifecycle_enabled: false,
245 })
246 }
247}
248
249impl<S: RegistryStore, L: RateLimiter> RegistryServer<S, L> {
250 /// Replace the rate-limiting policy (RFC-ACDP-0008 §4.3).
251 pub fn with_rate_limiter<L2: RateLimiter>(self, limiter: L2) -> RegistryServer<S, L2> {
252 RegistryServer {
253 store: self.store,
254 caps: self.caps,
255 authority: self.authority,
256 rate_limiter: limiter,
257 receipt_signer: self.receipt_signer,
258 mint_head_receipts: self.mint_head_receipts,
259 lifecycle_enabled: self.lifecycle_enabled,
260 }
261 }
262
263 /// Configure receipt minting (ACDP 0.2, RFC-ACDP-0010). Every
264 /// subsequent verified publish mints a registry-signed receipt
265 /// atomically with persistence, returns it in the publish response,
266 /// and serves it on retrieval.
267 ///
268 /// Also advertises the `acdp-registry-receipts` profile — a
269 /// registry without a signing key MUST NOT advertise it, so the
270 /// profile is bound to this call rather than to raw capabilities
271 /// input. Fails if the signer's `registry_did` does not match
272 /// `caps.registry_did` (a receipt minted under a foreign DID would
273 /// fail every consumer's serving-authority cross-check).
274 ///
275 /// Note: [`Self::publish_unverified_for_tests`] and
276 /// [`Self::publish_unverified_in_tenant_for_tests`] never mint — the
277 /// producer key is not resolved on either path, so a fingerprint
278 /// attestation would be false.
279 pub fn with_receipt_signer(
280 mut self,
281 signer: acdp_types::receipt::ReceiptSigner,
282 ) -> Result<Self, AcdpError> {
283 if signer.registry_did() != self.caps.registry_did {
284 return Err(AcdpError::SchemaViolation(format!(
285 "receipt signer registry_did '{}' ≠ capabilities.registry_did '{}'",
286 signer.registry_did(),
287 self.caps.registry_did
288 )));
289 }
290 // RFC-ACDP-0010 §11: registries advertising the receipts
291 // profile MUST advertise acdp_version >= 0.2.0.
292 self.require_min_acdp_version((0, 2, 0), "acdp-registry-receipts")?;
293 let profile = acdp_types::profile::Profile::RegistryReceipts.as_str();
294 if !self.caps.profiles.iter().any(|p| p == profile) {
295 self.caps.profiles.push(profile.to_string());
296 }
297 self.receipt_signer = Some(signer);
298 Ok(self)
299 }
300
301 /// Enable lineage-head receipt minting (ACDP 0.3, RFC-ACDP-0011).
302 /// Every subsequent [`Self::current`] response carries a freshly
303 /// minted head receipt (`as_of` = the registry clock at response
304 /// time, ms-truncated), signed with the RFC-ACDP-0010 receipt
305 /// signing key — head receipts introduce no new key role (§5, §8).
306 ///
307 /// Also advertises the `acdp-registry-head-receipts` profile. The
308 /// profile's prerequisite is `acdp-registry-receipts` (§9): this
309 /// method fails unless [`Self::with_receipt_signer`] was configured
310 /// first — a registry with no receipt key has nothing to sign head
311 /// receipts with, and MUST NOT advertise the profile (§6: no
312 /// degraded mode on `/current`). Registries advertising the profile
313 /// MUST advertise `acdp_version` >= 0.3.0 (§9).
314 pub fn with_lineage_head_receipts(mut self) -> Result<Self, AcdpError> {
315 if self.receipt_signer.is_none() {
316 return Err(AcdpError::SchemaViolation(
317 "acdp-registry-head-receipts requires the acdp-registry-receipts profile \
318 (RFC-ACDP-0011 §9): call with_receipt_signer first"
319 .into(),
320 ));
321 }
322 self.require_min_acdp_version((0, 3, 0), "acdp-registry-head-receipts")?;
323 let profile = acdp_types::profile::Profile::RegistryHeadReceipts.as_str();
324 if !self.caps.profiles.iter().any(|p| p == profile) {
325 self.caps.profiles.push(profile.to_string());
326 }
327 self.mint_head_receipts = true;
328 Ok(self)
329 }
330
331 /// Enable lifecycle events & retraction (ACDP 0.3, RFC-ACDP-0013).
332 /// Advertises the `acdp-registry-lifecycle` profile (prerequisite:
333 /// `acdp-registry-core`) and activates the
334 /// [`Self::retract_verified`] / [`Self::republish_verified`]
335 /// operation surface, the §7 status derivation (`retracted`
336 /// dominating `superseded` and `expired`), the §8.2 default-search
337 /// exclusion, and the §8.3 `/current` head exclusion.
338 ///
339 /// Registries advertising the profile MUST advertise `acdp_version`
340 /// ≥ 0.3.0 (§10). The paired [`RegistryStore`] must implement
341 /// [`RegistryStore::commit_lifecycle_event`] — the default trait
342 /// impl fails with `not_implemented`, so a mispaired backend fails
343 /// loudly on the first lifecycle write rather than silently
344 /// dropping a retraction.
345 pub fn with_lifecycle(mut self) -> Result<Self, AcdpError> {
346 self.require_min_acdp_version((0, 3, 0), "acdp-registry-lifecycle")?;
347 let profile = acdp_types::profile::Profile::RegistryLifecycle.as_str();
348 if !self.caps.profiles.iter().any(|p| p == profile) {
349 self.caps.profiles.push(profile.to_string());
350 }
351 self.lifecycle_enabled = true;
352 Ok(self)
353 }
354
355 /// Profile version gate: `capabilities.acdp_version` must be a plain
356 /// `MAJOR.MINOR.PATCH` version (the capabilities schema's
357 /// `^\d+\.\d+\.\d+$` form — malformed input is an error, never
358 /// coerced) and at least `min`.
359 fn require_min_acdp_version(&self, min: (u64, u64, u64), what: &str) -> Result<(), AcdpError> {
360 let parts: Vec<u64> = self
361 .caps
362 .acdp_version
363 .split('.')
364 .map(|p| p.parse::<u64>())
365 .collect::<Result<_, _>>()
366 .map_err(|_| {
367 AcdpError::SchemaViolation(format!(
368 "capabilities.acdp_version '{}' is not a plain MAJOR.MINOR.PATCH version",
369 self.caps.acdp_version
370 ))
371 })?;
372 let [major, minor, patch] = parts.as_slice() else {
373 return Err(AcdpError::SchemaViolation(format!(
374 "capabilities.acdp_version '{}' is not a plain MAJOR.MINOR.PATCH version",
375 self.caps.acdp_version
376 )));
377 };
378 if (*major, *minor, *patch) < min {
379 return Err(AcdpError::SchemaViolation(format!(
380 "{what} requires capabilities.acdp_version >= {}.{}.{}, got '{}'",
381 min.0, min.1, min.2, self.caps.acdp_version
382 )));
383 }
384 Ok(())
385 }
386
387 /// Borrow the underlying store. Useful for tests that want to
388 /// inspect side-effects directly.
389 pub fn store(&self) -> &S {
390 &self.store
391 }
392
393 /// `GET /.well-known/acdp.json`.
394 pub fn capabilities(&self) -> &CapabilitiesDocument {
395 &self.caps
396 }
397
398 /// **RFC-conformant publish.**
399 ///
400 /// Runs RFC-ACDP-0003 §2.1 steps 1–11:
401 ///
402 /// - **1–6.** [`PublishValidator::validate_post_schema`] — schema,
403 /// payload + embedded size, hash recomputation, algorithm /
404 /// key_id binding.
405 /// - **7–8.** [`acdp_verify::verify_publish_request_signature`] —
406 /// DID resolution + signature verification.
407 /// - **9.** Identifier assignment (`ctx_id`, `lineage_id`).
408 /// - **10.** Lineage coherence on supersession.
409 /// - **11.** Persistence and predecessor supersession.
410 ///
411 /// Steps 7–8 require a [`acdp_did::WebResolver`], so this method
412 /// is gated on the `client` feature.
413 #[cfg(feature = "client")]
414 #[cfg_attr(
415 feature = "tracing",
416 tracing::instrument(
417 name = "acdp.publish_verified",
418 skip_all,
419 fields(
420 agent_id = req.agent_id.as_str(),
421 version = req.version,
422 idempotency_key = idempotency_key.is_some(),
423 ),
424 err(Display)
425 )
426 )]
427 pub async fn publish_verified(
428 &self,
429 req: &PublishRequest,
430 idempotency_key: Option<&str>,
431 resolver: &acdp_did::WebResolver,
432 ) -> Result<PublishResponse, AcdpError> {
433 self.publish_verified_in_tenant(req, idempotency_key, resolver, None)
434 .await
435 }
436
437 /// Like [`Self::publish_verified`] but binds the publish to a tenant so a
438 /// multi-tenant store persists `tenant_id` atomically with the context row
439 /// (rather than via a separate, non-transactional stamping UPDATE that a
440 /// crash could leave stranded in the default bucket).
441 ///
442 /// `tenant = None` behaves identically to [`Self::publish_verified`] —
443 /// but note this method returns the commit OUTCOME, not a bare
444 /// `PublishResponse`, so it is not a drop-in for it. The bare-response
445 /// equivalent is [`Self::publish_verified_in_tenant`].
446 ///
447 /// Which entry points have an outcome twin, and why the rest do not:
448 /// the three `*_in_tenant` publish forms do (this one, `did_key`, and
449 /// `pinned`), because those are the paths a registry front-end answers a
450 /// real `POST /contexts` through. The non-tenant forms do not — each is a
451 /// `(…, None)` delegate to its `_in_tenant` twin, so a caller wanting the
452 /// outcome passes `tenant = None`. `publish_unverified_in_tenant_for_tests`
453 /// does not either, deliberately: it skips DID resolution and signature
454 /// verification, is not an RFC-conformant publish path, and no registry
455 /// serves production traffic through it.
456 #[cfg(feature = "client")]
457 pub async fn publish_verified_in_tenant_with_outcome(
458 &self,
459 req: &PublishRequest,
460 idempotency_key: Option<&str>,
461 resolver: &acdp_did::WebResolver,
462 tenant: Option<&str>,
463 ) -> Result<crate::registry::store::PublishCommitOutcome, AcdpError> {
464 // FEAT-01: hand the rest of the pipeline to the store as a
465 // single atomic commit. Idempotency lookup, predecessor
466 // verification, body insertion, predecessor supersession
467 // marking, and idempotency record writing all happen under one
468 // critical section. Two concurrent publishes against the same
469 // `supersedes` (or the same `Idempotency-Key`) can no longer
470 // both succeed.
471 let proven = self.prove_publish_identity(req, resolver).await?;
472 self.commit_proven(proven, idempotency_key, tenant)
473 }
474
475 /// **Prove** a did:web producer's identity for `req` — RFC-ACDP-0003
476 /// §2.1 steps 1–8 (schema/hash validation, DID resolution, signature
477 /// verification) plus the RFC-ACDP-0014 §5 step 2 self-revocation
478 /// check — without persisting anything. Pair with
479 /// [`Self::commit_proven`] to complete the publish; the two together
480 /// are exactly what [`Self::publish_verified_in_tenant_with_outcome`]
481 /// composes.
482 #[cfg(feature = "client")]
483 pub async fn prove_publish_identity<'a>(
484 &self,
485 req: &'a PublishRequest,
486 resolver: &acdp_did::WebResolver,
487 ) -> Result<Proven<'a>, AcdpError> {
488 // Rate-limit gate runs before any expensive work — RFC-ACDP-0008 §4.3.
489 self.check_publish_rate_limit(&req.agent_id)?;
490
491 let raw_bytes = serde_json::to_vec(req)?.len();
492 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
493 let validated = validator.validate_post_schema(req, raw_bytes)?;
494
495 // Steps 7–8: DID resolution + signature verification.
496 acdp_verify::verify_publish_request_signature(req, resolver).await?;
497
498 // RFC-ACDP-0014 §5 step 2 on the did:web publish path: a
499 // revocation MUST NOT be signed by the very key it revokes.
500 // `PublishValidator::validate_post_schema` (above) already
501 // enforces this for a did:key signer offline, purely from the
502 // body (`KeyRevocation::check_not_self_signed_did_key_lenient`)
503 // — but a did:web signer's fingerprint is not derivable without
504 // resolving its DID document. That resolution already happened
505 // unconditionally just above, to verify the signature
506 // (RFC-ACDP-0003 steps 7–8) — so this is NOT a new resolution.
507 // `producer_key_fingerprint` dispatches by method
508 // internally, so a did:key signer reaching this line would be a
509 // harmless, resolver-free recheck of what was already enforced
510 // above; a did:web signer instead gets a second, cache-hit
511 // resolve of the same DID (via `WebResolver`'s LRU cache)
512 // purely to derive a fingerprint from the key that already
513 // verified — no new I/O, no new failure mode. Scoped to
514 // key-revocation bodies at `acdp_version >= 0.3.0` so no other
515 // publish pays even that cached-lookup cost. Uses
516 // `is_key_revocation()` (both context-type spellings) rather
517 // than the standard-type-only gate `validate_post_schema` uses
518 // for full §4 validation — §5 step 2 has no §10 interim-form
519 // carve-out, so it must still fire on the interim spelling; see
520 // `check_not_self_signed_lenient`'s doc comment.
521 let revocation_check_needed = req.context_type.is_key_revocation()
522 && key_revocation_gate_applies(&self.caps.acdp_version);
523
524 // RFC-ACDP-0010: fingerprint the key that was just resolved and
525 // verified, for the receipt's `key_fingerprint` binding. Also
526 // needed (and computed here, not a second time) when the §5
527 // step 2 check above applies, so a key-revocation publish never
528 // triggers two DID resolutions for one fingerprint.
529 let fingerprint = if self.receipt_signer.is_some() || revocation_check_needed {
530 Some(producer_key_fingerprint(req, resolver).await?)
531 } else {
532 None
533 };
534
535 if revocation_check_needed {
536 // `fingerprint` is `Some` here because `revocation_check_needed`
537 // was one of the two disjuncts in the `if` just above that
538 // decided whether to compute it — but that's a non-local
539 // invariant spanning several lines, so treat a `None` as a
540 // recoverable internal-state error rather than panicking the
541 // publish path (this crate is `forbid(unsafe_code)` and ships
542 // to crates.io; see `AcdpError::RegistryInternal`'s other use
543 // in this file for the same "impossible state" idiom).
544 let fp = fingerprint.as_deref().ok_or_else(|| {
545 AcdpError::RegistryInternal(
546 "key-revocation fingerprint missing despite revocation_check_needed \
547 — this is an internal invariant violation, not a caller error"
548 .into(),
549 )
550 })?;
551 KeyRevocation::check_not_self_signed_lenient(req, fp)?;
552 }
553
554 Ok(Proven {
555 req,
556 fingerprint,
557 recomputed_hash: validated.recomputed_hash,
558 authority: self.authority.clone(),
559 })
560 }
561
562 /// [`Self::publish_verified_in_tenant_with_outcome`] with the insert/replay
563 /// distinction discarded — see that method for the full contract, which is
564 /// otherwise identical. This form is a one-line delegate to it, so the two
565 /// cannot drift.
566 ///
567 /// Answering `POST /contexts` needs the twin: RFC-ACDP-0003 requires
568 /// `201 Created` + `Location` on a fresh publish and `200 OK` on a same-hash
569 /// retry (idem-002 says NOT 201), and this signature cannot express which
570 /// one happened.
571 #[cfg(feature = "client")]
572 pub async fn publish_verified_in_tenant(
573 &self,
574 req: &PublishRequest,
575 idempotency_key: Option<&str>,
576 resolver: &acdp_did::WebResolver,
577 tenant: Option<&str>,
578 ) -> Result<PublishResponse, AcdpError> {
579 self.publish_verified_in_tenant_with_outcome(req, idempotency_key, resolver, tenant)
580 .await
581 .map(|o| o.into_response())
582 }
583
584 /// **RFC-conformant publish for `did:key` producers — no resolver.**
585 ///
586 /// Runs the same RFC-ACDP-0003 §2.1 pipeline as
587 /// [`Self::publish_verified`], but performs steps 7–8 via the pure
588 /// did:key verifier
589 /// ([`acdp_verify::verify_publish_request_signature_offline`]),
590 /// so it is available without the `client` feature. Rejects
591 /// `did:web` (and any other method) producers with
592 /// `key_resolution_failed` — those need the resolver-backed
593 /// [`Self::publish_verified`].
594 ///
595 /// The capabilities gate still applies: the request is refused
596 /// unless `supported_did_methods` includes `"did:key"`.
597 pub fn publish_verified_did_key(
598 &self,
599 req: &PublishRequest,
600 idempotency_key: Option<&str>,
601 ) -> Result<PublishResponse, AcdpError> {
602 self.publish_verified_did_key_in_tenant(req, idempotency_key, None)
603 }
604
605 /// Like [`Self::publish_verified_did_key`] but binds the publish to a
606 /// tenant so a multi-tenant store persists `tenant_id` atomically with
607 /// the context row — the same contract as
608 /// [`Self::publish_verified_in_tenant`].
609 ///
610 /// `tenant = None` behaves identically to
611 /// [`Self::publish_verified_did_key`] — but note this method returns the
612 /// commit OUTCOME, not a bare `PublishResponse`, so it is not a drop-in
613 /// for it. The bare-response equivalent is
614 /// [`Self::publish_verified_did_key_in_tenant`].
615 #[cfg_attr(
616 feature = "tracing",
617 tracing::instrument(
618 name = "acdp.publish_verified_did_key",
619 skip_all,
620 fields(
621 agent_id = req.agent_id.as_str(),
622 version = req.version,
623 idempotency_key = idempotency_key.is_some(),
624 ),
625 err(Display)
626 )
627 )]
628 pub fn publish_verified_did_key_in_tenant_with_outcome(
629 &self,
630 req: &PublishRequest,
631 idempotency_key: Option<&str>,
632 tenant: Option<&str>,
633 ) -> Result<crate::registry::store::PublishCommitOutcome, AcdpError> {
634 let proven = self.prove_publish_identity_did_key(req)?;
635 self.commit_proven(proven, idempotency_key, tenant)
636 }
637
638 /// **Prove** a did:key producer's identity for `req` — RFC-ACDP-0003
639 /// §2.1 steps 1–8, pure (no resolver, no network) — without
640 /// persisting anything. Pair with [`Self::commit_proven`] to complete
641 /// the publish; the two together are exactly what
642 /// [`Self::publish_verified_did_key_in_tenant_with_outcome`] composes.
643 pub fn prove_publish_identity_did_key<'a>(
644 &self,
645 req: &'a PublishRequest,
646 ) -> Result<Proven<'a>, AcdpError> {
647 self.check_publish_rate_limit(&req.agent_id)?;
648
649 let raw_bytes = serde_json::to_vec(req)?.len();
650 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
651 let validated = validator.validate_post_schema(req, raw_bytes)?;
652
653 // Steps 7–8, pure: did:key resolution + signature verification.
654 acdp_verify::verify_publish_request_signature_offline(req)?;
655
656 // did:key fingerprints are derivable from the DID itself — no
657 // resolver needed for the receipt binding.
658 let fingerprint = if self.receipt_signer.is_some() {
659 let material = acdp_did::key::resolve_did_key(req.agent_id.as_str())?;
660 Some(acdp_crypto::fingerprint::fingerprint_did_key_material(
661 &material,
662 )?)
663 } else {
664 None
665 };
666
667 Ok(Proven {
668 req,
669 fingerprint,
670 recomputed_hash: validated.recomputed_hash,
671 authority: self.authority.clone(),
672 })
673 }
674
675 /// [`Self::publish_verified_did_key_in_tenant_with_outcome`] with the insert/replay
676 /// distinction discarded — see that method for the full contract, which is
677 /// otherwise identical. This form is a one-line delegate to it, so the two
678 /// cannot drift.
679 ///
680 /// Answering `POST /contexts` needs the twin: RFC-ACDP-0003 requires
681 /// `201 Created` + `Location` on a fresh publish and `200 OK` on a same-hash
682 /// retry (idem-002 says NOT 201), and this signature cannot express which
683 /// one happened.
684 pub fn publish_verified_did_key_in_tenant(
685 &self,
686 req: &PublishRequest,
687 idempotency_key: Option<&str>,
688 tenant: Option<&str>,
689 ) -> Result<PublishResponse, AcdpError> {
690 self.publish_verified_did_key_in_tenant_with_outcome(req, idempotency_key, tenant)
691 .map(|o| o.into_response())
692 }
693
694 /// **NOT RFC-conformant.** Skips DID resolution and signature
695 /// verification (RFC-ACDP-0003 §2.1 steps 7–8).
696 ///
697 /// Intended for integration tests where DID resolution would require
698 /// a live network or mock server. Production callers MUST use
699 /// [`Self::publish_verified`].
700 ///
701 /// Delegates to [`Self::publish_unverified_in_tenant_for_tests`] with
702 /// no idempotency key and no tenant; use that method directly for an
703 /// idempotent-replay or tenant-stamped test publish.
704 #[doc(hidden)]
705 pub fn publish_unverified_for_tests(
706 &self,
707 req: &PublishRequest,
708 ) -> Result<PublishResponse, AcdpError> {
709 self.publish_unverified_in_tenant_for_tests(req, None, None)
710 }
711
712 /// Like [`Self::publish_unverified_for_tests`] but additionally
713 /// accepts an idempotency key and a tenant, so tests can exercise the
714 /// idempotent-replay and tenant-stamping behavior of
715 /// [`Self::commit_via_store`] without resorting to store-level
716 /// workarounds. `(None, None)` is identical to
717 /// [`Self::publish_unverified_for_tests`].
718 ///
719 /// **NOT RFC-conformant** — same caveats as
720 /// [`Self::publish_unverified_for_tests`]: skips RFC-ACDP-0003 §2.1
721 /// steps 7–8 (DID resolution + signature verification). Production
722 /// callers MUST use [`Self::publish_verified_in_tenant`].
723 ///
724 /// Passing an idempotency key to a registry whose capabilities don't
725 /// advertise `supports_idempotency_key` is a silent no-op — mirrors
726 /// [`Self::publish_verified_in_tenant`]'s contract via the shared
727 /// [`Self::commit_via_store`] gate.
728 #[doc(hidden)]
729 pub fn publish_unverified_in_tenant_for_tests(
730 &self,
731 req: &PublishRequest,
732 idempotency_key: Option<&str>,
733 tenant: Option<&str>,
734 ) -> Result<PublishResponse, AcdpError> {
735 // Rate-limit gate fires here too — the limiter is intentionally
736 // wired BEFORE validation so it works as a defensive cap even
737 // when the test path is used.
738 self.check_publish_rate_limit(&req.agent_id)?;
739
740 // RFC-ACDP-0010 §7: a receipts-advertising registry has no
741 // degraded mode — every persisted context must carry a receipt,
742 // and minting here would attest a `key_fingerprint` that was
743 // never resolved. Refuse outright rather than persist a
744 // receipt-less context — from either unverified test entry
745 // point, since neither resolves a producer key.
746 if self.receipt_signer.is_some() {
747 return Err(AcdpError::SchemaViolation(
748 "publish_unverified_for_tests / publish_unverified_in_tenant_for_tests are \
749 unavailable on a receipts-advertising registry (RFC-ACDP-0010 §7: no \
750 degraded mode); use publish_verified or publish_verified_did_key"
751 .into(),
752 ));
753 }
754 // RFC-ACDP-0014 §5 step 2 is deliberately NOT extended here for
755 // a did:web signer: this method's entire contract (see its doc
756 // comment above) is to skip DID resolution + signature
757 // verification, so there is no resolved key — and no
758 // resolver — to fingerprint. `validate_post_schema` below still
759 // enforces the did:key sub-case offline (`KeyRevocation::from_parts`),
760 // since that needs no resolution either; a did:web self-revocation
761 // published through this test-only bypass is not caught until a
762 // conformant path re-verifies it.
763 let raw_bytes = serde_json::to_vec(req)?.len();
764 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
765 let _validated = validator.validate_post_schema(req, raw_bytes)?;
766 self.commit_via_store(req, idempotency_key, tenant, None)
767 .map(|o| o.into_response())
768 }
769
770 /// **Publish already verified by the caller against an
771 /// operator-pinned key** (e.g. a demo/playground registry's
772 /// out-of-band pinned-key allowlist — a config-supplied public key
773 /// checked instead of a live-resolved DID document).
774 ///
775 /// Unlike [`Self::publish_unverified_for_tests`], this is safe to call
776 /// on a receipts-advertising registry: the caller has ALREADY
777 /// cryptographically verified `req`'s signature against
778 /// `verified_public_key_b64` before calling this method (steps 7–8 are
779 /// the caller's responsibility, not this method's — there is no DID
780 /// document or did:key to resolve for a pinned key, so this crate has
781 /// nothing further to verify), so the fingerprint of that key can be
782 /// attested in the minted receipt (RFC-ACDP-0010 §7: no degraded mode,
783 /// every persisted context must carry a receipt with a genuinely
784 /// resolved key_fingerprint).
785 ///
786 /// `verified_algorithm` MUST be `"ed25519"` or `"ecdsa-p256"` and MUST
787 /// be the algorithm the caller actually verified `verified_public_key_b64`
788 /// against — this method trusts the caller completely for verification;
789 /// it does not re-verify the signature itself, only recomputes the
790 /// fingerprint of the key the caller names.
791 pub fn publish_pinned_verified_in_tenant_with_outcome(
792 &self,
793 req: &PublishRequest,
794 idempotency_key: Option<&str>,
795 tenant: Option<&str>,
796 verified_public_key_b64: &str,
797 verified_algorithm: &str,
798 ) -> Result<crate::registry::store::PublishCommitOutcome, AcdpError> {
799 let proven =
800 self.prove_publish_identity_pinned(req, verified_public_key_b64, verified_algorithm)?;
801 self.commit_proven(proven, idempotency_key, tenant)
802 }
803
804 /// **Prove** an operator-pinned-key producer's identity for `req` —
805 /// RFC-ACDP-0003 §2.1 steps 1–6 plus the RFC-ACDP-0014 §5 step 2
806 /// self-revocation check (steps 7–8 are the CALLER's responsibility —
807 /// see the doc comment on
808 /// [`Self::publish_pinned_verified_in_tenant_with_outcome`]) — without
809 /// persisting anything. Pair with [`Self::commit_proven`] to complete
810 /// the publish; the two together are exactly what
811 /// [`Self::publish_pinned_verified_in_tenant_with_outcome`] composes.
812 pub fn prove_publish_identity_pinned<'a>(
813 &self,
814 req: &'a PublishRequest,
815 verified_public_key_b64: &str,
816 verified_algorithm: &str,
817 ) -> Result<Proven<'a>, AcdpError> {
818 self.check_publish_rate_limit(&req.agent_id)?;
819
820 let raw_bytes = serde_json::to_vec(req)?.len();
821 let validator = PublishValidator::for_authority(&self.caps, &self.authority);
822 let validated = validator.validate_post_schema(req, raw_bytes)?;
823
824 // RFC-ACDP-0014 §5 step 2 applies here too, and at no extra
825 // resolution cost: `verified_public_key_b64` is the key the
826 // *caller* already verified the signature against — there is
827 // no DID document to fetch, so fingerprinting it is a pure,
828 // local computation regardless of receipt minting. Unlike the
829 // did:web hook in `prove_publish_identity`, this adds no new I/O.
830 let revocation_check_needed = req.context_type.is_key_revocation()
831 && key_revocation_gate_applies(&self.caps.acdp_version);
832
833 let fingerprint = if self.receipt_signer.is_some() || revocation_check_needed {
834 Some(fingerprint_pinned_key(
835 verified_public_key_b64,
836 verified_algorithm,
837 )?)
838 } else {
839 None
840 };
841
842 if revocation_check_needed {
843 // See the identical guard in `prove_publish_identity` for why
844 // this is `ok_or_else` rather than `expect`: the `Some`-ness
845 // of `fingerprint` here depends on the `if` a few lines above
846 // matching this same `revocation_check_needed`, a non-local
847 // invariant that shouldn't panic the publish path if it's
848 // ever broken by a future edit.
849 let fp = fingerprint.as_deref().ok_or_else(|| {
850 AcdpError::RegistryInternal(
851 "key-revocation fingerprint missing despite revocation_check_needed \
852 — this is an internal invariant violation, not a caller error"
853 .into(),
854 )
855 })?;
856 KeyRevocation::check_not_self_signed_lenient(req, fp)?;
857 }
858
859 Ok(Proven {
860 req,
861 fingerprint,
862 recomputed_hash: validated.recomputed_hash,
863 authority: self.authority.clone(),
864 })
865 }
866
867 /// [`Self::publish_pinned_verified_in_tenant_with_outcome`] with the insert/replay
868 /// distinction discarded — see that method for the full contract, which is
869 /// otherwise identical. This form is a one-line delegate to it, so the two
870 /// cannot drift.
871 ///
872 /// `#[doc(hidden)]`, as it was before the twin was split out — splitting a
873 /// method must not quietly publish a deliberately hidden one. Its twin is
874 /// NOT hidden, and that asymmetry is deliberate: a registry front-end has
875 /// to call the twin to answer `201` vs `200` correctly, so it needs to be
876 /// discoverable in rustdoc and tracked by `cargo semver-checks`.
877 ///
878 /// Answering `POST /contexts` needs the twin: RFC-ACDP-0003 requires
879 /// `201 Created` + `Location` on a fresh publish and `200 OK` on a same-hash
880 /// retry (idem-002 says NOT 201), and this signature cannot express which
881 /// one happened.
882 #[doc(hidden)]
883 pub fn publish_pinned_verified_in_tenant(
884 &self,
885 req: &PublishRequest,
886 idempotency_key: Option<&str>,
887 tenant: Option<&str>,
888 verified_public_key_b64: &str,
889 verified_algorithm: &str,
890 ) -> Result<PublishResponse, AcdpError> {
891 self.publish_pinned_verified_in_tenant_with_outcome(
892 req,
893 idempotency_key,
894 tenant,
895 verified_public_key_b64,
896 verified_algorithm,
897 )
898 .map(|o| o.into_response())
899 }
900
901 /// Rate-limit gate shared by every publish path (RFC-ACDP-0008 §4.3).
902 /// Under the `tracing` feature a rejection emits a structured warn
903 /// event so operators can see limiter hits per agent.
904 fn check_publish_rate_limit(
905 &self,
906 agent_id: &acdp_types::primitives::AgentDid,
907 ) -> Result<(), AcdpError> {
908 match self.rate_limiter.check_publish(agent_id) {
909 Ok(()) => Ok(()),
910 Err(e) => {
911 #[cfg(feature = "tracing")]
912 tracing::warn!(
913 agent_id = agent_id.as_str(),
914 "publish rejected by rate limiter"
915 );
916 Err(e)
917 }
918 }
919 }
920
921 /// Drive `RegistryStore::commit_publish` from a validated request.
922 ///
923 /// Returns the `PublishCommitOutcome` **whole**. It used to unwrap both
924 /// variants to the same `PublishResponse` here, on the grounds that the
925 /// distinction "only matters internally for logging/tracing" — that was
926 /// wrong. A registry front-end needs it to answer `201 Created` on a
927 /// fresh publish and `200 OK` on an idempotent replay, and flattening it
928 /// at this layer made that undecidable for every caller: three of the
929 /// four publish paths in `acdp-registry-rs` had no way to tell a replay
930 /// from an insert and would have answered `201` to both.
931 ///
932 /// Callers that genuinely do not care unwrap with
933 /// [`PublishCommitOutcome::into_response`].
934 fn commit_via_store(
935 &self,
936 req: &PublishRequest,
937 idempotency_key: Option<&str>,
938 tenant: Option<&str>,
939 producer_key_fingerprint: Option<String>,
940 ) -> Result<crate::registry::store::PublishCommitOutcome, AcdpError> {
941 let idempotency = if self.caps.supports_idempotency_key {
942 idempotency_key.map(|key| crate::registry::store::PendingIdempotencyCommit {
943 key,
944 ttl: chrono::Duration::seconds(
945 self.caps
946 .limits
947 .idempotency_key_ttl_seconds
948 .unwrap_or(86_400) as i64,
949 ),
950 })
951 } else {
952 None
953 };
954 // RFC-ACDP-0010 minting hook — runs inside the store's critical
955 // section so the receipt persists atomically with the context.
956 #[allow(clippy::type_complexity)]
957 let minter: Option<
958 Box<dyn Fn(&Body) -> Result<serde_json::Value, AcdpError> + Send + Sync>,
959 > = match (&self.receipt_signer, producer_key_fingerprint) {
960 (Some(signer), Some(fp)) => Some(Box::new(move |body: &Body| {
961 let receipt = signer.mint(
962 &body.ctx_id,
963 &body.lineage_id,
964 &body.origin_registry,
965 body.created_at,
966 &body.content_hash,
967 &fp,
968 )?;
969 serde_json::to_value(receipt).map_err(AcdpError::from)
970 })),
971 _ => None,
972 };
973 // Keyed on the COMMITTING server's own config, not on whether
974 // `minter` happened to build — `minter` also depends on
975 // `producer_key_fingerprint`, which a caller external to this
976 // method controls (see `commit_proven`'s own additional guard for
977 // the specific case this protects against: a `Proven` established
978 // against a different, differently-configured `RegistryServer`
979 // instance that merely shares this one's `authority` string). A
980 // receipts-advertising registry with no fingerprint to mint from
981 // must still fail the §7 check below, not silently skip it.
982 let minted_expected = self.receipt_signer.is_some();
983
984 // RFC-ACDP-0014 §4 `supersedes`-row admission hook. `Some` iff
985 // the version gate is on AND this request actually carries a
986 // `supersedes` — the mandatory carry-forward from Phase 5:
987 // `check_revocation_supersession` has no internal version
988 // guard, so calling it unconditionally would reject a
989 // key-revocation-shaped body at e.g. acdp_version 0.2.0, where
990 // RFC §4:60 reserves that rejection for ≥0.3.0 registries.
991 //
992 // Captures `req` by reference (not `move`-owned data like
993 // `receipt_minter` above) so it can call
994 // `check_revocation_supersession(prev, req, acdp_version)` — the
995 // closure's hidden lifetime is exactly `req`'s, which the store
996 // threads through the same call as `PublishCommit::req`, so the
997 // two `'a`-tied fields agree. `acdp_version` is threaded through
998 // too (RFC-ACDP-0014 §10) so Arm 3 can pick its error code —
999 // `self.caps.acdp_version` outlives the closure via `self`.
1000 let admission_closure =
1001 if key_revocation_gate_applies(&self.caps.acdp_version) && req.supersedes.is_some() {
1002 Some(move |prev: &Body| {
1003 check_revocation_supersession(prev, req, &self.caps.acdp_version)
1004 })
1005 } else {
1006 None
1007 };
1008 #[allow(clippy::type_complexity)]
1009 let predecessor_admission: Option<
1010 &(dyn Fn(&Body) -> Result<(), AcdpError> + Send + Sync),
1011 > = admission_closure
1012 .as_ref()
1013 .map(|f| f as &(dyn Fn(&Body) -> Result<(), AcdpError> + Send + Sync));
1014
1015 let outcome = self
1016 .store
1017 .commit_publish(crate::registry::store::PublishCommit {
1018 req,
1019 authority: &self.authority,
1020 idempotency,
1021 tenant,
1022 receipt_minter: minter.as_deref(),
1023 predecessor_admission,
1024 })?;
1025 // Borrow rather than move: the tracing fields and the RFC-ACDP-0010
1026 // §7 check below both only read, and `outcome` is returned whole.
1027 let (response, replayed) = match &outcome {
1028 crate::registry::store::PublishCommitOutcome::Inserted(r) => (r, false),
1029 crate::registry::store::PublishCommitOutcome::IdempotentReplay(r) => (r, true),
1030 };
1031 #[cfg(feature = "tracing")]
1032 tracing::debug!(
1033 ctx_id = %response.ctx_id.0,
1034 lineage_id = %response.lineage_id.0,
1035 version = response.version,
1036 replayed,
1037 "publish committed"
1038 );
1039 // RFC-ACDP-0010 §7 belt-and-braces: a receipts-advertising
1040 // registry has no degraded mode. A store implementation that
1041 // ignores `receipt_minter` (e.g. compiled against the older
1042 // trait shape) must fail loudly here, not silently persist a
1043 // receipt-less context.
1044 //
1045 // Scoped to NEWLY INSERTED contexts only: an idempotent replay
1046 // returns the ORIGINAL publish response verbatim, and that
1047 // original may legitimately predate receipts (a record minted
1048 // before the registry enabled its signer, still inside the
1049 // idempotency TTL). Failing such a replay would turn a correct
1050 // producer retry into a 500 across the upgrade boundary — §7
1051 // attests what was persisted at publish time, not re-mint time.
1052 if minted_expected && !replayed && response.registry_receipt.is_none() {
1053 return Err(AcdpError::RegistryInternal(
1054 "receipt signer is configured but the store returned no receipt — \
1055 the RegistryStore implementation must invoke PublishCommit::receipt_minter \
1056 inside its commit (RFC-ACDP-0010 §7: no degraded mode)"
1057 .into(),
1058 ));
1059 }
1060 Ok(outcome)
1061 }
1062
1063 /// **Commit** a [`Proven`] publish — the second half of the
1064 /// `prove → commit` split (`prove_publish_identity*` /
1065 /// `commit_proven`, #273). Everything through RFC-ACDP-0003 §2.1
1066 /// steps 1–8 (and the RFC-ACDP-0014 §5 step 2 self-revocation check,
1067 /// where applicable) already passed to produce `proven` — this call
1068 /// runs the same atomic store commit
1069 /// (idempotency lookup, predecessor verification, insertion,
1070 /// predecessor-supersession marking) that
1071 /// [`Self::publish_verified_in_tenant_with_outcome`] and its siblings
1072 /// have always run, just reachable without re-deriving identity.
1073 ///
1074 /// Rejects a `proven` minted for a different registry authority with
1075 /// [`AcdpError::RegistryInternal`] rather than silently committing
1076 /// under the wrong one — a `Proven` carries the authority it was
1077 /// proved against specifically so this cross-registry mismatch is
1078 /// cheap to catch here, before persistence.
1079 pub fn commit_proven(
1080 &self,
1081 proven: Proven<'_>,
1082 idempotency_key: Option<&str>,
1083 tenant: Option<&str>,
1084 ) -> Result<crate::registry::store::PublishCommitOutcome, AcdpError> {
1085 if proven.authority != self.authority {
1086 return Err(AcdpError::RegistryInternal(format!(
1087 "commit_proven: Proven was established against authority '{}', but this \
1088 registry's authority is '{}' — refusing to commit a proof against the \
1089 wrong registry",
1090 proven.authority, self.authority
1091 )));
1092 }
1093 // The authority string alone is not a full registry-instance
1094 // identity check: two `RegistryServer`s can legitimately (or by
1095 // misconfiguration) share an `authority` while differing in
1096 // `receipt_signer`. Catch the dangerous direction explicitly —
1097 // proving against a signer-less instance, then committing on a
1098 // receipts-advertising one — here, with a message that names the
1099 // actual mistake, rather than relying solely on the generic §7
1100 // belt-and-braces check inside `commit_via_store` to catch it.
1101 if self.receipt_signer.is_some() && proven.fingerprint.is_none() {
1102 return Err(AcdpError::RegistryInternal(
1103 "commit_proven: this registry requires receipts (a receipt_signer is \
1104 configured) but the Proven carries no producer key fingerprint — it was \
1105 most likely established via a different RegistryServer instance (one with \
1106 no receipt_signer configured) that happens to share this one's authority; \
1107 refusing to commit rather than silently persist a receipt-less context \
1108 (RFC-ACDP-0010 §7: no degraded mode)"
1109 .into(),
1110 ));
1111 }
1112 debug_assert_eq!(
1113 proven.recomputed_hash, proven.req.content_hash,
1114 "Proven's recomputed_hash must always equal its request's content_hash by \
1115 construction — prove_publish_identity* fails closed with HashMismatch before \
1116 a Proven can exist otherwise"
1117 );
1118 self.commit_via_store(proven.req, idempotency_key, tenant, proven.fingerprint)
1119 }
1120
1121 /// `GET /contexts/{ctx_id}`.
1122 ///
1123 /// Applies the RFC-ACDP-0008 §4.5 disclosure rules:
1124 ///
1125 /// | Visibility | Authorized requester for retrieval |
1126 /// |--------------|-----------------------------------------------------|
1127 /// | `public` | anyone (when `caps.anonymous_public_reads` is true) |
1128 /// | `restricted` | producer (`agent_id`) **or** any DID in `audience` |
1129 /// | `private` | producer (`agent_id`) **or** any DID in `audience` |
1130 ///
1131 /// Returns `Ok(None)` (not `Err`) for unauthorized callers — prevents
1132 /// existence leakage via error codes.
1133 pub fn retrieve(
1134 &self,
1135 ctx_id: &CtxId,
1136 requester: Option<&AgentDid>,
1137 ) -> Result<Option<FullContext>, AcdpError> {
1138 let Some(ctx) = self.store.get(ctx_id)? else {
1139 return Ok(None);
1140 };
1141 if !can_retrieve(&ctx.body, requester, &self.caps) {
1142 return Ok(None);
1143 }
1144 Ok(Some(ctx))
1145 }
1146
1147 /// `GET /contexts/{ctx_id}/body`. See [`Self::retrieve`] for visibility rules.
1148 pub fn retrieve_body(
1149 &self,
1150 ctx_id: &CtxId,
1151 requester: Option<&AgentDid>,
1152 ) -> Result<Option<Body>, AcdpError> {
1153 Ok(self.retrieve(ctx_id, requester)?.map(|c| c.body))
1154 }
1155
1156 /// `GET /lineages/{lineage_id}`.
1157 ///
1158 /// BUG-03: applies the same visibility filter as `retrieve`. A
1159 /// caller who knows or guesses a `lineage_id` must not be able to
1160 /// surface restricted or private bodies through the lineage
1161 /// endpoint when `retrieve(ctx_id, requester)` would deny them.
1162 pub fn lineage(
1163 &self,
1164 lineage_id: &LineageId,
1165 requester: Option<&AgentDid>,
1166 ) -> Result<Vec<FullContext>, AcdpError> {
1167 let all = self.store.lineage(lineage_id)?;
1168 Ok(all
1169 .into_iter()
1170 .filter(|ctx| can_retrieve(&ctx.body, requester, &self.caps))
1171 .collect())
1172 }
1173
1174 /// `GET /lineages/{lineage_id}/current`.
1175 ///
1176 /// BUG-03 + BUG-04: returns the newest version visible to the
1177 /// requester that is neither `Superseded` nor `Retracted` (a
1178 /// retracted version is NEVER a head — RFC-ACDP-0013 §8.3, fixture
1179 /// `lc-003`; contrast `Expired`, which remains a servable head).
1180 /// `None` when the lineage is unknown, when every version is
1181 /// superseded or retracted (RFC-ACDP-0004 §5 as amended), or when
1182 /// no visible version exists. Because head selection excludes
1183 /// retracted versions, a lineage-head receipt can never name a
1184 /// retracted head (RFC-ACDP-0011 §4 as amended; the signer's mint
1185 /// refusal is the backstop).
1186 ///
1187 /// When the registry advertises `acdp-registry-head-receipts`
1188 /// ([`Self::with_lineage_head_receipts`]), the response carries a
1189 /// freshly minted lineage-head receipt (RFC-ACDP-0011 §6 rule 1:
1190 /// REQUIRED on `/current`, no degraded mode). Because the head is
1191 /// resolved *after* visibility filtering, the receipt attests the
1192 /// head as visible to this requester (§4: never an existence leak).
1193 pub fn current(
1194 &self,
1195 lineage_id: &LineageId,
1196 requester: Option<&AgentDid>,
1197 ) -> Result<Option<FullContext>, AcdpError> {
1198 let all = self.store.lineage(lineage_id)?;
1199 // `lineage` returns versions ordered from v1 → vN; iterate in
1200 // reverse to find the newest non-superseded version. `Active`
1201 // and `Expired` both qualify as valid current heads (a body
1202 // that expired without being superseded is still the latest
1203 // and the consumer needs to see it to know it has lapsed).
1204 for mut ctx in all.into_iter().rev() {
1205 if !matches!(
1206 ctx.registry_state.status,
1207 Status::Superseded | Status::Retracted
1208 ) && can_retrieve(&ctx.body, requester, &self.caps)
1209 {
1210 if self.mint_head_receipts {
1211 // RFC-ACDP-0011 §6: as_of is the registry's clock at
1212 // response time (ms-truncated by the signer); the
1213 // head fields are exactly the served response's, so
1214 // the §7 step 5 byte-match holds by construction.
1215 let signer = self.receipt_signer.as_ref().ok_or_else(|| {
1216 AcdpError::RegistryInternal(
1217 "head-receipt minting enabled without a receipt signer \
1218 (RFC-ACDP-0011 §9 prerequisite violated)"
1219 .into(),
1220 )
1221 })?;
1222 let receipt = signer.mint_lineage_head(
1223 lineage_id,
1224 &ctx.body.ctx_id,
1225 ctx.body.version,
1226 &ctx.registry_state.status,
1227 chrono::Utc::now(),
1228 )?;
1229 ctx.lineage_head_receipt = Some(serde_json::to_value(receipt)?);
1230 }
1231 return Ok(Some(ctx));
1232 }
1233 }
1234 Ok(None)
1235 }
1236
1237 /// `GET /contexts/search`.
1238 ///
1239 /// Applies the RFC-ACDP-0008 §4.5 search disclosure rules (note the
1240 /// asymmetry vs retrieval): private contexts surface in search only
1241 /// to their producer (audience members must already know the ctx_id).
1242 ///
1243 /// When `caps.anonymous_public_reads` is `false`, an anonymous search
1244 /// request is rejected outright with [`AcdpError::NotAuthorized`]
1245 /// (HTTP 403) rather than returning an empty `200`. An empty result
1246 /// set would still leak the registry's existence and confirm that
1247 /// the keyword query ran; the required response is `not_authorized`
1248 /// (RFC-ACDP-0005 §2.5.5, RFC-ACDP-0008 §6.3, fixture `vis-009`).
1249 pub fn search(
1250 &self,
1251 params: &SearchParams,
1252 requester: Option<&AgentDid>,
1253 ) -> Result<SearchResponse, AcdpError> {
1254 // BUG-01 + vis-009: reject anonymous search when the registry
1255 // does not allow anonymous reads. An empty 200 would still leak
1256 // the registry's existence (and that the query executed); the
1257 // normative response is 403 not_authorized.
1258 if requester.is_none() && !self.caps.anonymous_public_reads {
1259 return Err(AcdpError::NotAuthorized(
1260 "anonymous search requires authentication \
1261 (registry caps: anonymous_public_reads=false)"
1262 .into(),
1263 ));
1264 }
1265 // BUG-02: pass `anonymous_public_reads` to the store so search
1266 // and retrieve agree. A registry advertising the flag as false
1267 // MUST suppress public contexts for anonymous callers in BOTH
1268 // endpoints (RFC-ACDP-0008 §4.5).
1269 self.store
1270 .search(params, requester, self.caps.anonymous_public_reads)
1271 }
1272
1273 // ── Lifecycle events & retraction (ACDP 0.3, RFC-ACDP-0013 §6) ──────
1274 //
1275 // The logical handlers behind `POST /contexts/{ctx_id}/retract` and
1276 // `POST /contexts/{ctx_id}/republish`. An HTTP binding layer should
1277 // first run the raw request body through
1278 // [`crate::registry::lifecycle::parse_lifecycle_request`] (the
1279 // closed-envelope / `immutable_field` check of §6 step 2, fixture
1280 // `lc-002`), then hand the parsed event here. The typed
1281 // [`acdp_types::lifecycle::LifecycleEvent`] round-trips
1282 // byte-identically, so signature verification over its
1283 // re-serialization equals verification over the received bytes.
1284
1285 /// §6 steps 1–3, shared by both endpoints and all verification
1286 /// modes (signature *verification* itself is the caller's step —
1287 /// it differs by DID method):
1288 ///
1289 /// 1. **Visibility first** (RFC-ACDP-0008 §4.5): a context the
1290 /// requester could not retrieve yields `not_found` — lifecycle
1291 /// endpoints never leak existence, and error ordering never lets
1292 /// an unauthorized caller distinguish "exists but not yours".
1293 /// 2. **Event validation**: closed §4 semantics, the
1294 /// endpoint-binding rule (`retracted` on `/retract`,
1295 /// `republished` on `/republish` — which also excludes every
1296 /// unregistered `event_type`, §7.3), and the future-`occurred_at`
1297 /// rejection (120 s skew allowance, §4).
1298 /// 3. **Actor authentication**: `actor` MUST equal `body.agent_id`
1299 /// (`not_authorized` — the supersession rule of RFC-ACDP-0003
1300 /// §3.1 step 3; delegation remains out of scope) and the event
1301 /// MUST be signed (`schema_violation` when missing, §5).
1302 ///
1303 /// Returns the resolved context for the caller's verification step.
1304 fn lifecycle_precheck(
1305 &self,
1306 event: &acdp_types::lifecycle::LifecycleEvent,
1307 expected_type: &acdp_types::lifecycle::LifecycleEventType,
1308 requester: Option<&AgentDid>,
1309 ) -> Result<FullContext, AcdpError> {
1310 if !self.lifecycle_enabled {
1311 return Err(AcdpError::NotImplemented(
1312 "this registry does not advertise acdp-registry-lifecycle \
1313 (RFC-ACDP-0013 §6: lifecycle endpoints are not implemented)"
1314 .into(),
1315 ));
1316 }
1317 // Step 1 — resolve + visibility before ANY other check.
1318 let ctx = self
1319 .retrieve(&event.ctx_id, requester)?
1320 .ok_or_else(|| AcdpError::NotFound(format!("context '{}' not found", event.ctx_id)))?;
1321 // Step 2 — event validation.
1322 event.validate()?;
1323 if &event.event_type != expected_type {
1324 return Err(AcdpError::SchemaViolation(format!(
1325 "event_type '{}' does not match this endpoint (expected '{}', \
1326 RFC-ACDP-0013 §6 step 2)",
1327 event.event_type, expected_type
1328 )));
1329 }
1330 let now = chrono::Utc::now();
1331 if event.occurred_at > now + chrono::Duration::seconds(120) {
1332 return Err(AcdpError::SchemaViolation(format!(
1333 "event occurred_at '{}' is in the future beyond the 120s skew allowance \
1334 (RFC-ACDP-0013 §4)",
1335 event.occurred_at.format("%Y-%m-%dT%H:%M:%S%.3fZ")
1336 )));
1337 }
1338 // Step 3 — actor authentication.
1339 if event.actor != ctx.body.agent_id {
1340 return Err(AcdpError::NotAuthorized(format!(
1341 "event actor '{}' is not the context's producer — only the producer \
1342 (agent_id) may use the lifecycle endpoints (RFC-ACDP-0013 §6 step 3)",
1343 event.actor
1344 )));
1345 }
1346 // Producer-initiated events MUST be signed (§5); presence and
1347 // the key_id-DID == actor binding are checked here, the
1348 // cryptographic verification by the caller.
1349 event.actor_bound_signature()?;
1350 Ok(ctx)
1351 }
1352
1353 /// §6 steps 4–5: atomic transition + append via the store, mapping
1354 /// both outcomes (fresh append, byte-identical idempotent retry) to
1355 /// the post-transition full-retrieval envelope.
1356 fn lifecycle_commit(
1357 &self,
1358 event: &acdp_types::lifecycle::LifecycleEvent,
1359 ) -> Result<FullContext, AcdpError> {
1360 Ok(self.store.commit_lifecycle_event(event)?.into_context())
1361 }
1362
1363 /// Shared verified pipeline for both endpoints (resolver-backed).
1364 #[cfg(feature = "client")]
1365 async fn lifecycle_transition_verified(
1366 &self,
1367 event: &acdp_types::lifecycle::LifecycleEvent,
1368 expected_type: acdp_types::lifecycle::LifecycleEventType,
1369 requester: Option<&AgentDid>,
1370 resolver: &acdp_did::WebResolver,
1371 ) -> Result<FullContext, AcdpError> {
1372 let ctx = self.lifecycle_precheck(event, &expected_type, requester)?;
1373 // Full RFC-ACDP-0001 §5.11 pipeline over the event hash
1374 // (RFC-ACDP-0013 §5): resolution, assertionMethod, algorithm
1375 // binding, SSRF protections — the same pipeline as a publish.
1376 acdp_verify::verify_lifecycle_event(
1377 &serde_json::to_value(event)?,
1378 &event.ctx_id,
1379 &ctx.body.agent_id,
1380 None, // producer-only: registry events do not use the endpoints
1381 resolver,
1382 )
1383 .await?;
1384 self.lifecycle_commit(event)
1385 }
1386
1387 /// Shared verified pipeline for did:key producers — no resolver.
1388 fn lifecycle_transition_verified_did_key(
1389 &self,
1390 event: &acdp_types::lifecycle::LifecycleEvent,
1391 expected_type: acdp_types::lifecycle::LifecycleEventType,
1392 requester: Option<&AgentDid>,
1393 ) -> Result<FullContext, AcdpError> {
1394 let ctx = self.lifecycle_precheck(event, &expected_type, requester)?;
1395 acdp_verify::verify_lifecycle_event_offline(
1396 &serde_json::to_value(event)?,
1397 &event.ctx_id,
1398 &ctx.body.agent_id,
1399 None,
1400 )?;
1401 self.lifecycle_commit(event)
1402 }
1403
1404 /// **RFC-conformant retraction** — `POST /contexts/{ctx_id}/retract`
1405 /// (RFC-ACDP-0013 §6). Runs the full §6 pipeline: visibility, event
1406 /// validation (`retracted` on this endpoint), actor authentication,
1407 /// signature verification through the RFC-ACDP-0001 §5.11 resolver
1408 /// pipeline, strict-alternation transition validation, and the
1409 /// atomic append. Returns the post-transition full-retrieval
1410 /// envelope (`status: retracted`, event appended) — or the current
1411 /// state unchanged on a byte-identical `event_id` retry.
1412 ///
1413 /// Retraction is **mark-not-delete**: the body remains retrievable
1414 /// (§8.1), falls out of default searches (§8.2), and is never
1415 /// served from `/current` (§8.3).
1416 #[cfg(feature = "client")]
1417 pub async fn retract_verified(
1418 &self,
1419 event: &acdp_types::lifecycle::LifecycleEvent,
1420 requester: Option<&AgentDid>,
1421 resolver: &acdp_did::WebResolver,
1422 ) -> Result<FullContext, AcdpError> {
1423 self.lifecycle_transition_verified(
1424 event,
1425 acdp_types::lifecycle::LifecycleEventType::Retracted,
1426 requester,
1427 resolver,
1428 )
1429 .await
1430 }
1431
1432 /// **RFC-conformant republication** — `POST
1433 /// /contexts/{ctx_id}/republish` (RFC-ACDP-0013 §6): reverses a
1434 /// prior retraction. `status` re-derives per RFC-ACDP-0004 §4 as
1435 /// though the retraction had not occurred; both events remain in
1436 /// the append-only history. Same pipeline as
1437 /// [`Self::retract_verified`].
1438 #[cfg(feature = "client")]
1439 pub async fn republish_verified(
1440 &self,
1441 event: &acdp_types::lifecycle::LifecycleEvent,
1442 requester: Option<&AgentDid>,
1443 resolver: &acdp_did::WebResolver,
1444 ) -> Result<FullContext, AcdpError> {
1445 self.lifecycle_transition_verified(
1446 event,
1447 acdp_types::lifecycle::LifecycleEventType::Republished,
1448 requester,
1449 resolver,
1450 )
1451 .await
1452 }
1453
1454 /// [`Self::retract_verified`] for `did:key` producers — the §5
1455 /// signature verification is pure (the DID is the key), so this is
1456 /// available without the `client` feature. Rejects `did:web` (and
1457 /// any other method) actors with `key_resolution_failed`.
1458 pub fn retract_verified_did_key(
1459 &self,
1460 event: &acdp_types::lifecycle::LifecycleEvent,
1461 requester: Option<&AgentDid>,
1462 ) -> Result<FullContext, AcdpError> {
1463 self.lifecycle_transition_verified_did_key(
1464 event,
1465 acdp_types::lifecycle::LifecycleEventType::Retracted,
1466 requester,
1467 )
1468 }
1469
1470 /// [`Self::republish_verified`] for `did:key` producers.
1471 pub fn republish_verified_did_key(
1472 &self,
1473 event: &acdp_types::lifecycle::LifecycleEvent,
1474 requester: Option<&AgentDid>,
1475 ) -> Result<FullContext, AcdpError> {
1476 self.lifecycle_transition_verified_did_key(
1477 event,
1478 acdp_types::lifecycle::LifecycleEventType::Republished,
1479 requester,
1480 )
1481 }
1482
1483 /// **NOT RFC-conformant.** Skips signature verification (the §6
1484 /// step 3 cryptographic half; presence and actor binding are still
1485 /// enforced). Test-only, mirroring
1486 /// [`Self::publish_unverified_for_tests`].
1487 #[doc(hidden)]
1488 pub fn retract_unverified_for_tests(
1489 &self,
1490 event: &acdp_types::lifecycle::LifecycleEvent,
1491 requester: Option<&AgentDid>,
1492 ) -> Result<FullContext, AcdpError> {
1493 self.lifecycle_precheck(
1494 event,
1495 &acdp_types::lifecycle::LifecycleEventType::Retracted,
1496 requester,
1497 )?;
1498 self.lifecycle_commit(event)
1499 }
1500
1501 /// **NOT RFC-conformant.** See [`Self::retract_unverified_for_tests`].
1502 #[doc(hidden)]
1503 pub fn republish_unverified_for_tests(
1504 &self,
1505 event: &acdp_types::lifecycle::LifecycleEvent,
1506 requester: Option<&AgentDid>,
1507 ) -> Result<FullContext, AcdpError> {
1508 self.lifecycle_precheck(
1509 event,
1510 &acdp_types::lifecycle::LifecycleEventType::Republished,
1511 requester,
1512 )?;
1513 self.lifecycle_commit(event)
1514 }
1515
1516 /// Record a **registry-initiated** lifecycle event (RFC-ACDP-0013
1517 /// §6: deployment policy, legal compulsion). Does NOT use the
1518 /// producer endpoints or their actor rule: `actor` MUST equal the
1519 /// registry's own DID (`capabilities.registry_did`). Subject to the
1520 /// same append-only, uniqueness, transition, and shape rules; the
1521 /// event SHOULD be signed under a key in the registry's DID
1522 /// document (a registry advertising `acdp-registry-receipts` MUST
1523 /// sign — enforced here when a receipt signer is configured, per
1524 /// the §5 same-key precedent). This is the protocol-visible form of
1525 /// "removed by policy": the body stays served, the withdrawal is
1526 /// explicit and attributed.
1527 pub fn record_registry_lifecycle_event(
1528 &self,
1529 event: &acdp_types::lifecycle::LifecycleEvent,
1530 ) -> Result<FullContext, AcdpError> {
1531 if !self.lifecycle_enabled {
1532 return Err(AcdpError::NotImplemented(
1533 "this registry does not advertise acdp-registry-lifecycle \
1534 (RFC-ACDP-0013 §6)"
1535 .into(),
1536 ));
1537 }
1538 event.validate()?;
1539 if !event.event_type.is_registered() {
1540 return Err(AcdpError::SchemaViolation(format!(
1541 "event_type '{}' is not registered for acceptance in 0.3.0 \
1542 (RFC-ACDP-0013 §7.3)",
1543 event.event_type
1544 )));
1545 }
1546 if event.actor.as_str() != self.caps.registry_did {
1547 return Err(AcdpError::NotAuthorized(format!(
1548 "registry-initiated event actor '{}' ≠ this registry's DID '{}' \
1549 (RFC-ACDP-0013 §6)",
1550 event.actor, self.caps.registry_did
1551 )));
1552 }
1553 if self.receipt_signer.is_some() && !event.is_signed() {
1554 return Err(AcdpError::SchemaViolation(
1555 "a registry advertising acdp-registry-receipts MUST sign its lifecycle \
1556 events (RFC-ACDP-0013 §5)"
1557 .into(),
1558 ));
1559 }
1560 if event.is_signed() {
1561 // §5 actor binding for the registry key.
1562 event.actor_bound_signature()?;
1563 }
1564 self.lifecycle_commit(event)
1565 }
1566}
1567
1568/// RFC-ACDP-0008 §4.5 retrieval disclosure rule.
1569pub(crate) fn can_retrieve(
1570 body: &Body,
1571 requester: Option<&AgentDid>,
1572 caps: &CapabilitiesDocument,
1573) -> bool {
1574 match body.visibility {
1575 Visibility::Public => caps.anonymous_public_reads || requester.is_some(),
1576 Visibility::Restricted | Visibility::Private => match requester {
1577 None => false,
1578 Some(r) => {
1579 r == &body.agent_id
1580 || body
1581 .audience
1582 .as_deref()
1583 .is_some_and(|a| a.iter().any(|d| d == r))
1584 }
1585 },
1586 }
1587}
1588
1589/// Resolve and fingerprint the producer key named by
1590/// `signature.key_id` — the binding recorded in a receipt's
1591/// `key_fingerprint` (RFC-ACDP-0010), and (as of RFC-ACDP-0014 §5 step 2)
1592/// also checked against a did:web key-revocation's own
1593/// `revoked_key_fingerprint` in [`RegistryServer::publish_verified_in_tenant`].
1594/// Delegates to the same
1595/// [`acdp_crypto::fingerprint::fingerprint_for_key_id`] the consumer
1596/// cross-check uses, so mint-time and verify-time fingerprints cannot
1597/// drift. Callers MUST invoke this only after
1598/// `verify_publish_request_signature` succeeded, so the fingerprinted
1599/// key is the one that actually verified (the resolver's cache makes a
1600/// second resolution — receipt minting and/or the §5 step 2 check on
1601/// the same request — cheap, and `publish_verified_in_tenant` computes
1602/// it at most once per publish either way).
1603#[cfg(feature = "client")]
1604async fn producer_key_fingerprint(
1605 req: &PublishRequest,
1606 resolver: &acdp_did::WebResolver,
1607) -> Result<String, AcdpError> {
1608 acdp_crypto::fingerprint::fingerprint_for_key_id(
1609 &req.signature.key_id,
1610 &req.signature.algorithm,
1611 resolver,
1612 )
1613 .await
1614}
1615
1616/// Fingerprint a base64 public key the caller has already verified a
1617/// signature against (an operator-pinned key, not a resolved DID
1618/// document) — no resolution involved, just decode + dispatch by
1619/// algorithm. Used by [`RegistryServer::publish_pinned_verified_in_tenant`].
1620fn fingerprint_pinned_key(public_key_b64: &str, algorithm: &str) -> Result<String, AcdpError> {
1621 use base64::{engine::general_purpose::STANDARD, Engine};
1622
1623 let raw = STANDARD
1624 .decode(public_key_b64)
1625 .map_err(|e| AcdpError::KeyResolution(format!("pinned key is not valid base64: {e}")))?;
1626 match algorithm {
1627 "ed25519" => {
1628 let arr: [u8; 32] = raw.as_slice().try_into().map_err(|_| {
1629 AcdpError::KeyResolution(format!(
1630 "pinned ed25519 key must be 32 bytes, got {}",
1631 raw.len()
1632 ))
1633 })?;
1634 Ok(acdp_crypto::fingerprint::fingerprint_ed25519(&arr))
1635 }
1636 "ecdsa-p256" => acdp_crypto::fingerprint::fingerprint_p256_sec1(&raw),
1637 other => Err(AcdpError::UnsupportedAlgorithm(format!(
1638 "cannot fingerprint a pinned key for algorithm '{other}'"
1639 ))),
1640 }
1641}
1642
1643#[cfg(test)]
1644mod tests {
1645 use super::*;
1646 use crate::registry::store::{InMemoryStore, PublishCommitOutcome};
1647 use acdp_crypto::SigningKey;
1648 use acdp_producer::Producer;
1649 use acdp_types::capabilities::Limits;
1650 use acdp_types::primitives::{AgentDid, ContextType, Visibility};
1651
1652 fn caps() -> CapabilitiesDocument {
1653 CapabilitiesDocument {
1654 acdp_version: "0.1.0".into(),
1655 registry_did: "did:web:registry.example.com".into(),
1656 supported_signature_algorithms: vec!["ed25519".into()],
1657 supported_did_methods: vec!["did:web".into()],
1658 profiles: vec!["acdp-registry-core".into()],
1659 limits: Limits {
1660 max_payload_bytes: 1_048_576,
1661 max_embedded_bytes: 65_536,
1662 idempotency_key_ttl_seconds: None,
1663 max_publish_per_minute: None,
1664 },
1665 read_authentication_methods: vec![],
1666 anonymous_public_reads: true,
1667 supports_idempotency_key: false,
1668 extensions: Default::default(),
1669 }
1670 }
1671
1672 fn producer() -> Producer {
1673 Producer::new(
1674 SigningKey::from_bytes(&[1u8; 32]),
1675 AgentDid::new("did:web:agents.example.com:test"),
1676 "did:web:agents.example.com:test#key-1",
1677 )
1678 }
1679
1680 #[test]
1681 fn publish_v1_then_retrieve() {
1682 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1683 let p = producer();
1684 let req = p
1685 .publish_request()
1686 .title("v1")
1687 .context_type(ContextType::DataSnapshot)
1688 .visibility(Visibility::Public)
1689 .build()
1690 .unwrap();
1691 let resp = server.publish_unverified_for_tests(&req).unwrap();
1692 assert_eq!(resp.version, 1);
1693 let ctx = server.retrieve(&resp.ctx_id, None).unwrap().unwrap();
1694 assert_eq!(ctx.body.title, "v1");
1695 // Lineage round-trip
1696 let lineage = server.lineage(&resp.lineage_id, None).unwrap();
1697 assert_eq!(lineage.len(), 1);
1698 // Current points at the same record
1699 let cur = server.current(&resp.lineage_id, None).unwrap().unwrap();
1700 assert_eq!(cur.body.ctx_id, resp.ctx_id);
1701 }
1702
1703 #[test]
1704 fn supersession_marks_predecessor_and_returns_v2() {
1705 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1706 let p = producer();
1707 let v1_req = p
1708 .publish_request()
1709 .title("v1")
1710 .context_type(ContextType::DataSnapshot)
1711 .visibility(Visibility::Public)
1712 .build()
1713 .unwrap();
1714 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1715
1716 let v2_req = p
1717 .supersede(v1.ctx_id.clone())
1718 .version(2)
1719 .title("v2")
1720 .context_type(ContextType::DataSnapshot)
1721 .visibility(Visibility::Public)
1722 .build()
1723 .unwrap();
1724 let v2 = server.publish_unverified_for_tests(&v2_req).unwrap();
1725 assert_eq!(v2.version, 2);
1726 // v1 was marked superseded
1727 let v1_ctx = server.retrieve(&v1.ctx_id, None).unwrap().unwrap();
1728 assert!(matches!(
1729 v1_ctx.registry_state.status,
1730 acdp_types::Status::Superseded
1731 ));
1732 // Same lineage
1733 assert_eq!(v1.lineage_id, v2.lineage_id);
1734 // Current resolves to v2
1735 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1736 assert_eq!(cur.body.ctx_id, v2.ctx_id);
1737 }
1738
1739 /// FEAT-01: two concurrent publishes that both supersede the same
1740 /// v1 MUST resolve to exactly one success + one
1741 /// `SupersededTarget { AlreadySuperseded }`. The race was possible
1742 /// when the supersedes check, body insert, and predecessor mark
1743 /// lived in separate mutex acquisitions; `commit_publish` puts
1744 /// them under one critical section so only one of two contenders
1745 /// wins (RFC-ACDP-0003 §6).
1746 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1747 async fn concurrent_supersession_exactly_one_succeeds() {
1748 use std::sync::Arc;
1749 let server = Arc::new(RegistryServer::new(
1750 InMemoryStore::new(),
1751 caps(),
1752 "registry.example.com",
1753 ));
1754 let p = producer();
1755 let v1_req = p
1756 .publish_request()
1757 .title("v1")
1758 .context_type(ContextType::DataSnapshot)
1759 .visibility(Visibility::Public)
1760 .build()
1761 .unwrap();
1762 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1763
1764 // Pre-build BOTH v2 requests up front, then fire them in
1765 // parallel on a multi-threaded runtime. With the prior
1766 // non-atomic sequence the test would fail intermittently;
1767 // with `commit_publish` it's deterministic.
1768 let v2a_req = p
1769 .supersede(v1.ctx_id.clone())
1770 .version(2)
1771 .title("v2-A")
1772 .context_type(ContextType::DataSnapshot)
1773 .visibility(Visibility::Public)
1774 .build()
1775 .unwrap();
1776 let v2b_req = p
1777 .supersede(v1.ctx_id.clone())
1778 .version(2)
1779 .title("v2-B")
1780 .context_type(ContextType::DataSnapshot)
1781 .visibility(Visibility::Public)
1782 .build()
1783 .unwrap();
1784
1785 let s1 = Arc::clone(&server);
1786 let s2 = Arc::clone(&server);
1787 let h1 = tokio::task::spawn_blocking(move || s1.publish_unverified_for_tests(&v2a_req));
1788 let h2 = tokio::task::spawn_blocking(move || s2.publish_unverified_for_tests(&v2b_req));
1789 let (r1, r2) = (h1.await.unwrap(), h2.await.unwrap());
1790
1791 let outcomes = [r1, r2];
1792 let successes = outcomes.iter().filter(|r| r.is_ok()).count();
1793 let failures = outcomes.iter().filter(|r| r.is_err()).count();
1794 assert_eq!(
1795 successes, 1,
1796 "exactly one concurrent supersession MUST succeed; got {successes} successes / {failures} failures"
1797 );
1798 assert_eq!(failures, 1);
1799 // The loser MUST get AlreadySuperseded — the predecessor was
1800 // marked under the same lock the winner used.
1801 for r in &outcomes {
1802 if let Err(e) = r {
1803 match e {
1804 AcdpError::SupersededTarget { reason, .. } => assert_eq!(
1805 *reason,
1806 acdp_primitives::error::SupersessionReason::AlreadySuperseded,
1807 "concurrent loser MUST be AlreadySuperseded"
1808 ),
1809 other => panic!("concurrent loser had wrong error: {other:?}"),
1810 }
1811 }
1812 }
1813 }
1814
1815 #[test]
1816 fn hostile_supersession_by_non_owner_rejected_predecessor_unchanged() {
1817 // P0-2: an attacker controlling their own DID must not be able to
1818 // supersede a victim's context. Without the producer-continuity
1819 // check this marks the victim's context `Superseded` and re-points
1820 // `current(lineage)` at the attacker's body — a lineage takeover.
1821 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1822 let victim = producer_for(7, "did:web:agents.example.com:victim");
1823 let v1_req = victim
1824 .publish_request()
1825 .title("v1")
1826 .context_type(ContextType::DataSnapshot)
1827 .visibility(Visibility::Public)
1828 .build()
1829 .unwrap();
1830 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1831
1832 // Attacker signs their own valid v2, omitting lineage_id (the only
1833 // self-declared coherence arm), supersedes = victim's v1.
1834 let attacker = producer_for(9, "did:web:evil.example.com:attacker");
1835 let v2_req = attacker
1836 .supersede(v1.ctx_id.clone())
1837 .version(2)
1838 .title("hijacked")
1839 .context_type(ContextType::DataSnapshot)
1840 .visibility(Visibility::Public)
1841 .build()
1842 .unwrap();
1843 let err = server.publish_unverified_for_tests(&v2_req).unwrap_err();
1844 // Uniform with not-found: no existence / version / status oracle.
1845 match err {
1846 AcdpError::SupersededTarget { reason, .. } => {
1847 assert_eq!(reason, acdp_primitives::error::SupersessionReason::NotFound);
1848 }
1849 other => panic!("expected uniform SupersededTarget::NotFound, got {other:?}"),
1850 }
1851 // Predecessor MUST be untouched: still current, not superseded.
1852 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1853 assert_eq!(cur.body.ctx_id, v1.ctx_id);
1854 assert_eq!(cur.body.title, "v1");
1855 assert_eq!(
1856 cur.registry_state.status,
1857 acdp_types::primitives::Status::Active
1858 );
1859 }
1860
1861 #[test]
1862 fn owner_supersession_still_succeeds_after_ownership_check() {
1863 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1864 let p = producer();
1865 let v1_req = p
1866 .publish_request()
1867 .title("v1")
1868 .context_type(ContextType::DataSnapshot)
1869 .visibility(Visibility::Public)
1870 .build()
1871 .unwrap();
1872 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1873 let v2_req = p
1874 .supersede(v1.ctx_id.clone())
1875 .version(2)
1876 .title("v2")
1877 .context_type(ContextType::DataSnapshot)
1878 .visibility(Visibility::Public)
1879 .build()
1880 .unwrap();
1881 let v2 = server.publish_unverified_for_tests(&v2_req).unwrap();
1882 assert_eq!(v2.version, 2);
1883 let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1884 assert_eq!(cur.body.ctx_id, v2.ctx_id);
1885 }
1886
1887 #[test]
1888 fn supersession_with_unknown_target_rejected_as_not_found() {
1889 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1890 let p = producer();
1891 let phantom =
1892 CtxId("acdp://registry.example.com/12345678-1234-4321-8123-deadbeefcafe".into());
1893 let req = p
1894 .supersede(phantom)
1895 .version(2)
1896 .title("v2-orphan")
1897 .context_type(ContextType::DataSnapshot)
1898 .visibility(Visibility::Public)
1899 .build()
1900 .unwrap();
1901 let err = server.publish_unverified_for_tests(&req).unwrap_err();
1902 match err {
1903 AcdpError::SupersededTarget { reason, .. } => {
1904 assert_eq!(reason, acdp_primitives::error::SupersessionReason::NotFound);
1905 }
1906 other => panic!("expected SupersededTarget::NotFound, got {other:?}"),
1907 }
1908 }
1909
1910 #[test]
1911 fn version_mismatch_rejected() {
1912 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1913 let p = producer();
1914 let v1_req = p
1915 .publish_request()
1916 .title("v1")
1917 .context_type(ContextType::DataSnapshot)
1918 .visibility(Visibility::Public)
1919 .build()
1920 .unwrap();
1921 let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1922 // Build a v3 (wrong) supersession
1923 let v3_req = p
1924 .supersede(v1.ctx_id.clone())
1925 .version(3)
1926 .title("v3-skipped")
1927 .context_type(ContextType::DataSnapshot)
1928 .visibility(Visibility::Public)
1929 .build()
1930 .unwrap();
1931 let err = server.publish_unverified_for_tests(&v3_req).unwrap_err();
1932 match err {
1933 AcdpError::SupersededTarget { reason, .. } => {
1934 assert_eq!(
1935 reason,
1936 acdp_primitives::error::SupersessionReason::VersionMismatch
1937 );
1938 }
1939 other => panic!("expected VersionMismatch, got {other:?}"),
1940 }
1941 }
1942
1943 #[test]
1944 fn search_finds_published_context() {
1945 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1946 let p = producer();
1947 let req = p
1948 .publish_request()
1949 .title("Q1 portfolio risk")
1950 .context_type(ContextType::DataSnapshot)
1951 .visibility(Visibility::Public)
1952 .build()
1953 .unwrap();
1954 server.publish_unverified_for_tests(&req).unwrap();
1955 let resp = server
1956 .search(
1957 &SearchParams {
1958 q: Some("portfolio".into()),
1959 ..Default::default()
1960 },
1961 None,
1962 )
1963 .unwrap();
1964 assert_eq!(resp.matches.len(), 1);
1965 assert_eq!(resp.matches[0].title, "Q1 portfolio risk");
1966 }
1967
1968 // ── BUG-03 — lineage/current visibility filtering ──────────────────
1969
1970 /// BUG-03: a stranger calling `lineage()` MUST NOT see restricted
1971 /// bodies they aren't on the audience for. The retrieval predicate
1972 /// is now mirrored here.
1973 #[test]
1974 fn lineage_filters_restricted_for_stranger() {
1975 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1976 let p = producer();
1977 let audience = AgentDid::new("did:web:audience.example.com:reader");
1978 let req = p
1979 .publish_request()
1980 .title("restricted v1")
1981 .context_type(ContextType::DataSnapshot)
1982 .visibility(Visibility::Restricted)
1983 .audience(vec![audience.clone()])
1984 .build()
1985 .unwrap();
1986 let resp = server.publish_unverified_for_tests(&req).unwrap();
1987
1988 let stranger = AgentDid::new("did:web:other.example.com:reader");
1989 let stranger_view = server.lineage(&resp.lineage_id, Some(&stranger)).unwrap();
1990 assert!(
1991 stranger_view.is_empty(),
1992 "stranger MUST NOT see restricted bodies via lineage(); got {} entries",
1993 stranger_view.len()
1994 );
1995
1996 let audience_view = server.lineage(&resp.lineage_id, Some(&audience)).unwrap();
1997 assert_eq!(
1998 audience_view.len(),
1999 1,
2000 "audience member MUST see the restricted body via lineage()"
2001 );
2002 }
2003
2004 /// BUG-03: `current()` also filters by requester visibility.
2005 /// A stranger gets `None` for a private lineage.
2006 #[test]
2007 fn current_filters_private_for_stranger() {
2008 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2009 let p = producer();
2010 let req = p
2011 .publish_request()
2012 .title("private v1")
2013 .context_type(ContextType::DataSnapshot)
2014 .visibility(Visibility::Private)
2015 .build()
2016 .unwrap();
2017 let resp = server.publish_unverified_for_tests(&req).unwrap();
2018
2019 let stranger = AgentDid::new("did:web:other.example.com:reader");
2020 assert!(
2021 server
2022 .current(&resp.lineage_id, Some(&stranger))
2023 .unwrap()
2024 .is_none(),
2025 "stranger MUST NOT see private contexts via current()"
2026 );
2027
2028 let producer_did = AgentDid::new("did:web:agents.example.com:test");
2029 assert!(
2030 server
2031 .current(&resp.lineage_id, Some(&producer_did))
2032 .unwrap()
2033 .is_some(),
2034 "producer MUST see private contexts via current()"
2035 );
2036 }
2037
2038 // ── BUG-04 — current() superseded fallback ─────────────────────────
2039
2040 /// BUG-04: when every version of a lineage is `Superseded`,
2041 /// `current()` MUST return `None`. Previously the fallback returned
2042 /// the last entry projected, which is a protocol violation
2043 /// (RFC-ACDP-0004 §5: "If no such version exists, returns not_found").
2044 ///
2045 /// Constructing an all-superseded lineage requires a direct store
2046 /// mark — there's no publish path that produces this state today,
2047 /// but the registry's `current()` MUST not implicitly fall through.
2048 #[test]
2049 fn current_returns_none_when_all_superseded() {
2050 use crate::registry::store::RegistryStore;
2051 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2052 let p = producer();
2053 let req = p
2054 .publish_request()
2055 .title("v1")
2056 .context_type(ContextType::DataSnapshot)
2057 .visibility(Visibility::Public)
2058 .build()
2059 .unwrap();
2060 let resp = server.publish_unverified_for_tests(&req).unwrap();
2061 // Force the only version into Superseded directly.
2062 server.store().mark_superseded(&resp.ctx_id).unwrap();
2063
2064 let cur = server.current(&resp.lineage_id, None).unwrap();
2065 assert!(
2066 cur.is_none(),
2067 "all-superseded lineage MUST resolve to None per RFC-ACDP-0004 §5; got {cur:?}"
2068 );
2069 }
2070
2071 // ── BUG-01 / vis-009 — anonymous search honors anonymous_public_reads ──
2072
2073 /// BUG-01 + vis-009: a registry advertising `anonymous_public_reads:
2074 /// false` MUST reject an anonymous search with `not_authorized`
2075 /// (HTTP 403) — not an empty `200`, which would still leak the
2076 /// registry's existence. The same context surfaces with a `200`
2077 /// once the requester authenticates.
2078 #[test]
2079 fn search_suppresses_public_when_anonymous_public_reads_false() {
2080 let mut c = caps();
2081 c.anonymous_public_reads = false;
2082 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
2083 let p = producer();
2084 let req = p
2085 .publish_request()
2086 .title("public-but-flag-off")
2087 .context_type(ContextType::DataSnapshot)
2088 .visibility(Visibility::Public)
2089 .build()
2090 .unwrap();
2091 server.publish_unverified_for_tests(&req).unwrap();
2092
2093 // Anonymous: MUST be rejected with NotAuthorized (vis-009 s1).
2094 let err = server
2095 .search(
2096 &SearchParams {
2097 q: Some("public-but-flag-off".into()),
2098 ..Default::default()
2099 },
2100 None,
2101 )
2102 .unwrap_err();
2103 assert!(
2104 matches!(err, AcdpError::NotAuthorized(_)),
2105 "vis-009: anonymous search MUST be NotAuthorized when \
2106 anonymous_public_reads=false; got {err:?}"
2107 );
2108
2109 // Authenticated requester (any DID — public is universally visible
2110 // once authenticated): MUST see the context.
2111 let stranger = AgentDid::new("did:web:other.example.com:reader");
2112 let authed = server
2113 .search(
2114 &SearchParams {
2115 q: Some("public-but-flag-off".into()),
2116 ..Default::default()
2117 },
2118 Some(&stranger),
2119 )
2120 .unwrap();
2121 assert_eq!(
2122 authed.matches.len(),
2123 1,
2124 "authenticated search MUST see public contexts regardless of anonymous_public_reads"
2125 );
2126 }
2127
2128 // ── try_new validation tests ────────────────────────────────────────
2129
2130 #[test]
2131 fn try_new_rejects_did_authority_mismatch() {
2132 let mut c = caps();
2133 c.registry_did = "did:web:other.example.com".into(); // wrong authority
2134 let res = RegistryServer::try_new(InMemoryStore::new(), c, "registry.example.com");
2135 match res {
2136 Err(AcdpError::SchemaViolation(msg)) => {
2137 assert!(msg.contains("does not match expected"))
2138 }
2139 Err(other) => panic!("expected SchemaViolation, got {other:?}"),
2140 Ok(_) => panic!("expected Err"),
2141 }
2142 }
2143
2144 #[test]
2145 fn try_new_rejects_caps_missing_ed25519() {
2146 let mut c = caps();
2147 c.supported_signature_algorithms = vec!["ecdsa-p256".into()]; // missing ed25519
2148 let res = RegistryServer::try_new(InMemoryStore::new(), c, "registry.example.com");
2149 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
2150 }
2151
2152 #[test]
2153 fn try_new_accepts_valid_caps() {
2154 RegistryServer::try_new(InMemoryStore::new(), caps(), "registry.example.com").unwrap();
2155 }
2156
2157 // ── WIRE-04 — try_new authority-format validation ───────────────────
2158
2159 #[test]
2160 fn try_new_accepts_valid_dns_authority() {
2161 RegistryServer::try_new(InMemoryStore::new(), caps(), "registry.example.com").unwrap();
2162 }
2163
2164 #[test]
2165 fn try_new_rejects_host_port_authority() {
2166 // A `host:port` authority would mint `acdp://localhost:8443/<uuid>`
2167 // ctx_ids — a colon violates the acdp:// authority rule.
2168 let res = RegistryServer::try_new(InMemoryStore::new(), caps(), "localhost:8443");
2169 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
2170 }
2171
2172 #[test]
2173 fn try_new_rejects_uppercase_authority() {
2174 let res = RegistryServer::try_new(InMemoryStore::new(), caps(), "Registry.Example.Com");
2175 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
2176 }
2177
2178 #[test]
2179 fn try_new_rejects_url_form_authority() {
2180 let res =
2181 RegistryServer::try_new(InMemoryStore::new(), caps(), "https://registry.example.com");
2182 assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
2183 }
2184
2185 #[test]
2186 fn try_new_for_test_accepts_host_port() {
2187 // The test constructor skips the DNS-authority check; it still
2188 // enforces the DID binding, so the caps DID must match.
2189 let mut c = caps();
2190 c.registry_did = acdp_did::authority_to_did_web("localhost:8443");
2191 RegistryServer::try_new_for_test_authority(InMemoryStore::new(), c, "localhost:8443")
2192 .unwrap();
2193 }
2194
2195 // ── Visibility-enforcement tests (RFC-ACDP-0008 §4.5) ───────────────
2196
2197 fn producer_for(seed: u8, did: &str) -> Producer {
2198 Producer::new(
2199 SigningKey::from_bytes(&[seed; 32]),
2200 AgentDid::new(did),
2201 format!("{did}#key-1"),
2202 )
2203 }
2204
2205 #[test]
2206 fn retrieve_restricted_blocks_stranger_returns_none() {
2207 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2208 let owner = AgentDid::new("did:web:agents.example.com:owner");
2209 let audience_member = AgentDid::new("did:web:agents.example.com:friend");
2210 let p = producer_for(2, owner.as_str());
2211 let req = p
2212 .publish_request()
2213 .title("restricted")
2214 .context_type(ContextType::DataSnapshot)
2215 .visibility(Visibility::Restricted)
2216 .audience(vec![audience_member.clone()])
2217 .build()
2218 .unwrap();
2219 let resp = server.publish_unverified_for_tests(&req).unwrap();
2220 let stranger = AgentDid::new("did:web:agents.example.com:stranger");
2221
2222 assert!(server.retrieve(&resp.ctx_id, None).unwrap().is_none());
2223 assert!(server
2224 .retrieve(&resp.ctx_id, Some(&stranger))
2225 .unwrap()
2226 .is_none());
2227 assert!(server
2228 .retrieve(&resp.ctx_id, Some(&owner))
2229 .unwrap()
2230 .is_some());
2231 assert!(server
2232 .retrieve(&resp.ctx_id, Some(&audience_member))
2233 .unwrap()
2234 .is_some());
2235 }
2236
2237 #[test]
2238 fn search_restricted_filters_strangers() {
2239 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2240 let owner = AgentDid::new("did:web:agents.example.com:owner");
2241 let p = producer_for(3, owner.as_str());
2242 let req = p
2243 .publish_request()
2244 .title("hush hush")
2245 .context_type(ContextType::DataSnapshot)
2246 .visibility(Visibility::Restricted)
2247 .audience(vec![AgentDid::new("did:web:agents.example.com:friend")])
2248 .build()
2249 .unwrap();
2250 server.publish_unverified_for_tests(&req).unwrap();
2251
2252 let stranger = AgentDid::new("did:web:agents.example.com:stranger");
2253 let r_anon = server.search(&SearchParams::default(), None).unwrap();
2254 assert!(
2255 r_anon.matches.is_empty(),
2256 "anonymous must not see restricted"
2257 );
2258 let r_stranger = server
2259 .search(&SearchParams::default(), Some(&stranger))
2260 .unwrap();
2261 assert!(r_stranger.matches.is_empty());
2262 let r_owner = server
2263 .search(&SearchParams::default(), Some(&owner))
2264 .unwrap();
2265 assert_eq!(r_owner.matches.len(), 1);
2266 }
2267
2268 /// RFC-ACDP-0008 §4.5 asymmetry: a private context surfaces in search
2269 /// only to its producer — audience members can retrieve by id but can't
2270 /// discover via search.
2271 #[test]
2272 fn search_private_visible_only_to_producer() {
2273 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2274 let owner = AgentDid::new("did:web:agents.example.com:owner");
2275 let audience_member = AgentDid::new("did:web:agents.example.com:friend");
2276 let p = producer_for(4, owner.as_str());
2277 let req = p
2278 .publish_request()
2279 .title("private note")
2280 .context_type(ContextType::DataSnapshot)
2281 .visibility(Visibility::Private)
2282 .audience(vec![audience_member.clone()])
2283 .build()
2284 .unwrap();
2285 let resp = server.publish_unverified_for_tests(&req).unwrap();
2286
2287 let r_audience = server
2288 .search(&SearchParams::default(), Some(&audience_member))
2289 .unwrap();
2290 assert!(
2291 r_audience.matches.is_empty(),
2292 "audience must NOT see private in search"
2293 );
2294 let r_owner = server
2295 .search(&SearchParams::default(), Some(&owner))
2296 .unwrap();
2297 assert_eq!(
2298 r_owner.matches.len(),
2299 1,
2300 "owner sees their own private context"
2301 );
2302
2303 // Audience CAN retrieve directly by id.
2304 assert!(server
2305 .retrieve(&resp.ctx_id, Some(&audience_member))
2306 .unwrap()
2307 .is_some());
2308 }
2309
2310 // ── publish_verified offline-rejection tests ────────────────────────
2311 //
2312 // Full end-to-end `publish_verified` requires a TLS-mocked DID
2313 // document (because `WebResolver` is HTTPS-only). These tests cover
2314 // the rejection paths that fire BEFORE the resolver call so they
2315 // don't need a network: malformed key_id, non-did:web key_id,
2316 // agent_id ≠ key_id DID portion. Together with the existing
2317 // `verify_signature_envelope` algorithm-downgrade unit test, they
2318 // pin the entry checks of RFC-ACDP-0003 §2.1 steps 7–8 without
2319 // requiring a TLS mock harness.
2320
2321 #[cfg(feature = "client")]
2322 #[tokio::test]
2323 async fn publish_verified_rejects_non_did_web_key_id() {
2324 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2325 let p = producer();
2326 let mut req = p
2327 .publish_request()
2328 .title("v1")
2329 .context_type(ContextType::DataSnapshot)
2330 .visibility(Visibility::Public)
2331 .build()
2332 .unwrap();
2333 // Mutate post-build — validation already ran and accepted did:web.
2334 // Re-sign isn't necessary: the verifier rejects before signature
2335 // check. Use a *well-formed* did:key URL (a malformed one is
2336 // caught earlier by schema validation as of ACDP 0.2): the
2337 // key_id DID portion no longer matches the did:web agent_id, so
2338 // the binding check refuses it.
2339 let did_key = acdp_did::key::did_key_from_ed25519(
2340 &SigningKey::from_bytes(&[9u8; 32]).verifying_key_bytes(),
2341 );
2342 req.signature.key_id = acdp_did::key::did_key_url(&did_key).unwrap();
2343 let resolver = acdp_did::WebResolver::new();
2344 let err = server
2345 .publish_verified(&req, None, &resolver)
2346 .await
2347 .unwrap_err();
2348 match err {
2349 AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("did:web")),
2350 other => panic!("expected KeyNotAuthorized for non-did:web, got {other:?}"),
2351 }
2352 }
2353
2354 #[cfg(feature = "client")]
2355 #[tokio::test]
2356 async fn publish_verified_rejects_agent_id_keyid_mismatch() {
2357 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2358 let p = producer();
2359 let mut req = p
2360 .publish_request()
2361 .title("v1")
2362 .context_type(ContextType::DataSnapshot)
2363 .visibility(Visibility::Public)
2364 .build()
2365 .unwrap();
2366 req.signature.key_id = "did:web:other.example.com:agent#key-1".into();
2367 let resolver = acdp_did::WebResolver::new();
2368 let err = server
2369 .publish_verified(&req, None, &resolver)
2370 .await
2371 .unwrap_err();
2372 match err {
2373 AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("agent_id")),
2374 other => panic!("expected KeyNotAuthorized for agent_id mismatch, got {other:?}"),
2375 }
2376 }
2377
2378 #[cfg(feature = "client")]
2379 #[tokio::test]
2380 async fn publish_verified_rejects_keyid_without_fragment() {
2381 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2382 let p = producer();
2383 let mut req = p
2384 .publish_request()
2385 .title("v1")
2386 .context_type(ContextType::DataSnapshot)
2387 .visibility(Visibility::Public)
2388 .build()
2389 .unwrap();
2390 req.signature.key_id = "did:web:agents.example.com:test".into(); // no '#'
2391 let resolver = acdp_did::WebResolver::new();
2392 let err = server
2393 .publish_verified(&req, None, &resolver)
2394 .await
2395 .unwrap_err();
2396 // Schema validation (step 1) catches missing-fragment before
2397 // step 7 fires, so the surface error is SchemaViolation.
2398 assert!(
2399 matches!(
2400 err,
2401 AcdpError::SchemaViolation(_) | AcdpError::KeyResolution(_)
2402 ),
2403 "expected fragment-rejection error, got {err:?}"
2404 );
2405 }
2406
2407 // ── FEAT-04 idempotency tests ──────────────────────────────────────
2408
2409 fn caps_with_idempotency() -> CapabilitiesDocument {
2410 let mut c = caps();
2411 c.supports_idempotency_key = true;
2412 c.limits.idempotency_key_ttl_seconds = Some(86_400);
2413 c
2414 }
2415
2416 #[test]
2417 fn idempotency_same_hash_returns_original_response() {
2418 let server = RegistryServer::new(
2419 InMemoryStore::new(),
2420 caps_with_idempotency(),
2421 "registry.example.com",
2422 );
2423 let p = producer();
2424 let req = p
2425 .publish_request()
2426 .title("once")
2427 .context_type(ContextType::DataSnapshot)
2428 .visibility(Visibility::Public)
2429 .build()
2430 .unwrap();
2431 // Publish twice through the same idempotency key via the real
2432 // path — `publish_unverified_in_tenant_for_tests` can pass an
2433 // idempotency key straight through to `commit_via_store`, so
2434 // there is no more need to fake the store-level entry.
2435 let first = server
2436 .publish_unverified_in_tenant_for_tests(&req, Some("k-001"), None)
2437 .unwrap();
2438 let replayed = server
2439 .publish_unverified_in_tenant_for_tests(&req, Some("k-001"), None)
2440 .unwrap();
2441 assert_eq!(replayed.ctx_id, first.ctx_id);
2442 assert_eq!(replayed.lineage_id, first.lineage_id);
2443 assert_eq!(replayed.created_at, first.created_at);
2444 // And only one context was actually persisted.
2445 let resp = server.search(&SearchParams::default(), None).unwrap();
2446 assert_eq!(
2447 resp.matches.len(),
2448 1,
2449 "idempotent replay must not persist a second context"
2450 );
2451 }
2452
2453 #[test]
2454 fn idempotency_evicts_after_ttl() {
2455 let store = InMemoryStore::new();
2456 let agent = AgentDid::new("did:web:agents.example.com:test");
2457 let resp = PublishResponse {
2458 registry_receipt: None,
2459 ctx_id: acdp_types::CtxId("acdp://r/12345678-1234-4321-8123-000000000099".into()),
2460 lineage_id: acdp_types::LineageId(
2461 "lin:sha256:9999999999999999999999999999999999999999999999999999999999999999"
2462 .into(),
2463 ),
2464 version: 1,
2465 created_at: chrono::Utc::now(),
2466 status: Status::Active,
2467 };
2468 // Already-past expiration.
2469 let past = chrono::Utc::now() - chrono::Duration::seconds(1);
2470 store
2471 .idempotency_record(
2472 &agent,
2473 "expired",
2474 &acdp_types::ContentHash("sha256:0".into()),
2475 &resp,
2476 past,
2477 )
2478 .unwrap();
2479 // Lookup runs lazy eviction; the expired record MUST be gone.
2480 let prior = store.idempotency_lookup(&agent, "expired").unwrap();
2481 assert!(
2482 prior.is_none(),
2483 "lazy TTL eviction should drop expired record"
2484 );
2485 }
2486
2487 /// A receipts-advertising registry must refuse
2488 /// `publish_unverified_in_tenant_for_tests` identically to
2489 /// `publish_unverified_for_tests` (RFC-ACDP-0010 §7: no degraded
2490 /// mode) — the refusal is inherited by construction since the old
2491 /// method's body moved wholesale into the new one, but this proves
2492 /// it from the new name specifically.
2493 #[test]
2494 fn publish_unverified_in_tenant_for_tests_refuses_receipts_registry() {
2495 let mut c = caps();
2496 c.acdp_version = "0.2.0".into();
2497 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
2498 .with_receipt_signer(
2499 acdp_types::receipt::ReceiptSigner::new(
2500 SigningKey::from_bytes(&[0x33u8; 32]),
2501 "did:web:registry.example.com",
2502 "did:web:registry.example.com#receipt-key-1",
2503 )
2504 .unwrap(),
2505 )
2506 .unwrap();
2507 let p = producer();
2508 let req = p
2509 .publish_request()
2510 .title("should be refused")
2511 .context_type(ContextType::DataSnapshot)
2512 .visibility(Visibility::Public)
2513 .build()
2514 .unwrap();
2515 let err = server
2516 .publish_unverified_in_tenant_for_tests(&req, None, None)
2517 .unwrap_err();
2518 assert!(
2519 matches!(err, AcdpError::SchemaViolation(_)),
2520 "expected SchemaViolation refusal on a receipts-advertising registry, got {err:?}"
2521 );
2522 }
2523
2524 // ── Tenancy-recording test store ────────────────────────────────────
2525 //
2526 // `InMemoryStore::commit_publish` deliberately discards `tenant`
2527 // (store.rs:676-678: "InMemoryStore does not model tenancy") since
2528 // it is a single-tenant reference/test backend — so testing tenancy
2529 // plumbing against it directly would prove nothing. `RecordingStore`
2530 // wraps an `InMemoryStore` for all real behavior and additionally
2531 // captures the `tenant` argument each `commit_publish` call receives.
2532
2533 /// Delegates every [`RegistryStore`] method to an inner
2534 /// [`InMemoryStore`], additionally recording `commit.tenant` from
2535 /// each [`RegistryStore::commit_publish`] call.
2536 struct RecordingStore {
2537 inner: InMemoryStore,
2538 tenants: std::sync::Mutex<Vec<Option<String>>>,
2539 }
2540
2541 impl RecordingStore {
2542 fn new() -> Self {
2543 Self {
2544 inner: InMemoryStore::new(),
2545 tenants: std::sync::Mutex::new(Vec::new()),
2546 }
2547 }
2548 }
2549
2550 impl RegistryStore for RecordingStore {
2551 fn put(&self, body: Body) -> Result<(), AcdpError> {
2552 self.inner.put(body)
2553 }
2554
2555 fn get(&self, ctx_id: &CtxId) -> Result<Option<FullContext>, AcdpError> {
2556 self.inner.get(ctx_id)
2557 }
2558
2559 fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError> {
2560 self.inner.lineage(lineage_id)
2561 }
2562
2563 fn current(&self, lineage_id: &LineageId) -> Result<Option<FullContext>, AcdpError> {
2564 self.inner.current(lineage_id)
2565 }
2566
2567 fn mark_superseded(&self, ctx_id: &CtxId) -> Result<(), AcdpError> {
2568 self.inner.mark_superseded(ctx_id)
2569 }
2570
2571 fn first_version_ctx_id(&self, lineage_id: &LineageId) -> Result<Option<CtxId>, AcdpError> {
2572 self.inner.first_version_ctx_id(lineage_id)
2573 }
2574
2575 fn search(
2576 &self,
2577 params: &SearchParams,
2578 requester: Option<&AgentDid>,
2579 anonymous_public_reads: bool,
2580 ) -> Result<SearchResponse, AcdpError> {
2581 self.inner.search(params, requester, anonymous_public_reads)
2582 }
2583
2584 fn idempotency_lookup(
2585 &self,
2586 agent_id: &AgentDid,
2587 key: &str,
2588 ) -> Result<Option<crate::registry::store::IdempotencyRecord>, AcdpError> {
2589 self.inner.idempotency_lookup(agent_id, key)
2590 }
2591
2592 fn idempotency_record(
2593 &self,
2594 agent_id: &AgentDid,
2595 key: &str,
2596 hash: &acdp_types::primitives::ContentHash,
2597 response: &PublishResponse,
2598 expires_at: chrono::DateTime<chrono::Utc>,
2599 ) -> Result<(), AcdpError> {
2600 self.inner
2601 .idempotency_record(agent_id, key, hash, response, expires_at)
2602 }
2603
2604 fn idempotency_evict_expired(
2605 &self,
2606 now: chrono::DateTime<chrono::Utc>,
2607 ) -> Result<(), AcdpError> {
2608 self.inner.idempotency_evict_expired(now)
2609 }
2610
2611 fn commit_publish(
2612 &self,
2613 commit: crate::registry::store::PublishCommit<'_>,
2614 ) -> Result<crate::registry::store::PublishCommitOutcome, AcdpError> {
2615 self.tenants
2616 .lock()
2617 .unwrap()
2618 .push(commit.tenant.map(String::from));
2619 self.inner.commit_publish(commit)
2620 }
2621
2622 fn commit_lifecycle_event(
2623 &self,
2624 event: &acdp_types::lifecycle::LifecycleEvent,
2625 ) -> Result<crate::registry::store::LifecycleCommitOutcome, AcdpError> {
2626 self.inner.commit_lifecycle_event(event)
2627 }
2628 }
2629
2630 /// Proves `tenant` reaches `PublishCommit.tenant` verbatim from
2631 /// `publish_unverified_in_tenant_for_tests`, in call order.
2632 ///
2633 /// What this does NOT prove: tenant *isolation* (that two tenants'
2634 /// data cannot leak into each other's reads) — that is the durable
2635 /// backends' contract (`store.rs:274-279`), entirely out of scope
2636 /// for `InMemoryStore`/`RecordingStore`, which is why this test only
2637 /// asserts on the recorded argument, not on any read-side behavior.
2638 #[test]
2639 fn tenant_reaches_publish_commit_verbatim() {
2640 let server = RegistryServer::new(RecordingStore::new(), caps(), "registry.example.com");
2641 let p = producer();
2642
2643 let req_a = p
2644 .publish_request()
2645 .title("tenant a")
2646 .context_type(ContextType::DataSnapshot)
2647 .visibility(Visibility::Public)
2648 .build()
2649 .unwrap();
2650 server
2651 .publish_unverified_in_tenant_for_tests(&req_a, None, Some("tenant-a"))
2652 .unwrap();
2653
2654 let req_b = p
2655 .publish_request()
2656 .title("tenant none")
2657 .context_type(ContextType::DataSnapshot)
2658 .visibility(Visibility::Public)
2659 .build()
2660 .unwrap();
2661 server
2662 .publish_unverified_in_tenant_for_tests(&req_b, None, None)
2663 .unwrap();
2664
2665 let recorded = server.store().tenants.lock().unwrap().clone();
2666 assert_eq!(
2667 recorded,
2668 vec![Some("tenant-a".to_string()), None],
2669 "tenant must reach PublishCommit.tenant verbatim, in call order"
2670 );
2671 }
2672
2673 // ── FEAT-05 rate limiter tests ─────────────────────────────────────
2674
2675 struct AlwaysDeny;
2676 impl crate::registry::RateLimiter for AlwaysDeny {
2677 fn check_publish(&self, agent_id: &AgentDid) -> Result<(), AcdpError> {
2678 Err(AcdpError::RateLimited(format!("blocked: {agent_id}")))
2679 }
2680 }
2681
2682 #[test]
2683 fn rate_limiter_blocks_publish_before_persist() {
2684 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com")
2685 .with_rate_limiter(AlwaysDeny);
2686 let p = producer();
2687 let req = p
2688 .publish_request()
2689 .title("blocked")
2690 .context_type(ContextType::DataSnapshot)
2691 .visibility(Visibility::Public)
2692 .build()
2693 .unwrap();
2694 let err = server.publish_unverified_for_tests(&req).unwrap_err();
2695 assert!(matches!(err, AcdpError::RateLimited(_)));
2696 // And the store is empty — the limiter MUST short-circuit before persist.
2697 let resp = server.search(&SearchParams::default(), None).unwrap();
2698 assert!(
2699 resp.matches.is_empty(),
2700 "rate-limited publish must not persist"
2701 );
2702 }
2703
2704 #[test]
2705 fn created_at_is_ms_truncated() {
2706 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2707 let p = producer();
2708 let req = p
2709 .publish_request()
2710 .title("ms")
2711 .context_type(ContextType::DataSnapshot)
2712 .visibility(Visibility::Public)
2713 .build()
2714 .unwrap();
2715 let resp = server.publish_unverified_for_tests(&req).unwrap();
2716 // Nanosecond component of a ms-truncated timestamp is always a multiple of 1_000_000.
2717 assert_eq!(
2718 resp.created_at.timestamp_subsec_nanos() % 1_000_000,
2719 0,
2720 "created_at must be millisecond-truncated per RFC-ACDP-0001 §5.3"
2721 );
2722 }
2723
2724 // ── did:key publish (ACDP 0.2) ───────────────────────────────────────
2725
2726 fn did_key_request() -> acdp_types::publish::PublishRequest {
2727 let p = Producer::new_did_key(SigningKey::from_bytes(&[7u8; 32]));
2728 p.publish_request()
2729 .title("did:key publish")
2730 .context_type(ContextType::DataSnapshot)
2731 .visibility(Visibility::Public)
2732 .build()
2733 .unwrap()
2734 }
2735
2736 /// A registry that does NOT advertise `did:key` in
2737 /// `supported_did_methods` refuses a did:key publish with
2738 /// `key_resolution_failed` (permanent) — the anchor-plan decision.
2739 #[test]
2740 fn did_key_publish_rejected_when_not_advertised() {
2741 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2742 let err = server
2743 .publish_verified_did_key(&did_key_request(), None)
2744 .unwrap_err();
2745 assert!(
2746 matches!(err, AcdpError::KeyResolution(ref m) if m.contains("supported_did_methods")),
2747 "got {err:?}"
2748 );
2749 }
2750
2751 /// With `did:key` advertised, the offline pipeline runs end-to-end:
2752 /// schema → hash → pure key resolution → signature → persistence.
2753 /// No resolver, no network — works in a `server`-only build.
2754 #[test]
2755 fn did_key_publish_verified_end_to_end() {
2756 let mut c = caps();
2757 c.supported_did_methods.push("did:key".into());
2758 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
2759 let req = did_key_request();
2760 let resp = server.publish_verified_did_key(&req, None).unwrap();
2761 assert_eq!(resp.ctx_id.authority(), "registry.example.com");
2762
2763 // Tampered title → hash mismatch caught before signature.
2764 let mut tampered = did_key_request();
2765 tampered.title = "tampered".into();
2766 let err = server
2767 .publish_verified_did_key(&tampered, None)
2768 .unwrap_err();
2769 assert!(matches!(err, AcdpError::HashMismatch { .. }), "got {err:?}");
2770 }
2771
2772 /// Upgrade boundary: a registry that enables receipts must still
2773 /// honor idempotent replays of records minted BEFORE the signer
2774 /// existed. The §7 no-degraded-mode check applies to newly inserted
2775 /// contexts only — a replayed pre-receipts response (no
2776 /// `registry_receipt`) is returned verbatim, not failed as a 500.
2777 #[test]
2778 fn receiptless_idempotent_replay_survives_enabling_receipts() {
2779 let mut c = caps();
2780 c.acdp_version = "0.2.0".into();
2781 c.supported_did_methods.push("did:key".into());
2782 c.supports_idempotency_key = true;
2783 c.limits.idempotency_key_ttl_seconds = Some(86_400);
2784 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
2785 .with_receipt_signer(
2786 acdp_types::receipt::ReceiptSigner::new(
2787 SigningKey::from_bytes(&[0x11u8; 32]),
2788 "did:web:registry.example.com",
2789 "did:web:registry.example.com#receipt-key-1",
2790 )
2791 .unwrap(),
2792 )
2793 .unwrap();
2794
2795 // Simulate a record persisted before receipts were enabled: the
2796 // stored response carries no `registry_receipt`.
2797 let req = did_key_request();
2798 let pre_receipts_response = acdp_types::publish::PublishResponse {
2799 ctx_id: CtxId(format!(
2800 "acdp://registry.example.com/{}",
2801 uuid::Uuid::new_v4()
2802 )),
2803 lineage_id: acdp_crypto::derive_lineage_id(&CtxId(
2804 "acdp://registry.example.com/v1".into(),
2805 )),
2806 version: 1,
2807 created_at: acdp_primitives::time::trunc_ms(chrono::Utc::now()),
2808 status: Status::Active,
2809 registry_receipt: None,
2810 };
2811 server
2812 .store()
2813 .idempotency_record(
2814 &req.agent_id,
2815 "pre-receipts-key",
2816 &req.content_hash,
2817 &pre_receipts_response,
2818 chrono::Utc::now() + chrono::Duration::hours(1),
2819 )
2820 .unwrap();
2821
2822 // Same agent + key + content_hash → the replay must return the
2823 // original receipt-less response, not RegistryInternal.
2824 let resp = server
2825 .publish_verified_did_key(&req, Some("pre-receipts-key"))
2826 .expect("replay of a pre-receipts record must succeed");
2827 assert_eq!(resp.ctx_id, pre_receipts_response.ctx_id);
2828 assert!(
2829 resp.registry_receipt.is_none(),
2830 "replay returns the original response verbatim"
2831 );
2832
2833 // A FRESH publish on the same server still enforces minting.
2834 let p2 = Producer::new_did_key(SigningKey::from_bytes(&[8u8; 32]));
2835 let fresh = p2
2836 .publish_request()
2837 .title("fresh after enabling receipts")
2838 .context_type(ContextType::DataSnapshot)
2839 .visibility(Visibility::Public)
2840 .build()
2841 .unwrap();
2842 let fresh_resp = server.publish_verified_did_key(&fresh, None).unwrap();
2843 assert!(
2844 fresh_resp.registry_receipt.is_some(),
2845 "new inserts on a receipts registry must mint"
2846 );
2847 }
2848
2849 /// A pinned-key publish (the caller already verified the signature
2850 /// against an operator-pinned key, e.g. a demo registry's
2851 /// `playground.pinned_keys` allowlist) mints a receipt whose
2852 /// `key_fingerprint` matches the pinned key — proving
2853 /// `publish_pinned_verified_in_tenant` is safe on a
2854 /// receipts-advertising registry, unlike `publish_unverified_for_tests`.
2855 #[test]
2856 fn pinned_verified_publish_mints_receipt_with_correct_fingerprint() {
2857 use base64::{engine::general_purpose::STANDARD, Engine};
2858
2859 let key = SigningKey::from_bytes(&[3u8; 32]);
2860 let verifying_key_bytes = key.verifying_key_bytes();
2861 let pub_b64 = STANDARD.encode(verifying_key_bytes);
2862 let did = "did:web:agents.example.com:pinned-agent";
2863 let p = Producer::new(key, AgentDid::new(did), format!("{did}#key-1"));
2864 let req = p
2865 .publish_request()
2866 .title("pinned publish")
2867 .context_type(ContextType::DataSnapshot)
2868 .visibility(Visibility::Public)
2869 .build()
2870 .unwrap();
2871
2872 let mut c = caps();
2873 c.acdp_version = "0.2.0".into();
2874 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
2875 .with_receipt_signer(
2876 acdp_types::receipt::ReceiptSigner::new(
2877 SigningKey::from_bytes(&[0x22u8; 32]),
2878 "did:web:registry.example.com",
2879 "did:web:registry.example.com#receipt-key-1",
2880 )
2881 .unwrap(),
2882 )
2883 .unwrap();
2884
2885 let resp = server
2886 .publish_pinned_verified_in_tenant(&req, None, None, &pub_b64, "ed25519")
2887 .expect("pinned-verified publish must succeed on a receipts registry");
2888 let receipt = resp
2889 .registry_receipt
2890 .expect("a receipts-advertising registry must mint a receipt");
2891 assert_eq!(
2892 receipt["key_fingerprint"].as_str().unwrap(),
2893 acdp_crypto::fingerprint::fingerprint_ed25519(&verifying_key_bytes)
2894 );
2895 }
2896
2897 /// Without a receipt signer configured, `publish_pinned_verified_in_tenant`
2898 /// still succeeds — it just mints no receipt (the fingerprint is only
2899 /// ever needed for the receipt binding).
2900 #[test]
2901 fn pinned_verified_publish_without_receipt_signer_succeeds_with_no_receipt() {
2902 use base64::{engine::general_purpose::STANDARD, Engine};
2903
2904 let key = SigningKey::from_bytes(&[4u8; 32]);
2905 let pub_b64 = STANDARD.encode(key.verifying_key_bytes());
2906 let did = "did:web:agents.example.com:pinned-agent-2";
2907 let p = Producer::new(key, AgentDid::new(did), format!("{did}#key-1"));
2908 let req = p
2909 .publish_request()
2910 .title("pinned publish, no receipts")
2911 .context_type(ContextType::DataSnapshot)
2912 .visibility(Visibility::Public)
2913 .build()
2914 .unwrap();
2915
2916 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2917 let resp = server
2918 .publish_pinned_verified_in_tenant(&req, None, None, &pub_b64, "ed25519")
2919 .unwrap();
2920 assert!(resp.registry_receipt.is_none());
2921 }
2922
2923 /// `publish_verified_did_key` refuses did:web producers — they need
2924 /// the resolver-backed `publish_verified`.
2925 #[test]
2926 fn did_key_publish_path_refuses_did_web() {
2927 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2928 let p = producer();
2929 let req = p
2930 .publish_request()
2931 .title("did:web on the offline path")
2932 .context_type(ContextType::DataSnapshot)
2933 .visibility(Visibility::Public)
2934 .build()
2935 .unwrap();
2936 let err = server.publish_verified_did_key(&req, None).unwrap_err();
2937 assert!(
2938 matches!(err, AcdpError::KeyResolution(_)),
2939 "did:web on the offline path must be refused, got {err:?}"
2940 );
2941 }
2942
2943 // ── Phase 8: prove/commit split (#273) ────────────────────────────
2944
2945 /// `prove_publish_identity_did_key` + `commit_proven` must succeed and
2946 /// persist exactly like the composed
2947 /// `publish_verified_did_key_in_tenant_with_outcome` they replace the
2948 /// body of — proving the split is a genuine decomposition of the
2949 /// existing pipeline, not a different one.
2950 #[test]
2951 fn prove_then_commit_did_key_round_trip_matches_direct_publish() {
2952 let mut c = caps();
2953 c.supported_did_methods.push("did:key".into());
2954 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
2955 let req = did_key_request();
2956
2957 let proven = server
2958 .prove_publish_identity_did_key(&req)
2959 .expect("prove must succeed for a validly signed did:key request");
2960 assert_eq!(proven.agent_id(), &req.agent_id);
2961 assert_eq!(proven.request().content_hash, req.content_hash);
2962
2963 // `commit_proven` below debug_asserts recomputed_hash == request().content_hash
2964 // internally — this round trip is what exercises that invariant.
2965 let outcome = server
2966 .commit_proven(proven, None, None)
2967 .expect("commit_proven must persist a proven publish");
2968 let resp = outcome.into_response();
2969 assert_eq!(resp.ctx_id.authority(), "registry.example.com");
2970 assert_eq!(resp.version, 1);
2971
2972 let ctx = server.retrieve(&resp.ctx_id, None).unwrap().unwrap();
2973 assert_eq!(ctx.body.content_hash, req.content_hash);
2974 }
2975
2976 /// The pinned-key `prove`/`commit` split must still thread the
2977 /// fingerprint through to receipt minting exactly like
2978 /// `publish_pinned_verified_in_tenant_with_outcome` does directly.
2979 #[test]
2980 fn prove_then_commit_pinned_round_trip_mints_receipt_with_correct_fingerprint() {
2981 use base64::{engine::general_purpose::STANDARD, Engine};
2982
2983 let key = SigningKey::from_bytes(&[5u8; 32]);
2984 let verifying_key_bytes = key.verifying_key_bytes();
2985 let pub_b64 = STANDARD.encode(verifying_key_bytes);
2986 let did = "did:web:agents.example.com:pinned-proven-agent";
2987 let p = Producer::new(key, AgentDid::new(did), format!("{did}#key-1"));
2988 let req = p
2989 .publish_request()
2990 .title("pinned publish via prove/commit")
2991 .context_type(ContextType::DataSnapshot)
2992 .visibility(Visibility::Public)
2993 .build()
2994 .unwrap();
2995
2996 let mut c = caps();
2997 c.acdp_version = "0.2.0".into();
2998 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
2999 .with_receipt_signer(
3000 acdp_types::receipt::ReceiptSigner::new(
3001 SigningKey::from_bytes(&[0x33u8; 32]),
3002 "did:web:registry.example.com",
3003 "did:web:registry.example.com#receipt-key-1",
3004 )
3005 .unwrap(),
3006 )
3007 .unwrap();
3008
3009 let proven = server
3010 .prove_publish_identity_pinned(&req, &pub_b64, "ed25519")
3011 .expect("prove must succeed for a validly pinned request");
3012 let expected_fp = acdp_crypto::fingerprint::fingerprint_ed25519(&verifying_key_bytes);
3013 assert_eq!(proven.key_fingerprint(), Some(expected_fp.as_str()));
3014
3015 let outcome = server
3016 .commit_proven(proven, None, None)
3017 .expect("commit_proven must persist and mint a receipt");
3018 let resp = outcome.into_response();
3019 let receipt = resp
3020 .registry_receipt
3021 .expect("a receipts-advertising registry must mint a receipt");
3022 assert_eq!(receipt["key_fingerprint"].as_str().unwrap(), expected_fp);
3023 }
3024
3025 /// `prove_publish_identity` (the did:web variant) runs the same
3026 /// pre-resolution rejections as `publish_verified` — proving the
3027 /// async prove entry point exists and is wired into the same
3028 /// validation pipeline, without needing a live/mocked resolver.
3029 #[cfg(feature = "client")]
3030 #[tokio::test]
3031 async fn prove_publish_identity_rejects_non_did_web_key_id_before_producing_proven() {
3032 let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
3033 let p = producer();
3034 let mut req = p
3035 .publish_request()
3036 .title("v1")
3037 .context_type(ContextType::DataSnapshot)
3038 .visibility(Visibility::Public)
3039 .build()
3040 .unwrap();
3041 let did_key = acdp_did::key::did_key_from_ed25519(
3042 &SigningKey::from_bytes(&[10u8; 32]).verifying_key_bytes(),
3043 );
3044 req.signature.key_id = acdp_did::key::did_key_url(&did_key).unwrap();
3045 let resolver = acdp_did::WebResolver::new();
3046 let err = server
3047 .prove_publish_identity(&req, &resolver)
3048 .await
3049 .unwrap_err();
3050 match err {
3051 AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("did:web")),
3052 other => panic!("expected KeyNotAuthorized for non-did:web, got {other:?}"),
3053 }
3054 }
3055
3056 /// `commit_proven` must refuse a `Proven` established against a
3057 /// different registry authority rather than silently persisting it —
3058 /// the "prove against server A, commit on server B" footgun the
3059 /// authority check exists to close.
3060 #[test]
3061 fn commit_proven_rejects_proven_from_different_authority() {
3062 let mut c1 = caps();
3063 c1.supported_did_methods.push("did:key".into());
3064 let mut c2 = caps();
3065 c2.supported_did_methods.push("did:key".into());
3066 let server_a = RegistryServer::new(InMemoryStore::new(), c1, "registry-a.example.com");
3067 let server_b = RegistryServer::new(InMemoryStore::new(), c2, "registry-b.example.com");
3068
3069 let req = did_key_request();
3070 let proven = server_a
3071 .prove_publish_identity_did_key(&req)
3072 .expect("prove must succeed against server A");
3073
3074 let err = server_b
3075 .commit_proven(proven, None, None)
3076 .expect_err("commit_proven must refuse a Proven minted for a different authority");
3077 match err {
3078 AcdpError::RegistryInternal(msg) => {
3079 assert!(
3080 msg.contains("registry-a.example.com")
3081 && msg.contains("registry-b.example.com"),
3082 "error should name both authorities, got: {msg}"
3083 );
3084 }
3085 other => panic!("expected RegistryInternal authority-mismatch, got {other:?}"),
3086 }
3087 }
3088
3089 /// The `authority` string alone is not a full registry-instance
3090 /// identity check: two `RegistryServer`s can share an `authority`
3091 /// while genuinely differing in configuration. `commit_proven` must
3092 /// refuse — not silently persist a receipt-less context — when a
3093 /// `Proven` minted on a signer-less instance is committed on a
3094 /// receipts-advertising instance that merely happens to share the
3095 /// first one's authority (RFC-ACDP-0010 §7: no degraded mode). Before
3096 /// the fix this reproduces, the commit succeeded and the persisted
3097 /// context carried `registry_receipt: None` on a registry whose own
3098 /// capabilities advertise receipts.
3099 #[test]
3100 fn commit_proven_refuses_cross_instance_same_authority_signer_mismatch() {
3101 let mut c = caps();
3102 c.supported_did_methods.push("did:key".into());
3103 c.registry_did = "did:web:shared.example.com".into();
3104 c.acdp_version = "0.2.0".into();
3105 let signer_less =
3106 RegistryServer::new(InMemoryStore::new(), c.clone(), "shared.example.com");
3107 let receipts_enabled = RegistryServer::new(InMemoryStore::new(), c, "shared.example.com")
3108 .with_receipt_signer(
3109 acdp_types::receipt::ReceiptSigner::new(
3110 SigningKey::from_bytes(&[0x44u8; 32]),
3111 "did:web:shared.example.com",
3112 "did:web:shared.example.com#receipt-key-1",
3113 )
3114 .unwrap(),
3115 )
3116 .unwrap();
3117
3118 let req = did_key_request();
3119 let proven = signer_less
3120 .prove_publish_identity_did_key(&req)
3121 .expect("prove must succeed against the signer-less instance");
3122 assert!(
3123 proven.key_fingerprint().is_none(),
3124 "a signer-less instance must not compute a fingerprint"
3125 );
3126
3127 let err = receipts_enabled
3128 .commit_proven(proven, None, None)
3129 .expect_err(
3130 "commit_proven must refuse a fingerprint-less Proven on a receipts-advertising \
3131 registry, even though the authority string matches",
3132 );
3133 match err {
3134 AcdpError::RegistryInternal(msg) => {
3135 assert!(
3136 msg.contains("no producer key fingerprint"),
3137 "error should name the actual mistake, got: {msg}"
3138 );
3139 }
3140 other => panic!("expected RegistryInternal fingerprint-mismatch, got {other:?}"),
3141 }
3142 }
3143
3144 /// The `prove → commit` split's core invariant: `commit_proven` is
3145 /// only reachable via a `Proven`, and the only way to mint one is a
3146 /// successful `prove_publish_identity*` call — a request that fails
3147 /// RFC-ACDP-0003 §2.1 verification (here: a tampered body, caught by
3148 /// the hash-mismatch check) produces no `Proven` at all, so there is
3149 /// no path from a bare `PublishRequest` to `commit_proven` for it.
3150 #[test]
3151 fn prove_publish_identity_did_key_produces_no_proven_for_a_tampered_request() {
3152 let mut c = caps();
3153 c.supported_did_methods.push("did:key".into());
3154 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
3155
3156 let mut tampered = did_key_request();
3157 tampered.title = "tampered".into();
3158 let err = server
3159 .prove_publish_identity_did_key(&tampered)
3160 .expect_err("prove must fail for a request whose hash no longer matches its body");
3161 assert!(matches!(err, AcdpError::HashMismatch { .. }), "got {err:?}");
3162 // No `Proven` was produced for `tampered` — structurally, nothing
3163 // else in this crate's public API could have manufactured one
3164 // either, since `Proven` has no public constructor.
3165 }
3166
3167 // ── the insert/replay distinction, per entry point ───────────────────
3168 //
3169 // These are the tests that did not exist anywhere before U-564. The
3170 // consuming registry's own idem-001..004 chain looks like it covers this
3171 // and does not: it builds its harness with the playground enabled and no
3172 // pinned keys, so every publish in that chain takes the one branch that
3173 // re-queries `idempotency_lookup` by hand.
3174 //
3175 // Stated precisely, because a looser earlier version of this comment was
3176 // simply false: what was missing on the three branches that go through
3177 // `commit_via_store` is coverage of the insert/replay DISTINCTION, not
3178 // replay coverage as such. `receiptless_idempotent_replay_survives_enabling_receipts`
3179 // predates this work and does drive a did:key replay — and it, not
3180 // anything added here, is what first caught a delegate that dropped the
3181 // receipt. What nothing asserted was WHICH of the two a publish was, which
3182 // is the only thing a front-end can use to choose 201 over 200, and that is
3183 // why flattening the outcome went unnoticed.
3184
3185 /// Caps that advertise `did:key` AND idempotency. `supports_idempotency_key`
3186 /// is the gate `commit_via_store` reads before it passes a key to the store
3187 /// at all — with the default `false` from [`caps`], a second publish with
3188 /// the same key is a second INSERT and no replay is reachable.
3189 ///
3190 /// Measured, because the first version of this note claimed the opposite
3191 /// and was wrong: dropping `supports_idempotency_key = true` — from here
3192 /// and from the inline `caps()` in the pinned test — makes these tests
3193 /// FAIL (117 passed, 3 failed), it does not make them pass vacuously.
3194 /// Three distinct tests fail with three distinct messages, not one
3195 /// assertion three times. The cap is load-bearing for reachability, and
3196 /// its absence is loud rather than silent.
3197 fn caps_idempotent_did_key() -> CapabilitiesDocument {
3198 let mut c = caps();
3199 c.supported_did_methods.push("did:key".into());
3200 c.supports_idempotency_key = true;
3201 c.limits.idempotency_key_ttl_seconds = Some(86_400);
3202 c
3203 }
3204
3205 /// did:key branch: first publish inserts, the same request with the same
3206 /// `Idempotency-Key` replays.
3207 ///
3208 /// A registry front-end reads exactly this to choose `201 Created` +
3209 /// `Location` vs `200 OK`; RFC-ACDP-0003's idem-002 requires 200 on the
3210 /// second call and says explicitly NOT 201.
3211 #[test]
3212 fn did_key_publish_reports_inserted_then_idempotent_replay() {
3213 let server = RegistryServer::new(
3214 InMemoryStore::new(),
3215 caps_idempotent_did_key(),
3216 "registry.example.com",
3217 );
3218 let req = did_key_request();
3219
3220 let first = server
3221 .publish_verified_did_key_in_tenant_with_outcome(&req, Some("k-did-key"), None)
3222 .expect("first publish must succeed");
3223 assert!(
3224 !first.is_replay(),
3225 "a first publish is an insert, not a replay"
3226 );
3227 assert!(matches!(first, PublishCommitOutcome::Inserted(_)));
3228
3229 let second = server
3230 .publish_verified_did_key_in_tenant_with_outcome(&req, Some("k-did-key"), None)
3231 .expect("same-hash retry with the same key must succeed");
3232 assert!(
3233 second.is_replay(),
3234 "a same-key same-hash retry is a replay — answering 201 here violates idem-002"
3235 );
3236 assert!(matches!(second, PublishCommitOutcome::IdempotentReplay(_)));
3237
3238 // idem-002 also requires the replay to return the ORIGINAL response,
3239 // so assert identity rather than merely "it replayed".
3240 assert_eq!(
3241 first.response().ctx_id,
3242 second.response().ctx_id,
3243 "a replay must return the original response verbatim"
3244 );
3245 }
3246
3247 /// Pinned branch: same property, reached through a different entry point.
3248 ///
3249 /// Worth its own test rather than trusting that it shares
3250 /// `commit_via_store` with the did:key path: what is under test is each
3251 /// PUBLIC entry point's contract, and a delegate that dropped the outcome
3252 /// would be invisible to a test of the other one.
3253 #[test]
3254 fn pinned_publish_reports_inserted_then_idempotent_replay() {
3255 use base64::{engine::general_purpose::STANDARD, Engine};
3256
3257 let key = SigningKey::from_bytes(&[4u8; 32]);
3258 let pub_b64 = STANDARD.encode(key.verifying_key_bytes());
3259 let did = "did:web:agents.example.com:pinned-idem";
3260 let p = Producer::new(key, AgentDid::new(did), format!("{did}#key-1"));
3261 let req = p
3262 .publish_request()
3263 .title("pinned publish, idempotent retry")
3264 .context_type(ContextType::DataSnapshot)
3265 .visibility(Visibility::Public)
3266 .build()
3267 .unwrap();
3268
3269 let mut c = caps();
3270 c.supports_idempotency_key = true;
3271 c.limits.idempotency_key_ttl_seconds = Some(86_400);
3272 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
3273
3274 let first = server
3275 .publish_pinned_verified_in_tenant_with_outcome(
3276 &req,
3277 Some("k-pinned"),
3278 None,
3279 &pub_b64,
3280 "ed25519",
3281 )
3282 .expect("first pinned publish must succeed");
3283 assert!(!first.is_replay(), "a first publish is an insert");
3284
3285 let second = server
3286 .publish_pinned_verified_in_tenant_with_outcome(
3287 &req,
3288 Some("k-pinned"),
3289 None,
3290 &pub_b64,
3291 "ed25519",
3292 )
3293 .expect("pinned same-hash retry must succeed");
3294 assert!(
3295 second.is_replay(),
3296 "a same-key same-hash pinned retry is a replay"
3297 );
3298 assert_eq!(first.response().ctx_id, second.response().ctx_id);
3299 }
3300
3301 /// The bare-response entry points must keep returning exactly what they
3302 /// returned before U-564 — they are now one-line delegates, and this pins
3303 /// that the delegation is lossless rather than assuming it.
3304 ///
3305 /// Compared across a REPLAY, on one server, deliberately. Two independent
3306 /// inserts can never be compared field-for-field: the store assigns a
3307 /// fresh `ctx_id` per insert and `lineage_id` is derived from it, so an
3308 /// insert-vs-insert test can only assert the handful of fields the request
3309 /// determines — which is exactly the kind of weakened assertion that
3310 /// passes while the interesting field differs. Replaying the first publish
3311 /// through the bare entry point makes the two responses the SAME record,
3312 /// so full equality is meaningful.
3313 #[test]
3314 fn bare_entry_point_matches_its_outcome_twin() {
3315 // A receipt signer is attached DELIBERATELY. Without one,
3316 // `registry_receipt` is `None` on both sides and the "receipt
3317 // included" half of the assertion below is unbacked — a delegate that
3318 // dropped the receipt would sail through. With one, `minted_expected`
3319 // is true (it is `minter.is_some()`, and the minter needs both a
3320 // signer and a producer fingerprint), so the insert carries a real
3321 // receipt and the replay must return it verbatim.
3322 let mut c = caps_idempotent_did_key();
3323 c.acdp_version = "0.2.0".into();
3324 let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
3325 .with_receipt_signer(
3326 acdp_types::receipt::ReceiptSigner::new(
3327 SigningKey::from_bytes(&[0x21u8; 32]),
3328 "did:web:registry.example.com",
3329 "did:web:registry.example.com#receipt-key-1",
3330 )
3331 .unwrap(),
3332 )
3333 .unwrap();
3334 let req = did_key_request();
3335
3336 let inserted = server
3337 .publish_verified_did_key_in_tenant_with_outcome(&req, Some("k-a"), None)
3338 .unwrap();
3339 assert!(
3340 inserted.response().registry_receipt.is_some(),
3341 "fixture precondition: the insert must actually mint a receipt, or \
3342 the receipt half of this test proves nothing"
3343 );
3344 assert!(!inserted.is_replay());
3345 let via_twin = inserted.into_response();
3346
3347 let via_bare = server
3348 .publish_verified_did_key_in_tenant(&req, Some("k-a"), None)
3349 .unwrap();
3350
3351 // `PublishResponse` has no `PartialEq`, and comparing a chosen subset
3352 // of fields is how a delegate that drops one goes unnoticed. Compare
3353 // the serialized form instead: it is the whole wire surface, which is
3354 // also the surface this contract is actually about.
3355 assert_eq!(
3356 serde_json::to_value(&via_twin).unwrap(),
3357 serde_json::to_value(&via_bare).unwrap(),
3358 "the bare entry point must return the replayed record verbatim — \
3359 it is a delegate to the twin and must lose nothing, receipt included"
3360 );
3361 }
3362}