diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91943dc02c2..0546c051af4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e392a8b252f..acaf252b727 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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..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 diff --git a/NOSTR.md b/NOSTR.md index 8eb1c7fbf16..a52320fcd8b 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -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: @@ -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 diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 671158c5b51..384a109a27f 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -406,6 +406,9 @@ pub struct ChannelIdentity { pub struct LockedMemberSnapshot { /// Canonical active members captured behind the lock. pub members: Vec, + /// 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>, /// 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. @@ -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 @@ -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::("is_agent")? { + agent_pubkeys.push(row.try_get("pubkey")?); + } + row_to_member_record(row) + }) .collect::>>()?; let identity = sqlx::query_as::<_, (String, Vec, Vec)>( "SELECT channel_type::text, labels, created_by FROM channels WHERE community_id = $1 AND id = $2", @@ -622,6 +636,7 @@ pub async fn lock_member_snapshot( }); Ok(LockedMemberSnapshot { members, + agent_pubkeys, identity, channel_id, relay_pubkey: relay_pubkey.to_vec(), @@ -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], @@ -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", ) @@ -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> { - 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. @@ -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::>(); nostr::EventBuilder::new(nostr::Kind::Custom(39002), "") .tags(tags) @@ -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. @@ -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); @@ -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 ); } } diff --git a/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs b/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs index 38fa98e0376..4a03fcd27e6 100644 --- a/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs +++ b/crates/buzz-relay/src/api/invites/postgres_tests/owner_admission_tests.rs @@ -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, @@ -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( @@ -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". @@ -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 = sqlx::query_scalar( "SELECT tags FROM events WHERE community_id = $1 AND channel_id = $2 \ @@ -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; } diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 4536e462a5e..046816d5608 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -2686,12 +2686,29 @@ mod tests { community, channel, &agent.public_key().to_bytes(), - MemberRole::Member, + MemberRole::Admin, Some(&creator.public_key().to_bytes()), ) .await .expect("add unowned agent"); + let (conn, _ctrl) = registered_pending_conn(&state, community, "roster-lock"); + crate::handlers::side_effects::emit_group_discovery_events( + &conn.tenant, + &state, + channel, + ) + .await + .expect("publish before enrollment"); + let before_tags = crate::handlers::group_state_postgres_tests::group_state_tags( + &state, + &conn.tenant, + channel, + 39002, + ) + .await; + assert!(!before_tags.iter().any(|tag| tag[0] == "agent")); + // Hold every republish permit and the roster lock the republish // needs. let permits = Arc::clone(&state.roster_republish_semaphore) @@ -2708,7 +2725,6 @@ mod tests { let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") .expect("sign NIP-OA credential"); let auth_tag: Vec = serde_json::from_str(&auth_tag).expect("tag JSON"); - let (conn, _ctrl) = registered_pending_conn(&state, community, "roster-lock"); tokio::time::timeout( std::time::Duration::from_secs(2), handle_auth( @@ -2746,13 +2762,12 @@ mod tests { .expect("read live roster"); live }; - let shows_bot = |live: &Option| { + let shows_agent = |live: &Option| { 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()) }) }; @@ -2763,23 +2778,31 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(500)).await; let waiting = live_roster().await; assert_eq!(waiting, before, "no republish runs without a permit"); - assert!(!shows_bot(&waiting), "{waiting:?}"); + assert!(!shows_agent(&waiting), "{waiting:?}"); - // Once a permit is free, the background republish names the agent - // `bot` in the live roster. + // Once a permit is free, the roster gains an agent trait while + // preserving the agent's admin authority. drop(permits); let deadline = Instant::now() + std::time::Duration::from_secs(10); loop { let live = live_roster().await; - if shows_bot(&live) { + if shows_agent(&live) { break; } assert!( Instant::now() < deadline, - "the roster must converge to bot after the lock is released: {live:?}" + "the roster must identify the agent after the lock is released: {live:?}" ); tokio::time::sleep(std::time::Duration::from_millis(50)).await; } + let tags = crate::handlers::group_state_postgres_tests::group_state_tags( + &state, + &conn.tenant, + channel, + 39002, + ) + .await; + assert!(tags.contains(&vec!["p".into(), agent_hex, "".into(), "admin".into()])); } /// An untagged AUTH that read a NULL stored owner just before a @@ -4061,6 +4084,57 @@ mod tests { Case::MemberOwner | Case::NonMemberOwner => {} } + // A private FI owner assertion must refresh an existing elevated + // membership without requiring any public profile or NIP-OA tag. + let channel = if matches!(case, Case::MemberOwner) { + use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole}; + for key in [agent, owner.public_key()] { + state + .db + .ensure_user(community, key.as_bytes()) + .await + .expect("ensure user"); + } + let channel = state + .db + .create_channel( + community, + "fi-agent-roster", + ChannelType::Stream, + ChannelVisibility::Open, + None, + owner.public_key().as_bytes(), + None, + ) + .await + .expect("create channel") + .id; + state + .db + .add_member( + community, + channel, + agent.as_bytes(), + MemberRole::Admin, + Some(owner.public_key().as_bytes()), + ) + .await + .expect("add unowned admin"); + crate::handlers::side_effects::emit_group_discovery_events( + &tenant, &state, channel, + ) + .await + .expect("publish before FI enrollment"); + let tags = crate::handlers::group_state_postgres_tests::group_state_tags( + &state, &tenant, channel, 39002, + ) + .await; + assert!(!tags.iter().any(|tag| tag[0] == "agent")); + Some(channel) + } else { + None + }; + harness.run(harness.auth_event(), state.clone()).await; if matches!(case, Case::MemberOwner) { @@ -4079,6 +4153,28 @@ mod tests { .is_agent_owner(community, agent.as_bytes(), owner.public_key().as_bytes()) .await .expect("read stored owner")); + let channel = channel.expect("member owner has a prior roster"); + let deadline = Instant::now() + std::time::Duration::from_secs(10); + loop { + let tags = crate::handlers::group_state_postgres_tests::group_state_tags( + &state, &tenant, channel, 39002, + ) + .await; + if tags.contains(&vec!["agent".into(), agent.to_hex()]) { + assert!(tags.contains(&vec![ + "p".into(), + agent.to_hex(), + "".into(), + "admin".into() + ])); + break; + } + assert!( + Instant::now() < deadline, + "FI enrollment must refresh the existing roster" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } } else { harness.assert_fi_terminal(buzz_auth::DenialClass::AuthorizationDenied); } diff --git a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs index 0e3859c05b9..733f3f6115f 100644 --- a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs @@ -94,6 +94,11 @@ pub(crate) async fn group_state_tags( let event = events .first() .unwrap_or_else(|| panic!("no kind:{kind} for {channel_id}")); + assert_eq!(event.event.pubkey, state.relay_keypair.public_key()); + event + .event + .verify() + .expect("valid relay-signed group state"); event .event .tags @@ -197,6 +202,105 @@ async fn group_state_events_carry_type_then_creator() { } } +/// Ownership classifies agents across roles without editable profile hints. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn group_members_publish_recorded_agent_type_independent_of_role() { + let state = state().await; + let creator = Keys::generate(); + let agent = Keys::generate(); + let agent_bytes = agent.public_key().to_bytes(); + let agent_hex = agent.public_key().to_hex(); + for owned in [true, false] { + let tenant = community(&state, "group-state-agent").await; + let channel_id = create_channel(&state, &tenant, &creator, &[]).await; + state + .db + .ensure_user(tenant.community(), &agent_bytes) + .await + .unwrap(); + if owned { + state + .db + .ensure_user(tenant.community(), &creator.public_key().to_bytes()) + .await + .unwrap(); + state + .db + .set_agent_owner( + tenant.community(), + &agent_bytes, + &creator.public_key().to_bytes(), + ) + .await + .unwrap(); + } + let roles: &[&str] = if owned { + &["member", "admin", "owner", "guest"] + } else { + &["bot"] + }; + for role in roles { + let add = EventBuilder::new(Kind::Custom(9000), "") + .tags([ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["p", &agent_hex]).unwrap(), + Tag::parse(["role", role]).unwrap(), + ]) + .sign_with_keys(&creator) + .unwrap(); + let result = ingest_event(&state, &tenant, add, http_auth(&creator)) + .await + .expect("change role"); + assert!(result.accepted); + let tags = group_state_tags(&state, &tenant, channel_id, 39002).await; + let traits: Vec<_> = tags + .iter() + .filter(|tag| tag[0] == "agent") + .cloned() + .collect(); + assert_eq!( + traits, + if owned { + vec![vec!["agent".to_string(), agent_hex.clone()]] + } else { + vec![] + } + ); + assert!(tags.contains(&vec![ + "p".to_string(), + agent_hex.clone(), + "".to_string(), + match *role { + "member" | "bot" => + if owned { + "bot" + } else { + "member" + }, + role => role, + } + .to_string() + ])); + } + let remove = EventBuilder::new(Kind::Custom(9001), "") + .tags([ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["p", &agent_hex]).unwrap(), + ]) + .sign_with_keys(&creator) + .unwrap(); + let result = ingest_event(&state, &tenant, remove, http_auth(&creator)) + .await + .expect("remove member"); + assert!(result.accepted); + let tags = group_state_tags(&state, &tenant, channel_id, 39002).await; + assert!(!tags + .iter() + .any(|tag| (tag[0] == "agent" || tag[0] == "p") && tag[1] == agent_hex)); + } +} + /// The creator tag names the kind:9007 signer, not the current owner. /// Mutation: derive `P` from the owner role → RED after the transfer. #[tokio::test] diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index d55a02b748b..b0662a98f50 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1366,8 +1366,9 @@ fn group_members_tags( group_id: &str, identity: Option<&buzz_db::channel::ChannelIdentity>, members: &[MemberRecord], + agent_pubkeys: &[Vec], ) -> anyhow::Result> { - let mut tags: Vec = Vec::with_capacity(members.len() + 3); + let mut tags: Vec = Vec::with_capacity(members.len() + agent_pubkeys.len() + 3); tags.push(Tag::parse(["d", group_id])?); if let Some(identity) = identity { tags.extend(group_state_identity_tags(identity)?); @@ -1378,6 +1379,9 @@ fn group_members_tags( // because the canonical relay is implicit (this event is signed by it). tags.push(Tag::parse(["p", &pubkey_hex, "", &member.role])?); } + for key in agent_pubkeys { + tags.push(Tag::parse(["agent", &hex::encode(key)])?); + } Ok(tags) } @@ -1390,6 +1394,7 @@ async fn store_group_members_event( &group_id, member_snapshot.identity.as_ref(), &member_snapshot.members, + &member_snapshot.agent_pubkeys, )?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -4147,9 +4152,8 @@ async fn republish_roster_candidates( } /// Republish, in the background, the kind:39002 roster of every channel -/// where `agent` is an active ordinary member. Call it after recording the -/// agent's owner: rosters show `bot` for an owned agent, so each of those -/// rosters now shows the wrong label. +/// where `agent` is an active member. Call it after recording the agent's +/// owner: every role gains an agent trait, and ordinary members show `bot`. /// /// The channels are listed inside the task, after the owner record has /// committed, so a channel the agent joins concurrently is either listed here @@ -4158,14 +4162,14 @@ async fn republish_roster_candidates( /// writer. That lock can wait up to the lock timeout, so the caller does not /// wait: first AUTH must finish inside its own deadline. A failure is logged /// and counted and does not stop the other channels. A roster left stale (for -/// example by a relay that stopped first) shows only the wrong label, which -/// has the same permissions; the next membership change repairs it. +/// example by a relay that stopped first) lacks the new trait or role label; +/// permissions stay the same, and the next membership change repairs it. pub fn spawn_agent_roster_republish(tenant: &TenantContext, state: Arc, agent: Vec) { let (community_id, host) = (tenant.community(), tenant.host().to_owned()); tokio::spawn(async move { let channels = match state .db - .list_ordinary_member_channels(community_id, &agent) + .list_active_member_channels(community_id, &agent) .await { Ok(channels) => channels, @@ -4752,7 +4756,7 @@ mod tests { labels: Vec::new(), created_by: vec![0xab; 32], }; - let tags = group_members_tags(&channel_id.to_string(), Some(&identity), &members) + let tags = group_members_tags(&channel_id.to_string(), Some(&identity), &members, &[]) .expect("build tags"); assert_eq!( tags.len(), diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index 27ed3a8a400..55ec3452383 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -81,6 +81,21 @@ would mix creators with members. **Creation time.** The `created_at` tag does not change when the channel is edited. Use it, not the event's `created_at`, to compare the age of channels. +**Agent members.** A kind:39002 roster MAY carry `["agent", ""]` +for each active member with a recorded agent owner in this community. This is +positive identity evidence, independent of the member's channel role and editable +profile. It does not disclose the owner, establish public ownership attribution, +or grant permissions. The four-field `p` tags remain unchanged. Clients accept an +agent tag only from the selected relay-signed roster, with exactly two fields and +a lowercase 64-character hexadecimal key present in that roster. + +These traits are captured when the roster is published, including on membership +or role changes. First recording an owner schedules republication for all active +channel memberships, using the existing bounded roster publisher. This does not +scan older snapshots on upgrade. A failed refresh or older relay may leave a +snapshot without traits until its next normal publication. Missing traits mean +unknown, not human. + **Older events.** A relay adds these tags only when it publishes a channel-state event. An event that a relay version without this NIP published can lack them, including one published during an upgrade. Such an event stays diff --git a/scripts/check-branch-skew.sh b/scripts/check-branch-skew.sh index 0c75126ccfe..3480f11ee44 100755 --- a/scripts/check-branch-skew.sh +++ b/scripts/check-branch-skew.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Pre-push guard: CI checks the PR merged with main, so local runs on a -# skewed branch can pass while CI fails. Block the push only when -# origin/main has changed files this branch also touches. +# Pre-push guard: CI checks the PR merged with its base. Use gh's configured +# PR base for stacked branches, defaulting to main. Block when that base has +# changed files this branch also touches. set -euo pipefail branch=$(git rev-parse --abbrev-ref HEAD) @@ -9,16 +9,22 @@ if [ "$branch" = "main" ] || [ "$branch" = "HEAD" ]; then exit 0 fi -git fetch --quiet origin main || true -git rev-parse --verify --quiet origin/main >/dev/null || exit 0 +target=$(git config --get "branch.$branch.gh-merge-base") || target=main +if ! git check-ref-format "refs/heads/$target" || [ "$target" = "$branch" ]; then + echo "Invalid PR base for $branch: '$target'" >&2 + exit 1 +fi +target_ref="refs/remotes/origin/$target" +git fetch --quiet origin "+refs/heads/$target:$target_ref" +target_oid=$(git rev-parse --verify "$target_ref^{commit}") -base=$(git merge-base HEAD origin/main) -if [ "$base" = "$(git rev-parse origin/main)" ]; then +base=$(git merge-base HEAD "$target_oid") +if [ "$base" = "$target_oid" ]; then exit 0 fi overlap=$(comm -12 \ - <(git diff --name-only "$base" origin/main -- | sort) \ + <(git diff --name-only "$base" "$target_oid" -- | sort) \ <(git diff --name-only "$base" HEAD -- | sort)) if [ -z "$overlap" ]; then @@ -26,9 +32,9 @@ if [ -z "$overlap" ]; then fi { - echo "Branch is behind origin/main, and main changed files this branch also touches:" + echo "Branch is behind origin/$target, which changed files this branch also touches:" echo "$overlap" | sed 's/^/ /' - echo "Local checks ran on a tree CI will never test. Run 'git merge origin/main'," + echo "Local checks ran on a tree CI will never test. Run 'git merge origin/$target'," echo "resolve, re-run checks, then push." } >&2 exit 1 diff --git a/scripts/test-branch-skew.sh b/scripts/test-branch-skew.sh new file mode 100755 index 00000000000..ffbf74bc7b8 --- /dev/null +++ b/scripts/test-branch-skew.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +guard="$repo_root/scripts/check-branch-skew.sh" +fixture_root=$(mktemp -d) +trap 'rm -rf "$fixture_root"' EXIT +upstream="$fixture_root/upstream" +client="$fixture_root/client" + +# Construct synthetic history without running contributor hooks on fixture commits. +commit_fixture() { + local repo="$1" file="$2" content="$3" tree oid + local args=(-m fixture) + printf '%s\n' "$content" >"$repo/$file" + git -C "$repo" add "$file" + tree=$(git -C "$repo" write-tree) + if git -C "$repo" rev-parse --verify HEAD >/dev/null 2>&1; then + args+=(-p HEAD) + fi + oid=$(GIT_AUTHOR_NAME=Fixture GIT_AUTHOR_EMAIL=fixture@example.com \ + GIT_COMMITTER_NAME=Fixture GIT_COMMITTER_EMAIL=fixture@example.com \ + git -C "$repo" commit-tree "$tree" "${args[@]}") + git -C "$repo" update-ref HEAD "$oid" +} + +expect_pass() { + (cd "$client" && "$guard") >"$fixture_root/result" 2>&1 || { + cat "$fixture_root/result" >&2 + echo "Expected pass: $1" >&2 + exit 1 + } +} + +expect_fail() { + if (cd "$client" && "$guard") >"$fixture_root/result" 2>&1; then + echo "Expected failure: $1" >&2 + exit 1 + fi +} + +git init -q -b main "$upstream" +commit_fixture "$upstream" shared base +git -C "$upstream" branch parent +git -C "$upstream" branch stable +git clone -q "$upstream" "$client" +git -C "$client" checkout -qb feature +commit_fixture "$client" shared feature +expect_pass 'ordinary branch based on current main' + +commit_fixture "$upstream" shared main-advanced +expect_fail 'ordinary branch overlaps newer main' +grep -q 'behind origin/main' "$fixture_root/result" + +git -C "$client" config branch.feature.gh-merge-base parent +expect_pass 'stacked branch uses parent despite overlapping main' +git -C "$upstream" checkout -q parent +commit_fixture "$upstream" other disjoint +expect_pass 'parent advances in a disjoint file' +commit_fixture "$upstream" shared parent-advanced +expect_fail 'stacked branch overlaps newer parent' +grep -q 'behind origin/parent' "$fixture_root/result" + +for invalid in '' 'bad base' feature; do + git -C "$client" config branch.feature.gh-merge-base "$invalid" + expect_fail "invalid base '$invalid'" + grep -q 'Invalid PR base' "$fixture_root/result" +done +git -C "$client" config branch.feature.gh-merge-base missing +expect_fail 'missing remote branch' + +# A cached tracking ref must not turn an unavailable upstream into success. +git -C "$client" config branch.feature.gh-merge-base stable +expect_pass 'cached base passes while upstream is reachable' +git -C "$client" config remote.origin.url "$fixture_root/unavailable" +expect_fail 'fetch failure with cached parent' + +echo 'Branch-skew regression checks passed'