Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
49 changes: 30 additions & 19 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 50 additions & 4 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -569,6 +570,18 @@ pub struct SendMessageParams {
pub broadcast: bool,
pub files: Vec<String>,
pub mentions: Vec<String>,
pub idempotency_key: Option<String>,
}

/// 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<String, CliError> {
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(
Expand Down Expand Up @@ -677,16 +690,38 @@ 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() {
object.insert(
"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(())
Expand Down Expand Up @@ -880,6 +915,7 @@ pub async fn dispatch(
broadcast,
files,
mentions,
idempotency_key,
} => {
cmd_send_message(
client,
Expand All @@ -891,6 +927,7 @@ pub async fn dispatch(
broadcast,
files,
mentions,
idempotency_key,
},
)
.await
Expand Down Expand Up @@ -993,15 +1030,24 @@ 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::{
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
};
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";
Expand Down
5 changes: 4 additions & 1 deletion crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <UUID> --content \"hello\"\n buzz messages send --channel <UUID> --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel <UUID> --content -"
after_help = "Examples:\n buzz messages send --channel <UUID> --content \"hello\"\n buzz messages send --channel <UUID> --content \"@alice check this\"\n buzz messages send --channel <UUID> --content \"retry-safe\" --idempotency-key work-outbox-123\n echo \"hello from stdin\" | buzz messages send --channel <UUID> --content -"
)]
Send {
/// Channel UUID (from 'buzz channels list')
Expand All @@ -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<String>,
/// Stable caller key for durable retry safety. Reusing it with different message content conflicts.
#[arg(long)]
idempotency_key: Option<String>,
},
/// Send a code diff / patch to a channel
SendDiff {
Expand Down
Loading
Loading