From c942a11f8f55d64dbf1c1ed5468cc9aa9a049073 Mon Sep 17 00:00:00 2001 From: Will Griffin Date: Sat, 8 Aug 2026 22:51:19 -0600 Subject: [PATCH 1/7] feat(cli): add idempotent message delivery Signed-off-by: Will Griffin --- crates/buzz-cli/src/commands/messages.rs | 54 +++- crates/buzz-cli/src/lib.rs | 5 +- crates/buzz-db/src/event.rs | 243 ++++++++++++++++++ crates/buzz-db/src/lib.rs | 25 +- crates/buzz-db/src/migration.rs | 8 +- crates/buzz-relay/src/api/bridge.rs | 8 + crates/buzz-relay/src/conformance/mod.rs | 2 +- crates/buzz-relay/src/handlers/event.rs | 2 +- crates/buzz-relay/src/handlers/ingest.rs | 191 +++++++++++++- .../0028_message_idempotency_receipts.sql | 12 + schema/schema.sql | 14 + 11 files changed, 553 insertions(+), 11 deletions(-) create mode 100644 migrations/0028_message_idempotency_receipts.sql diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b56..e78c46602e6 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1,5 +1,6 @@ use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection}; -use nostr::PublicKey; +use nostr::{PublicKey, Tag}; +use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::client::{normalize_events, normalize_write_response, BuzzClient}; @@ -569,6 +570,18 @@ pub struct SendMessageParams { pub broadcast: bool, pub files: Vec, pub mentions: Vec, + pub idempotency_key: Option, +} + +/// Convert a caller's opaque retry key to the fixed-width value stored in the +/// event receipt tag. The raw key never leaves the CLI process. +fn idempotency_key_digest(key: &str) -> Result { + if key.is_empty() || key.len() > 256 || key.chars().any(char::is_control) { + return Err(CliError::Usage( + "--idempotency-key must be 1–256 non-control UTF-8 bytes".into(), + )); + } + Ok(hex::encode(Sha256::digest(key.as_bytes()))) } pub async fn cmd_send_message( @@ -677,9 +690,23 @@ pub async fn cmd_send_message( } }; + let idempotency_enabled = p.idempotency_key.is_some(); + let builder = if let Some(key) = p.idempotency_key.as_deref() { + let key_digest = idempotency_key_digest(key)?; + let tag = Tag::parse(["buzz-idempotency", key_digest.as_str()]) + .map_err(|e| CliError::Other(format!("build idempotency tag failed: {e}")))?; + builder.tag(tag) + } else { + builder + }; let event = client.sign_event(builder)?; let emitted_mentions = event_mention_pubkeys(&event); - let resp = client.submit_event(event).await?; + let resp = match client.submit_event(event).await { + Err(CliError::Relay { status: 409, body }) if idempotency_enabled => { + return Err(CliError::Conflict(body)); + } + other => other?, + }; let mut output: serde_json::Value = serde_json::from_str(&normalize_write_response(&resp)) .unwrap_or_else(|_| serde_json::json!({ "response": resp })); if let Some(object) = output.as_object_mut() { @@ -687,6 +714,14 @@ pub async fn cmd_send_message( "mention_pubkeys".into(), serde_json::json!(emitted_mentions), ); + if idempotency_enabled { + let replayed = object.get("message").and_then(serde_json::Value::as_str) + == Some("idempotency-replay"); + object.insert( + "idempotency".into(), + serde_json::json!({ "replayed": replayed }), + ); + } } println!("{output}"); Ok(()) @@ -880,6 +915,7 @@ pub async fn dispatch( broadcast, files, mentions, + idempotency_key, } => { cmd_send_message( client, @@ -891,6 +927,7 @@ pub async fn dispatch( broadcast, files, mentions, + idempotency_key, }, ) .await @@ -993,8 +1030,8 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, - missing_members, normalize_explicit_mentions, parse_member_pubkeys, + event_mention_pubkeys, find_root_from_tags, idempotency_key_digest, match_profiles_by_name, + merge_message_mentions, missing_members, normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, }; use buzz_sdk::mentions::{ @@ -1002,6 +1039,15 @@ mod tests { }; use serde_json::json; + #[test] + fn idempotency_key_digest_is_stable_and_rejects_empty_keys() { + assert_eq!( + idempotency_key_digest("outbox-event-1").expect("digest"), + idempotency_key_digest("outbox-event-1").expect("same digest") + ); + assert!(idempotency_key_digest("").is_err()); + } + const ID_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const ID_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const PUBKEY: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 8a8bb053b0f..3cca4a7f75f 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -352,7 +352,7 @@ buzz agents archived" pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" + after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n buzz messages send --channel --content \"retry-safe\" --idempotency-key work-outbox-123\n echo \"hello from stdin\" | buzz messages send --channel --content -" )] Send { /// Channel UUID (from 'buzz channels list') @@ -376,6 +376,9 @@ pub enum MessagesCmd { /// Pubkey to mention (hex or npub; repeatable). Supplying any explicit identity permits unresolved or ambiguous @Name text as presentation-only; uniquely resolved member names still notify. #[arg(long = "mention")] mentions: Vec, + /// Stable caller key for durable retry safety. Reusing it with different message content conflicts. + #[arg(long)] + idempotency_key: Option, }, /// Send a code diff / patch to a channel SendDiff { diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index a670a13402e..7b37374c7a8 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -1111,6 +1111,40 @@ pub struct ThreadMetadataParams<'a> { pub broadcast: bool, } +/// Caller idempotency material for a channel message. +/// +/// The key is already a fixed-length opaque digest; never persist a caller's +/// raw idempotency token in an event or database row. +#[derive(Debug)] +pub struct MessageIdempotencyParams<'a> { + /// Authenticated message author (32-byte Nostr pubkey). + pub author_pubkey: &'a [u8], + /// Opaque, caller-derived idempotency key digest (32 bytes). + pub key: &'a [u8], + /// Digest of the request's semantic message payload (32 bytes). + pub semantic_digest: &'a [u8], +} + +/// Result of an atomic idempotent message write. +#[derive(Debug)] +pub enum IdempotentMessageInsertOutcome { + /// A receipt was created for this request. The event may already have been + /// stored only when an identical signed event won a separate exact-ID race. + Created { + /// Stored message event. + stored_event: Box, + /// Whether this call inserted the event row. + event_was_inserted: bool, + }, + /// The receipt already exists with the same semantic digest. + Replay { + /// Canonical event ID recorded by the original successful write. + event_id: Vec, + }, + /// The caller reused a key for a different semantic payload. + Conflict, +} + async fn insert_event_with_thread_metadata_tx( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, @@ -1302,6 +1336,76 @@ pub async fn insert_event_with_thread_metadata( Ok(result) } +/// Atomically establish a caller-idempotency receipt and persist its message. +/// +/// The unique receipt key serializes concurrent callers. If the winning +/// transaction commits, a waiter observes either its canonical event ID (same +/// digest) or a conflict (different digest); if it rolls back, the waiter can +/// become the winner. Consequently a committed receipt never points to an +/// absent event. +pub async fn insert_idempotent_message_with_thread_metadata( + pool: &PgPool, + community_id: CommunityId, + event: &Event, + channel_id: Uuid, + thread_meta: Option>, + idempotency: MessageIdempotencyParams<'_>, +) -> Result { + let mut tx = pool.begin().await?; + let inserted_receipt: Option<(Vec,)> = sqlx::query_as( + r#" + INSERT INTO message_idempotency_receipts + (community_id, author_pubkey, channel_id, idempotency_key, semantic_digest, event_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT DO NOTHING + RETURNING event_id + "#, + ) + .bind(community_id.as_uuid()) + .bind(idempotency.author_pubkey) + .bind(channel_id) + .bind(idempotency.key) + .bind(idempotency.semantic_digest) + .bind(event.id.as_bytes().as_slice()) + .fetch_optional(&mut *tx) + .await?; + + if inserted_receipt.is_none() { + let (existing_digest, event_id): (Vec, Vec) = sqlx::query_as( + r#" + SELECT semantic_digest, event_id + FROM message_idempotency_receipts + WHERE community_id = $1 AND author_pubkey = $2 AND channel_id = $3 + AND idempotency_key = $4 + "#, + ) + .bind(community_id.as_uuid()) + .bind(idempotency.author_pubkey) + .bind(channel_id) + .bind(idempotency.key) + .fetch_one(&mut *tx) + .await?; + if existing_digest == idempotency.semantic_digest { + return Ok(IdempotentMessageInsertOutcome::Replay { event_id }); + } + return Ok(IdempotentMessageInsertOutcome::Conflict); + } + + let (stored_event, event_was_inserted) = insert_event_with_thread_metadata_tx( + &mut tx, + community_id, + event, + Some(channel_id), + thread_meta, + ) + .await?; + tx.commit().await?; + Ok(IdempotentMessageInsertOutcome::Created { + stored_event: Box::new(stored_event), + event_was_inserted, + }) +} + /// Atomically insert a kind:7 reaction event and its reaction row. /// /// Ordering is load-bearing: resolve target, upsert/reactivate the reaction row, @@ -1565,6 +1669,145 @@ mod tests { id } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn idempotency_receipt_is_atomic_under_concurrency_and_replays_canonical_event() { + let pool = setup_pool().await; + let community_uuid = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_uuid); + let channel = make_test_channel(&pool, community_uuid, None).await; + let author = Keys::generate(); + let event_a = EventBuilder::new(Kind::Custom(9), "retry-safe message") + .sign_with_keys(&author) + .expect("sign first event"); + let event_b = EventBuilder::new(Kind::Custom(9), "retry-safe message") + .custom_created_at(nostr::Timestamp::from(event_a.created_at.as_secs() + 1)) + .sign_with_keys(&author) + .expect("sign competing event"); + let author_bytes = author.public_key().to_bytes(); + let key = [7u8; 32]; + let digest = [9u8; 32]; + + let first = insert_idempotent_message_with_thread_metadata( + &pool, + community, + &event_a, + channel, + None, + MessageIdempotencyParams { + author_pubkey: &author_bytes, + key: &key, + semantic_digest: &digest, + }, + ); + let second = insert_idempotent_message_with_thread_metadata( + &pool, + community, + &event_b, + channel, + None, + MessageIdempotencyParams { + author_pubkey: &author_bytes, + key: &key, + semantic_digest: &digest, + }, + ); + let (first, second) = tokio::join!(first, second); + let (first, second) = (first.expect("first write"), second.expect("second write")); + let canonical = match (&first, &second) { + ( + IdempotentMessageInsertOutcome::Created { stored_event, .. }, + IdempotentMessageInsertOutcome::Replay { event_id }, + ) + | ( + IdempotentMessageInsertOutcome::Replay { event_id }, + IdempotentMessageInsertOutcome::Created { stored_event, .. }, + ) => { + assert_eq!( + event_id.as_slice(), + stored_event.event.id.as_bytes().as_slice() + ); + stored_event.event.id.to_hex() + } + other => panic!("one caller must create and one replay, got {other:?}"), + }; + let stored: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id = $1 AND channel_id = $2", + ) + .bind(community_uuid) + .bind(channel) + .fetch_one(&pool) + .await + .expect("count stored events"); + assert_eq!( + stored, 1, + "concurrent retries must create one visible event" + ); + + let conflicting = EventBuilder::new(Kind::Custom(9), "different payload") + .sign_with_keys(&author) + .expect("sign conflicting event"); + assert!(matches!( + insert_idempotent_message_with_thread_metadata( + &pool, + community, + &conflicting, + channel, + None, + MessageIdempotencyParams { + author_pubkey: &author_bytes, + key: &key, + semantic_digest: &[3u8; 32], + }, + ) + .await + .expect("conflict result"), + IdempotentMessageInsertOutcome::Conflict + )); + + // A rejected event must roll the freshly-inserted receipt back too; + // otherwise an ambiguous failed delivery would permanently consume a + // caller key without producing a canonical event to replay. + let rollback_key = [8u8; 32]; + let rejected = EventBuilder::new(Kind::Custom(KIND_AUTH as u16), "not stored") + .sign_with_keys(&author) + .expect("sign rejected event"); + assert!(matches!( + insert_idempotent_message_with_thread_metadata( + &pool, + community, + &rejected, + channel, + None, + MessageIdempotencyParams { + author_pubkey: &author_bytes, + key: &rollback_key, + semantic_digest: &digest, + }, + ) + .await, + Err(DbError::AuthEventRejected) + )); + assert!(matches!( + insert_idempotent_message_with_thread_metadata( + &pool, + community, + &event_a, + channel, + None, + MessageIdempotencyParams { + author_pubkey: &author_bytes, + key: &rollback_key, + semantic_digest: &digest, + }, + ) + .await + .expect("retry after rollback"), + IdempotentMessageInsertOutcome::Created { .. } + )); + assert_eq!(canonical.len(), 64, "canonical id stays a Nostr hex id"); + } + async fn make_test_channel( pool: &PgPool, community_id: Uuid, diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9b268767470..06037eadc50 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -55,7 +55,10 @@ pub mod user; pub mod workflow; pub use error::{DbError, Result}; -pub use event::{EventQuery, ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT}; +pub use event::{ + EventQuery, IdempotentMessageInsertOutcome, MessageIdempotencyParams, + ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT, +}; use chrono::{DateTime, Utc}; use sqlx::postgres::{PgConnection, PgPoolOptions}; @@ -2091,6 +2094,26 @@ impl Db { Ok(result) } + /// Atomically persist a channel message and its caller-idempotency receipt. + pub async fn insert_idempotent_message_with_thread_metadata( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Uuid, + thread_meta: Option>, + idempotency: event::MessageIdempotencyParams<'_>, + ) -> Result { + event::insert_idempotent_message_with_thread_metadata( + &self.pool, + community_id, + event, + channel_id, + thread_meta, + idempotency, + ) + .await + } + /// Atomically insert a kind:7 reaction event and its reaction row. #[allow(clippy::too_many_arguments)] pub async fn insert_reaction_event_with_thread_metadata( diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 65ca1567212..0ea5df80a2a 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -561,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 27); + assert_eq!(migrations.len(), 28); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -940,6 +940,12 @@ mod tests { desired_schema.contains("idx_channels_id_live"), "desired-state schema must carry the channel-id lookup index", ); + assert_eq!(migrations[27].version, 28); + let idempotency = migrations[27].sql.as_str(); + assert!(idempotency.contains("CREATE TABLE message_idempotency_receipts")); + assert!(idempotency.contains("community_id UUID NOT NULL")); + assert!(idempotency + .contains("PRIMARY KEY (community_id, author_pubkey, channel_id, idempotency_key)")); } #[test] diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453fd..ddb352e7096 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -859,6 +859,14 @@ async fn submit_event_authed( response: api_error(StatusCode::BAD_REQUEST, &msg), } } + Err(IngestError::Conflict(msg)) => { + crate::handlers::ingest::reject_with_transport("http", "invalid"); + let e = api_error(StatusCode::CONFLICT, &msg); + SubmitOutcome::Err { + status: e.0, + response: e, + } + } Err(IngestError::AuthFailed(msg)) => { crate::handlers::ingest::reject_with_transport("http", "auth"); let e = api_error(StatusCode::FORBIDDEN, &msg); diff --git a/crates/buzz-relay/src/conformance/mod.rs b/crates/buzz-relay/src/conformance/mod.rs index 93ebe5de9f5..ce976b2109e 100644 --- a/crates/buzz-relay/src/conformance/mod.rs +++ b/crates/buzz-relay/src/conformance/mod.rs @@ -432,7 +432,7 @@ impl Drop for EmitGuard { pub fn sanitized_reason_for(err: &crate::handlers::ingest::IngestError) -> SanitizedReason { use crate::handlers::ingest::IngestError as E; match err { - E::Rejected(_) => SanitizedReason::Invalid, + E::Rejected(_) | E::Conflict(_) => SanitizedReason::Invalid, E::AuthFailed(_) => SanitizedReason::Restricted, E::Internal(_) => SanitizedReason::ServerError, } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a9cdffcdecb..1ae67afaf8a 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -748,7 +748,7 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { // Sanitize internal errors — don't leak DB/system details over WS. let (msg, reason) = match &e { - IngestError::Rejected(m) => (m.clone(), "invalid"), + IngestError::Rejected(m) | IngestError::Conflict(m) => (m.clone(), "invalid"), IngestError::AuthFailed(m) => (m.clone(), "auth"), IngestError::Internal(_) => ("error: internal server error".to_string(), "error"), }; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index fcd0d70728f..4b5b6b64216 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use chrono::Utc; +use sha2::{Digest, Sha256}; use tracing::{debug, error, info, warn}; use uuid::Uuid; @@ -179,10 +180,92 @@ pub enum IngestError { Rejected(String), /// Auth/scope error — WS: OK false, HTTP: 401/403. AuthFailed(String), + /// A caller reused an idempotency key for a different message payload. + Conflict(String), /// Server error — WS: OK false, HTTP: 500. Internal(String), } +const MESSAGE_IDEMPOTENCY_TAG: &str = "buzz-idempotency"; + +#[derive(Debug, Clone)] +struct MessageIdempotency { + key: [u8; 32], + semantic_digest: [u8; 32], +} + +/// Read Buzz's message idempotency tag and derive the payload digest used by +/// the durable receipt. The tag carries a SHA-256 digest of the caller's raw +/// key, so the key itself is never persisted in the public Nostr event. +fn message_idempotency( + event: &Event, + channel_id: Uuid, +) -> Result, IngestError> { + let tags: Vec<_> = event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == MESSAGE_IDEMPOTENCY_TAG) + .collect(); + if tags.is_empty() { + return Ok(None); + } + if !matches!( + event_kind_u32(event), + KIND_STREAM_MESSAGE | KIND_FORUM_POST | KIND_FORUM_COMMENT + ) { + return Err(IngestError::Rejected(format!( + "invalid: {MESSAGE_IDEMPOTENCY_TAG} is only supported for channel messages" + ))); + } + if tags.len() != 1 { + return Err(IngestError::Rejected(format!( + "invalid: exactly one {MESSAGE_IDEMPOTENCY_TAG} tag is allowed" + ))); + } + let parts = tags[0].as_slice(); + let Some(key_hex) = parts.get(1) else { + return Err(IngestError::Rejected(format!( + "invalid: {MESSAGE_IDEMPOTENCY_TAG} tag requires a key digest" + ))); + }; + if parts.len() != 2 || key_hex.len() != 64 || !key_hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(IngestError::Rejected(format!( + "invalid: {MESSAGE_IDEMPOTENCY_TAG} key digest must be 64 hexadecimal characters" + ))); + } + let key: [u8; 32] = hex::decode(key_hex) + .map_err(|_| IngestError::Rejected("invalid: malformed idempotency key digest".into()))? + .try_into() + .map_err(|_| IngestError::Rejected("invalid: malformed idempotency key digest".into()))?; + + // `created_at`, event ID, signature, idempotency tag, and NIP-OA auth are + // transport/authentication details. Excluding them lets a caller safely + // re-sign the same user-visible message after an ambiguous response. + let semantic_tags: Vec> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + match parts.first().map(String::as_str) { + Some(MESSAGE_IDEMPOTENCY_TAG | "auth") => None, + _ => Some(parts.to_vec()), + } + }) + .collect(); + let semantic = serde_json::json!({ + "channel_id": channel_id, + "content": event.content, + "kind": event_kind_u32(event), + "tags": semantic_tags, + }); + let semantic_bytes = serde_json::to_vec(&semantic) + .map_err(|e| IngestError::Internal(format!("error: serialize idempotency payload: {e}")))?; + Ok(Some(MessageIdempotency { + key, + semantic_digest: Sha256::digest(semantic_bytes).into(), + })) +} + fn map_relay_admin_error(error: super::relay_admin::RelayAdminError) -> IngestError { use super::relay_admin::RelayAdminError; match error { @@ -2759,7 +2842,65 @@ async fn ingest_event_inner( }); } - let (stored_event, was_inserted) = if buzz_core::kind::is_replaceable(kind_u32) { + let message_idempotency = if let Some(ch_id) = channel_id { + message_idempotency(&event, ch_id)? + } else { + None + }; + let is_idempotent_message = message_idempotency.is_some(); + + let (stored_event, was_inserted) = if let Some(idempotency) = message_idempotency.as_ref() { + let ch_id = channel_id.expect("idempotent messages have a channel"); + let thread_params = thread_meta.as_ref().map(|m| m.as_params()); + match state + .db + .insert_idempotent_message_with_thread_metadata( + tenant.community(), + &event, + ch_id, + thread_params, + buzz_db::MessageIdempotencyParams { + author_pubkey: auth.pubkey().as_bytes(), + key: &idempotency.key, + semantic_digest: &idempotency.semantic_digest, + }, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: database error: {e}")))? + { + buzz_db::IdempotentMessageInsertOutcome::Created { + stored_event, + event_was_inserted, + } => (*stored_event, event_was_inserted), + buzz_db::IdempotentMessageInsertOutcome::Replay { event_id } => { + if event_id.len() != 32 { + return Err(IngestError::Internal( + "error: idempotency receipt contains an invalid event id".into(), + )); + } + let canonical_event_id = hex::encode(event_id); + emit( + tracer, + TraceAction::WriteDuplicate { + msg_id: msg_id_label(event.id.as_bytes()), + channel: channel_label(ch_id), + claimed_community: claimed_community_from_event(&event), + }, + state_for_request(tenant, auth.pubkey()), + ); + return Ok(IngestResult { + event_id: canonical_event_id, + accepted: true, + message: "idempotency-replay".into(), + }); + } + buzz_db::IdempotentMessageInsertOutcome::Conflict => { + return Err(IngestError::Conflict( + "idempotency key was already used for a different message payload".into(), + )); + } + } + } else if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. state @@ -2899,7 +3040,11 @@ async fn ingest_event_inner( Ok(IngestResult { event_id: event_id_hex, accepted: true, - message: String::new(), + message: if is_idempotent_message { + "idempotency-created".into() + } else { + String::new() + }, }) } @@ -2916,6 +3061,48 @@ mod tests { }; use nostr::{EventBuilder, Kind}; + #[test] + fn message_idempotency_digest_ignores_retry_and_auth_envelope_fields() { + let key_digest = "a".repeat(64); + let channel = Uuid::new_v4(); + let tags = [ + nostr::Tag::parse(["h", &channel.to_string()]).expect("channel tag"), + nostr::Tag::parse([MESSAGE_IDEMPOTENCY_TAG, &key_digest]).expect("idempotency tag"), + ]; + let first = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "same message") + .tags(tags.clone()) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign first"); + let retry = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "same message") + .tags([ + tags[0].clone(), + tags[1].clone(), + nostr::Tag::parse(["auth", "rotated-auth", "conditions", "signature"]) + .expect("auth tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign retry"); + let changed = EventBuilder::new( + Kind::Custom(KIND_STREAM_MESSAGE as u16), + "different message", + ) + .tags(tags) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign changed"); + + let first = message_idempotency(&first, channel) + .expect("first digest") + .expect("first tag"); + let retry = message_idempotency(&retry, channel) + .expect("retry digest") + .expect("retry tag"); + let changed = message_idempotency(&changed, channel) + .expect("changed digest") + .expect("changed tag"); + assert_eq!(first.key, retry.key); + assert_eq!(first.semantic_digest, retry.semantic_digest); + assert_ne!(first.semantic_digest, changed.semantic_digest); + } /// A banned relay admin must be refused with the same wire prefix and /// transport status as every other durable-restriction refusal: /// `blocked:` and (via `bridge.rs`'s `AuthFailed` arm) HTTP 403 — never diff --git a/migrations/0028_message_idempotency_receipts.sql b/migrations/0028_message_idempotency_receipts.sql new file mode 100644 index 00000000000..f784a8388b8 --- /dev/null +++ b/migrations/0028_message_idempotency_receipts.sql @@ -0,0 +1,12 @@ +-- Durable caller idempotency for channel messages. The receipt and the event +-- are written in one transaction so a retry can always return a canonical ID. +CREATE TABLE message_idempotency_receipts ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + author_pubkey BYTEA NOT NULL CHECK (octet_length(author_pubkey) = 32), + channel_id UUID NOT NULL, + idempotency_key BYTEA NOT NULL CHECK (octet_length(idempotency_key) = 32), + semantic_digest BYTEA NOT NULL CHECK (octet_length(semantic_digest) = 32), + event_id BYTEA NOT NULL CHECK (octet_length(event_id) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, author_pubkey, channel_id, idempotency_key) +); diff --git a/schema/schema.sql b/schema/schema.sql index 9f3449b0666..a4d1af5edc7 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -275,6 +275,20 @@ CREATE INDEX idx_events_not_before ON events (community_id, not_before) -- EXPLAIN before its work lands (Quinn option A; Max's index-spelling caveat). CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +-- ── Message idempotency receipts ──────────────────────────────────────────── +-- A caller key is scoped to its authenticated author and channel. The relay +-- inserts this receipt and its canonical event in one transaction. +CREATE TABLE message_idempotency_receipts ( + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE, + author_pubkey BYTEA NOT NULL CHECK (octet_length(author_pubkey) = 32), + channel_id UUID NOT NULL, + idempotency_key BYTEA NOT NULL CHECK (octet_length(idempotency_key) = 32), + semantic_digest BYTEA NOT NULL CHECK (octet_length(semantic_digest) = 32), + event_id BYTEA NOT NULL CHECK (octet_length(event_id) = 32), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, author_pubkey, channel_id, idempotency_key) +); + -- ── Event mentions ──────────────────────────────────────────────────────────── -- Conformance: "Channel-less global events and DMs" (#p fan-out). The join to -- events MUST carry the community tuple (e.community_id = m.community_id AND From 47711e9ccb397ebde5be3d5b06ad3f9bd24c2cc0 Mon Sep 17 00:00:00 2001 From: Will Griffin Date: Sun, 9 Aug 2026 15:29:41 -0600 Subject: [PATCH 2/7] fix(db): index mentions for idempotent messages Signed-off-by: Will Griffin --- .github/workflows/ci.yml | 8 ++++ crates/buzz-db/src/lib.rs | 89 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e65157705ad..89756ce1f0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -692,6 +692,14 @@ jobs: --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Idempotent message database contract + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-db) and test(/idempotency_receipt_is_atomic_under_concurrency_and_replays_canonical_event|idempotent_message_insert_populates_mentions/)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Workspace profile (kind:9033) gate tests # Call-site integration for the 9033 authorization gate: open relay # rosterless/steward transitions and the closed-relay admin/owner rule, diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 06037eadc50..b64d5d73d56 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -2103,7 +2103,7 @@ impl Db { thread_meta: Option>, idempotency: event::MessageIdempotencyParams<'_>, ) -> Result { - event::insert_idempotent_message_with_thread_metadata( + let outcome = event::insert_idempotent_message_with_thread_metadata( &self.pool, community_id, event, @@ -2111,7 +2111,18 @@ impl Db { thread_meta, idempotency, ) - .await + .await?; + if let event::IdempotentMessageInsertOutcome::Created { + event_was_inserted: true, + .. + } = &outcome + { + if let Err(e) = insert_mentions(&self.pool, community_id, event, Some(channel_id)).await + { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + } + Ok(outcome) } /// Atomically insert a kind:7 reaction event and its reaction row. @@ -6253,6 +6264,80 @@ mod tests { .expect("insert channel"); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn idempotent_message_insert_populates_mentions() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (pool, db_name) = create_scratch_db(&admin, "idem_mention").await; + let cleanup_pool = pool.clone(); + let test_result = tokio::spawn(async move { + let db = Db::from_pool(pool); + let community_uuid = make_community(&db.pool).await; + let community = CommunityId::from_uuid(community_uuid); + let channel = Uuid::new_v4(); + insert_channel(&db.pool, community_uuid, channel).await; + + let author = Keys::generate(); + let mentioned = Keys::generate(); + let mentioned_hex = mentioned.public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(9), "retry-safe mention") + .tags([Tag::parse(["p", mentioned_hex.as_str()]).expect("p tag")]) + .sign_with_keys(&author) + .expect("sign event"); + let author_bytes = author.public_key().to_bytes(); + + let outcome = db + .insert_idempotent_message_with_thread_metadata( + community, + &event, + channel, + None, + event::MessageIdempotencyParams { + author_pubkey: &author_bytes, + key: &[7u8; 32], + semantic_digest: &[9u8; 32], + }, + ) + .await + .expect("insert idempotent message"); + assert!(matches!( + outcome, + event::IdempotentMessageInsertOutcome::Created { + event_was_inserted: true, + .. + } + )); + + let mention_rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM event_mentions \ + WHERE community_id = $1 AND event_id = $2 \ + AND pubkey_hex = $3 AND channel_id = $4", + ) + .bind(community_uuid) + .bind(event.id.as_bytes().as_slice()) + .bind(mentioned_hex) + .bind(channel) + .fetch_one(&db.pool) + .await + .expect("count mention rows"); + assert_eq!(mention_rows, 1); + }) + .await; + + cleanup_pool.close().await; + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE {db_name} WITH (FORCE)" + ))) + .execute(&admin) + .await + .expect("drop idempotent mention scratch DB"); + test_result.expect("idempotent mention test task"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn allowlist_is_scoped_to_community() { From a6f9ccd2cc7d2749a7d59b036c40d8e5c370e75d Mon Sep 17 00:00:00 2001 From: Will Griffin Date: Mon, 10 Aug 2026 08:19:39 -0600 Subject: [PATCH 3/7] fix(db): make idempotent mention indexing atomic Signed-off-by: Will Griffin --- crates/buzz-db/src/event.rs | 3 ++ crates/buzz-db/src/lib.rs | 65 ++++++++++++++++++++++++++++++------- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 7b37374c7a8..3254a7b113d 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -1399,6 +1399,9 @@ pub async fn insert_idempotent_message_with_thread_metadata( thread_meta, ) .await?; + if event_was_inserted { + crate::insert_mentions_on(&mut tx, community_id, event, Some(channel_id)).await?; + } tx.commit().await?; Ok(IdempotentMessageInsertOutcome::Created { stored_event: Box::new(stored_event), diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index b64d5d73d56..d3403b149e4 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -97,13 +97,22 @@ fn event_replacement_lock_key( /// Extract p-tag mentions from an event and insert into the `event_mentions` table. /// -/// Called after event insertion. Failures are logged but do not block event storage. /// Uses `INSERT ... ON CONFLICT DO NOTHING` so duplicate inserts are silently skipped. pub async fn insert_mentions( pool: &PgPool, community_id: CommunityId, event: &nostr::Event, channel_id: Option, +) -> Result<()> { + let mut conn = pool.acquire().await?; + insert_mentions_on(&mut conn, community_id, event, channel_id).await +} + +pub(crate) async fn insert_mentions_on( + conn: &mut PgConnection, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, ) -> Result<()> { let p_tags: Vec<&str> = event .tags @@ -167,7 +176,7 @@ pub async fn insert_mentions( qb.push(" ON CONFLICT DO NOTHING"); - qb.build().execute(pool).await?; + qb.build().execute(&mut *conn).await?; Ok(()) } @@ -2112,16 +2121,6 @@ impl Db { idempotency, ) .await?; - if let event::IdempotentMessageInsertOutcome::Created { - event_was_inserted: true, - .. - } = &outcome - { - if let Err(e) = insert_mentions(&self.pool, community_id, event, Some(channel_id)).await - { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); - } - } Ok(outcome) } @@ -6290,6 +6289,48 @@ mod tests { .expect("sign event"); let author_bytes = author.public_key().to_bytes(); + sqlx::query( + "CREATE FUNCTION fail_idempotent_mention() RETURNS trigger \ + LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced mention failure'; END $$", + ) + .execute(&db.pool) + .await + .expect("create forced mention failure function"); + sqlx::query( + "CREATE TRIGGER fail_idempotent_mention \ + BEFORE INSERT ON event_mentions \ + FOR EACH ROW EXECUTE FUNCTION fail_idempotent_mention()", + ) + .execute(&db.pool) + .await + .expect("create forced mention failure trigger"); + + let failed = db + .insert_idempotent_message_with_thread_metadata( + community, + &event, + channel, + None, + event::MessageIdempotencyParams { + author_pubkey: &author_bytes, + key: &[7u8; 32], + semantic_digest: &[9u8; 32], + }, + ) + .await; + assert!( + failed.is_err(), + "mention failure must abort message delivery" + ); + sqlx::query("DROP TRIGGER fail_idempotent_mention ON event_mentions") + .execute(&db.pool) + .await + .expect("drop forced mention failure trigger"); + sqlx::query("DROP FUNCTION fail_idempotent_mention()") + .execute(&db.pool) + .await + .expect("drop forced mention failure function"); + let outcome = db .insert_idempotent_message_with_thread_metadata( community, From 6e3fc29187bb76c0c2f923c83ab12cc1ce06ab79 Mon Sep 17 00:00:00 2001 From: Will Griffin Date: Mon, 10 Aug 2026 09:32:12 -0600 Subject: [PATCH 4/7] fix(relay): avoid panic in idempotent ingestion Signed-off-by: Will Griffin --- crates/buzz-relay/src/handlers/ingest.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 4b5b6b64216..669a3abb272 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2843,21 +2843,20 @@ async fn ingest_event_inner( } let message_idempotency = if let Some(ch_id) = channel_id { - message_idempotency(&event, ch_id)? + message_idempotency(&event, ch_id)?.map(|idempotency| (ch_id, idempotency)) } else { None }; let is_idempotent_message = message_idempotency.is_some(); - let (stored_event, was_inserted) = if let Some(idempotency) = message_idempotency.as_ref() { - let ch_id = channel_id.expect("idempotent messages have a channel"); + let (stored_event, was_inserted) = if let Some((ch_id, idempotency)) = &message_idempotency { let thread_params = thread_meta.as_ref().map(|m| m.as_params()); match state .db .insert_idempotent_message_with_thread_metadata( tenant.community(), &event, - ch_id, + *ch_id, thread_params, buzz_db::MessageIdempotencyParams { author_pubkey: auth.pubkey().as_bytes(), @@ -2883,7 +2882,7 @@ async fn ingest_event_inner( tracer, TraceAction::WriteDuplicate { msg_id: msg_id_label(event.id.as_bytes()), - channel: channel_label(ch_id), + channel: channel_label(*ch_id), claimed_community: claimed_community_from_event(&event), }, state_for_request(tenant, auth.pubkey()), From 05ecacdd4b680185650e50ef5aa5c52cf9092111 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 9 Aug 2026 10:39:21 -0600 Subject: [PATCH 5/7] ci(security): allow retired relay pool advisory (#5404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - temporarily allow the informational `RUSTSEC-2026-0243` advisory for the retired `nostr-relay-pool` crate - document the exact MeshLLM → `nostr-sdk 0.44.1` transitive path and removal condition - keep every other advisory and the global dependency policy enforced ## Why an exception RustSec provides no patched `nostr-relay-pool` release because the standalone crate was absorbed into `nostr-sdk >= 0.45`. Buzz inherits it through pinned MeshLLM v0.74. A direct test bump to `nostr-sdk 0.45.1` removed the retired crate but produced 13 MeshLLM API compilation errors, so the durable fix requires an upstream source migration rather than a lockfile update. This narrow exception restores the required Security check while that migration is completed. It must be removed once MeshLLM adopts `nostr-sdk >= 0.45`. ## Validation - `bin/cargo-deny --locked check --config deny.toml advisories` - `bin/cargo-deny --locked check` - `git diff --check origin/main...HEAD` - mandatory pre-push Rust and desktop/Tauri checks ## Scope One four-line `deny.toml` addition. No Rust source, lockfile, runtime, or release behavior changes. Signed-off-by: Wes Co-authored-by: Carl (cherry picked from commit d2ebaa95a7d2565fb217fdfae56bafb9509be444) --- deny.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deny.toml b/deny.toml index d3c5fcd4bc7..c432a20ea4f 100644 --- a/deny.toml +++ b/deny.toml @@ -15,6 +15,10 @@ ignore = [ # remove these when upstream catches up. { id = "RUSTSEC-2026-0194", reason = "transitive via rust-s3 and mesh-llm→plist; trusted-input XML only; no upstream fix available yet" }, { id = "RUSTSEC-2026-0195", reason = "transitive via rust-s3 and mesh-llm→plist; trusted-input XML only; no upstream fix available yet" }, + # nostr-relay-pool 0.44.3 — informational/unmaintained, not a vulnerability. + # Transitive via mesh-llm 0.74 → nostr-sdk 0.44.1. Remove after mesh-llm + # migrates to nostr-sdk >= 0.45, which absorbed the standalone relay pool. + { id = "RUSTSEC-2026-0243", reason = "transitive via mesh-llm; upstream nostr-sdk 0.45 migration requires API changes" }, ] [licenses] From 896a57e8e8ee60430807cf505ab1623341d7a19e Mon Sep 17 00:00:00 2001 From: Will Griffin Date: Thu, 13 Aug 2026 08:08:22 -0600 Subject: [PATCH 6/7] fix(ci): skip Block cache export from pull requests Signed-off-by: Will Griffin --- .github/workflows/docker.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 564cd74e9dd..de9397598ed 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -178,8 +178,12 @@ jobs: outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: | type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} + # Pull requests build against the public Block cache but never write + # it: a same-repository PR in a downstream fork has a token for the + # fork, not ghcr.io/block/buzz, and cache export would fail after a + # successful build. cache-to: | - ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} + ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} - name: Build and push debug image by digest id: build-debug @@ -402,7 +406,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }} - cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} + cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} - name: Export digest if: github.event_name != 'pull_request' env: From 9dcad2275d940496328214302fa6f4bf32382afa Mon Sep 17 00:00:00 2001 From: Will Griffin Date: Thu, 13 Aug 2026 08:38:28 -0600 Subject: [PATCH 7/7] fix(security): update webbrowser advisory patch Signed-off-by: Will Griffin --- Cargo.lock | 49 +++++++++++++++++++++-------------- desktop/src-tauri/Cargo.lock | 50 ++++++++++++++++++------------------ 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 937ead564a0..25ab12676f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -128,7 +128,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1634,7 +1634,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2282,7 +2282,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -2493,7 +2493,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2731,7 +2731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3137,7 +3137,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", + "windows-link 0.1.3", "windows-result 0.4.1", ] @@ -5899,7 +5899,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6043,6 +6043,17 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -8039,7 +8050,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8098,7 +8109,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8370,7 +8381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8865,7 +8876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9482,7 +9493,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9495,7 +9506,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10231,7 +10242,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10649,15 +10660,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "ef62a3d5f7b2411119a11b6f62570dbff91d7105e011a20fb83fbf8f5761c40f" dependencies = [ - "core-foundation 0.10.1", "jni 0.22.4", "log", "ndk-context", "objc2", + "objc2-app-kit", "objc2-foundation", "url", "web-sys", @@ -10809,7 +10820,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index afd119c84d9..d00b210c907 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -210,7 +210,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -221,7 +221,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1601,7 +1601,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -2268,7 +2268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 1.0.109", ] [[package]] @@ -2446,7 +2446,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2727,7 +2727,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3235,7 +3235,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", + "windows-link 0.1.3", "windows-result 0.4.1", ] @@ -5641,7 +5641,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5800,7 +5800,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6297,7 +6297,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6993,7 +6993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -8019,7 +8019,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8750,7 +8750,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8820,7 +8820,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9076,7 +9076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9683,7 +9683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10697,10 +10697,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10722,7 +10722,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook 0.3.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11454,7 +11454,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11555,7 +11555,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -12160,15 +12160,15 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +checksum = "ef62a3d5f7b2411119a11b6f62570dbff91d7105e011a20fb83fbf8f5761c40f" dependencies = [ - "core-foundation 0.10.1", "jni 0.22.4", "log", "ndk-context", "objc2", + "objc2-app-kit", "objc2-foundation", "url", "web-sys", @@ -12400,7 +12400,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]]