mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(relay): add announce channels with a join_role setting (#8143)
🤖 ## Summary Channels are Buzz's main way to control access, but today they cannot separate reading from writing. In an open channel, anyone who can read can also write. So there is no way to say "many people read, a few people write". Announcements, release feeds, plugin release records and shared configuration all need that. This PR adds **announce channels**: anyone can follow and read them, but only members can post. It does this with one new channel setting and without changing what any role means. - **Roles mean the same thing in every channel.** A *guest* can read and leave, but cannot write. A *member* (or *bot*) can write. *Admins* and *owners* also manage the channel. The relay already had the guest role, but no client used it until now. - **A new setting, `join_role` (`member` | `guest`), picks the role a new person gets** when nobody chooses one: when they join by themselves, or when someone adds them without a role. The default, `member`, keeps today's behavior for every existing channel. - **An open channel with `join_role = guest` is an announce channel.** Anyone can find, read and join it. People who join are guests, so they cannot write until someone makes them a member. The relay enforces these rules, so a client cannot get around them. What each role can do (the same in every channel): | Action | Guest | Member or bot | Admin / owner | | --- | --- | --- | --- | | Read, leave | ✅ | ✅ | ✅ | | Post, reply, edit, delete own, react, artifacts, canvas, forum, pins, topic, typing, workflow messages, save or run workflows, huddles | ❌ | ✅ | ✅ | | Add people | ❌ | ✅ as member, guest or bot | ✅ any role | | Make a guest a member (person or agent) | ❌ | ✅ | ✅ | | Any other role change | ❌ | ❌ | ✅ | | Change name, about, visibility, TTL, `join_role` | ❌ | ❌ | ✅ | Other rules: - **Joining by yourself.** A person who joins (kind:9021), or adds themselves (kind:9000 with their own key), gets the channel's `join_role`. They may ask for `join_role` or a lower role, never a higher one. So in an announce channel nobody can join as a member by themselves. - **Non-members.** A non-member may write only where joining would let them write: an open channel with `join_role = member`. That is today's behavior for open channels. - **Agents.** Agents follow the same rules as people. Any member who can write may make a guest agent a member. Limits that only an agent's owner may lift belong to the later IFC work (its "restricted" agent level), not to this PR. That work only refuses more actions, so it can be added later without breaking anything here. - **Leaving always works.** A member may leave an archived channel too (kind:9022, or kind:9001 naming themselves). The last owner still cannot leave. - **Agent owners keep edit and delete.** Today the human owner of an agent can edit (kind:40003) and delete (kind:9005) that agent's messages without being a member. This PR keeps that existing behavior in announce channels too, and documents and tests it. It does not let the owner post. - **Changing the setting changes no roles.** Existing members can still write after a change to `guest`. Existing guests stay read-only after a change back to `member`. A refused write gets `restricted: only members can post in this channel`. ## Details - **One write rule.** The relay's channel write check decides from the actor's role and the channel's `join_role`. Some event types skip that check and run their own: message edit, deleting your own message (kind:9005), topic and purpose (kind:9002), add-member (kind:9000), workflow messages and joining a huddle. Each of these now uses the same rule. Membership changes (add, remove, leave) do not need write access, so a guest can always leave. If the relay finds a stored `join_role` value it does not know, it treats it as `guest`, so an unknown value can never give more access. - **Admission is decided in the database transaction.** The membership write (`put_member`) makes the whole admission decision inside its transaction, after it takes the channel's membership lock: `join_role` for a missing role tag, the self-join ceiling, guests cannot add people, who may grant admin or owner, and role changes (`MemberRole::may_change_role`). The relay's request check uses the same rules, but the database is the authority, so a write that skips the request check cannot get around them. A change to `visibility` or `join_role` takes the same lock, so an add and a setting change cannot interleave. - **No creator exception.** `put_member` used to give owner to a channel's creator who added themselves to a private channel without an active membership. A removed creator could use that: their join (kind:9021) passes the open-channel check, the channel becomes private before the join takes the membership lock, and the join restored them as owner. Channel creation already makes the creator owner in the same transaction, so the exception is removed. A removed creator is now admitted like anyone else. - **Huddles.** Joining a huddle needs write access in the huddle channel **and** in its parent channel. The relay checks both before the join. When it commits the join, it applies the same rule again while it holds both channels' membership locks: a member needs a role that can post, and a non-member needs an open channel whose `join_role` is `member`. A guest in either channel cannot join. A join is also refused when the joiner loses membership or the channel's `join_role` changes before the commit. - **One lock order.** Each writer that takes more than one of a channel's locks takes them in this order: membership lock, TTL lock, channel row. Settings changes, roster publication, TTL refresh and huddle joins all use this order. Before this change, a `join_role` change on an ephemeral channel at the same time as a roster publication could deadlock. - **Workflows.** Saving a workflow (kind:30620) skips the generic write check, so it now checks the sender's role itself and needs a writing role before anything is stored. Every path that starts a workflow run, including the manual trigger (kind:46020), needs the workflow's owner to hold a writing role now (and an elevated role for an elevated workflow). So a workflow saved while its owner was a member stops running after the owner is made a guest. - **Channel names are checked before storage.** kind:9002 and kind:9007 refuse a name longer than the `channels.name` column (255 characters) before the event is stored. Before this, the relay accepted the event, then the name update failed, and the rest of the update (for example `join_role`) was silently skipped. This PR only adds this check. Storing the event and applying the update in one transaction is a larger change, and it is not in this PR. - **Changing the setting.** Owners and admins send a channel-metadata update (kind:9002) with `["join_role", "guest"]` or `["join_role", "member"]`. Buzz applies these updates as partial updates; a missing tag means "no change". The relay posts a `join_role_changed` system message in the channel. - **What clients see.** The relay signs a channel description event (kind:39000) that clients read. One shared builder now writes it for both the relay and the `buzz-admin` backfill, which had a copy that had drifted from the relay's. It always publishes an explicit `["join_role", …]` tag, plus the standard NIP-29 group access flags: | `visibility` | `join_role` | `public` / `private` | `restricted` | `closed` | | --- | --- | --- | --- | --- | | open | `member` | public | | | | open | `guest` | public | ✓ | | | private | either | private | ✓ | ✓ | This also fixes an old bug: `closed` ("join requests are ignored") was published for every channel, even open channels that accept joins. - **CLI and SDK.** `buzz channels update --join-role guest|member`. `buzz_sdk::build_update_channel` takes a new `join_role` argument and always writes it as an explicit tag. - **Docs.** `NOSTR.md` has an "Announce channels" section with the role table and the flag table. - **Tests.** Relay end-to-end tests, run in the CI relay E2E step: - `test_nip29_announce_channel_wire`: who can set `join_role`, the kind:39000 flags, joining as a guest, refused guest and non-member posts, reactions and adds, member posts, a member making a guest a member who then posts, changing the setting back without changing roles, and leaving. - `test_nip29_member_raises_guest_agent`: a member who is not the agent's owner can make a guest agent a member. - `test_nip29_announce_channel_wire_workflow_send_needs_write_role`: a guest's workflow message is refused; a member's is accepted. - `test_nip29_departure_wire_archived_channel_allows_self_leave`: guests and members can leave an archived channel. - `test_private_channel_any_member_can_invite` now expects a private-channel guest to be refused when it adds someone (it used to expect success). - `test_nip29_announce_channel_wire_workflow_commands_need_write_role`: saving and triggering a workflow, by a member and by a guest, in announce and normal channels; a guest cannot run a workflow saved while it was a member. - `test_nip29_announce_channel_wire_overlong_name_is_refused_before_storage`: a kind:9002 with an overlong name and `join_role` is refused, and kind:39000 still shows the old settings. - `test_nip29_announce_channel_wire_agent_owner_exception`: an agent's owner, as a guest and as a non-member, can edit and delete the agent's messages, while ordinary guests are still refused. - `test_nip29_guest_is_read_only_in_normal_channel`: a guest in an ordinary channel cannot write, and joining an ordinary channel still gives `member`. Unit tables cover the self-join rule for each `join_role`, and every actor, current role and requested role for the role-change rule. Database tests check the admission rules in `put_member` (`put_member_enforces_announce_admission`), that an add which waits on an uncommitted change to `guest` is decided on the new setting (`put_member_waits_for_a_concurrent_join_role_change`), and that a member can make a guest a member but not an admin. Unit tests cover the name limit and the workflow owner rule (`owner_authority_denies_roles_that_cannot_write`). A relay test covers huddle joins with different roles in the huddle and its parent, and a commit after a demotion, a `join_role` change or a lost membership (`huddle_join_needs_a_write_role_in_the_huddle_and_its_parent`). Lock-order tests: `join_role_change_waits_for_a_roster_publication_on_an_ephemeral_channel` reproduced a real deadlock (`40P01`) against the old order. `f2d_join_lock_order_serializes_membership_and_settings_writers` checks that membership and settings writers wait for a join and then succeed. `removed_creator_cannot_rejoin_a_private_channel` checks that `put_member` refuses a removed creator on a private channel, and `removed_creator_join_racing_a_private_switch_is_refused` holds the membership lock so a removed creator's kind:9021 passes the open-channel check, then makes the channel private before the join continues. Each new test was run against the old code and failed. ## Rollout 1. **Before the deploy, count guest rows** (`SELECT count(*) FROM channel_members WHERE role = 'guest' AND removed_at IS NULL`). We expect none. Any guest that exists loses the ability to post, change the topic and add people when this ships. If some exist, decide first whether to make them members. 2. Deploy. Every channel stays `join_role = member`, so nothing else changes. 3. **Do not set any channel to `join_role = guest` until no old relay is running.** Old relays ignore the column, so they would let guests and non-members write in that channel. 4. Rerun the `buzz-admin` channel reconcile so open channels drop the wrong `closed` flag. ## Migration and rollback - Migration `0067` adds `channels.join_role TEXT NOT NULL DEFAULT 'member'` with a CHECK constraint. Postgres stores a constant default in table metadata, so existing rows are not rewritten and there is no backfill. The relay runs the migration at startup. - Existing channels keep the old `closed` flag until their next change, or until the reconcile in Rollout step 4. - **Before rolling back the relay**, set every announce channel back to `join_role = member`. An older relay ignores the column and does not stop guests from writing, so those channels would accept posts from their guests and give `member` to new joiners again. ## Not in this PR Client UI: a Follow button, hiding the composer for guests, and an admin toggle. Clients that ignore the new tag keep working. A guest who tries to post sees the `restricted` error. ## Stack 1. #8143 (this PR): announce channels 2. #8494: store the bot role only for keys with a recorded agent owner --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -709,7 +709,7 @@ mod postgres_tests {
|
||||
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||
migrations.sort_by_key(|migration| migration.version);
|
||||
|
||||
assert_eq!(migrations.len(), 66);
|
||||
assert_eq!(migrations.len(), 67);
|
||||
assert_eq!(migrations[64].version, 65);
|
||||
let retire_ttl = strip_sql_comments(migrations[64].sql.as_str());
|
||||
for statement in [
|
||||
@@ -761,6 +761,11 @@ mod postgres_tests {
|
||||
.sql
|
||||
.as_str()
|
||||
.contains("ADD COLUMN through_message_id"));
|
||||
assert_eq!(migrations[66].version, 67);
|
||||
assert!(migrations[66]
|
||||
.sql
|
||||
.as_str()
|
||||
.contains("ADD COLUMN join_role TEXT NOT NULL DEFAULT 'member'"));
|
||||
assert_eq!(migrations[48].version, 49);
|
||||
assert_eq!(migrations[49].version, 50);
|
||||
assert_eq!(migrations[50].version, 51);
|
||||
|
||||
@@ -215,7 +215,26 @@ async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer()
|
||||
let (pool, name) = create_scratch_db_through(&admin, "tw_prebuild_writer", Some(48)).await;
|
||||
let community = Uuid::new_v4();
|
||||
let channel = Uuid::new_v4();
|
||||
seed_community_channel(&pool, community, channel, &nostr::Keys::generate()).await;
|
||||
// Plain SQL: the schema stops at 0048, so the current channel store's
|
||||
// column list (which includes later columns such as `join_role`) does not
|
||||
// apply yet.
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||||
.bind(community)
|
||||
.bind(format!("tw-prebuild-{}.example", community.simple()))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert community");
|
||||
sqlx::query(
|
||||
"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \
|
||||
VALUES ($1, $2, $3, 'stream', 'open', $4)",
|
||||
)
|
||||
.bind(channel)
|
||||
.bind(community)
|
||||
.bind(format!("tw-prebuild-{channel}"))
|
||||
.bind(nostr::Keys::generate().public_key().to_bytes().to_vec())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert channel");
|
||||
sqlx::query("CREATE INDEX CONCURRENTLY idx_thread_metadata_window ON thread_metadata (community_id,root_event_id,event_created_at DESC,event_id ASC)")
|
||||
.execute(&pool).await.unwrap();
|
||||
let oid: i64 = sqlx::query_scalar("SELECT 'idx_thread_metadata_window'::regclass::oid::bigint")
|
||||
|
||||
@@ -16,7 +16,10 @@ use buzz_datastore_tracing::datastore_span;
|
||||
// Re-export the canonical enum definitions from buzz-core.
|
||||
// These live in core (zero I/O deps) so the SDK can share them
|
||||
// without pulling in sqlx/tokio.
|
||||
pub use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole};
|
||||
pub use buzz_core::channel::{
|
||||
decide_member_put, ChannelJoinRole, ChannelType, ChannelVisibility, MemberPut, MemberPutDenial,
|
||||
MemberRole,
|
||||
};
|
||||
|
||||
// Keep the established channel module paths compatible while membership SQL
|
||||
// and invariants live in their dedicated store module.
|
||||
@@ -78,6 +81,9 @@ pub struct ChannelRecord {
|
||||
pub ttl_deadline: Option<DateTime<Utc>>,
|
||||
/// Labels published as `["t", <label>]` after the channel type tag.
|
||||
pub labels: Vec<String>,
|
||||
/// The role a new person gets (`"member"` or `"guest"`). Parse with
|
||||
/// [`buzz_core::channel::ChannelJoinRole::parse_fail_closed`].
|
||||
pub join_role: String,
|
||||
}
|
||||
|
||||
impl ChannelRecord {
|
||||
@@ -169,7 +175,7 @@ pub async fn create_channel(
|
||||
nip29_group_id, topic_required, max_members,
|
||||
topic, topic_set_by, topic_set_at,
|
||||
purpose, purpose_set_by, purpose_set_at,
|
||||
ttl_seconds, ttl_deadline, labels
|
||||
ttl_seconds, ttl_deadline, labels, join_role
|
||||
FROM channels WHERE community_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
@@ -231,7 +237,7 @@ pub async fn create_channel_with_id(
|
||||
nip29_group_id, topic_required, max_members,
|
||||
topic, topic_set_by, topic_set_at,
|
||||
purpose, purpose_set_by, purpose_set_at,
|
||||
ttl_seconds, ttl_deadline, labels
|
||||
ttl_seconds, ttl_deadline, labels, join_role
|
||||
FROM channels WHERE community_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
@@ -470,7 +476,7 @@ async fn get_channel_with_operation(
|
||||
nip29_group_id, topic_required, max_members,
|
||||
topic, topic_set_by, topic_set_at,
|
||||
purpose, purpose_set_by, purpose_set_at,
|
||||
ttl_seconds, ttl_deadline, labels
|
||||
ttl_seconds, ttl_deadline, labels, join_role
|
||||
FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL
|
||||
"#,
|
||||
)
|
||||
@@ -559,7 +565,7 @@ async fn list_channels_with_operation(
|
||||
nip29_group_id, topic_required, max_members,
|
||||
topic, topic_set_by, topic_set_at,
|
||||
purpose, purpose_set_by, purpose_set_at,
|
||||
ttl_seconds, ttl_deadline, labels
|
||||
ttl_seconds, ttl_deadline, labels, join_role
|
||||
FROM channels
|
||||
WHERE community_id = $1 AND deleted_at IS NULL AND visibility::text = $2
|
||||
ORDER BY created_at DESC
|
||||
@@ -579,7 +585,7 @@ async fn list_channels_with_operation(
|
||||
nip29_group_id, topic_required, max_members,
|
||||
topic, topic_set_by, topic_set_at,
|
||||
purpose, purpose_set_by, purpose_set_at,
|
||||
ttl_seconds, ttl_deadline, labels
|
||||
ttl_seconds, ttl_deadline, labels, join_role
|
||||
FROM channels
|
||||
WHERE community_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
@@ -644,9 +650,103 @@ pub(crate) fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<Channe
|
||||
ttl_seconds,
|
||||
ttl_deadline,
|
||||
labels: row.try_get("labels")?,
|
||||
// Selected by every channel-record query. A missing column is an
|
||||
// error, never a silent `member`: the write gate depends on it.
|
||||
join_role: row.try_get("join_role")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The tags of a channel's relay-signed NIP-29 kind:39000 metadata event.
|
||||
///
|
||||
/// This is the single builder for kind:39000: the relay's discovery emitter
|
||||
/// and the `buzz-admin` backfill both call it, so the published flags cannot
|
||||
/// drift apart. `members` is used only for DM participant `p` tags.
|
||||
///
|
||||
/// The NIP-29 access flags follow the channel's settings:
|
||||
/// - `private` (members-only read), or Buzz's explicit `public`;
|
||||
/// - `restricted` (only a writing role may write) for private channels and
|
||||
/// for open channels with `join_role = guest`; an unknown `join_role` fails
|
||||
/// closed to `guest`;
|
||||
/// - `closed` (join requests ignored) only when kind:9021 cannot self-join,
|
||||
/// which is every non-open channel.
|
||||
///
|
||||
/// Buzz also publishes the explicit `["join_role", ...]` value, so clients
|
||||
/// can tell what joining gives (show Join or Follow) without inferring it
|
||||
/// from the flags.
|
||||
pub fn group_metadata_tags(channel: &ChannelRecord, members: &[MemberRecord]) -> Vec<Vec<String>> {
|
||||
let tag = |parts: &[&str]| parts.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>();
|
||||
let mut tags = vec![
|
||||
tag(&["d", &channel.id.to_string()]),
|
||||
tag(&["name", &channel.name]),
|
||||
];
|
||||
if let Some(desc) = channel.description.as_deref().filter(|d| !d.is_empty()) {
|
||||
tags.push(tag(&["about", desc]));
|
||||
}
|
||||
let open = channel.visibility == ChannelVisibility::Open.as_str();
|
||||
if open {
|
||||
// Explicit "public" tag complements NIP-29's absence-of-"private"
|
||||
// convention, making channel visibility self-describing for clients.
|
||||
tags.push(tag(&["public"]));
|
||||
} else {
|
||||
tags.push(tag(&["private"]));
|
||||
}
|
||||
let join_role = ChannelJoinRole::parse_fail_closed(&channel.join_role);
|
||||
if !open || join_role == ChannelJoinRole::Guest {
|
||||
tags.push(tag(&["restricted"]));
|
||||
}
|
||||
// NIP-29 hidden tag: hide metadata from non-members. Set for DMs and
|
||||
// for private system channels. Not a security boundary — access control
|
||||
// is handled by channel-scoped storage.
|
||||
if channel.channel_type == ChannelType::System.as_str() && !open {
|
||||
tags.push(tag(&["hidden"]));
|
||||
}
|
||||
if channel.channel_type == ChannelType::Dm.as_str() {
|
||||
tags.push(tag(&["hidden"]));
|
||||
// Include participant pubkeys in kind:39000 for DMs so clients can
|
||||
// resolve display names without a separate kind:39002 fetch.
|
||||
for m in members {
|
||||
tags.push(tag(&["p", &hex::encode(&m.pubkey)]));
|
||||
}
|
||||
}
|
||||
if !open {
|
||||
tags.push(tag(&["closed"]));
|
||||
}
|
||||
// Channel type tag so clients can distinguish stream/forum/dm without
|
||||
// inference, then one `t` per label, then the creator. The same tags lead
|
||||
// 39001 and 39002 (see `buzz_core::channel::group_state_identity_tags`).
|
||||
tags.push(tag(&["t", &channel.channel_type]));
|
||||
for label in &channel.labels {
|
||||
tags.push(tag(&["t", label]));
|
||||
}
|
||||
tags.push(tag(&["P", &hex::encode(&channel.created_by)]));
|
||||
// Creation time, so readers can pick the oldest of two channels. The
|
||||
// event's own created_at changes on every edit.
|
||||
tags.push(tag(&[
|
||||
buzz_core::channel::CHANNEL_CREATED_AT_TAG,
|
||||
&channel.created_at.timestamp().to_string(),
|
||||
]));
|
||||
tags.push(tag(&["join_role", join_role.as_str()]));
|
||||
// Optional topic / purpose for richer client UX
|
||||
if let Some(topic) = channel.topic.as_deref().filter(|t| !t.is_empty()) {
|
||||
tags.push(tag(&["topic", topic]));
|
||||
}
|
||||
if let Some(purpose) = channel.purpose.as_deref().filter(|p| !p.is_empty()) {
|
||||
tags.push(tag(&["purpose", purpose]));
|
||||
}
|
||||
// Archived state — clients use this to hide channels from the sidebar.
|
||||
if channel.archived_at.is_some() {
|
||||
tags.push(tag(&["archived", "true"]));
|
||||
}
|
||||
// Ephemeral channel TTL — clients use this to show countdown timers.
|
||||
if let Some(ttl) = channel.ttl_seconds {
|
||||
tags.push(tag(&["ttl", &ttl.to_string()]));
|
||||
}
|
||||
if let Some(deadline) = channel.ttl_deadline {
|
||||
tags.push(tag(&["ttl_deadline", &deadline.to_rfc3339()]));
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
/// Partial update for channel metadata. Every field is `None` to leave the
|
||||
/// column unchanged.
|
||||
#[derive(Default)]
|
||||
@@ -664,6 +764,8 @@ pub struct ChannelUpdate {
|
||||
/// Replacement label set (empty clears it), or `None` to leave unchanged.
|
||||
/// Callers validate with `buzz_core::channel::parse_channel_labels`.
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// New join role (`"member"`/`"guest"`), or `None` to leave unchanged.
|
||||
pub join_role: Option<String>,
|
||||
}
|
||||
|
||||
/// Updates channel metadata dynamically.
|
||||
@@ -681,6 +783,7 @@ pub async fn update_channel(
|
||||
&& updates.visibility.is_none()
|
||||
&& updates.ttl_seconds.is_none()
|
||||
&& updates.labels.is_none()
|
||||
&& updates.join_role.is_none()
|
||||
{
|
||||
return Err(DbError::InvalidData(
|
||||
"at least one field must be provided for update".to_string(),
|
||||
@@ -710,6 +813,10 @@ pub async fn update_channel(
|
||||
set_parts.push(format!("visibility = ${param_idx}::channel_visibility"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if updates.join_role.is_some() {
|
||||
set_parts.push(format!("join_role = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if let Some(ref ttl) = updates.ttl_seconds {
|
||||
// Set ttl_seconds, then reset the deadline from now (or clear both).
|
||||
set_parts.push(format!("ttl_seconds = ${param_idx}"));
|
||||
@@ -742,6 +849,9 @@ pub async fn update_channel(
|
||||
if let Some(ref vis) = updates.visibility {
|
||||
q = q.bind(vis);
|
||||
}
|
||||
if let Some(ref join_role) = updates.join_role {
|
||||
q = q.bind(join_role);
|
||||
}
|
||||
if let Some(ref ttl) = updates.ttl_seconds {
|
||||
q = q.bind(*ttl);
|
||||
}
|
||||
@@ -751,6 +861,31 @@ pub async fn update_channel(
|
||||
q = q.bind(community_id.as_uuid());
|
||||
q = q.bind(channel_id);
|
||||
|
||||
// Every writer that takes more than one of a channel's locks takes them
|
||||
// in this order: membership lock, then TTL lock, then the channel row.
|
||||
// Roster publication holds the membership lock and then refreshes the
|
||||
// TTL (TTL lock, row update) at commit; a huddle join takes the
|
||||
// membership lock before it locks the row. Taking the row first here
|
||||
// would let either of them wait on this transaction while it waits on
|
||||
// them.
|
||||
let mut tx = crate::begin_community_write_transaction(
|
||||
pool,
|
||||
community_id,
|
||||
crate::observability::WriterOperation::EventWrite,
|
||||
)
|
||||
.await?;
|
||||
// Visibility and `join_role` decide admission, which `put_member` and a
|
||||
// huddle join decide under the channel's membership lock. Take that lock
|
||||
// too, so an admission either sees the old setting and commits first, or
|
||||
// sees the new one.
|
||||
if updates.visibility.is_some() || updates.join_role.is_some() {
|
||||
crate::channel_members::acquire_channel_membership_lock(
|
||||
tx.conn(),
|
||||
community_id,
|
||||
channel_id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
// T1a repair: a TTL change can flip this channel's event-commit fast
|
||||
// path (`event_follow_up::refresh_channel_ttls` reads ttl_seconds under
|
||||
// a SHARED per-channel advisory lock). Take the same key EXCLUSIVE
|
||||
@@ -759,34 +894,16 @@ pub async fn update_channel(
|
||||
// then the latest word. Non-TTL updates don't touch the fast path and
|
||||
// skip the lock.
|
||||
if updates.ttl_seconds.is_some() {
|
||||
let mut tx = crate::begin_community_write_transaction(
|
||||
pool,
|
||||
community_id,
|
||||
crate::observability::WriterOperation::EventWrite,
|
||||
)
|
||||
.await?;
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
|
||||
.bind(channel_ttl_lock_key(community_id, channel_id))
|
||||
.execute(tx.conn())
|
||||
.await?;
|
||||
let result = q.execute(tx.conn()).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::ChannelNotFound(channel_id));
|
||||
}
|
||||
tx.commit().await?;
|
||||
} else {
|
||||
let mut tx = crate::begin_community_write_transaction(
|
||||
pool,
|
||||
community_id,
|
||||
crate::observability::WriterOperation::EventWrite,
|
||||
)
|
||||
.await?;
|
||||
let result = q.execute(tx.conn()).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::ChannelNotFound(channel_id));
|
||||
}
|
||||
tx.commit().await?;
|
||||
}
|
||||
let result = q.execute(tx.conn()).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::ChannelNotFound(channel_id));
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
get_channel_with_operation(
|
||||
pool,
|
||||
@@ -1309,6 +1426,133 @@ pub(crate) fn channel_ttl_lock_key(community: CommunityId, channel: Uuid) -> Str
|
||||
format!("buzz_channel_ttl:{}:{channel}", community.as_uuid())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod group_metadata_tag_tests {
|
||||
use super::*;
|
||||
|
||||
fn channel(visibility: &str, join_role: &str) -> ChannelRecord {
|
||||
let now = Utc::now();
|
||||
ChannelRecord {
|
||||
id: Uuid::nil(),
|
||||
name: "news".into(),
|
||||
channel_type: "stream".into(),
|
||||
visibility: visibility.into(),
|
||||
description: None,
|
||||
canvas: None,
|
||||
created_by: vec![1; 32],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
archived_at: None,
|
||||
deleted_at: None,
|
||||
nip29_group_id: None,
|
||||
topic_required: false,
|
||||
max_members: None,
|
||||
topic: None,
|
||||
topic_set_by: None,
|
||||
topic_set_at: None,
|
||||
purpose: None,
|
||||
purpose_set_by: None,
|
||||
purpose_set_at: None,
|
||||
ttl_seconds: None,
|
||||
ttl_deadline: None,
|
||||
labels: Vec::new(),
|
||||
join_role: join_role.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn flags(channel: &ChannelRecord) -> Vec<&'static str> {
|
||||
let tags = group_metadata_tags(channel, &[]);
|
||||
["private", "public", "restricted", "closed"]
|
||||
.into_iter()
|
||||
.filter(|flag| tags.iter().any(|t| t == &vec![flag.to_string()]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The NIP-29 access flags follow visibility and join_role. Open channels
|
||||
/// never publish `closed`: kind:9021 self-join works there.
|
||||
#[test]
|
||||
fn access_flags_follow_settings() {
|
||||
let cases: &[(&str, &str, &[&str])] = &[
|
||||
("open", "member", &["public"]),
|
||||
("open", "guest", &["public", "restricted"]),
|
||||
("private", "member", &["private", "restricted", "closed"]),
|
||||
("private", "guest", &["private", "restricted", "closed"]),
|
||||
// An unknown join_role value fails closed to `guest`.
|
||||
("open", "bogus", &["public", "restricted"]),
|
||||
];
|
||||
for (visibility, join_role, expected) in cases {
|
||||
assert_eq!(
|
||||
flags(&channel(visibility, join_role)),
|
||||
*expected,
|
||||
"{visibility}/{join_role}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_the_explicit_join_role_value() {
|
||||
let tags = group_metadata_tags(&channel("open", "guest"), &[]);
|
||||
assert!(tags.contains(&vec!["join_role".to_string(), "guest".to_string()]));
|
||||
let tags = group_metadata_tags(&channel("open", "member"), &[]);
|
||||
assert!(tags.contains(&vec!["join_role".to_string(), "member".to_string()]));
|
||||
// An unknown stored value is published as the value the relay enforces.
|
||||
let tags = group_metadata_tags(&channel("open", "bogus"), &[]);
|
||||
assert!(tags.contains(&vec!["join_role".to_string(), "guest".to_string()]));
|
||||
}
|
||||
|
||||
/// The type `t` tag comes first among `t` tags, then the creator `P`,
|
||||
/// as on 39001 and 39002; kind:39000 also carries the creation time.
|
||||
#[test]
|
||||
fn publishes_the_type_creator_and_creation_time() {
|
||||
let channel = channel("open", "member");
|
||||
let tags = group_metadata_tags(&channel, &[]);
|
||||
let at = |name: &str| tags.iter().position(|t| t[0] == name).expect(name);
|
||||
assert_eq!(tags[at("t")], vec!["t", "stream"]);
|
||||
assert_eq!(at("P"), at("t") + 1);
|
||||
assert_eq!(tags[at("P")], vec!["P".to_string(), "01".repeat(32)]);
|
||||
assert_eq!(
|
||||
tags[at("created_at")],
|
||||
vec![
|
||||
"created_at".to_string(),
|
||||
channel.created_at.timestamp().to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Labels follow the type `t` tag and come before the creator `P`, the
|
||||
/// same order as `buzz_core::channel::group_state_identity_tags`.
|
||||
#[test]
|
||||
fn publishes_labels_between_the_type_and_the_creator() {
|
||||
let mut channel = channel("open", "member");
|
||||
channel.labels = vec!["workspace".into(), "agent-attention".into()];
|
||||
let tags = group_metadata_tags(&channel, &[]);
|
||||
let identity: Vec<&Vec<String>> =
|
||||
tags.iter().filter(|t| t[0] == "t" || t[0] == "P").collect();
|
||||
let expected = buzz_core::channel::group_state_identity_tags(
|
||||
&channel.channel_type,
|
||||
&channel.labels,
|
||||
&channel.created_by,
|
||||
)
|
||||
.expect("identity tags")
|
||||
.into_iter()
|
||||
.map(|tag| tag.to_vec())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(identity, expected.iter().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// Private system channels are hidden, like DMs; open ones are not.
|
||||
#[test]
|
||||
fn hides_private_system_channels() {
|
||||
let hidden = |visibility: &str| {
|
||||
let mut channel = channel(visibility, "member");
|
||||
channel.channel_type = "system".into();
|
||||
group_metadata_tags(&channel, &[]).contains(&vec!["hidden".to_string()])
|
||||
};
|
||||
assert!(hidden("private"));
|
||||
assert!(!hidden("open"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod postgres_tests {
|
||||
use super::*;
|
||||
@@ -1459,6 +1703,64 @@ mod postgres_tests {
|
||||
.any(|row| row.id == channel_id && row.name == "community-b-channel"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn join_role_defaults_to_member_and_updates_round_trip() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_test_community(&pool).await;
|
||||
let community = CommunityId::from_uuid(community_id);
|
||||
let owner_pk = random_pubkey();
|
||||
ensure_user(&pool, community, &owner_pk)
|
||||
.await
|
||||
.expect("ensure owner");
|
||||
let channel = create_test_channel(
|
||||
&pool,
|
||||
community_id,
|
||||
"test-join-role-round-trip",
|
||||
ChannelType::Stream,
|
||||
ChannelVisibility::Open,
|
||||
None,
|
||||
&owner_pk,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create channel");
|
||||
assert_eq!(channel.join_role, "member");
|
||||
|
||||
let updated = update_channel(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
ChannelUpdate {
|
||||
join_role: Some("guest".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("set join_role");
|
||||
assert_eq!(updated.join_role, "guest");
|
||||
assert_eq!(
|
||||
get_channel(&pool, community, channel.id)
|
||||
.await
|
||||
.expect("reload")
|
||||
.join_role,
|
||||
"guest"
|
||||
);
|
||||
|
||||
// The column CHECK rejects values the relay does not know.
|
||||
assert!(update_channel(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
ChannelUpdate {
|
||||
join_role: Some("admin".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_unarchive_expired_ephemeral_channel_renews_ttl_deadline() {
|
||||
|
||||
@@ -178,7 +178,7 @@ pub async fn verify_channel_roster_fence_behavior(pool: &sqlx::PgPool) -> Result
|
||||
/// Take the per-channel membership lock. MUST be the first statement in the
|
||||
/// transaction that then reads roles/owner counts and writes membership, so the
|
||||
/// whole check-then-write sequence is atomic against a concurrent one.
|
||||
async fn acquire_channel_membership_lock(
|
||||
pub(crate) async fn acquire_channel_membership_lock(
|
||||
tx: &mut PgConnection,
|
||||
community_id: CommunityId,
|
||||
channel_id: Uuid,
|
||||
@@ -328,6 +328,48 @@ pub async fn lock_channel_archived_at_in_transaction(
|
||||
Ok(archived_at.flatten())
|
||||
}
|
||||
|
||||
/// Take the shared TTL lock of `channel_id` on a caller-owned transaction.
|
||||
///
|
||||
/// An event in an ephemeral channel refreshes the channel's TTL at commit,
|
||||
/// which takes this lock and then updates the channel row. A transaction that
|
||||
/// locks that row earlier takes this lock first, so the order stays
|
||||
/// membership lock, TTL lock, row against a TTL change in `update_channel`.
|
||||
/// The commit-time refresh takes the same lock again, which is a no-op.
|
||||
pub async fn lock_channel_ttl_shared_in_transaction(
|
||||
tx: &mut AdmittedTx,
|
||||
channel_id: Uuid,
|
||||
) -> Result<()> {
|
||||
let community_id = tx.community();
|
||||
sqlx::query("SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))")
|
||||
.bind(crate::channel::channel_ttl_lock_key(
|
||||
community_id,
|
||||
channel_id,
|
||||
))
|
||||
.execute(tx.conn())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The live channel's `(visibility, join_role)` on a caller-owned transaction,
|
||||
/// or `None` when the channel is missing or deleted. Both settings change only
|
||||
/// under the channel's membership lock, so a caller holding that lock reads
|
||||
/// the value that stays in force until it commits.
|
||||
pub async fn channel_write_settings_in_transaction(
|
||||
tx: &mut AdmittedTx,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let community_id = tx.community();
|
||||
let settings: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT visibility::text, join_role FROM channels \
|
||||
WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(channel_id)
|
||||
.fetch_optional(tx.conn())
|
||||
.await?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Check whether a pubkey is an active channel member on a caller-owned transaction.
|
||||
///
|
||||
/// Runs the same query as `is_member` but within the caller's transaction so
|
||||
@@ -352,6 +394,17 @@ pub async fn is_member_in_transaction(
|
||||
Ok(cnt > 0)
|
||||
}
|
||||
|
||||
/// Read a pubkey's active channel role on a caller-owned transaction, or
|
||||
/// `None` when it is not an active member.
|
||||
pub async fn active_role_in_transaction(
|
||||
tx: &mut AdmittedTx,
|
||||
channel_id: Uuid,
|
||||
pubkey: &[u8],
|
||||
) -> Result<Option<String>> {
|
||||
let community_id = tx.community();
|
||||
get_active_role_tx(tx.conn(), community_id, channel_id, pubkey).await
|
||||
}
|
||||
|
||||
/// Auto-add a member on a caller-owned transaction (for ephemeral-channel admission).
|
||||
///
|
||||
/// Inserts or reactivates the membership row at `Member` role with the given
|
||||
@@ -605,17 +658,7 @@ pub async fn lock_member_snapshot(
|
||||
|
||||
/// Add a member to a channel.
|
||||
///
|
||||
/// Role enforcement:
|
||||
/// - Open channels: `invited_by` is optional; role is forced to `Member` regardless of
|
||||
/// what the caller passes — callers cannot self-assign elevated roles.
|
||||
/// - Private channels: requires an `invited_by` who is an active member, or the channel
|
||||
/// creator bootstrapping their own first membership. Any active member may add an
|
||||
/// ordinary member, guest, or bot; only owners/admins may grant elevated roles.
|
||||
/// - Elevated roles (`Owner`, `Admin`) may only be granted by an existing owner/admin,
|
||||
/// even on open channels.
|
||||
///
|
||||
/// The entire check-then-insert sequence runs inside a transaction to prevent TOCTOU
|
||||
/// races (e.g. the inviter being removed between the role check and the INSERT).
|
||||
/// The caller picks the role explicitly. See [`put_member`].
|
||||
pub async fn add_member(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
@@ -623,6 +666,45 @@ pub async fn add_member(
|
||||
pubkey: &[u8],
|
||||
role: MemberRole,
|
||||
invited_by: Option<&[u8]>,
|
||||
) -> Result<MemberRecord> {
|
||||
put_member(
|
||||
pool,
|
||||
community_id,
|
||||
channel_id,
|
||||
pubkey,
|
||||
Some(role),
|
||||
invited_by,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Add a member to a channel, or change an active member's role (kind:9000,
|
||||
/// and the kind:9021 self-join).
|
||||
///
|
||||
/// `invited_by` is the actor; `None` means the target is adding themselves.
|
||||
/// `requested` is `None` when the event has no `role` tag: an active member
|
||||
/// then keeps their role, and a new member gets the channel's `join_role`.
|
||||
///
|
||||
/// This is the authority for every admission rule
|
||||
/// ([`buzz_core::channel::decide_member_put`]), and for the last-owner rule.
|
||||
/// The relay validator applies the same rules earlier only to give the client
|
||||
/// a real error. Everything the decision reads — the channel's visibility and
|
||||
/// `join_role`, and the actor's and target's active roles — is read in one
|
||||
/// transaction after the channel's membership lock. A change to visibility or
|
||||
/// `join_role` takes the same lock ([`crate::channel::update_channel`]), so
|
||||
/// neither a membership write nor a setting change can slip between the
|
||||
/// decision and the write.
|
||||
///
|
||||
/// There is no creator exception: channel creation installs the creator as
|
||||
/// owner in the same transaction, so a creator without an active row was
|
||||
/// removed and is admitted like anyone else.
|
||||
pub async fn put_member(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
channel_id: Uuid,
|
||||
pubkey: &[u8],
|
||||
requested: Option<MemberRole>,
|
||||
invited_by: Option<&[u8]>,
|
||||
) -> Result<MemberRecord> {
|
||||
if pubkey.len() != 32 {
|
||||
return Err(DbError::InvalidData(format!(
|
||||
@@ -630,6 +712,7 @@ pub async fn add_member(
|
||||
pubkey.len()
|
||||
)));
|
||||
}
|
||||
let actor = invited_by.unwrap_or(pubkey);
|
||||
|
||||
let mut tx = crate::begin_community_write_transaction(
|
||||
pool,
|
||||
@@ -638,112 +721,53 @@ pub async fn add_member(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// First statement: serialize the whole role-check / owner-count / upsert
|
||||
// First statement: serialize the whole decision / owner-count / upsert
|
||||
// sequence against concurrent membership writes on this channel.
|
||||
acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?;
|
||||
|
||||
let channel = get_channel_tx(tx.conn(), community_id, channel_id).await?;
|
||||
|
||||
let effective_role = if channel.visibility == "private" {
|
||||
let inviter = invited_by.ok_or_else(|| {
|
||||
DbError::AccessDenied("private channel requires an invite".to_string())
|
||||
})?;
|
||||
|
||||
// Bootstrap: channel creator may add themselves as the first member.
|
||||
let is_creator_bootstrap = inviter == pubkey && inviter == channel.created_by.as_slice();
|
||||
|
||||
if !is_creator_bootstrap {
|
||||
let inviter_role_str = get_active_role_tx(tx.conn(), community_id, channel_id, inviter)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
DbError::AccessDenied("inviter is not an active member".to_string())
|
||||
})?;
|
||||
|
||||
let inviter_role: MemberRole = inviter_role_str.parse().map_err(|_| {
|
||||
DbError::InvalidData(format!("invalid role in database: {inviter_role_str}"))
|
||||
})?;
|
||||
|
||||
// Any active member may extend private-channel access with an
|
||||
// ordinary role. Granting owner/admin remains reserved for an
|
||||
// existing owner/admin.
|
||||
if role.is_elevated() && !inviter_role.is_elevated() {
|
||||
return Err(DbError::AccessDenied(
|
||||
"only owners/admins may grant elevated roles".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
role
|
||||
} else {
|
||||
// Open channel: anyone may join, but only existing owners/admins may grant
|
||||
// elevated roles. Self-join always gets Member.
|
||||
if role.is_elevated() {
|
||||
let granter_role = match invited_by {
|
||||
Some(inv) => get_active_role_tx(tx.conn(), community_id, channel_id, inv).await?,
|
||||
None => None,
|
||||
};
|
||||
match granter_role.as_deref() {
|
||||
Some("owner") | Some("admin") => role,
|
||||
_ => {
|
||||
return Err(DbError::AccessDenied(
|
||||
"only owners/admins may grant elevated roles".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
role
|
||||
}
|
||||
};
|
||||
|
||||
// Changing an *active* member's role is privileged in BOTH directions.
|
||||
// Demotion is as consequential as promotion: only owners/admins may grant
|
||||
// elevated roles, so a demoted owner cannot restore themselves. Guarding
|
||||
// only `role.is_elevated()` above therefore left owner→member demotion
|
||||
// unauthorized-by-anyone. Re-adding an active member with the role they
|
||||
// already hold stays idempotent and unguarded — the huddle bot-add and
|
||||
// kind:9021 join paths rely on that.
|
||||
//
|
||||
// Deliberately keyed on the *active* role. A soft-removed row's stored role
|
||||
// is history, not live authority: `removed_at` says it is no longer in
|
||||
// force. Reactivation therefore lands at whatever `effective_role` the
|
||||
// checks above already authorized — `Member` for any unprivileged caller,
|
||||
// elevated only when a currently-elevated granter asked for it. Inferring
|
||||
// current authority from a removed row would make soft-deleted ownership a
|
||||
// resurrection token: an owner removed by another owner could self-rejoin
|
||||
// via kind:9021 (`Member, None`) and silently regain ownership.
|
||||
let private = channel.visibility == "private";
|
||||
let join_role = buzz_core::channel::ChannelJoinRole::parse_fail_closed(&channel.join_role);
|
||||
let current_role = get_active_role_tx(tx.conn(), community_id, channel_id, pubkey).await?;
|
||||
if let Some(current_role) = current_role.filter(|r| r != effective_role.as_str()) {
|
||||
let actor_role = match invited_by {
|
||||
Some(inviter) => {
|
||||
get_active_role_tx(tx.conn(), community_id, channel_id, inviter).await?
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let actor_role: Option<MemberRole> = actor_role.and_then(|r| r.parse().ok());
|
||||
if !actor_role.is_some_and(|r| r.is_elevated()) {
|
||||
return Err(DbError::AccessDenied(
|
||||
"only owners/admins may change an active member's role".to_string(),
|
||||
));
|
||||
}
|
||||
let actor_role = if actor == pubkey {
|
||||
current_role.clone()
|
||||
} else {
|
||||
get_active_role_tx(tx.conn(), community_id, channel_id, actor).await?
|
||||
};
|
||||
let actor_role: Option<MemberRole> = actor_role
|
||||
.map(|r| {
|
||||
r.parse()
|
||||
.map_err(|_| DbError::InvalidData(format!("invalid role in database: {r}")))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
// Defense-in-depth, mirroring `remove_member`: a demotion must not
|
||||
// strip the channel of its last owner, which would leave nobody able
|
||||
// to moderate, edit metadata, or re-grant ownership.
|
||||
if current_role == "owner" && effective_role != MemberRole::Owner {
|
||||
let row = sqlx::query(
|
||||
"SELECT COUNT(*) as cnt FROM channel_members \
|
||||
WHERE community_id = $1 AND channel_id = $2 AND role = 'owner' AND removed_at IS NULL",
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(channel_id)
|
||||
.fetch_one(tx.conn())
|
||||
.await?;
|
||||
let owner_count: i64 = row.try_get("cnt")?;
|
||||
if owner_count <= 1 {
|
||||
return Err(DbError::AccessDenied(
|
||||
"cannot demote the last owner — transfer ownership first".to_string(),
|
||||
));
|
||||
}
|
||||
let effective_role = buzz_core::channel::decide_member_put(buzz_core::channel::MemberPut {
|
||||
private,
|
||||
join_role,
|
||||
actor_role,
|
||||
target_is_actor: actor == pubkey,
|
||||
target_role: current_role.as_deref(),
|
||||
requested,
|
||||
})
|
||||
.map_err(|denial| DbError::AccessDenied(member_put_denial_message(denial).to_string()))?;
|
||||
|
||||
// Defense-in-depth, mirroring `remove_member`: a demotion must not strip
|
||||
// the channel of its last owner, which would leave nobody able to
|
||||
// moderate, edit metadata, or re-grant ownership.
|
||||
if current_role.as_deref() == Some("owner") && effective_role != MemberRole::Owner {
|
||||
let row = sqlx::query(
|
||||
"SELECT COUNT(*) as cnt FROM channel_members \
|
||||
WHERE community_id = $1 AND channel_id = $2 AND role = 'owner' AND removed_at IS NULL",
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(channel_id)
|
||||
.fetch_one(tx.conn())
|
||||
.await?;
|
||||
let owner_count: i64 = row.try_get("cnt")?;
|
||||
if owner_count <= 1 {
|
||||
return Err(DbError::AccessDenied(
|
||||
"cannot demote the last owner — transfer ownership first".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,6 +806,20 @@ pub async fn add_member(
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// The client-visible reason for a [`buzz_core::channel::MemberPutDenial`].
|
||||
fn member_put_denial_message(denial: buzz_core::channel::MemberPutDenial) -> &'static str {
|
||||
use buzz_core::channel::MemberPutDenial as D;
|
||||
match denial {
|
||||
D::NotAMember => "private channel requires an invite from an active member",
|
||||
D::ElevatedRoleGrant => "only owners/admins may grant elevated roles",
|
||||
D::GuestCannotAdd => "only members can add people in this channel",
|
||||
D::SelfJoinAboveJoinRole => {
|
||||
"joining this channel gives the guest role; ask a member to make you a member"
|
||||
}
|
||||
D::RoleChange => "only owners/admins may change an active member's role",
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a member from a channel (soft delete).
|
||||
///
|
||||
/// `actor_pubkey` must be an active owner/admin, the agent's owner, or the member
|
||||
@@ -1471,7 +1509,7 @@ async fn get_channel_tx(
|
||||
nip29_group_id, topic_required, max_members,
|
||||
topic, topic_set_by, topic_set_at,
|
||||
purpose, purpose_set_by, purpose_set_at,
|
||||
ttl_seconds, ttl_deadline, labels
|
||||
ttl_seconds, ttl_deadline, labels, join_role
|
||||
FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL
|
||||
"#,
|
||||
)
|
||||
@@ -1567,7 +1605,7 @@ pub async fn get_accessible_channels(
|
||||
c.nip29_group_id, c.topic_required, c.max_members,
|
||||
c.topic, c.topic_set_by, c.topic_set_at,
|
||||
c.purpose, c.purpose_set_by, c.purpose_set_at,
|
||||
c.ttl_seconds, c.ttl_deadline, c.labels,
|
||||
c.ttl_seconds, c.ttl_deadline, c.labels, c.join_role,
|
||||
(cm.channel_id IS NOT NULL) AS is_member
|
||||
FROM channels c
|
||||
LEFT JOIN channel_members cm
|
||||
@@ -1862,6 +1900,28 @@ impl Db {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Adds a member or changes a role, deciding the role from the request
|
||||
/// and current channel state (see [`put_member`]).
|
||||
#[datastore_span(name = "put_member", system = "postgresql")]
|
||||
pub async fn put_member(
|
||||
&self,
|
||||
community_id: CommunityId,
|
||||
channel_id: Uuid,
|
||||
pubkey: &[u8],
|
||||
requested: Option<MemberRole>,
|
||||
invited_by: Option<&[u8]>,
|
||||
) -> Result<MemberRecord> {
|
||||
put_member(
|
||||
&self.pool,
|
||||
community_id,
|
||||
channel_id,
|
||||
pubkey,
|
||||
requested,
|
||||
invited_by,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Removes a member from a channel.
|
||||
#[datastore_span(name = "remove_member", system = "postgresql")]
|
||||
pub async fn remove_member(
|
||||
@@ -2978,6 +3038,436 @@ mod postgres_tests {
|
||||
assert_eq!(owners, 1, "channel must still have its owner");
|
||||
}
|
||||
|
||||
/// A member may promote a guest to member, as they may add a new member.
|
||||
/// They still cannot grant an elevated role, and a guest or non-member
|
||||
/// cannot promote anyone.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn member_may_promote_a_guest_to_member_only() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_test_community(&pool).await;
|
||||
let community = CommunityId::from_uuid(community_id);
|
||||
let owner = random_pubkey();
|
||||
let member = random_pubkey();
|
||||
let guest = random_pubkey();
|
||||
let other_guest = random_pubkey();
|
||||
let outsider = random_pubkey();
|
||||
for pk in [&owner, &member, &guest, &other_guest, &outsider] {
|
||||
ensure_user(&pool, community, pk)
|
||||
.await
|
||||
.expect("ensure user");
|
||||
}
|
||||
let channel = create_test_channel(
|
||||
&pool,
|
||||
community_id,
|
||||
"member-promotes-guest",
|
||||
ChannelType::Stream,
|
||||
ChannelVisibility::Open,
|
||||
None,
|
||||
&owner,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create channel");
|
||||
add_member(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
&member,
|
||||
MemberRole::Member,
|
||||
Some(&owner),
|
||||
)
|
||||
.await
|
||||
.expect("add member");
|
||||
for pk in [&guest, &other_guest] {
|
||||
add_member(&pool, community, channel.id, pk, MemberRole::Guest, None)
|
||||
.await
|
||||
.expect("join as guest");
|
||||
}
|
||||
|
||||
for (actor, role) in [
|
||||
(&other_guest, MemberRole::Member),
|
||||
(&outsider, MemberRole::Member),
|
||||
(&member, MemberRole::Admin),
|
||||
] {
|
||||
let err = add_member(&pool, community, channel.id, &guest, role, Some(actor))
|
||||
.await
|
||||
.expect_err("promotion must be refused");
|
||||
assert!(matches!(err, DbError::AccessDenied(_)), "{err:?}");
|
||||
}
|
||||
|
||||
add_member(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
&guest,
|
||||
MemberRole::Member,
|
||||
Some(&member),
|
||||
)
|
||||
.await
|
||||
.expect("a member may promote a guest");
|
||||
let role = get_member_role(&pool, community, channel.id, &guest)
|
||||
.await
|
||||
.expect("read role");
|
||||
assert_eq!(role.as_deref(), Some("member"));
|
||||
|
||||
// Promotion is one way: the member cannot demote them again.
|
||||
let err = add_member(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
&guest,
|
||||
MemberRole::Guest,
|
||||
Some(&member),
|
||||
)
|
||||
.await
|
||||
.expect_err("a member cannot demote a member");
|
||||
assert!(matches!(err, DbError::AccessDenied(_)), "{err:?}");
|
||||
|
||||
// Roles mean the same for agents: any member may raise a guest
|
||||
// agent, not only its owner.
|
||||
let agent = random_pubkey();
|
||||
let agent_owner = random_pubkey();
|
||||
for pk in [&agent, &agent_owner] {
|
||||
ensure_user(&pool, community, pk)
|
||||
.await
|
||||
.expect("ensure user");
|
||||
}
|
||||
set_agent_owner(&pool, community, &agent, &agent_owner)
|
||||
.await
|
||||
.expect("set agent owner");
|
||||
add_member(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
&agent,
|
||||
MemberRole::Guest,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("agent joins as guest");
|
||||
add_member(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
&agent,
|
||||
MemberRole::Member,
|
||||
Some(&member),
|
||||
)
|
||||
.await
|
||||
.expect("any member may raise a guest agent");
|
||||
let role = get_member_role(&pool, community, channel.id, &agent)
|
||||
.await
|
||||
.expect("read role");
|
||||
assert_eq!(role.as_deref(), Some("member"));
|
||||
}
|
||||
|
||||
/// `put_member` enforces admission itself, so a caller that skips the
|
||||
/// relay validator still cannot join an announce channel above
|
||||
/// `join_role`, and a guest still cannot add anyone. No role tag keeps an
|
||||
/// active member's role and gives a new member `join_role`.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn put_member_enforces_announce_admission() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_test_community(&pool).await;
|
||||
let community = CommunityId::from_uuid(community_id);
|
||||
let owner = random_pubkey();
|
||||
let joiner = random_pubkey();
|
||||
let invitee = random_pubkey();
|
||||
let outsider = random_pubkey();
|
||||
for pk in [&owner, &joiner, &invitee, &outsider] {
|
||||
ensure_user(&pool, community, pk)
|
||||
.await
|
||||
.expect("ensure user");
|
||||
}
|
||||
let channel = create_test_channel(
|
||||
&pool,
|
||||
community_id,
|
||||
"put-member-announce",
|
||||
ChannelType::Stream,
|
||||
ChannelVisibility::Open,
|
||||
None,
|
||||
&owner,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create channel");
|
||||
crate::channel::update_channel(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
crate::channel::ChannelUpdate {
|
||||
join_role: Some("guest".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("make announce channel");
|
||||
|
||||
// A joiner cannot ask for more than `join_role`.
|
||||
let err = put_member(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
&joiner,
|
||||
Some(MemberRole::Member),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("self-join above join_role");
|
||||
assert!(matches!(err, DbError::AccessDenied(_)), "{err:?}");
|
||||
// No role tag: a new member gets `join_role`.
|
||||
let joined = put_member(&pool, community, channel.id, &joiner, None, None)
|
||||
.await
|
||||
.expect("self-join");
|
||||
assert_eq!(joined.role, "guest");
|
||||
|
||||
// A guest cannot add anyone, and neither can a non-member where
|
||||
// joining gives `guest`.
|
||||
for actor in [&joiner, &outsider] {
|
||||
let err = put_member(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
&invitee,
|
||||
Some(MemberRole::Guest),
|
||||
Some(actor),
|
||||
)
|
||||
.await
|
||||
.expect_err("guest or outsider add");
|
||||
assert!(matches!(err, DbError::AccessDenied(_)), "{err:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
get_member_role(&pool, community, channel.id, &invitee)
|
||||
.await
|
||||
.expect("read role"),
|
||||
None
|
||||
);
|
||||
|
||||
// A member adds with no role tag: the new member gets `join_role`;
|
||||
// then no role tag keeps the role the owner raised them to.
|
||||
let added = put_member(&pool, community, channel.id, &invitee, None, Some(&owner))
|
||||
.await
|
||||
.expect("owner adds");
|
||||
assert_eq!(added.role, "guest");
|
||||
put_member(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
&invitee,
|
||||
Some(MemberRole::Member),
|
||||
Some(&owner),
|
||||
)
|
||||
.await
|
||||
.expect("owner raises");
|
||||
let kept = put_member(&pool, community, channel.id, &invitee, None, Some(&owner))
|
||||
.await
|
||||
.expect("re-add without role tag");
|
||||
assert_eq!(kept.role, "member");
|
||||
}
|
||||
|
||||
/// A `join_role` change and an admission are ordered by the membership
|
||||
/// lock: a join that waits on an uncommitted change to `guest` is decided
|
||||
/// on the new setting once it commits, not on the old one.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn put_member_waits_for_a_concurrent_join_role_change() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_test_community(&pool).await;
|
||||
let community = CommunityId::from_uuid(community_id);
|
||||
let owner = random_pubkey();
|
||||
let joiner = random_pubkey();
|
||||
for pk in [&owner, &joiner] {
|
||||
ensure_user(&pool, community, pk)
|
||||
.await
|
||||
.expect("ensure user");
|
||||
}
|
||||
let channel = create_test_channel(
|
||||
&pool,
|
||||
community_id,
|
||||
"put-member-join-role-race",
|
||||
ChannelType::Stream,
|
||||
ChannelVisibility::Open,
|
||||
None,
|
||||
&owner,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create channel");
|
||||
|
||||
// The same lock order as `update_channel`: the lock, then the row update.
|
||||
let mut tx = pool.begin().await.expect("begin");
|
||||
acquire_channel_membership_lock(&mut tx, community, channel.id)
|
||||
.await
|
||||
.expect("lock");
|
||||
sqlx::query("UPDATE channels SET join_role = 'guest' WHERE community_id = $1 AND id = $2")
|
||||
.bind(community_id)
|
||||
.bind(channel.id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("change join_role");
|
||||
|
||||
let join = tokio::spawn({
|
||||
let pool = pool.clone();
|
||||
let channel_id = channel.id;
|
||||
let joiner = joiner.clone();
|
||||
async move {
|
||||
put_member(
|
||||
&pool,
|
||||
community,
|
||||
channel_id,
|
||||
&joiner,
|
||||
Some(MemberRole::Member),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
assert!(
|
||||
!join.is_finished(),
|
||||
"the join must wait for the setting change"
|
||||
);
|
||||
tx.commit().await.expect("commit setting change");
|
||||
|
||||
let err = join
|
||||
.await
|
||||
.expect("join task")
|
||||
.expect_err("the join is decided on join_role = guest");
|
||||
assert!(matches!(err, DbError::AccessDenied(_)), "{err:?}");
|
||||
assert_eq!(
|
||||
get_member_role(&pool, community, channel.id, &joiner)
|
||||
.await
|
||||
.expect("read role"),
|
||||
None
|
||||
);
|
||||
|
||||
// The other order: `update_channel` waits for an admission that holds
|
||||
// the lock.
|
||||
let mut admission = pool.begin().await.expect("begin admission");
|
||||
acquire_channel_membership_lock(&mut admission, community, channel.id)
|
||||
.await
|
||||
.expect("lock");
|
||||
let update = tokio::spawn({
|
||||
let pool = pool.clone();
|
||||
let channel_id = channel.id;
|
||||
async move {
|
||||
crate::channel::update_channel(
|
||||
&pool,
|
||||
community,
|
||||
channel_id,
|
||||
crate::channel::ChannelUpdate {
|
||||
join_role: Some("member".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
assert!(
|
||||
!update.is_finished(),
|
||||
"a join_role change must wait for an admission in progress"
|
||||
);
|
||||
admission.commit().await.expect("commit admission");
|
||||
update
|
||||
.await
|
||||
.expect("update task")
|
||||
.expect("join_role change");
|
||||
}
|
||||
|
||||
/// A `join_role` change on an ephemeral channel and a roster publication
|
||||
/// take the channel's locks in one order. The roster holds the membership
|
||||
/// lock and refreshes the channel's TTL (TTL lock, then row) at commit. A
|
||||
/// settings change that locked the row first and then waited for the
|
||||
/// membership lock would deadlock with it, or make the refresh time out
|
||||
/// and be dropped.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn join_role_change_waits_for_a_roster_publication_on_an_ephemeral_channel() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_test_community(&pool).await;
|
||||
let community = CommunityId::from_uuid(community_id);
|
||||
let owner = random_pubkey();
|
||||
let channel = create_test_channel(
|
||||
&pool,
|
||||
community_id,
|
||||
"roster-join-role-lock-order",
|
||||
ChannelType::Stream,
|
||||
ChannelVisibility::Open,
|
||||
None,
|
||||
&owner,
|
||||
Some(3600),
|
||||
)
|
||||
.await
|
||||
.expect("create ephemeral channel");
|
||||
let deadline_before = channel.ttl_deadline.expect("ephemeral deadline");
|
||||
|
||||
let snapshot_pool = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.acquire_timeout(std::time::Duration::from_secs(1))
|
||||
.connect(&crate::test_support::database_url())
|
||||
.await
|
||||
.expect("connect one-connection pool");
|
||||
let relay_keys = Keys::generate();
|
||||
let mut snapshot = lock_member_snapshot(
|
||||
&snapshot_pool,
|
||||
community,
|
||||
channel.id,
|
||||
&relay_keys.public_key().to_bytes(),
|
||||
)
|
||||
.await
|
||||
.expect("capture locked roster");
|
||||
|
||||
let update = tokio::spawn({
|
||||
let pool = pool.clone();
|
||||
let channel_id = channel.id;
|
||||
async move {
|
||||
crate::channel::update_channel(
|
||||
&pool,
|
||||
community,
|
||||
channel_id,
|
||||
crate::channel::ChannelUpdate {
|
||||
join_role: Some("guest".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
assert!(
|
||||
!update.is_finished(),
|
||||
"the join_role change must wait for the roster publication"
|
||||
);
|
||||
|
||||
let event = nostr::EventBuilder::new(nostr::Kind::Custom(39002), "")
|
||||
.tags(vec![
|
||||
nostr::Tag::parse(["d", &channel.id.to_string()]).expect("d tag"),
|
||||
nostr::Tag::parse(["p", &hex::encode(&owner), "", "owner"]).expect("p tag"),
|
||||
])
|
||||
.sign_with_keys(&relay_keys)
|
||||
.expect("sign roster");
|
||||
let (_, inserted) = snapshot
|
||||
.replace_member_event(&event)
|
||||
.await
|
||||
.expect("replace roster");
|
||||
assert!(inserted);
|
||||
snapshot.release().await.expect("publish roster");
|
||||
|
||||
let updated = update
|
||||
.await
|
||||
.expect("update task")
|
||||
.expect("join_role change after the roster");
|
||||
assert_eq!(updated.join_role, "guest");
|
||||
let deadline_after = updated.ttl_deadline.expect("ephemeral deadline");
|
||||
assert!(
|
||||
deadline_after > deadline_before,
|
||||
"the roster's TTL refresh must not be dropped: {deadline_before} -> {deadline_after}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The fix must not break legitimate role management: an owner demoting a
|
||||
/// co-owner (while another owner remains) must still succeed, and promotion
|
||||
/// by an owner must still succeed.
|
||||
@@ -4035,6 +4525,56 @@ mod postgres_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A removed creator is not re-admitted to a private channel by being its
|
||||
/// creator. A kind:9021 self-join can pass the relay's open-channel check
|
||||
/// and reach `put_member` after the channel became private; it must be
|
||||
/// refused there, not turned into a creator bootstrap that restores
|
||||
/// ownership. The same holds for a self-add that names the creator as
|
||||
/// inviter and for one that asks for `owner`.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires PostgreSQL"]
|
||||
async fn removed_creator_cannot_rejoin_a_private_channel() {
|
||||
let pool = setup_pool().await;
|
||||
let (community, channel_id, creator, other_owner) =
|
||||
channel_with_two_owners(&pool, "removed-creator-private").await;
|
||||
|
||||
remove_member(&pool, community, channel_id, &creator, &other_owner)
|
||||
.await
|
||||
.expect("an owner may remove the creator");
|
||||
crate::channel::update_channel(
|
||||
&pool,
|
||||
community,
|
||||
channel_id,
|
||||
crate::channel::ChannelUpdate {
|
||||
visibility: Some("private".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("make the channel private");
|
||||
|
||||
for (requested, invited_by) in [
|
||||
(None, None),
|
||||
(None, Some(creator.as_slice())),
|
||||
(Some(MemberRole::Owner), Some(creator.as_slice())),
|
||||
] {
|
||||
let result = put_member(
|
||||
&pool, community, channel_id, &creator, requested, invited_by,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(DbError::AccessDenied(_))),
|
||||
"a removed creator must not rejoin a private channel \
|
||||
(requested {requested:?}, inviter set {}): {result:?}",
|
||||
invited_by.is_some()
|
||||
);
|
||||
}
|
||||
let role = get_member_role(&pool, community, channel_id, &creator)
|
||||
.await
|
||||
.expect("read creator role");
|
||||
assert_eq!(role, None, "the removed creator must stay removed");
|
||||
}
|
||||
|
||||
/// The other side of the same boundary: reactivation may reach an elevated
|
||||
/// role, but only because a *currently* elevated granter asked for it.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -76,7 +76,7 @@ pub async fn find_dm_by_participants(
|
||||
created_by, created_at, updated_at, archived_at, deleted_at,
|
||||
nip29_group_id, topic_required, max_members,
|
||||
topic, topic_set_by, topic_set_at,
|
||||
purpose, purpose_set_by, purpose_set_at, labels
|
||||
purpose, purpose_set_by, purpose_set_at, labels, join_role
|
||||
FROM channels
|
||||
WHERE community_id = $1
|
||||
AND participant_hash = $2
|
||||
@@ -142,7 +142,7 @@ pub async fn create_dm(
|
||||
created_by, created_at, updated_at, archived_at, deleted_at,
|
||||
nip29_group_id, topic_required, max_members,
|
||||
topic, topic_set_by, topic_set_at,
|
||||
purpose, purpose_set_by, purpose_set_at, labels
|
||||
purpose, purpose_set_by, purpose_set_at, labels, join_role
|
||||
FROM channels
|
||||
WHERE community_id = $1
|
||||
AND participant_hash = $2
|
||||
@@ -212,7 +212,7 @@ pub async fn create_dm(
|
||||
created_by, created_at, updated_at, archived_at, deleted_at,
|
||||
nip29_group_id, topic_required, max_members,
|
||||
topic, topic_set_by, topic_set_at,
|
||||
purpose, purpose_set_by, purpose_set_at, labels
|
||||
purpose, purpose_set_by, purpose_set_at, labels, join_role
|
||||
FROM channels WHERE community_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
@@ -533,6 +533,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
|
||||
ttl_seconds: row.try_get("ttl_seconds").unwrap_or(None),
|
||||
ttl_deadline: row.try_get("ttl_deadline").unwrap_or(None),
|
||||
labels: row.try_get("labels")?,
|
||||
join_role: row.try_get("join_role")?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user