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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ All database access. Uses `sqlx::query()` (runtime, not compile-time macros) —
| `error.rs` | Database error types |

**Channel types:** `Stream`, `Forum`, `Dm`, `Workflow`, `System`
**Member roles:** `Owner`, `Admin`, `Member`, `Guest`, `Bot`
**Member roles:** `Owner`, `Admin`, `Member`, `Guest`, `Bot` (`Bot` = `Member` permissions; the relay stores `member` and shows `bot` for keys with a recorded agent owner)
**Workflow statuses:** `Active`, `Disabled`, `Archived`
**Run statuses:** `Pending`, `Running`, `WaitingApproval`, `Completed`, `Failed`, `Cancelled`

Expand Down
25 changes: 25 additions & 0 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,31 @@ relay to specific external Nostr identities without granting full access.
SELECT encode(pubkey, 'hex'), added_at, note FROM pubkey_allowlist;
```

### The bot role

`bot` is a label, not a permission tier. It has the same permissions as
`member`, and the relay chooses between the two:

- When an add or role change ends at `member` or `bot`, the relay stores
`member`. It ignores which of the two the client asked for. Asking for the
other one is not a role change.
- The relay shows an ordinary member as `bot` if the key has a recorded
agent owner, and as `member` otherwise, in kind:39002 rosters and every
other place it reports a role.
- An agent owner is recorded once, by the first NIP-OA authentication, and
never changes. When it is recorded, the relay republishes the kind:39002
rosters of that key's channels so they show `bot`.
- `owner`, `admin` and `guest` are stored and shown as asked.

Clients follow three rules:

- **Write:** send `member` for an ordinary member. The relay accepts `bot`
from older clients and stores the same role it would store for `member`.
- **Parse:** read a `bot` role in a kind:39002 roster or kind:9000 as `member`
as soon as it is parsed. Do not keep `bot` as a separate client role.
- **Identity:** never infer that a key is an agent from its channel role. A
key is an agent only when a verified NIP-OA attestation says so.

### Announce channels

Roles have the same permissions in every channel. A channel's settings
Expand Down
61 changes: 53 additions & 8 deletions crates/buzz-core/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,13 @@ pub fn decide_member_put(put: MemberPut<'_>) -> Result<MemberRole, MemberPutDeni
// No role tag: an active member keeps their role.
return current.parse().map_err(|_| MemberPutDenial::RoleChange);
};
if current == requested.as_str() {
// `member` and `bot` are one role: the relay stores `member` and shows
// `bot` when the target has a recorded agent owner, so asking for the
// other one is not a role change.
if current
.parse::<MemberRole>()
.is_ok_and(|current| current.is_same_role(requested))
{
return Ok(requested);
}
// A stored role this build does not know is changed only by an elevated
Expand Down Expand Up @@ -439,7 +445,9 @@ impl FromStr for ChannelType {
/// A member's role within a channel.
///
/// The hierarchy for permission checks is: Owner > Admin > Member > Guest.
/// Bot is a **separate designation** — it is not part of the linear hierarchy.
/// Bot has the same permissions as Member. The relay stores `member` for
/// both and shows `bot` for a key with a recorded agent owner, so the role
/// only labels an owned agent; it grants and refuses nothing extra.
/// Use [`MemberRole::permission_level`] for numeric comparisons in authorization.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberRole {
Expand All @@ -451,7 +459,8 @@ pub enum MemberRole {
Member,
/// Read-only external participant.
Guest,
/// Automated agent or integration (not in the role hierarchy).
/// A member whose key has a recorded agent owner. Same permissions as
/// [`MemberRole::Member`]; the relay computes it when it reads a role.
Bot,
}

Expand All @@ -472,6 +481,19 @@ impl MemberRole {
matches!(self, Self::Owner | Self::Admin)
}

/// Whether this is the ordinary member role, `member` or `bot`. A client
/// may send either; the relay shows `bot` exactly when the key has a
/// recorded agent owner.
pub fn is_ordinary_member(&self) -> bool {
matches!(self, Self::Member | Self::Bot)
}

/// Whether `self` and `other` are the same role: equal, or both ordinary.
/// `member` and `bot` are one role shown with two labels.
pub fn is_same_role(self, other: MemberRole) -> bool {
self == other || (self.is_ordinary_member() && other.is_ordinary_member())
}

/// Whether this role may write in a channel: post, react, edit, change
/// topic, add people, push to git. The same in every channel: every role
/// except [`MemberRole::Guest`] may, and a bot counts as a member.
Expand Down Expand Up @@ -504,21 +526,18 @@ impl MemberRole {

/// Numeric permission level for authorization comparisons.
///
/// Higher = more privileged. Bot returns 0 (must use explicit grants).
/// Higher = more privileged. Bot has the same level as Member.
/// Use `role.permission_level() >= required.permission_level()` for checks.
pub fn permission_level(self) -> u8 {
match self {
Self::Owner => 4,
Self::Admin => 3,
Self::Member => 2,
Self::Member | Self::Bot => 2,
Self::Guest => 1,
Self::Bot => 0,
}
}

/// Returns true if this role meets or exceeds the required role's permission level.
///
/// Bot never meets any requirement (returns false for all non-Bot requirements).
pub fn has_at_least(self, required: MemberRole) -> bool {
self.permission_level() >= required.permission_level()
}
Expand Down Expand Up @@ -759,6 +778,32 @@ mod tests {
}
}

#[test]
fn bot_has_the_member_permission_level() {
assert_eq!(
MemberRole::Bot.permission_level(),
MemberRole::Member.permission_level()
);
for required in [
MemberRole::Owner,
MemberRole::Admin,
MemberRole::Member,
MemberRole::Guest,
MemberRole::Bot,
] {
assert_eq!(
MemberRole::Bot.has_at_least(required),
MemberRole::Member.has_at_least(required),
"{required}"
);
}
assert!(MemberRole::Bot.is_ordinary_member());
assert!(MemberRole::Member.is_ordinary_member());
for role in [MemberRole::Owner, MemberRole::Admin, MemberRole::Guest] {
assert!(!role.is_ordinary_member(), "{role}");
}
}

#[test]
fn only_guests_cannot_post() {
for role in [
Expand Down
35 changes: 21 additions & 14 deletions crates/buzz-core/src/git_perms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,9 @@ pub fn parse_protection_tag_with_warnings(
.parse()
.map_err(|_| RuleParseError::InvalidRole(role_str.to_string()))?;
// Reject push:bot and push:guest — nonsensical rules.
// Bot is promoted to Member at the policy layer; push:bot is meaningless.
// Guest cannot push regardless; push:guest would be confusing.
// Bot has the member permission level, so push:bot would mean
// push:member. Guest cannot push regardless; push:guest would be
// confusing.
if matches!(role, MemberRole::Bot | MemberRole::Guest) {
return Err(RuleParseError::InvalidRole(role_str.to_string()));
}
Expand Down Expand Up @@ -931,19 +932,25 @@ mod tests {
}

#[test]
fn evaluate_bot_cannot_push_without_explicit_grant() {
fn evaluate_bot_pushes_like_a_member() {
let rules = vec![];
let update = RefUpdate {
ref_name: "refs/heads/feature".to_string(),
kind: UpdateKind::FastForward,
old_oid: "a".repeat(40),
new_oid: "b".repeat(40),
};
// Bot has permission_level 0 at the core evaluator level.
// NOTE: The policy layer (policy.rs) promotes Bot → Member before calling
// evaluate_push, so bots in a channel CAN push in practice. This test
// verifies the raw evaluator behavior; the promotion is tested in policy.
assert!(evaluate_ref_update(&update, MemberRole::Bot, &rules).is_err());
for (ref_name, kind) in [
("refs/heads/feature", UpdateKind::FastForward),
("refs/heads/feature", UpdateKind::NonFastForward),
("refs/heads/main", UpdateKind::FastForward),
] {
let update = RefUpdate {
ref_name: ref_name.to_string(),
kind,
old_oid: "a".repeat(40),
new_oid: "b".repeat(40),
};
assert_eq!(
evaluate_ref_update(&update, MemberRole::Bot, &rules).is_ok(),
evaluate_ref_update(&update, MemberRole::Member, &rules).is_ok(),
"{ref_name} {kind:?}"
);
}
}

#[test]
Expand Down
Loading
Loading