Skip to content
Closed
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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,18 @@ jobs:
--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,
# against real Postgres. #[ignore]d in the default suite, selected
# explicitly here — see handlers::relay_admin::tests.
run: |
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-relay) and test(/handlers::relay_admin::tests/)' \
--run-ignored ignored-only
env:
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
- name: NIP-ER reminder e2e
# Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path
# validation, author-only read filtering, and scheduler delivery against
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

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

12 changes: 12 additions & 0 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ nak req -k 9 --tag "h=<channel-uuid>" --stream \
nak event -k 7 -c "+" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
--auth --sec <privkey> ws://localhost:3000

# Subscribe to reactions to channel messages — include #h for live delivery (see note below)
nak req -k 7 --tag "h=<channel-uuid>" --stream \
--auth --sec <privkey> ws://localhost:3000

# Delete a message (#h optional; #e required; must be self-authored)
nak event -k 5 -c "reason" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
--auth --sec <privkey> ws://localhost:3000
Expand All @@ -185,6 +189,14 @@ nak req -k 1059 --tag "p=<your-hex-pubkey>" \
--auth --sec <privkey> ws://localhost:3000
```

> **Note:** The relay derives a reaction's channel from its `#e` target (client `#h` is
> ignored for channel determination). Reactions to channel-scoped events are therefore
> channel-scoped. Live fan-out keeps channel-scoped and global subscriptions strictly
> separate, which means a kinds-only subscription (`{"kinds":[7]}`) receives none of
> those reactions — subscribe with `{"kinds":[7],"#h":["<channel-uuid>"]}` instead.
> `#h` matching works whether or not the signed reaction carries an `h` tag: explicit
> `h` tags are matched directly, and tagless reactions match via their stored channel.

### Tested Clients (Direct)

| Client | Platform | Evidence | Notes |
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,19 @@ New to Buzz? Pick the path that matches you.

### I just want to try the app

Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest) — macOS (`.dmg`), Linux (`.AppImage` / `.deb`), or Windows (`.exe`). Install it like any other app.
Grab a packaged build from the [latest release](https://github.com/block/buzz/releases/latest):

| Platform | File |
|---|---|
| macOS (Apple Silicon) | `Buzz_<version>_aarch64.dmg` |
| macOS (Intel) | `Buzz_<version>_x64.dmg` |
| Linux (x86_64) | `Buzz_<version>_amd64.AppImage` or `Buzz_<version>_amd64.deb` |
| Windows (x64) | `Buzz_<version>_x64-setup_alpha-unsigned.exe` |

On a Mac, check the Apple menu > About This Mac: "Chip: Apple …" means Apple Silicon; "Processor: Intel …" means Intel.

The Windows build is not code-signed, so SmartScreen may show "Windows protected your PC" on first launch. If available, click **More info**, then **Run anyway**.


By default the app connects to `ws://localhost:3000`. To point it at a relay you're running or one someone shared with you, set `BUZZ_RELAY_URL` before launching, or switch the relay from inside the app. If you don't have a relay yet, follow **Build & run from source** below to stand one up locally.

Expand Down
99 changes: 95 additions & 4 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1768,6 +1768,41 @@ pub enum ModerationCmd {
},
}

/// Normalize hand-authored `BUZZ_AUTH_TAG` input to strict JSON.
///
/// `.env` files and shell exports sometimes carry the tag in the unquoted
/// shorthand `[auth,<hex>,<conditions>,<hex>]` (quotes dropped by hand).
/// When the input is not valid JSON but is bracket-delimited, rewrite it as
/// a JSON array of the comma-separated fields (an empty field `,,` becomes
/// `""`, matching the canonical form `["auth","hex","","hex"]`).
///
/// This is presentation-layer leniency at the configuration edge only: the
/// output is always fed through the SDK's strict `parse_auth_tag` /
/// `verify_auth_tag`, which enforce structure, hex, the conditions grammar,
/// and the BIP-340 signature. Inputs that are already valid JSON — or not
/// recognizable as the shorthand — are returned unchanged so the strict
/// parser reports the error on the original bytes.
fn normalize_auth_tag_input(input: &str) -> String {
let trimmed = input.trim();
if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
return trimmed.to_owned();
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let fields: Vec<&str> = trimmed[1..trimmed.len() - 1]
.split(',')
.map(str::trim)
.collect();
// Only a plausible 4-field auth tag is rewritten; anything else is
// passed through untouched for the strict parser to reject with an
// error that references the caller's original input.
if fields.len() == 4 && !fields.iter().any(|f| f.contains('"')) {
// serde_json cannot fail serializing a Vec<&str>.
return serde_json::to_string(&fields).expect("string array serializes");
}
}
trimmed.to_owned()
}

