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: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ jobs:
run: |
scripts/test-postgres-test-discovery.sh
scripts/test-postgres-test-wrapper.sh
- name: PR base overlap guard
run: scripts/test-branch-skew.sh
- name: Release workflow source contract
run: scripts/test-release-ref-contract.sh
- name: Relay image eligibility contract
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ This installs a `commit-msg` hook that adds the sign-off trailer automatically f

We review as capacity allows — focused PRs that follow this guide move fastest.

For a PR based on another branch, set GitHub CLI's `branch.<branch>.gh-merge-base`
Git setting to that base branch. The pre-push overlap guard uses this setting
(default: `main`) and requires a successful fetch from `origin`. Keep it aligned
with the PR's actual base, including after retargeting. Other local test lanes
still select changes against `origin/main` and can run broader checks.

---

## Setting Up the Development Environment
Expand Down
17 changes: 12 additions & 5 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,11 @@ relay to specific external Nostr identities without granting full access.
- 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`.
- An agent owner is recorded once from verified NIP-OA or NIP-FI authentication,
or an owner-attested invite claim, and never changes. When it is recorded,
the relay republishes the kind:39002
rosters of that key's channels so they carry agent evidence. Ordinary
memberships also show `bot`; other roles stay unchanged.
- `owner`, `admin` and `guest` are stored and shown as asked.

Clients follow three rules:
Expand All @@ -123,8 +125,13 @@ Clients follow three rules:
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.
- **Identity:** never infer that a key is an agent from its channel role.
A verified NIP-OA attestation or an `agent` tag in the selected relay-signed
kind:39002 roster supplies positive agent evidence. The roster tag reflects
recorded ownership in this community, including for owners, admins and guests.
It discloses no owner and grants no permissions; public owner attribution
still requires a verified NIP-OA attestation. Missing tags mean unknown,
including on older snapshots. See [NIP-CL](docs/nips/NIP-CL.md).

### Announce channels

Expand Down
116 changes: 91 additions & 25 deletions crates/buzz-db/src/store/channel_members.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,9 @@ pub struct ChannelIdentity {
pub struct LockedMemberSnapshot {
/// Canonical active members captured behind the lock.
pub members: Vec<MemberRecord>,
/// Active members with a recorded agent owner in this community.
/// This is positive evidence only; absence does not establish a human.
pub agent_pubkeys: Vec<Vec<u8>>,
/// The channel's type, labels and creator, read on the same connection, so the
/// roster carries the same identity tags as the channel's other
/// group-state events. `None` when no channel row exists.
Expand Down Expand Up @@ -592,8 +595,13 @@ pub async fn lock_member_snapshot(
let rows = sqlx::query(concat!(
"SELECT cm.channel_id, cm.pubkey, ",
member_role_label!(),
" AS role, cm.joined_at, cm.invited_by, cm.removed_at",
" AS role, cm.joined_at, cm.invited_by, cm.removed_at,",
r#"
EXISTS (
SELECT 1 FROM users u
WHERE u.community_id = cm.community_id AND u.pubkey = cm.pubkey
AND u.agent_owner_pubkey IS NOT NULL
) AS is_agent
FROM channel_members cm
JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL
WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.removed_at IS NULL
Expand All @@ -604,9 +612,15 @@ pub async fn lock_member_snapshot(
.bind(channel_id)
.fetch_all(tx.conn())
.await?;
let mut agent_pubkeys = Vec::new();
let members = rows
.into_iter()
.map(row_to_member_record)
.map(|row| {
if row.try_get::<bool, _>("is_agent")? {
agent_pubkeys.push(row.try_get("pubkey")?);
}
row_to_member_record(row)
})
.collect::<Result<Vec<_>>>()?;
let identity = sqlx::query_as::<_, (String, Vec<String>, Vec<u8>)>(
"SELECT channel_type::text, labels, created_by FROM channels WHERE community_id = $1 AND id = $2",
Expand All @@ -622,6 +636,7 @@ pub async fn lock_member_snapshot(
});
Ok(LockedMemberSnapshot {
members,
agent_pubkeys,
identity,
channel_id,
relay_pubkey: relay_pubkey.to_vec(),
Expand Down Expand Up @@ -1825,10 +1840,9 @@ pub async fn list_large_channel_rosters_needing_reconciliation(
.collect()
}

/// The channels where `pubkey` is an active ordinary member (`member` or
/// `bot`). Recording an agent owner changes the label these rows show, so
/// their rosters are republished.
pub async fn list_ordinary_member_channels(
/// The nondeleted channels where `pubkey` is an active member of any role.
/// Recording an agent owner adds a roster trait even when the role stays the same.
pub async fn list_active_member_channels(
pool: &PgPool,
community_id: CommunityId,
pubkey: &[u8],
Expand All @@ -1842,7 +1856,7 @@ pub async fn list_ordinary_member_channels(
"SELECT cm.channel_id FROM channel_members cm \
JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id \
AND c.deleted_at IS NULL \
WHERE cm.community_id = $1 AND cm.pubkey = $2 AND cm.role IN ('member', 'bot') \
WHERE cm.community_id = $1 AND cm.pubkey = $2 \
AND cm.removed_at IS NULL \
ORDER BY cm.channel_id",
)
Expand Down Expand Up @@ -2529,14 +2543,14 @@ impl Db {
.await
}

/// Returns the channels where `pubkey` is an active ordinary member.
#[datastore_span(name = "list_ordinary_member_channels", system = "postgresql")]
pub async fn list_ordinary_member_channels(
/// Returns the nondeleted channels where `pubkey` is an active member of any role.
#[datastore_span(name = "list_active_member_channels", system = "postgresql")]
pub async fn list_active_member_channels(
&self,
community_id: CommunityId,
pubkey: &[u8],
) -> Result<Vec<Uuid>> {
list_ordinary_member_channels(&self.pool, community_id, pubkey).await
list_active_member_channels(&self.pool, community_id, pubkey).await
}

/// Returns full channel records for all channels a user can access.
Expand Down Expand Up @@ -5934,6 +5948,9 @@ mod postgres_tests {
nostr::Tag::parse(["p", &hex::encode(&m.pubkey), "", &m.role])
.expect("p tag")
}))
.chain(snapshot.agent_pubkeys.iter().map(|key| {
nostr::Tag::parse(["agent", &hex::encode(key)]).expect("agent tag")
}))
.collect::<Vec<_>>();
nostr::EventBuilder::new(nostr::Kind::Custom(39002), "")
.tags(tags)
Expand Down Expand Up @@ -5965,6 +5982,7 @@ mod postgres_tests {
.lock_member_snapshot(community, channel, &relay_pubkey)
.await
.expect("capture roster");
assert!(snapshot.agent_pubkeys.is_empty());
let captured = roster(&snapshot, at);
// The owner record does not take the membership lock, so it commits
// while the publisher holds it.
Expand All @@ -5990,20 +6008,26 @@ mod postgres_tests {
.lock_member_snapshot(community, channel, &relay_pubkey)
.await
.expect("capture roster for republish");
assert_eq!(snapshot.agent_pubkeys, vec![agent.clone()]);
let republished = roster(&snapshot, at + 1);
snapshot
.replace_member_event(&republished)
.await
.expect("republish");
snapshot.release().await.expect("commit republish");
assert_eq!(agent_label(&live_tags().await).as_deref(), Some("bot"));
let tags = live_tags().await;
assert_eq!(agent_label(&tags).as_deref(), Some("bot"));
assert!(tags
.as_array()
.expect("tags")
.contains(&serde_json::json!(["agent", hex::encode(&agent)])));
}

/// The republish after an owner record lists the agent's active ordinary
/// memberships only.
/// Republish candidates cover every active role in this community, but not
/// removed memberships or deleted channels.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn ordinary_member_channels_list_only_active_ordinary_rows() {
async fn active_member_channels_exclude_removed_deleted_and_other_communities() {
let pool = setup_pool().await;
let community_id = make_test_community(&pool).await;
let community = CommunityId::from_uuid(community_id);
Expand All @@ -6014,25 +6038,67 @@ mod postgres_tests {
.await
.expect("ensure user");
}
let member = open_test_channel(&pool, community_id, &creator).await;
let removed = open_test_channel(&pool, community_id, &creator).await;
let guest = open_test_channel(&pool, community_id, &creator).await;
for channel in [member, removed] {
add_member(&pool, community, channel, &agent, MemberRole::Member, None)
let mut expected = Vec::new();
for role in [
MemberRole::Member,
MemberRole::Bot,
MemberRole::Admin,
MemberRole::Owner,
MemberRole::Guest,
] {
let channel = open_test_channel(&pool, community_id, &creator).await;
add_member(&pool, community, channel, &agent, role, Some(&creator))
.await
.expect("add agent");
.expect("add active agent");
expected.push(channel);
}
add_member(&pool, community, guest, &agent, MemberRole::Guest, None)
let removed = open_test_channel(&pool, community_id, &creator).await;
let deleted = open_test_channel(&pool, community_id, &creator).await;
for channel in [removed, deleted] {
add_member(
&pool,
community,
channel,
&agent,
MemberRole::Admin,
Some(&creator),
)
.await
.expect("add agent as guest");
.expect("add excluded agent");
}
remove_member(&pool, community, removed, &agent, &agent)
.await
.expect("agent leaves");
sqlx::query("UPDATE channels SET deleted_at = now() WHERE community_id = $1 AND id = $2")
.bind(community_id)
.bind(deleted)
.execute(&pool)
.await
.expect("delete channel");
let other_id = make_test_community(&pool).await;
let other = CommunityId::from_uuid(other_id);
for key in [&creator, &agent] {
ensure_user(&pool, other, key)
.await
.expect("ensure other user");
}
let other_channel = open_test_channel(&pool, other_id, &creator).await;
add_member(
&pool,
other,
other_channel,
&agent,
MemberRole::Admin,
Some(&creator),
)
.await
.expect("add agent in other community");
expected.sort();
assert_eq!(
list_ordinary_member_channels(&pool, community, &agent)
list_active_member_channels(&pool, community, &agent)
.await
.expect("list channels"),
vec![member]
expected
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ mod postgres_tests {
use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole};

let (state, host, community, issuer) = fixture().await;
let tenant = buzz_core::tenant::TenantContext::resolved(community, &host);
let v2 = mint_code(
state.clone(),
&host,
Expand All @@ -274,7 +275,12 @@ mod postgres_tests {
.await
.expect("seed agent user");
let mut channels = Vec::new();
for name in ["claim-roster-a", "claim-roster-b"] {
for (name, role) in [
("claim-member", MemberRole::Member),
("claim-admin", MemberRole::Admin),
("claim-owner", MemberRole::Owner),
("claim-guest", MemberRole::Guest),
] {
let channel = state
.db
.create_channel(
Expand All @@ -295,12 +301,22 @@ mod postgres_tests {
community,
channel,
&agent_bytes,
MemberRole::Member,
role,
Some(&issuer.public_key().to_bytes()),
)
.await
.expect("add unowned agent");
channels.push(channel);
crate::handlers::side_effects::emit_group_discovery_events(
&tenant, &state, channel,
)
.await
.expect("publish before claim");
let tags = crate::handlers::group_state_postgres_tests::group_state_tags(
&state, &tenant, channel, 39002,
)
.await;
assert!(!tags.iter().any(|tag| tag[0] == "agent"));
channels.push((channel, role));
}

// An owner check before the claim cached "not owner".
Expand Down Expand Up @@ -338,7 +354,7 @@ mod postgres_tests {

let agent_hex = agent.public_key().to_hex();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
for channel in channels {
for (channel, role) in channels {
loop {
let live: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT tags FROM events WHERE community_id = $1 AND channel_id = $2 \
Expand All @@ -349,20 +365,34 @@ mod postgres_tests {
.fetch_optional(state.db.pool())
.await
.expect("read live roster");
let shows_bot =
let shows_agent =
live.as_ref()
.and_then(|tags| tags.as_array())
.is_some_and(|tags| {
tags.iter().any(|tag| {
tag[0] == "p" && tag[1] == agent_hex.as_str() && tag[3] == "bot"
})
tags.iter()
.any(|tag| tag[0] == "agent" && tag[1] == agent_hex.as_str())
});
if shows_bot {
if shows_agent {
let tags = crate::handlers::group_state_postgres_tests::group_state_tags(
&state, &tenant, channel, 39002,
)
.await;
let expected = if role == MemberRole::Member {
"bot"
} else {
role.as_str()
};
assert!(tags.contains(&vec![
"p".into(),
agent_hex.clone(),
"".into(),
expected.into()
]));
break;
}
assert!(
std::time::Instant::now() < deadline,
"code {code}: the roster must show the claimant as bot: {live:?}"
"code {code}: the roster must identify the claimant as an agent: {live:?}"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
Expand Down
Loading