Skip to content
Open
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
24 changes: 23 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -740,11 +740,33 @@ jobs:
- name: Relay E2E tests
run: |
cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture --skip invite_guest_
cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture
env:
RELAY_URL: ws://localhost:3000
GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr
- name: Restart relay with guest membership enforcement
run: |
relay_pid="$(cat /tmp/buzz-relay.pid)"
kill "${relay_pid}"
for attempt in $(seq 1 30); do
if ! kill -0 "${relay_pid}" 2>/dev/null; then
break
fi
if [ "${attempt}" -eq 30 ]; then
echo "Relay did not stop within 30 seconds" >&2
exit 1
fi
sleep 1
done
BUZZ_REQUIRE_RELAY_MEMBERSHIP=true \
BUZZ_REAPER_INTERVAL_SECS=1 \
./scripts/start-relay-for-tests.sh --no-build
- name: Guest invite relay E2E
run: |
cargo test -p buzz-test-client --test e2e_relay invite_guest_ -- --ignored --nocapture
env:
RELAY_URL: ws://localhost:3000
- name: Upload relay logs
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
Expand Down
5 changes: 5 additions & 0 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ When `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, every authenticated connection is che
`relay_members` table. In today's single-community deployment this is the relay-wide member list; in multi-community mode the same rule is scoped to the host-derived community. Only pubkeys with a row for that community may use that community. The relay owner
is bootstrapped automatically from `RELAY_OWNER_PUBKEY` on startup.

Channel-scoped guests also require this setting. A guest row is admitted only
to its explicit private-channel grants. Changing
`BUZZ_REQUIRE_RELAY_MEMBERSHIP` to `false` opens the relay to every key,
including keys previously stored as guests.

### CLI: Managing Members

Use `buzz-admin` — the operator CLI shipped in the relay image — to manage relay membership.
Expand Down
70 changes: 62 additions & 8 deletions crates/buzz-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ async fn run(cli: Cli) -> Result<i32> {
}

async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {
if let Err(msg) = validate_role(&role) {
if let Err(msg) = validate_add_role(&role) {
eprintln!("error: {msg}");
return Ok(1);
}
Expand Down Expand Up @@ -193,7 +193,7 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {

async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> Result<i32> {
if let Some(ref role) = role_filter {
if let Err(msg) = validate_role(role) {
if let Err(msg) = validate_removal_role(role) {
eprintln!("error: {msg}");
return Ok(1);
}
Expand All @@ -215,7 +215,7 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> R
db.remove_relay_member_if_role(tenant.community(), &pubkey_hex, role)
.await
} else {
db.remove_relay_member(tenant.community(), &pubkey_hex)
db.remove_relay_member_and_block_invites(tenant.community(), &pubkey_hex)
.await
};

Expand Down Expand Up @@ -286,8 +286,8 @@ async fn cmd_list_members() -> Result<i32> {
Ok(0)
}

/// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`.
fn validate_role(role: &str) -> std::result::Result<(), String> {
/// Validate roles that can be created directly by the operator CLI.
fn validate_add_role(role: &str) -> std::result::Result<(), String> {
match role {
"member" | "admin" => Ok(()),
"owner" => {
Expand All @@ -299,6 +299,26 @@ fn validate_role(role: &str) -> std::result::Result<(), String> {
}
}

/// Validate a conditional removal role.
///
/// Guests cannot be created directly by this CLI because they require a
/// channel-bound grant, but an operator may remove an existing guest.
fn validate_removal_role(role: &str) -> std::result::Result<(), String> {
match role {
"member" | "admin" | "guest" => Ok(()),
"owner" => {
Err("role 'owner' cannot be removed — change RELAY_OWNER_PUBKEY config".to_string())
}
other => Err(format!(
"invalid role '{other}': must be 'member', 'admin', or 'guest'"
)),
}
}

fn is_public_relay_member(role: &str) -> bool {
role != "guest"
}

/// Parse a bech32 npub or 64-char hex pubkey into lowercase hex.
fn parse_pubkey_hex(input: &str) -> std::result::Result<String, String> {
nostr::PublicKey::parse(input)
Expand Down Expand Up @@ -343,11 +363,20 @@ async fn publish_membership_list_with_bump(
};

let members = db.list_relay_members(tenant.community()).await?;
let public_member_count = members
.iter()
.filter(|member| is_public_relay_member(&member.role))
.count();

let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
let mut tags: Vec<Tag> = Vec::with_capacity(public_member_count + 1);
// NIP-70 protected-event marker — prevents re-broadcasting by third parties.
tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?);
for member in &members {
// Channel guests are intentionally absent from the community-wide NIP-43
// roster. Their identity is visible only through their granted channel.
for member in members
.iter()
.filter(|member| is_public_relay_member(&member.role))
{
tags.push(
Tag::parse(["member", &member.pubkey, &member.role])
.map_err(|e| anyhow::anyhow!("failed to build member tag: {e}"))?,
Expand Down Expand Up @@ -376,7 +405,7 @@ async fn publish_membership_list_with_bump(
}

tracing::info!(
member_count = members.len(),
member_count = public_member_count,
ts,
"NIP-43 membership list published by buzz-admin"
);
Expand Down Expand Up @@ -582,3 +611,28 @@ async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
);
Ok(())
}

#[cfg(test)]
mod tests {
use super::{is_public_relay_member, validate_add_role, validate_removal_role};

#[test]
fn add_and_removal_roles_preserve_guest_admission_boundary() {
assert!(validate_add_role("member").is_ok());
assert!(validate_add_role("admin").is_ok());
assert!(validate_add_role("guest").is_err());

assert!(validate_removal_role("member").is_ok());
assert!(validate_removal_role("admin").is_ok());
assert!(validate_removal_role("guest").is_ok());
assert!(validate_removal_role("owner").is_err());
}

#[test]
fn public_membership_snapshot_excludes_channel_guests() {
assert!(is_public_relay_member("owner"));
assert!(is_public_relay_member("admin"));
assert!(is_public_relay_member("member"));
assert!(!is_public_relay_member("guest"));
}
}
28 changes: 28 additions & 0 deletions crates/buzz-auth/src/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,20 @@ impl Scope {
]
}

/// Scopes granted to a channel-scoped relay guest.
///
/// Channel IDs in the authentication context provide the resource
/// boundary; these scopes only enable messages, channel discovery, and
/// reading profiles already visible inside that channel.
pub fn channel_guest() -> Vec<Scope> {
vec![
Self::MessagesRead,
Self::MessagesWrite,
Self::ChannelsRead,
Self::UsersRead,
]
}

/// Return the canonical wire-format string for this scope (e.g. `"messages:read"`).
pub fn as_str(&self) -> &str {
match self {
Expand Down Expand Up @@ -231,6 +245,20 @@ mod tests {
);
}

#[test]
fn channel_guest_excludes_privileged_scopes() {
let scopes = Scope::channel_guest();
assert_eq!(scopes.len(), 4);
assert!(scopes.contains(&Scope::MessagesRead));
assert!(scopes.contains(&Scope::MessagesWrite));
assert!(scopes.contains(&Scope::ChannelsRead));
assert!(scopes.contains(&Scope::UsersRead));
assert!(!scopes.contains(&Scope::UsersWrite));
assert!(!scopes.contains(&Scope::ChannelsWrite));
assert!(!scopes.contains(&Scope::FilesRead));
assert!(!scopes.contains(&Scope::ReposRead));
}

#[test]
fn all_known_returns_all_known_variants() {
let all = Scope::all_known();
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-core/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ pub enum MemberRole {
Admin,
/// Standard participant.
Member,
/// Read-only external participant.
/// External participant without channel-management authority.
Guest,
/// Automated agent or integration (not in the role hierarchy).
Bot,
Expand Down
Loading