Skip to content

Commit 4b41887

Browse files
fryzefryze
authored andcommitted
feat(auth): add read-only relay observer role
Signed-off-by: fryze <fryze@fryzes-MacBook-Pro.local>
1 parent 3c7f288 commit 4b41887

16 files changed

Lines changed: 754 additions & 97 deletions

File tree

NOSTR.md

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,21 @@ nak req -k 1059 --tag "p=<your-hex-pubkey>" \
213213

214214
## Relay Membership (NIP-43)
215215

216-
When `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, every authenticated connection is checked against the
217-
`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
218-
is bootstrapped automatically from `RELAY_OWNER_PUBKEY` on startup.
216+
Every authenticated connection is checked for a direct role in the
217+
`relay_members` table. When `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, pubkeys without
218+
a qualifying row are denied; on an open relay they retain the normal open-relay
219+
authority. In today's single-community deployment this is the relay-wide member
220+
list; in multi-community mode the same rule is scoped to the host-derived
221+
community. The relay owner is bootstrapped automatically from
222+
`RELAY_OWNER_PUBKEY` on startup.
223+
224+
The `observer` role is directly provisioned and grants exactly
225+
`messages:read`. Existing channel membership still determines which private
226+
channel events it may read. It cannot delegate through NIP-OA or use event
227+
submission, media, Git, huddle, GIF, workflow, membership, join, or leave
228+
mutation paths. Observer pubkeys and their role are included in the relay's
229+
kind:13534 membership snapshot, so observer identities are operational service
230+
identities and must not be treated as secret.
219231

220232
### CLI: Managing Members
221233

@@ -227,6 +239,7 @@ In a Docker Compose deployment, use `run.sh`:
227239
./run.sh add-member npub1abc...
228240
./run.sh add-member <64-char-hex-pubkey>
229241
./run.sh add-member npub1abc... --role admin
242+
./run.sh add-member npub1observer... --role observer
230243

231244
# Remove a member
232245
./run.sh remove-member npub1abc...
@@ -241,6 +254,7 @@ Or invoke `buzz-admin` directly inside the container:
241254
```bash
242255
docker compose exec relay buzz-admin add-member --pubkey npub1abc...
243256
docker compose exec relay buzz-admin add-member --pubkey npub1abc... --role admin
257+
docker compose exec relay buzz-admin add-member --pubkey npub1observer... --role observer
244258
docker compose exec relay buzz-admin remove-member --pubkey npub1abc...
245259
docker compose exec relay buzz-admin list-members
246260
```
@@ -271,9 +285,9 @@ the sender to be authenticated (NIP-42) as the relay owner or an admin.
271285

272286
| Kind | Action | Required tags |
273287
|------|--------|---------------|
274-
| 9030 | Add member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
275-
| 9031 | Remove member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
276-
| 9032 | Change role | `["p", "<hex-pubkey>"]`, `["role", "member\|admin"]` |
288+
| 9030 | Add member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin\|observer"]`; only the owner may grant `admin` or `observer` |
289+
| 9031 | Remove member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin\|observer"]` |
290+
| 9032 | Change role | `["p", "<hex-pubkey>"]`, `["role", "member\|admin\|observer"]`; owner only |
277291
| 9033 | Set workspace profile (icon) | `["icon", "<https-url or data:image/* URL>"]` (empty clears) |
278292

279293
Example using `nak`:

crates/buzz-admin/src/main.rs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ enum Command {
5252
#[arg(long)]
5353
pubkey: String,
5454

55-
/// Role: "admin" or "member" (default: member). Cannot be "owner" —
56-
/// use RELAY_OWNER_PUBKEY config to set the relay owner.
55+
/// Role: "admin", "member", or "observer" (default: member). An
56+
/// observer can read channel events but cannot mutate relay state.
57+
/// Cannot be "owner" — use RELAY_OWNER_PUBKEY config to set the owner.
5758
#[arg(long, default_value = "member")]
5859
role: String,
5960
},
@@ -299,15 +300,15 @@ async fn cmd_list_members() -> Result<i32> {
299300
Ok(0)
300301
}
301302

302-
/// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`.
303+
/// Validate a role that can be directly provisioned by the operator CLI.
303304
fn validate_role(role: &str) -> std::result::Result<(), String> {
304305
match role {
305-
"member" | "admin" => Ok(()),
306+
"member" | "admin" | "observer" => Ok(()),
306307
"owner" => {
307308
Err("role 'owner' cannot be set via CLI — use RELAY_OWNER_PUBKEY config".to_string())
308309
}
309310
other => Err(format!(
310-
"invalid role '{other}': must be 'member' or 'admin'"
311+
"invalid role '{other}': must be 'member', 'admin', or 'observer'"
311312
)),
312313
}
313314
}
@@ -631,3 +632,18 @@ async fn reconcile_channels(
631632
);
632633
Ok(())
633634
}
635+
636+
#[cfg(test)]
637+
mod tests {
638+
use super::validate_role;
639+
640+
#[test]
641+
fn operator_can_provision_only_supported_non_owner_roles() {
642+
for role in ["member", "admin", "observer"] {
643+
assert_eq!(validate_role(role), Ok(()), "role {role}");
644+
}
645+
assert!(validate_role("owner").is_err());
646+
assert!(validate_role("future-role").is_err());
647+
assert!(validate_role("").is_err());
648+
}
649+
}

