lib

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

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:
Mcrates/authority/src/authorization.rs | 112+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcrates/events/src/draft.rs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/outbox/src/error.rs | 3+++
Mcrates/outbox/src/lib.rs | 2+-
Mcrates/outbox/src/model.rs | 40++++++++++++++++++++++++++++++++++++++++
Mcrates/outbox/src/store.rs | 273++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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");