lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 79964de9a20412169666d1e492e1e3e200b1ee9c
parent 76529e0da67e19a6570fbc1ddcd435182e3ca791
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 09:02:03 +0000

outbox: allow delegated publish targets

- add explicit empty-target opt-in for outbox enqueue inputs
- relax the outbox quorum schema invariant for delegated publishing
- preserve default rejection of accidental empty relay sets
- cover draft and signed delegated target enqueue behavior

Diffstat:
Mcrates/outbox/migrations/0001_outbox.up.sql | 2+-
Mcrates/outbox/src/model.rs | 14++++++++++++++
Mcrates/outbox/src/store.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 99 insertions(+), 3 deletions(-)

diff --git a/crates/outbox/migrations/0001_outbox.up.sql b/crates/outbox/migrations/0001_outbox.up.sql @@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS outbox_event ( signed_event_json TEXT, raw_event_json TEXT, state TEXT NOT NULL CHECK (state IN ('draft_queued', 'signing', 'signed', 'publishing', 'published', 'sign_retryable', 'publish_retryable', 'failed_terminal', 'cancelled')), - accepted_quorum INTEGER NOT NULL CHECK (accepted_quorum > 0), + accepted_quorum INTEGER NOT NULL CHECK (accepted_quorum >= 0), attempt_count INTEGER NOT NULL, claim_token TEXT, claim_owner TEXT, diff --git a/crates/outbox/src/model.rs b/crates/outbox/src/model.rs @@ -127,6 +127,7 @@ pub struct RadrootsOutboxOperationInput { pub draft: RadrootsFrozenEventDraft, pub target_relays: Vec<String>, pub idempotency_key: Option<String>, + pub allow_empty_target_relays: bool, pub created_at_ms: i64, } @@ -142,6 +143,7 @@ impl RadrootsOutboxOperationInput { draft, target_relays, idempotency_key: None, + allow_empty_target_relays: false, created_at_ms, } } @@ -150,6 +152,11 @@ impl RadrootsOutboxOperationInput { self.idempotency_key = Some(idempotency_key.into()); self } + + pub fn allow_empty_target_relays(mut self) -> Self { + self.allow_empty_target_relays = true; + self + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -159,6 +166,7 @@ pub struct RadrootsOutboxSignedOperationInput { pub signed_event: RadrootsSignedNostrEvent, pub target_relays: Vec<String>, pub idempotency_key: Option<String>, + pub allow_empty_target_relays: bool, pub event_store_inserted: bool, pub event_store_ingested_at_ms: i64, pub created_at_ms: i64, @@ -180,6 +188,7 @@ impl RadrootsOutboxSignedOperationInput { signed_event, target_relays, idempotency_key: None, + allow_empty_target_relays: false, event_store_inserted, event_store_ingested_at_ms, created_at_ms, @@ -190,6 +199,11 @@ impl RadrootsOutboxSignedOperationInput { self.idempotency_key = Some(idempotency_key.into()); self } + + pub fn allow_empty_target_relays(mut self) -> Self { + self.allow_empty_target_relays = true; + self + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -118,7 +118,7 @@ impl RadrootsOutbox { input: RadrootsOutboxOperationInput, ) -> Result<RadrootsOutboxEnqueueReceipt, RadrootsOutboxError> { let target_relays = ordered_unique_relays(input.target_relays); - if target_relays.is_empty() { + if target_relays.is_empty() && !input.allow_empty_target_relays { return Err(RadrootsOutboxError::EmptyTargetRelays); } let digest_relays = digest_relays(target_relays.as_slice()); @@ -216,7 +216,7 @@ impl RadrootsOutbox { ) -> Result<RadrootsOutboxEnqueueReceipt, RadrootsOutboxError> { validate_signed_nostr_event_matches_draft(&input.signed_event, &input.draft)?; let target_relays = ordered_unique_relays(input.target_relays); - if target_relays.is_empty() { + if target_relays.is_empty() && !input.allow_empty_target_relays { return Err(RadrootsOutboxError::EmptyTargetRelays); } let digest_relays = digest_relays(target_relays.as_slice()); @@ -1638,6 +1638,88 @@ mod tests { } #[tokio::test] + async fn enqueue_allows_explicitly_delegated_empty_target_relays() { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + let draft = post_draft(hex_64('a').as_str(), "delegated empty"); + + let receipt = outbox + .enqueue_operation( + RadrootsOutboxOperationInput::new("publish_post", draft, Vec::new(), 1_000) + .allow_empty_target_relays(), + ) + .await + .expect("delegated empty relays"); + + let event = outbox + .get_event(receipt.outbox_event_id) + .await + .expect("event") + .expect("event"); + assert_eq!(event.accepted_quorum, 0); + assert_eq!( + outbox + .relay_statuses(receipt.outbox_event_id) + .await + .expect("relay statuses"), + Vec::new() + ); + + let claimed = outbox + .claim_next_ready_event("worker-a", "claim-a", 2_000, 1_000) + .await + .expect("claim") + .expect("claim"); + assert_eq!(claimed.target_relays, Vec::<String>::new()); + } + + #[tokio::test] + async fn enqueue_signed_allows_explicitly_delegated_empty_target_relays() { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "signed delegated empty"); + let signed_event = + radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); + + let receipt = outbox + .enqueue_signed_operation( + RadrootsOutboxSignedOperationInput::new( + "publish_post", + draft, + signed_event.clone(), + Vec::new(), + false, + 1_007, + 1_000, + ) + .allow_empty_target_relays(), + ) + .await + .expect("delegated signed empty relays"); + + let event = outbox + .get_event(receipt.outbox_event_id) + .await + .expect("event") + .expect("event"); + assert_eq!(event.accepted_quorum, 0); + assert_eq!(event.signed_event, Some(signed_event.clone())); + assert_eq!( + outbox + .relay_statuses(receipt.outbox_event_id) + .await + .expect("relay statuses"), + Vec::new() + ); + + let claimed = outbox + .claim_next_ready_signed_event("publisher-a", "claim-a", 2_000, 1_000) + .await + .expect("claim") + .expect("claim"); + assert_eq!(claimed.signed_event, Some(signed_event)); + assert_eq!(claimed.target_relays, Vec::<String>::new()); + } + + #[tokio::test] async fn claim_next_ready_event_returns_none_when_no_work_is_ready() { let outbox = RadrootsOutbox::open_memory().await.expect("open");