crates/buzz-auth/src/scope.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ pub enum Scope {
6161
}
6262

6363
impl Scope {
64+
/// Return the complete authority granted to a directly provisioned observer.
65+
///
66+
/// Observers may read channel events only. Channel and community visibility
67+
/// remain independently constrained by the relay's existing membership gates.
68+
pub fn observer_read_only() -> Vec<Scope> {
69+
vec![Self::MessagesRead]
70+
}
71+
6472
/// Return a `Vec` containing every known scope variant.
6573
///
6674
/// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header
@@ -246,4 +254,12 @@ mod tests {
246254
);
247255
}
248256
}
257+
258+
#[test]
259+
fn observer_authority_is_explicit_nonempty_and_read_only() {
260+
let scopes = Scope::observer_read_only();
261+
assert_eq!(scopes, vec![Scope::MessagesRead]);
262+
assert!(!scopes.is_empty());
263+
assert!(scopes.iter().all(|scope| scope.as_str().ends_with(":read")));
264+
}
249265
}

crates/buzz-db/src/runtime/migration.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,7 +702,7 @@ mod postgres_tests {
702702
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
703703
migrations.sort_by_key(|migration| migration.version);
704704

705-
assert_eq!(migrations.len(), 44);
705+
assert_eq!(migrations.len(), 45);
706706
assert_eq!(migrations[0].version, 1);
707707
assert_eq!(&*migrations[0].description, "initial schema");
708708
assert!(migrations[0]
@@ -1287,6 +1287,12 @@ mod postgres_tests {
12871287
desired_schema.contains("'rate_limit_violations'\n ]::TEXT[])"),
12881288
"schema.sql exclusion list must match the pre-0041 body after ledger removal"
12891289
);
1290+
1291+
assert_eq!(migrations[44].version, 45);
1292+
let observer_role = migrations[44].sql.as_str();
1293+
assert!(observer_role.contains("relay_members_role_check"));
1294+
assert!(observer_role.contains("'observer'"));
1295+
assert!(desired_schema.contains("'observer'"));
12901296
}
12911297

12921298
#[test]

crates/buzz-db/src/store/relay_members.rs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use crate::{observability, replaceable, CommunityId, Db, RouteDecision, RoutePre
2020
pub struct RelayMember {
2121
/// 64-char lowercase hex pubkey.
2222
pub pubkey: String,
23-
/// Role: `"owner"`, `"admin"`, or `"member"`.
23+
/// Role: `"owner"`, `"admin"`, `"member"`, or `"observer"`.
2424
pub role: String,
2525
/// Hex pubkey of who added this member, or `None` for bootstrap entries.
2626
pub added_by: Option<String>,
@@ -1272,6 +1272,56 @@ mod postgres_tests {
12721272
);
12731273
}
12741274

1275+
/// The authoritative NIP-43 snapshot must advertise the same observer role
1276+
/// that drives admission. Omitting observers would create protocol/DB drift;
1277+
/// dropping the role would hide the relay policy clients are allowed to
1278+
/// display even though the server remains authoritative for permissions.
1279+
#[tokio::test]
1280+
#[ignore = "requires Postgres"]
1281+
async fn observer_role_round_trips_through_nip43_snapshot_and_reconciliation() {
1282+
let pool = setup_pool().await;
1283+
let community = make_test_community(&pool).await;
1284+
let observer = test_pubkey();
1285+
add_relay_member(&pool, community, &observer, "observer", None)
1286+
.await
1287+
.expect("add observer");
1288+
1289+
let db = Db::from_pool(pool.clone());
1290+
let relay_keys = nostr::Keys::generate();
1291+
let (snapshot, inserted, member_count) = db
1292+
.publish_nip43_membership_locked(community, &relay_keys)
1293+
.await
1294+
.expect("publish membership snapshot");
1295+
assert!(inserted);
1296+
assert_eq!(member_count, 1);
1297+
assert!(snapshot.event.tags.iter().any(|tag| {
1298+
tag.as_slice()
1299+
== [
1300+
"member".to_string(),
1301+
observer.clone(),
1302+
"observer".to_string(),
1303+
]
1304+
}));
1305+
assert!(!db
1306+
.nip43_membership_snapshot_needs_reconciliation_for_maintenance(
1307+
community,
1308+
&relay_keys.public_key(),
1309+
)
1310+
.await
1311+
.expect("snapshot matches canonical observer row"));
1312+
1313+
update_relay_member_role(&pool, community, &observer, "member")
1314+
.await
1315+
.expect("change observer role");
1316+
assert!(db
1317+
.nip43_membership_snapshot_needs_reconciliation_for_maintenance(
1318+
community,
1319+
&relay_keys.public_key(),
1320+
)
1321+
.await
1322+
.expect("role drift is detected"));
1323+
}
1324+
12751325
/// Owner bootstrap is community-scoped: bootstrapping the owner in A does not
12761326
/// make that pubkey an owner (or member) of B. Guards against a global
12771327
/// `INSERT ... (pubkey, role)` bootstrap leaking the owner across tenants.

0 commit comments

Comments
 (0)