From b5f7145af0d918bef810b32d8c1c09ca74c35475 Mon Sep 17 00:00:00 2001 From: Ariel Diaz Date: Sat, 8 Aug 2026 08:30:36 -0400 Subject: [PATCH 1/9] feat: add channel-scoped guest invite links Signed-off-by: Ariel Diaz --- crates/buzz-db/src/lib.rs | 6 +- crates/buzz-db/src/migration.rs | 10 +- crates/buzz-db/src/relay_invite.rs | 172 ++++++++++++++++-- crates/buzz-relay/src/api/invites.rs | 75 ++++++-- .../features/channels/ui/MembersSidebar.tsx | 15 ++ .../ui/InviteLinkSection.tsx | 15 +- desktop/src/shared/api/invites.test.mjs | 38 ++++ desktop/src/shared/api/invites.ts | 14 ++ .../0026_channel_scoped_relay_invites.sql | 17 ++ schema/schema.sql | 6 + 10 files changed, 337 insertions(+), 31 deletions(-) create mode 100644 migrations/0026_channel_scoped_relay_invites.sql diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 2a3ba9a63e2..e9f5733e0ae 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -3058,8 +3058,12 @@ impl Db { created_by: &str, ttl_secs: u64, max_uses: Option, + channel_id: Option, ) -> Result { - relay_invite::mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await + relay_invite::mint_relay_invite( + &self.pool, community, created_by, ttl_secs, max_uses, channel_id, + ) + .await } /// Delete one bounded batch of invites expired before `cutoff`. diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1d1b7e05d42..8b8e5ed7c70 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -560,7 +560,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 25); + assert_eq!(migrations.len(), 26); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -899,11 +899,19 @@ mod tests { .contains("CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at)")); assert!(!relay_invites.contains("_operator_global_tables")); + assert_eq!(migrations[25].version, 26); + let channel_invites = migrations[25].sql.as_str(); + assert!(channel_invites.contains("ADD COLUMN channel_id UUID")); + assert!(channel_invites.contains("FOREIGN KEY (community_id, channel_id)")); + assert!(channel_invites.contains("REFERENCES channels (community_id, id)")); + let desired_schema = include_str!("../../../schema/schema.sql"); assert!( desired_schema.contains("CREATE TABLE join_policy_acceptances"), "desired-state schema must include join-policy evidence used by invite claims", ); + assert!(desired_schema.contains("channel_id UUID")); + assert!(desired_schema.contains("CREATE INDEX relay_invites_channel_idx")); } #[test] diff --git a/crates/buzz-db/src/relay_invite.rs b/crates/buzz-db/src/relay_invite.rs index 82b71b07bb0..fce82642b18 100644 --- a/crates/buzz-db/src/relay_invite.rs +++ b/crates/buzz-db/src/relay_invite.rs @@ -39,6 +39,8 @@ pub enum ClaimOutcome { use_count: i32, /// Remaining slots, or `None` when the invite is unlimited. uses_remaining: Option, + /// Channel granted by this invite, or `None` for a community invite. + channel_id: Option, }, /// The claimer was already a member. `use_count` was NOT incremented. AlreadyMember { @@ -46,6 +48,8 @@ pub enum ClaimOutcome { use_count: i32, /// Remaining slots, or `None` when the invite is unlimited. uses_remaining: Option, + /// Channel scoped by this invite, or `None` for a community invite. + channel_id: Option, }, /// The invite's `expires_at` has passed. Expired, @@ -70,6 +74,8 @@ pub struct MintedInvite { pub uses_remaining: Option, /// The invite's database-generated UUID. pub invite_id: uuid::Uuid, + /// Channel granted on claim, or `None` for a community invite. + pub channel_id: Option, } fn validate_mint_inputs(ttl_secs: u64, max_uses: Option) -> Result<()> { @@ -101,6 +107,7 @@ pub async fn mint_relay_invite( created_by: &str, ttl_secs: u64, max_uses: Option, + channel_id: Option, ) -> Result { validate_mint_inputs(ttl_secs, max_uses)?; @@ -112,8 +119,8 @@ pub async fn mint_relay_invite( let expires_at = now + chrono::Duration::seconds(ttl_secs as i64); let row = sqlx::query( - "INSERT INTO relay_invites (community_id, token_hash, max_uses, expires_at, created_by) \ - VALUES ($1, $2, $3, $4, $5) \ + "INSERT INTO relay_invites (community_id, token_hash, max_uses, expires_at, created_by, channel_id) \ + VALUES ($1, $2, $3, $4, $5, $6) \ RETURNING id", ) .bind(community.as_uuid()) @@ -121,6 +128,7 @@ pub async fn mint_relay_invite( .bind(max_uses) .bind(expires_at) .bind(created_by) + .bind(channel_id) .fetch_one(pool) .await?; @@ -132,6 +140,7 @@ pub async fn mint_relay_invite( max_uses, uses_remaining: max_uses, invite_id, + channel_id, }) } @@ -209,7 +218,7 @@ pub async fn claim_relay_invite( // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. let row = sqlx::query( - "SELECT id, max_uses, use_count, expires_at \ + "SELECT id, max_uses, use_count, expires_at, channel_id, created_by \ FROM relay_invites \ WHERE community_id = $1 AND token_hash = $2 \ FOR UPDATE", @@ -230,6 +239,8 @@ pub async fn claim_relay_invite( let max_uses: Option = invite.try_get("max_uses")?; let use_count: i32 = invite.try_get("use_count")?; let expires_at: DateTime = invite.try_get("expires_at")?; + let channel_id: Option = invite.try_get("channel_id")?; + let created_by: String = invite.try_get("created_by")?; // Expiry is checked before membership deliberately. An expired bearer must // not authorize fresh policy-acceptance evidence, even for an existing @@ -248,15 +259,31 @@ pub async fn claim_relay_invite( let uses_remaining = || max_uses.map(|mu| mu - use_count); - // 5. Check existing membership. - let existing = + // 5. Check existing community and optional channel membership. + let existing_relay = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(claimer_pubkey) .fetch_optional(&mut *tx) .await?; - if existing.is_some() { + let existing_channel = if let Some(channel_id) = channel_id { + sqlx::query( + "SELECT 1 FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 \ + AND pubkey = decode($3, 'hex') AND removed_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(claimer_pubkey) + .fetch_optional(&mut *tx) + .await? + .is_some() + } else { + true + }; + + if existing_relay.is_some() && existing_channel { // 6. Already a member — insert policy evidence but do NOT increment. if let Some(version) = policy_version { sqlx::query( @@ -280,6 +307,7 @@ pub async fn claim_relay_invite( return Ok(ClaimOutcome::AlreadyMember { use_count, uses_remaining: uses_remaining(), + channel_id, }); } @@ -301,7 +329,7 @@ pub async fn claim_relay_invite( // 8. Insert relay member. The conflict branch covers a claimant admitted // concurrently through a different invite: only the transaction that // actually inserted membership may consume this invite. - let inserted = sqlx::query( + let relay_inserted = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, 'member', 'invite') \ ON CONFLICT (community_id, pubkey) DO NOTHING", @@ -313,6 +341,28 @@ pub async fn claim_relay_invite( .rows_affected() > 0; + let channel_inserted = if let Some(channel_id) = channel_id { + sqlx::query( + "INSERT INTO channel_members \ + (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, decode($3, 'hex'), 'guest', decode($4, 'hex')) \ + ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET \ + removed_at = NULL, removed_by = NULL, role = 'guest', \ + invited_by = EXCLUDED.invited_by \ + WHERE channel_members.removed_at IS NOT NULL", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(claimer_pubkey) + .bind(&created_by) + .execute(&mut *tx) + .await? + .rows_affected() + > 0 + } else { + false + }; + // 9. Insert join-policy acceptance evidence. This is required for both a // new member and a claimant whose concurrent membership insert won first. if let Some(version) = policy_version { @@ -327,7 +377,7 @@ pub async fn claim_relay_invite( .await?; } - if !inserted { + if !relay_inserted && !channel_inserted { tx.commit().await?; log_claim_outcome( community, @@ -339,6 +389,7 @@ pub async fn claim_relay_invite( return Ok(ClaimOutcome::AlreadyMember { use_count, uses_remaining: uses_remaining(), + channel_id, }); } @@ -367,6 +418,7 @@ pub async fn claim_relay_invite( Ok(ClaimOutcome::Joined { use_count: new_use_count, uses_remaining: new_uses_remaining, + channel_id, }) } @@ -411,6 +463,11 @@ mod tests { .execute(&mut *tx) .await .expect("delete test members"); + sqlx::query("DELETE FROM channels WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test channels"); sqlx::query("DELETE FROM communities WHERE id = $1") .bind(community.as_uuid()) .execute(&mut *tx) @@ -455,7 +512,7 @@ mod tests { let community = make_test_community(&pool).await; let first = test_pubkey(); let second = test_pubkey(); - let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1), None) .await .expect("mint bounded invite"); let hash = hash_v2_code(&invite.code); @@ -467,6 +524,7 @@ mod tests { ClaimOutcome::Joined { use_count: 1, uses_remaining: Some(0), + channel_id: None, } ); assert_eq!( @@ -476,6 +534,7 @@ mod tests { ClaimOutcome::AlreadyMember { use_count: 1, uses_remaining: Some(0), + channel_id: None, } ); assert_eq!( @@ -501,7 +560,7 @@ mod tests { let community = make_test_community(&pool).await; let first = test_pubkey(); let second = test_pubkey(); - let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1), None) .await .expect("mint bounded invite"); let hash = hash_v2_code(&invite.code); @@ -545,7 +604,7 @@ mod tests { let pool = setup_pool().await; let community_a = make_test_community(&pool).await; let community_b = make_test_community(&pool).await; - let invite = mint_relay_invite(&pool, community_a, "owner", 3600, Some(2)) + let invite = mint_relay_invite(&pool, community_a, "owner", 3600, Some(2), None) .await .expect("mint invite"); let hash = hash_v2_code(&invite.code); @@ -582,10 +641,10 @@ mod tests { async fn retention_sweep_deletes_only_invites_older_than_cutoff() { let pool = setup_pool().await; let community = make_test_community(&pool).await; - let old = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let old = mint_relay_invite(&pool, community, "owner", 3600, Some(1), None) .await .expect("mint old invite"); - let recent = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let recent = mint_relay_invite(&pool, community, "owner", 3600, Some(1), None) .await .expect("mint recent invite"); let cutoff = Utc::now() - chrono::Duration::days(30); @@ -620,7 +679,7 @@ mod tests { async fn unlimited_invites_count_each_new_member() { let pool = setup_pool().await; let community = make_test_community(&pool).await; - let invite = mint_relay_invite(&pool, community, "owner", 3600, None) + let invite = mint_relay_invite(&pool, community, "owner", 3600, None, None) .await .expect("mint unlimited invite"); let hash = hash_v2_code(&invite.code); @@ -633,6 +692,7 @@ mod tests { ClaimOutcome::Joined { use_count: expected_count, uses_remaining: None, + channel_id: None, } ); } @@ -640,13 +700,95 @@ mod tests { delete_test_community(&pool, community).await; } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn scoped_invite_adds_existing_community_member_as_channel_guest_once() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let channel_id = Uuid::new_v4(); + let owner = test_pubkey(); + let guest = test_pubkey(); + + sqlx::query( + "INSERT INTO channels \ + (community_id, id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, 'guest-test', 'stream', 'private', decode($3, 'hex'))", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(&owner) + .execute(&pool) + .await + .expect("insert private channel"); + sqlx::query( + "INSERT INTO channel_members \ + (community_id, channel_id, pubkey, role, invited_by) \ + VALUES ($1, $2, decode($3, 'hex'), 'owner', decode($3, 'hex'))", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(&owner) + .execute(&pool) + .await + .expect("insert channel owner"); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, 'member', 'test')", + ) + .bind(community.as_uuid()) + .bind(&guest) + .execute(&pool) + .await + .expect("insert existing community member"); + + let invite = mint_relay_invite(&pool, community, &owner, 3600, Some(1), Some(channel_id)) + .await + .expect("mint scoped invite"); + let hash = hash_v2_code(&invite.code); + + assert_eq!( + claim_relay_invite(&pool, community, &hash, &guest, None) + .await + .expect("claim scoped invite"), + ClaimOutcome::Joined { + use_count: 1, + uses_remaining: Some(0), + channel_id: Some(channel_id), + } + ); + let role: String = sqlx::query_scalar( + "SELECT role::text FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 \ + AND pubkey = decode($3, 'hex') AND removed_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(&guest) + .fetch_one(&pool) + .await + .expect("read guest role"); + assert_eq!(role, "guest"); + + assert_eq!( + claim_relay_invite(&pool, community, &hash, &guest, None) + .await + .expect("retry scoped invite"), + ClaimOutcome::AlreadyMember { + use_count: 1, + uses_remaining: Some(0), + channel_id: Some(channel_id), + } + ); + delete_test_community(&pool, community).await; + } + #[tokio::test] #[ignore = "requires Postgres"] async fn policy_evidence_failure_rolls_back_membership_and_consumption() { let pool = setup_pool().await; let community = make_test_community(&pool).await; let pubkey = test_pubkey(); - let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1), None) .await .expect("mint bounded invite"); let hash = hash_v2_code(&invite.code); diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6104171ccad..561060413a1 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -58,6 +58,9 @@ pub struct MintInviteRequest { /// must be an integer from 1 through [`MAX_INVITE_USES`]. #[serde(default)] pub max_uses: Option, + /// Optional private channel to grant with the read-only guest role. + #[serde(default)] + pub channel_id: Option, } fn validate_mint_request( @@ -271,7 +274,8 @@ pub async fn mint_invite( ) -> Result, (StatusCode, Json)> { let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites", &body).await?; - // Authz mirrors kind:9030 (add member): owner or admin only. + // Community invites require a relay owner/admin. Channel-scoped invites + // may instead be minted by that channel's owner/admin. let sender_hex = pubkey.to_hex(); let member = state .db @@ -279,13 +283,6 @@ pub async fn mint_invite( .await .map_err(|e| internal_error(&format!("invite mint role lookup: {e}")))?; let role = member.map(|m| m.role).unwrap_or_default(); - if role != "owner" && role != "admin" { - return Err(api_error( - StatusCode::FORBIDDEN, - "only relay owners and admins can create invites", - )); - } - let request: MintInviteRequest = if body.is_empty() { MintInviteRequest::default() } else { @@ -297,12 +294,52 @@ pub async fn mint_invite( })? }; + if let Some(channel_id) = request.channel_id { + let sender_bytes = pubkey.to_bytes(); + let channel = state + .db + .get_channel(tenant.community(), channel_id) + .await + .map_err(|_| api_error(StatusCode::NOT_FOUND, "channel_not_found"))?; + if channel.channel_type == "dm" { + return Err(api_error( + StatusCode::BAD_REQUEST, + "channel invites are not available for direct messages", + )); + } + let channel_role = state + .db + .get_members(tenant.community(), channel_id) + .await + .map_err(|e| internal_error(&format!("channel invite role lookup: {e}")))? + .into_iter() + .find(|member| member.pubkey == sender_bytes) + .map(|member| member.role); + if !matches!(channel_role.as_deref(), Some("owner" | "admin")) { + return Err(api_error( + StatusCode::FORBIDDEN, + "only channel owners and admins can create channel invites", + )); + } + } else if role != "owner" && role != "admin" { + return Err(api_error( + StatusCode::FORBIDDEN, + "only relay owners and admins can create invites", + )); + } + let (ttl, max_uses) = validate_mint_request(&request)?; // Mint a v2 opaque, database-backed invite. let invite = state .db - .mint_relay_invite(tenant.community(), &sender_hex, ttl, max_uses) + .mint_relay_invite( + tenant.community(), + &sender_hex, + ttl, + max_uses, + request.channel_id, + ) .await .map_err(|error| match error { buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, &message), @@ -334,6 +371,8 @@ pub async fn mint_invite( "expires_at": expires_at_unix, "max_uses": invite.max_uses, "uses_remaining": invite.uses_remaining, + "channel_id": invite.channel_id, + "channel_role": invite.channel_id.map(|_| "guest"), "url": format!("{scheme}://{}/invite/{}", tenant.host(), invite.code), }))) } @@ -400,7 +439,7 @@ pub async fn claim_invite( .map_err(|e| internal_error(&format!("v2 invite claim: {e}")))?; return match outcome { - buzz_db::relay_invite::ClaimOutcome::Joined { .. } => { + buzz_db::relay_invite::ClaimOutcome::Joined { channel_id, .. } => { tracing::info!( community = %tenant.community(), member = %claimer_hex, @@ -415,19 +454,26 @@ pub async fn claim_invite( if let Err(e) = publish_nip43_membership_list(&tenant, &state).await { tracing::warn!("failed to publish NIP-43 membership list after v2 claim: {e}"); } + if let Some(channel_id) = channel_id { + state.invalidate_membership(&tenant, channel_id, &pubkey.to_bytes()); + } Ok(Json(serde_json::json!({ "status": "joined", "community_id": tenant.community().to_string(), "host": tenant.host(), "role": "member", + "channel_id": channel_id, + "channel_role": channel_id.map(|_| "guest"), }))) } - buzz_db::relay_invite::ClaimOutcome::AlreadyMember { .. } => { + buzz_db::relay_invite::ClaimOutcome::AlreadyMember { channel_id, .. } => { Ok(Json(serde_json::json!({ "status": "already_member", "community_id": tenant.community().to_string(), "host": tenant.host(), "role": "member", + "channel_id": channel_id, + "channel_role": channel_id.map(|_| "guest"), }))) } buzz_db::relay_invite::ClaimOutcome::Expired => { @@ -789,6 +835,7 @@ mod tests { super::MintInviteRequest { ttl_secs: Some(MIN_INVITE_TTL_SECS), max_uses: Some(1), + channel_id: None, }, (MIN_INVITE_TTL_SECS, Some(1)), ), @@ -796,6 +843,7 @@ mod tests { super::MintInviteRequest { ttl_secs: Some(MAX_INVITE_TTL_SECS), max_uses: Some(MAX_INVITE_USES), + channel_id: None, }, (MAX_INVITE_TTL_SECS, Some(MAX_INVITE_USES)), ), @@ -810,22 +858,27 @@ mod tests { super::MintInviteRequest { ttl_secs: None, max_uses: Some(0), + channel_id: None, }, super::MintInviteRequest { ttl_secs: None, max_uses: Some(-1), + channel_id: None, }, super::MintInviteRequest { ttl_secs: None, max_uses: Some(MAX_INVITE_USES + 1), + channel_id: None, }, super::MintInviteRequest { ttl_secs: Some(MIN_INVITE_TTL_SECS - 1), max_uses: None, + channel_id: None, }, super::MintInviteRequest { ttl_secs: Some(MAX_INVITE_TTL_SECS + 1), max_uses: None, + channel_id: None, }, ] { assert_eq!( diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c6349546a23..87157bd4b3f 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -56,6 +56,8 @@ import { managedAgentPairAction, } from "@/features/agents/managedAgentRuntimeStatus"; import { EditRespondToDialog } from "./EditRespondToDialog"; +import { InviteLinkSection } from "@/features/community-members/ui/InviteLinkSection"; +import { DEFAULT_INVITE_TTL_SECS } from "@/features/community-members/ui/InviteLinkSection"; import { useMembersSidebarActions } from "./useMembersSidebarActions"; import { useMembersSidebarModeration } from "./useMembersSidebarModeration"; const MEMBER_ADD_RESULT_LIMIT = 50; @@ -153,6 +155,9 @@ export function MembersSidebar({ const [addingMemberPubkeys, setAddingMemberPubkeys] = React.useState< ReadonlySet >(() => new Set()); + const [inviteTtlSecs, setInviteTtlSecs] = React.useState( + DEFAULT_INVITE_TTL_SECS, + ); const identityQuery = useIdentityQuery(); const membersQuery = useChannelMembersQuery(channelId, open); const addMembersMutation = useAddChannelMembersMutation(channelId); @@ -726,6 +731,16 @@ export function MembersSidebar({
+ {canManageMembers && channel.channelType !== "dm" ? ( +
+ +
+ ) : null}
void; ttlSecs: number; }) { const [copyStatus, setCopyStatus] = React.useState("idle"); const [maxUsesEnabled, setMaxUsesEnabled] = React.useState(true); - const [maxUsesInput, setMaxUsesInput] = React.useState("3"); + const [maxUsesInput, setMaxUsesInput] = React.useState(channelId ? "1" : "3"); const parsedMaxUses = Number(maxUsesInput); const maxUsesValid = !maxUsesEnabled || @@ -75,6 +79,7 @@ export function InviteLinkSection({ const invite = await mintInvite({ ttlSecs, maxUses: maxUsesEnabled ? parsedMaxUses : null, + channelId, }); await writeTextToClipboard(invite.url); setCopyStatus("copied"); @@ -92,9 +97,13 @@ export function InviteLinkSection({