async fn run(cli: Cli) -> Result<(), CliError> {
let relay_url = client::normalize_relay_url(&cli.relay);

Expand All @@ -1788,17 +1823,28 @@ async fn run(cli: Cli) -> Result<(), CliError> {
.map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?;

// NIP-OA: parse and verify the auth tag if provided.
//
// `BUZZ_AUTH_TAG` is hand-authored configuration, so the unquoted raw
// shorthand `[auth,hex,,hex]` is normalized to JSON here — at this input
// edge only. The SDK grammar and the `x-auth-tag` wire format stay strict
// JSON; all validation and signature verification happen on the strict
// path below, unchanged.
let (auth_tag, auth_tag_json) = match cli.auth_tag {
Some(ref json) if !json.is_empty() => {
let tag = buzz_sdk::nip_oa::parse_auth_tag(json)
Some(ref input) if !input.is_empty() => {
let json = normalize_auth_tag_input(input);
let tag = buzz_sdk::nip_oa::parse_auth_tag(&json)
.map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?;
buzz_sdk::nip_oa::verify_auth_tag(json, &keys.public_key()).map_err(|e| {
buzz_sdk::nip_oa::verify_auth_tag(&json, &keys.public_key()).map_err(|e| {
CliError::Auth(format!(
"BUZZ_AUTH_TAG verification failed for pubkey {}: {e}",
keys.public_key().to_hex()
))
})?;
(Some(tag), Some(json.clone()))
// Canonical wire form derives from the parsed-and-verified tag
// (same shape as buzz-acp's RestClient), never from raw input.
let canonical = serde_json::to_string(tag.as_slice())
.map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG serialization failed: {e}")))?;
(Some(tag), Some(canonical))
}
_ => (None, None),
};
Expand Down Expand Up @@ -1835,6 +1881,51 @@ mod tests {
use super::*;
use clap::CommandFactory;

/// Raw shorthand `[auth,hex,,hex]` normalizes to strict JSON; the empty
/// conditions field becomes `""`.
#[test]
fn normalize_auth_tag_raw_shorthand() {
let owner = "a".repeat(64);
let sig = "b".repeat(128);

let raw = format!("[auth,{owner},,{sig}]");
let json = normalize_auth_tag_input(&raw);
let parsed: Vec<String> = serde_json::from_str(&json).expect("output must be JSON");
assert_eq!(parsed, vec!["auth", &owner, "", &sig]);

// With conditions and surrounding whitespace (shell/.env artifacts).
let raw = format!(" [auth, {owner} , kind=9, {sig}] \n");
let json = normalize_auth_tag_input(&raw);
let parsed: Vec<String> = serde_json::from_str(&json).expect("output must be JSON");
assert_eq!(parsed, vec!["auth", &owner, "kind=9", &sig]);
}

/// Valid JSON input passes through byte-identical (modulo outer trim) —
/// the normalizer must never rewrite well-formed input.
#[test]
fn normalize_auth_tag_json_passthrough() {
let owner = "a".repeat(64);
let sig = "b".repeat(128);
let json_in = serde_json::json!(["auth", owner, "kind=9", sig]).to_string();
assert_eq!(normalize_auth_tag_input(&json_in), json_in);
}

/// Inputs that are neither JSON nor a plausible 4-field shorthand pass
/// through unchanged, so the strict parser rejects the original bytes.
#[test]
fn normalize_auth_tag_leaves_garbage_untouched() {
for garbage in [
"not a tag",
"[auth,too,few]",
"[a,b,c,d,e]",
r#"[auth,"quoted",x,y]"#, // quote chars => not the shorthand
"[]",
"{\"auth\":1}",
] {
assert_eq!(normalize_auth_tag_input(garbage), garbage.trim());
}
}

/// Smoke test: CLI definition is valid and parseable.
#[test]
fn cli_definition_is_valid() {
Expand Down
105 changes: 104 additions & 1 deletion crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3999,8 +3999,33 @@ impl Db {
}

/// Returns `true` if `pubkey` (64-char hex) is a member of `community`.
///
/// Replica-routed on the bounded arm — the one PERMISSION read routed by
/// explicit product decision (bounded-stale membership beats the 10s
/// cache it replaced). Admits and revokes may lag by at most the budget
/// `B`; everything else fails closed to the writer, exactly like
/// [`Db::query_events_routed_bounded`]. Not precedent for routing other
/// permission reads.
pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result<bool> {
relay_members::is_relay_member(&self.pool, community, pubkey).await
let path = "relay_membership";
match self.route_read(path, RoutePredicate::Bounded).await {
RouteDecision::Replica(mut tx, _entry, reason) => {
match relay_members::is_relay_member_on(&mut tx, community, pubkey).await {
Ok(is_member) => {
Self::record_route(path, "replica", reason);
Ok(is_member)
}
Err(e) => {
tracing::warn!(path, "replica read failed; re-running on writer: {e}");
Self::record_route(path, "writer", "replica_error");
relay_members::is_relay_member(&self.pool, community, pubkey).await
}
}
}
RouteDecision::Writer => {
relay_members::is_relay_member(&self.pool, community, pubkey).await
}
}
}

/// Returns the relay member record for `pubkey` in `community`, or `None` if not found.
Expand Down Expand Up @@ -4096,6 +4121,12 @@ impl Db {
relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await
}

/// Returns `true` if any member of `community` holds the `admin` or
/// `owner` role.
pub async fn has_admin_or_owner(&self, community: CommunityId) -> Result<bool> {
relay_members::has_admin_or_owner(&self.pool, community).await
}

/// Atomically transfers ownership of `community` to `new_owner_pubkey`,
/// demoting the previous owner(s) to `member`. Verifies
/// `expected_owner_pubkey` matches the current owner inside the same
Expand Down Expand Up @@ -7408,6 +7439,78 @@ mod tests {
drop_scratch_db(&admin, writer, &wname).await;
}

/// Routed relay-membership check: budget unset ⇒ writer; budget set +
/// fresh proved entry ⇒ replica (bounded arm); over-budget entry ⇒
/// writer. Divergent membership rows prove which pool answered.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn is_relay_member_is_bounded_routed_and_fails_closed() {
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (writer, wname) = create_scratch_db(&admin, "mem_w").await;
let (replica, rname) = create_scratch_db(&admin, "mem_r").await;

let community = Uuid::new_v4();
for pool in [&writer, &replica] {
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(community)
.bind(format!("member-routing-{}.example", community.simple()))
.execute(pool)
.await
.expect("insert community");
}
let cid = CommunityId::from_uuid(community);
let writer_only = "aa".repeat(32);
let replica_only = "bb".repeat(32);
relay_members::add_relay_member(&writer, cid, &writer_only, "member", None)
.await
.expect("seed writer member");
relay_members::add_relay_member(&replica, cid, &replica_only, "member", None)
.await
.expect("seed replica member");

let mut db = Db::from_pools(writer.clone(), replica.clone());
db.fence().force_open_for_tests(chrono::Utc::now());

// Budget unset ⇒ bounded arm disabled ⇒ writer.
assert!(
db.is_relay_member(cid, &writer_only)
.await
.expect("gate off"),
"budget unset must answer from the writer"
);
assert!(!db.is_relay_member(cid, &replica_only).await.unwrap());

// Budget set + fresh entry ⇒ replica.
db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5)));
assert!(
db.is_relay_member(cid, &replica_only)
.await
.expect("gate on"),
"budget set must answer from the replica"
);
assert!(!db.is_relay_member(cid, &writer_only).await.unwrap());

// Entry older than the budget ⇒ fail closed to the writer. Close
// first so no prior fresh entry can be the one proved (matches the
// count test; today `force_open_for_tests_at` also clears the ring).
db.fence().close();
db.fence().force_open_for_tests_at(
chrono::Utc::now(),
std::time::Instant::now() - std::time::Duration::from_secs(10),
);
assert!(
db.is_relay_member(cid, &writer_only)
.await
.expect("entry too old"),
"an over-budget entry must fail closed to the writer"
);

drop_scratch_db(&admin, replica, &rname).await;
drop_scratch_db(&admin, writer, &wname).await;
}

/// Community separation across every routed seam, verified on
/// REPLICA-SERVED reads.
///
Expand Down
29 changes: 28 additions & 1 deletion crates/buzz-db/src/relay_members.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,41 @@ pub struct RelayMember {

/// Returns `true` if `pubkey` (64-char hex) is a member of `community`.
pub async fn is_relay_member(pool: &PgPool, community: CommunityId, pubkey: &str) -> Result<bool> {
let mut conn = pool.acquire().await?;
is_relay_member_on(&mut conn, community, pubkey).await
}

/// [`is_relay_member`] on a specific session — the replica-routing path runs
/// the lookup on the exact reader connection whose heartbeat observation
/// proved fence coverage.
pub(crate) async fn is_relay_member_on(
conn: &mut sqlx::PgConnection,
community: CommunityId,
pubkey: &str,
) -> Result<bool> {
let row = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2")
.bind(community.as_uuid())
.bind(pubkey)
.fetch_optional(pool)
.fetch_optional(conn)
.await?;
Ok(row.is_some())
}

/// Returns `true` if any member of `community` holds the `admin` or `owner`
/// role. Open relays don't *enforce* the roster, but startup
/// (`bootstrap_owner`) and operator provisioning still populate it — this is
/// how the workspace-profile gate detects whether a steward exists.
pub async fn has_admin_or_owner(pool: &PgPool, community: CommunityId) -> Result<bool> {
let row = sqlx::query(
"SELECT 1 FROM relay_members \
WHERE community_id = $1 AND role IN ('admin', 'owner') LIMIT 1",
)
.bind(community.as_uuid())
.fetch_optional(pool)
.await?;
Ok(row.is_some())
}

/// Returns the relay member record for `pubkey` in `community`, or `None`.
pub async fn get_relay_member(
pool: &PgPool,
Expand Down
Loading
Loading