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
42 changes: 16 additions & 26 deletions crates/buzz-db/src/runtime/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,14 +777,27 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);

assert_eq!(migrations.len(), 80);
assert_eq!(migrations.len(), 81);
for (index, migration) in migrations.iter().enumerate() {
assert_eq!(migration.version, (index + 1) as i64);
}
assert!(migrations[79]
.sql
.as_str()
.contains("ADD COLUMN auto_join BOOLEAN NOT NULL DEFAULT false"));
assert_eq!(migrations[80].version, 81);
let retire_roster_fence = strip_sql_comments(migrations[80].sql.as_str());
for statement in [
"SET LOCAL lock_timeout = '1s';",
"SET LOCAL statement_timeout = '5s';",
"DROP TRIGGER trg_events_guard_channel_roster_snapshot ON events;",
"DROP FUNCTION guard_channel_roster_snapshot();",
] {
assert!(
retire_roster_fence.contains(statement),
"0081 must run {statement}"
);
}
assert!(migrations[70]
.sql
.as_str()
Expand Down Expand Up @@ -1336,31 +1349,8 @@ mod postgres_tests {
assert!(roster_fence.contains("snapshot_members IS DISTINCT FROM canonical_members"));
assert!(roster_fence.contains("ERRCODE = '23514'"));

// Fresh desired-state bootstrap must install the identical executable
// fence as migration 0032. CI and isolated relay startup use schema.sql
// without running migrations, so drift reopens rolling-deploy races.
// The desired state keeps the function and its trigger in separate
// files, so compare each statement rather than one contiguous span.
fn extract_span<'a>(sql: &'a str, start: &str, end: &str) -> &'a str {
let begin = sql.find(start).expect("span start");
let relative_end = sql[begin..].find(end).expect("span end");
&sql[begin..begin + relative_end + end.len()]
}
for (start, end) in [
(
"CREATE OR REPLACE FUNCTION guard_channel_roster_snapshot()",
"$$ LANGUAGE plpgsql;",
),
(
"CREATE TRIGGER trg_events_guard_channel_roster_snapshot",
" FOR EACH ROW EXECUTE FUNCTION guard_channel_roster_snapshot();",
),
] {
assert_eq!(
extract_span(roster_fence, start, end),
extract_span(&desired_schema, start, end)
);
}
// Migration 0081 retires this fence; the desired state never installs it.
assert!(!desired_schema.contains("guard_channel_roster_snapshot"));

// The single-row heartbeat table is updated continuously. Prevent
// autovacuum from truncating its heap so standby queries are not
Expand Down
Loading
Loading