commit 4d37ba6ec129e04a69cfc8f612d84c61911817fe
parent 65eb3825d7f932535fba7fdb563108c7ff19e205
Author: triesap <tyson@radroots.org>
Date: Mon, 15 Jun 2026 13:49:16 -0700
outbox: add signed enqueue substrate
- add shared signed-event draft validation in radroots_events and reuse it from authority
- add signed outbox operation input and direct signed enqueue persistence
- test signed enqueue idempotency, draft mismatch rejection, and push-ready records
- validation: cargo check -p radroots_outbox; cargo test -p radroots_outbox; cargo test -p radroots_events draft; cargo test -p radroots_authority
Diffstat:
6 files changed, 538 insertions(+), 57 deletions(-)
diff --git a/crates/authority/src/authorization.rs b/crates/authority/src/authorization.rs
@@ -5,7 +5,8 @@ use radroots_events::contract::{RadrootsEventContract, event_contract};
#[cfg(test)]
use radroots_events::draft::RadrootsSignedNostrEventParts;
use radroots_events::draft::{
- RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, compute_nip01_event_id,
+ RadrootsDraftError, RadrootsFrozenEventDraft, RadrootsSignedNostrEvent,
+ validate_signed_nostr_event_matches_draft,
};
#[cfg(not(feature = "std"))]
@@ -89,62 +90,65 @@ pub fn validate_signed_event_matches_draft(
signed_event: &RadrootsSignedNostrEvent,
draft: &RadrootsFrozenEventDraft,
) -> Result<(), RadrootsAuthorityError> {
- if signed_event.pubkey.as_str() != draft.expected_pubkey.as_str() {
- return Err(RadrootsAuthorityError::SignedEventPubkeyMismatch {
- expected_pubkey: draft.expected_pubkey.clone(),
- actual_pubkey: signed_event.pubkey.clone(),
- });
- }
- if signed_event.id.as_str() != draft.expected_event_id.as_str() {
- return Err(RadrootsAuthorityError::SignedEventIdMismatch {
- expected_event_id: draft.expected_event_id.clone(),
- actual_event_id: signed_event.id.clone(),
- });
- }
- if signed_event.created_at != draft.created_at {
- return Err(RadrootsAuthorityError::SignedEventCreatedAtMismatch {
- expected_created_at: draft.created_at,
- actual_created_at: signed_event.created_at,
- });
- }
- if signed_event.kind != draft.kind {
- return Err(RadrootsAuthorityError::SignedEventKindMismatch {
- expected_kind: draft.kind,
- actual_kind: signed_event.kind,
- });
- }
- if signed_event.tags != draft.tags {
- return Err(RadrootsAuthorityError::SignedEventTagsMismatch {
- expected_len: draft.tags.len(),
- actual_len: signed_event.tags.len(),
- });
- }
- if signed_event.content != draft.content {
- return Err(RadrootsAuthorityError::SignedEventContentMismatch {
- expected_len: draft.content.len(),
- actual_len: signed_event.content.len(),
- });
- }
- let computed_event_id = compute_nip01_event_id(
- signed_event.pubkey.as_str(),
- signed_event.created_at,
- signed_event.kind,
- &signed_event.tags,
- signed_event.content.as_str(),
- )
- .map_err(
- |error| RadrootsAuthorityError::SignedEventComputedIdInvalid {
- message: error.to_string(),
+ validate_signed_nostr_event_matches_draft(signed_event, draft)
+ .map_err(authority_error_from_draft_validation)
+}
+
+fn authority_error_from_draft_validation(error: RadrootsDraftError) -> RadrootsAuthorityError {
+ match error {
+ RadrootsDraftError::SignedEventPubkeyMismatch {
+ expected_pubkey,
+ actual_pubkey,
+ } => RadrootsAuthorityError::SignedEventPubkeyMismatch {
+ expected_pubkey,
+ actual_pubkey,
+ },
+ RadrootsDraftError::SignedEventIdMismatch {
+ expected_event_id,
+ actual_event_id,
+ } => RadrootsAuthorityError::SignedEventIdMismatch {
+ expected_event_id,
+ actual_event_id,
+ },
+ RadrootsDraftError::SignedEventCreatedAtMismatch {
+ expected_created_at,
+ actual_created_at,
+ } => RadrootsAuthorityError::SignedEventCreatedAtMismatch {
+ expected_created_at,
+ actual_created_at,
+ },
+ RadrootsDraftError::SignedEventKindMismatch {
+ expected_kind,
+ actual_kind,
+ } => RadrootsAuthorityError::SignedEventKindMismatch {
+ expected_kind,
+ actual_kind,
+ },
+ RadrootsDraftError::SignedEventTagsMismatch {
+ expected_len,
+ actual_len,
+ } => RadrootsAuthorityError::SignedEventTagsMismatch {
+ expected_len,
+ actual_len,
},
- )?
- .into_string();
- if computed_event_id.as_str() != signed_event.id.as_str() {
- return Err(RadrootsAuthorityError::SignedEventComputedIdMismatch {
- expected_event_id: signed_event.id.clone(),
+ RadrootsDraftError::SignedEventContentMismatch {
+ expected_len,
+ actual_len,
+ } => RadrootsAuthorityError::SignedEventContentMismatch {
+ expected_len,
+ actual_len,
+ },
+ RadrootsDraftError::SignedEventComputedIdMismatch {
+ expected_event_id,
computed_event_id,
- });
+ } => RadrootsAuthorityError::SignedEventComputedIdMismatch {
+ expected_event_id,
+ computed_event_id,
+ },
+ error => RadrootsAuthorityError::SignedEventComputedIdInvalid {
+ message: error.to_string(),
+ },
}
- Ok(())
}
#[cfg(test)]
diff --git a/crates/events/src/draft.rs b/crates/events/src/draft.rs
@@ -30,6 +30,34 @@ pub enum RadrootsDraftError {
expected_kind: u32,
actual_kind: u32,
},
+ SignedEventPubkeyMismatch {
+ expected_pubkey: String,
+ actual_pubkey: String,
+ },
+ SignedEventIdMismatch {
+ expected_event_id: String,
+ actual_event_id: String,
+ },
+ SignedEventCreatedAtMismatch {
+ expected_created_at: u32,
+ actual_created_at: u32,
+ },
+ SignedEventKindMismatch {
+ expected_kind: u32,
+ actual_kind: u32,
+ },
+ SignedEventTagsMismatch {
+ expected_len: usize,
+ actual_len: usize,
+ },
+ SignedEventContentMismatch {
+ expected_len: usize,
+ actual_len: usize,
+ },
+ SignedEventComputedIdMismatch {
+ expected_event_id: String,
+ computed_event_id: String,
+ },
IdParse(RadrootsIdParseError),
JsonString(String),
}
@@ -48,6 +76,55 @@ impl fmt::Display for RadrootsDraftError {
f,
"event contract `{contract_id}` expects kind {expected_kind}, got {actual_kind}"
),
+ Self::SignedEventPubkeyMismatch {
+ expected_pubkey,
+ actual_pubkey,
+ } => write!(
+ f,
+ "signed event pubkey mismatch: expected {expected_pubkey}, got {actual_pubkey}"
+ ),
+ Self::SignedEventIdMismatch {
+ expected_event_id,
+ actual_event_id,
+ } => write!(
+ f,
+ "signed event id mismatch: expected {expected_event_id}, got {actual_event_id}"
+ ),
+ Self::SignedEventCreatedAtMismatch {
+ expected_created_at,
+ actual_created_at,
+ } => write!(
+ f,
+ "signed event created_at mismatch: expected {expected_created_at}, got {actual_created_at}"
+ ),
+ Self::SignedEventKindMismatch {
+ expected_kind,
+ actual_kind,
+ } => write!(
+ f,
+ "signed event kind mismatch: expected {expected_kind}, got {actual_kind}"
+ ),
+ Self::SignedEventTagsMismatch {
+ expected_len,
+ actual_len,
+ } => write!(
+ f,
+ "signed event tags mismatch: expected {expected_len} tags, got {actual_len} tags"
+ ),
+ Self::SignedEventContentMismatch {
+ expected_len,
+ actual_len,
+ } => write!(
+ f,
+ "signed event content mismatch: expected {expected_len} bytes, got {actual_len} bytes"
+ ),
+ Self::SignedEventComputedIdMismatch {
+ expected_event_id,
+ computed_event_id,
+ } => write!(
+ f,
+ "signed event computed id mismatch: expected {expected_event_id}, computed {computed_event_id}"
+ ),
Self::IdParse(error) => write!(f, "{error}"),
Self::JsonString(error) => write!(f, "json string serialization failed: {error}"),
}
@@ -189,6 +266,63 @@ impl RadrootsSignedNostrEvent {
}
}
+pub fn validate_signed_nostr_event_matches_draft(
+ signed_event: &RadrootsSignedNostrEvent,
+ draft: &RadrootsFrozenEventDraft,
+) -> Result<(), RadrootsDraftError> {
+ if signed_event.pubkey.as_str() != draft.expected_pubkey.as_str() {
+ return Err(RadrootsDraftError::SignedEventPubkeyMismatch {
+ expected_pubkey: draft.expected_pubkey.clone(),
+ actual_pubkey: signed_event.pubkey.clone(),
+ });
+ }
+ if signed_event.id.as_str() != draft.expected_event_id.as_str() {
+ return Err(RadrootsDraftError::SignedEventIdMismatch {
+ expected_event_id: draft.expected_event_id.clone(),
+ actual_event_id: signed_event.id.clone(),
+ });
+ }
+ if signed_event.created_at != draft.created_at {
+ return Err(RadrootsDraftError::SignedEventCreatedAtMismatch {
+ expected_created_at: draft.created_at,
+ actual_created_at: signed_event.created_at,
+ });
+ }
+ if signed_event.kind != draft.kind {
+ return Err(RadrootsDraftError::SignedEventKindMismatch {
+ expected_kind: draft.kind,
+ actual_kind: signed_event.kind,
+ });
+ }
+ if signed_event.tags != draft.tags {
+ return Err(RadrootsDraftError::SignedEventTagsMismatch {
+ expected_len: draft.tags.len(),
+ actual_len: signed_event.tags.len(),
+ });
+ }
+ if signed_event.content != draft.content {
+ return Err(RadrootsDraftError::SignedEventContentMismatch {
+ expected_len: draft.content.len(),
+ actual_len: signed_event.content.len(),
+ });
+ }
+ let computed_event_id = compute_nip01_event_id(
+ signed_event.pubkey.as_str(),
+ signed_event.created_at,
+ signed_event.kind,
+ &signed_event.tags,
+ signed_event.content.as_str(),
+ )?
+ .into_string();
+ if computed_event_id.as_str() != signed_event.id.as_str() {
+ return Err(RadrootsDraftError::SignedEventComputedIdMismatch {
+ expected_event_id: signed_event.id.clone(),
+ computed_event_id,
+ });
+ }
+ Ok(())
+}
+
pub fn compute_nip01_event_id(
pubkey: &str,
created_at: u32,
@@ -364,4 +498,35 @@ mod tests {
assert_eq!(decoded, signed);
assert_eq!(decoded.pubkey, hex_64('e'));
}
+
+ #[test]
+ fn signed_event_validation_rejects_draft_mismatches() {
+ let draft = RadrootsFrozenEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ 1_700_000_000,
+ vec![vec!["t".to_owned(), "soil".to_owned()]],
+ "hello",
+ "a".repeat(64),
+ )
+ .expect("draft");
+ let signed = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
+ id: draft.expected_event_id.clone(),
+ pubkey: draft.expected_pubkey.clone(),
+ created_at: draft.created_at,
+ kind: draft.kind,
+ tags: draft.tags.clone(),
+ content: "changed".to_owned(),
+ sig: "b".repeat(128),
+ raw_json: "{}".to_owned(),
+ })
+ .expect("signed");
+
+ let error =
+ validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
+ assert!(matches!(
+ error,
+ RadrootsDraftError::SignedEventContentMismatch { .. }
+ ));
+ }
}
diff --git a/crates/outbox/src/error.rs b/crates/outbox/src/error.rs
@@ -13,6 +13,9 @@ pub enum RadrootsOutboxError {
#[error("Event store error: {0}")]
EventStore(#[from] radroots_event_store::RadrootsEventStoreError),
+ #[error("Signed event does not match frozen draft: {0}")]
+ SignedEventDraftMismatch(#[from] radroots_events::draft::RadrootsDraftError),
+
#[error("target relays cannot be empty")]
EmptyTargetRelays,
diff --git a/crates/outbox/src/lib.rs b/crates/outbox/src/lib.rs
@@ -11,6 +11,6 @@ pub use model::{
RadrootsOutboxClaimedEvent, RadrootsOutboxEnqueueReceipt, RadrootsOutboxEnqueueStatus,
RadrootsOutboxEventRecord, RadrootsOutboxEventState, RadrootsOutboxEventStoreIngestReceipt,
RadrootsOutboxOperationInput, RadrootsOutboxOperationRecord, RadrootsOutboxOperationStatus,
- RadrootsOutboxRelayStatus, RadrootsOutboxRelayStatusRecord,
+ RadrootsOutboxRelayStatus, RadrootsOutboxRelayStatusRecord, RadrootsOutboxSignedOperationInput,
};
pub use store::RadrootsOutbox;
diff --git a/crates/outbox/src/model.rs b/crates/outbox/src/model.rs
@@ -152,6 +152,46 @@ impl RadrootsOutboxOperationInput {
}
}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsOutboxSignedOperationInput {
+ pub operation_kind: String,
+ pub draft: RadrootsFrozenEventDraft,
+ pub signed_event: RadrootsSignedNostrEvent,
+ pub target_relays: Vec<String>,
+ pub idempotency_key: Option<String>,
+ pub event_store_inserted: bool,
+ pub event_store_ingested_at_ms: i64,
+ pub created_at_ms: i64,
+}
+
+impl RadrootsOutboxSignedOperationInput {
+ pub fn new(
+ operation_kind: impl Into<String>,
+ draft: RadrootsFrozenEventDraft,
+ signed_event: RadrootsSignedNostrEvent,
+ target_relays: Vec<String>,
+ event_store_inserted: bool,
+ event_store_ingested_at_ms: i64,
+ created_at_ms: i64,
+ ) -> Self {
+ Self {
+ operation_kind: operation_kind.into(),
+ draft,
+ signed_event,
+ target_relays,
+ idempotency_key: None,
+ event_store_inserted,
+ event_store_ingested_at_ms,
+ created_at_ms,
+ }
+ }
+
+ pub fn with_idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self {
+ self.idempotency_key = Some(idempotency_key.into());
+ self
+ }
+}
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RadrootsOutboxEnqueueStatus {
Inserted,
diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs
@@ -6,11 +6,13 @@ use crate::model::{
RadrootsOutboxClaimedEvent, RadrootsOutboxEnqueueReceipt, RadrootsOutboxEnqueueStatus,
RadrootsOutboxEventRecord, RadrootsOutboxEventState, RadrootsOutboxEventStoreIngestReceipt,
RadrootsOutboxOperationInput, RadrootsOutboxOperationRecord, RadrootsOutboxOperationStatus,
- RadrootsOutboxRelayStatus, RadrootsOutboxRelayStatusRecord,
+ RadrootsOutboxRelayStatus, RadrootsOutboxRelayStatusRecord, RadrootsOutboxSignedOperationInput,
};
use radroots_event_store::{RadrootsEventIngest, RadrootsEventStore};
use radroots_events::RadrootsNostrEvent;
-use radroots_events::draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent};
+use radroots_events::draft::{
+ RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, validate_signed_nostr_event_matches_draft,
+};
use serde::Serialize;
use sha2::{Digest, Sha256};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult};
@@ -164,6 +166,108 @@ impl RadrootsOutbox {
})
}
+ pub async fn enqueue_signed_operation(
+ &self,
+ input: RadrootsOutboxSignedOperationInput,
+ ) -> Result<RadrootsOutboxEnqueueReceipt, RadrootsOutboxError> {
+ validate_signed_nostr_event_matches_draft(&input.signed_event, &input.draft)?;
+ let target_relays = canonical_relays(input.target_relays);
+ if target_relays.is_empty() {
+ return Err(RadrootsOutboxError::EmptyTargetRelays);
+ }
+ let digest = idempotency_digest(
+ input.operation_kind.as_str(),
+ input.draft.expected_pubkey.as_str(),
+ &input.draft,
+ &target_relays,
+ )?;
+ let accepted_quorum = target_relays.len() as i64;
+ let mut tx = self.pool.begin().await?;
+
+ if let Some(idempotency_key) = input.idempotency_key.as_deref()
+ && let Some(existing) = existing_idempotent_operation(
+ &mut tx,
+ input.operation_kind.as_str(),
+ input.draft.expected_pubkey.as_str(),
+ idempotency_key,
+ )
+ .await?
+ {
+ if existing.idempotency_digest != digest {
+ return Err(RadrootsOutboxError::IdempotencyConflict {
+ operation_kind: input.operation_kind,
+ expected_pubkey: input.draft.expected_pubkey,
+ idempotency_key: idempotency_key.to_owned(),
+ existing_digest: existing.idempotency_digest,
+ new_digest: digest,
+ });
+ }
+ tx.commit().await?;
+ return Ok(RadrootsOutboxEnqueueReceipt {
+ status: RadrootsOutboxEnqueueStatus::Existing,
+ operation_id: existing.operation_id,
+ outbox_event_id: existing.outbox_event_id,
+ expected_event_id: existing.event_id,
+ idempotency_digest: digest,
+ });
+ }
+
+ let operation = sqlx::query(
+ "INSERT INTO outbox_operation(operation_kind, expected_pubkey, idempotency_key, idempotency_digest, status, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?)",
+ )
+ .bind(input.operation_kind.as_str())
+ .bind(input.draft.expected_pubkey.as_str())
+ .bind(input.idempotency_key.as_deref())
+ .bind(digest.as_str())
+ .bind(RadrootsOutboxOperationStatus::Queued.as_str())
+ .bind(input.created_at_ms)
+ .bind(input.created_at_ms)
+ .execute(&mut *tx)
+ .await?;
+ let operation_id = operation.last_insert_rowid();
+ let draft_json = serde_json::to_string(&input.draft)?;
+ let signed_event_json = serde_json::to_string(&input.signed_event)?;
+ let event = sqlx::query(
+ "INSERT INTO outbox_event(operation_id, event_id, expected_pubkey, draft_json, signed_event_json, raw_event_json, state, accepted_quorum, attempt_count, next_attempt_after_ms, event_store_ingested, event_store_inserted, event_store_ingested_at_ms, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 1, ?, ?, ?, ?)",
+ )
+ .bind(operation_id)
+ .bind(input.draft.expected_event_id.as_str())
+ .bind(input.draft.expected_pubkey.as_str())
+ .bind(draft_json.as_str())
+ .bind(signed_event_json.as_str())
+ .bind(input.signed_event.raw_json.as_str())
+ .bind(RadrootsOutboxEventState::Signed.as_str())
+ .bind(accepted_quorum)
+ .bind(input.created_at_ms)
+ .bind(bool_i64(input.event_store_inserted))
+ .bind(input.event_store_ingested_at_ms)
+ .bind(input.created_at_ms)
+ .bind(input.created_at_ms)
+ .execute(&mut *tx)
+ .await?;
+ let outbox_event_id = event.last_insert_rowid();
+
+ for relay_url in target_relays {
+ sqlx::query(
+ "INSERT INTO outbox_event_relay_status(outbox_event_id, relay_url, status, attempt_count) VALUES (?, ?, ?, 0)",
+ )
+ .bind(outbox_event_id)
+ .bind(relay_url.as_str())
+ .bind(RadrootsOutboxRelayStatus::Pending.as_str())
+ .execute(&mut *tx)
+ .await?;
+ }
+
+ tx.commit().await?;
+ Ok(RadrootsOutboxEnqueueReceipt {
+ status: RadrootsOutboxEnqueueStatus::Inserted,
+ operation_id,
+ outbox_event_id,
+ expected_event_id: input.draft.expected_event_id,
+ idempotency_digest: digest,
+ })
+ }
+
pub async fn get_operation(
&self,
operation_id: i64,
@@ -1014,6 +1118,26 @@ mod tests {
)
}
+ fn signed_operation_input(
+ draft: RadrootsFrozenEventDraft,
+ signed_event: RadrootsSignedNostrEvent,
+ created_at_ms: i64,
+ ) -> RadrootsOutboxSignedOperationInput {
+ RadrootsOutboxSignedOperationInput::new(
+ "publish_post",
+ draft,
+ signed_event,
+ vec![
+ RELAY_PRIMARY_WSS.to_owned(),
+ RELAY_SECONDARY_WSS.to_owned(),
+ RELAY_PRIMARY_WSS.to_owned(),
+ ],
+ true,
+ created_at_ms + 7,
+ created_at_ms,
+ )
+ }
+
fn fixture_keys() -> RadrootsNostrKeys {
let secret_key =
RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key");
@@ -1289,6 +1413,151 @@ mod tests {
}
#[tokio::test]
+ async fn enqueue_signed_operation_stores_pushable_signed_event_without_claim() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "signed");
+ let signed_event =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event");
+ let expected_raw_json = signed_event.raw_json.clone();
+
+ let receipt = outbox
+ .enqueue_signed_operation(
+ signed_operation_input(draft.clone(), signed_event.clone(), 1_000)
+ .with_idempotency_key("signed-a"),
+ )
+ .await
+ .expect("signed enqueue");
+
+ assert_eq!(receipt.status, RadrootsOutboxEnqueueStatus::Inserted);
+ assert_eq!(receipt.expected_event_id, draft.expected_event_id);
+
+ let event = outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("event")
+ .expect("event");
+ assert_eq!(event.state, RadrootsOutboxEventState::Signed);
+ assert_eq!(event.signed_event, Some(signed_event.clone()));
+ assert_eq!(event.raw_event_json, Some(expected_raw_json));
+ assert!(event.event_store_ingested);
+ assert!(event.event_store_inserted);
+ assert_eq!(event.event_store_ingested_at_ms, Some(1_007));
+ assert_eq!(event.claim_token, None);
+ assert_eq!(event.claim_owner, None);
+ assert_eq!(event.claim_expires_at_ms, None);
+
+ let statuses = outbox
+ .relay_statuses(receipt.outbox_event_id)
+ .await
+ .expect("statuses");
+ assert_eq!(statuses.len(), 2);
+ assert!(
+ statuses
+ .iter()
+ .all(|status| status.status == RadrootsOutboxRelayStatus::Pending)
+ );
+
+ let claimed = outbox
+ .claim_next_ready_event("publisher-a", "claim-a", 2_000, 1_000)
+ .await
+ .expect("claim")
+ .expect("claimed");
+ assert_eq!(claimed.state, RadrootsOutboxEventState::Publishing);
+ assert_eq!(claimed.signed_event, Some(signed_event));
+ assert_eq!(
+ claimed.target_relays,
+ vec![RELAY_SECONDARY_WSS.to_owned(), RELAY_PRIMARY_WSS.to_owned()]
+ );
+ }
+
+ #[tokio::test]
+ async fn enqueue_signed_operation_idempotency_reuses_existing_signed_record() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "idem-signed");
+ let signed_event =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event");
+
+ let first = outbox
+ .enqueue_signed_operation(
+ signed_operation_input(draft.clone(), signed_event.clone(), 1_000)
+ .with_idempotency_key("signed-idem"),
+ )
+ .await
+ .expect("first");
+ let second = outbox
+ .enqueue_signed_operation(
+ signed_operation_input(draft.clone(), signed_event, 1_100)
+ .with_idempotency_key("signed-idem"),
+ )
+ .await
+ .expect("second");
+
+ assert_eq!(first.status, RadrootsOutboxEnqueueStatus::Inserted);
+ assert_eq!(second.status, RadrootsOutboxEnqueueStatus::Existing);
+ assert_eq!(first.operation_id, second.operation_id);
+ assert_eq!(first.outbox_event_id, second.outbox_event_id);
+ assert_eq!(table_count(&outbox, "outbox_operation").await, 1);
+ assert_eq!(table_count(&outbox, "outbox_event").await, 1);
+
+ let changed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "changed");
+ let changed_signed =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &changed_draft).expect("signed");
+ let conflict = outbox
+ .enqueue_signed_operation(
+ signed_operation_input(changed_draft, changed_signed, 1_200)
+ .with_idempotency_key("signed-idem"),
+ )
+ .await
+ .expect_err("conflict");
+ assert!(matches!(
+ conflict,
+ RadrootsOutboxError::IdempotencyConflict { .. }
+ ));
+ }
+
+ #[tokio::test]
+ async fn enqueue_signed_operation_rejects_mismatched_signed_event() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "trusted");
+ let other_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "other");
+ let signed_event =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &other_draft).expect("signed event");
+
+ let error = outbox
+ .enqueue_signed_operation(signed_operation_input(draft, signed_event, 1_000))
+ .await
+ .expect_err("mismatch");
+
+ assert!(matches!(
+ error,
+ RadrootsOutboxError::SignedEventDraftMismatch(_)
+ ));
+ assert_eq!(table_count(&outbox, "outbox_operation").await, 0);
+ assert_eq!(table_count(&outbox, "outbox_event").await, 0);
+ }
+
+ #[tokio::test]
+ async fn enqueue_signed_operation_rejects_event_id_mismatch() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "bad-id");
+ let mut signed_event =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event");
+ signed_event.id = hex_64('f');
+
+ let error = outbox
+ .enqueue_signed_operation(signed_operation_input(draft, signed_event, 1_000))
+ .await
+ .expect_err("mismatch");
+
+ assert!(matches!(
+ error,
+ RadrootsOutboxError::SignedEventDraftMismatch(_)
+ ));
+ assert_eq!(table_count(&outbox, "outbox_operation").await, 0);
+ assert_eq!(table_count(&outbox, "outbox_event").await, 0);
+ }
+
+ #[tokio::test]
async fn claim_token_guards_updates_and_expired_signing_claim_recovers() {
let outbox = RadrootsOutbox::open_memory().await.expect("open");
let draft = post_draft(hex_64('a').as_str(), "hello");