mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(relay): add moderator relay-member role with full capability grid
Add a tenant-scoped 'moderator' role to relay_members that holds
ViewQueue, ResolveReport, DeleteMessage, Kick, Timeout, Untimeout
capabilities community-wide. Ban/Unban remain admin+ only. Guard
rails prevent a moderator from Kicking or Timing-out the community
owner or a fellow admin.
Relay/DB changes:
- migrations/0028_relay_moderator_role.sql: additive migration that
drops and recreates the relay_members.role CHECK to include
'moderator'; schema/schema.sql snapshot updated; migration count
assertion advanced to 28
- moderation_authz.rs: ModerationAuthority::CommunityModerator; full
decide_authority arm for 'moderator' with exhaustive unit tests
- side_effects.rs: kind 9005 validator gains additive relay-role path
through authorize_moderation_action(DeleteMessage); kind 9001 gains
additive Kick path + routes to remove_member_as_community_moderator
(new preauthorized DB mutation) when actor has no channel role
- moderation_commands.rs: 9044 resolve builds one resolved report-action
value (required_capability, target_author, channel_id) from stored
report + get_event_by_id_including_deleted; normalization matrix
enforced before authorization; capability map delete→DeleteMessage,
kick→Kick, ban→Ban, timeout→Timeout, dismiss/escalate→ResolveReport
- relay_admin.rs: 9030/9032 accept 'moderator'; 9031 admin removal uses
atomic role = ANY('member','moderator') predicate (no TOCTOU race)
- buzz-db channel.rs: remove_member_as_community_moderator acquires same
per-channel membership lock as remove_member, re-checks last-owner
protection in-transaction, performs soft removal without channel role
- relay main.rs metrics role set extended; buzz-admin CLI role validation
extended; deploy/compose/run.sh docs updated
Desktop changes:
- capabilities.ts: moderationCapabilities(role) helper returning
typed capability struct; single source of truth consumed by both
MessageModerationMenuItems and ModerationQueueCard
- ModerationQueueCard: uses moderationCapabilities; passes canBan into
resolvableActions; uses moderatorDeleteMessage (kind 9005) for delete
- MessageModerationMenuItems: capability-aware per action; Ban/Unban
hidden for moderators
- TimelineMessageRow: moderator delete affordance via 9005 path when
canDelete && !isOwnMessage
- CommunityMembersSettingsCard: moderator role shown with ShieldHalf
icon; role management extended to moderator tier
- relayMembers.ts/types.ts/e2eBridge.ts: 'moderator' added to all role
unions; downgrade-to-member normalization updated
- events.rs: VALID_RELAY_ROLES includes 'moderator'
- commands/moderator.rs: new Tauri command moderator_delete_message
builds kind 9005 inline (avoids file-size ratchet growth on messages.rs)
- shared/api/moderator.ts: moderatorDeleteMessage TS wrapper
Tests added:
- 13 unit tests for decide_authority (moderator grid exhaustive)
- 9 unit tests for moderationCapabilities exhaustive cases
- 5 unit tests for resolvableActions(canBan=false)
- 3 DB integration tests for remove_member_as_community_moderator
(ordinary kick, last-owner guard, non-member not-found) [ignored]
- 1 DB integration test for admin remove member/moderator matrix [ignored]
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
co-authored by
Will Pfleger
parent
cb4a73e17d
commit
85782fc71a
@@ -286,15 +286,15 @@ async fn cmd_list_members() -> Result<i32> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`.
|
||||
/// Validate that `role` is `"member"`, `"moderator"`, or `"admin"`. Rejects `"owner"`.
|
||||
fn validate_role(role: &str) -> std::result::Result<(), String> {
|
||||
match role {
|
||||
"member" | "admin" => Ok(()),
|
||||
"member" | "moderator" | "admin" => Ok(()),
|
||||
"owner" => {
|
||||
Err("role 'owner' cannot be set via CLI — use RELAY_OWNER_PUBKEY config".to_string())
|
||||
}
|
||||
other => Err(format!(
|
||||
"invalid role '{other}': must be 'member' or 'admin'"
|
||||
"invalid role '{other}': must be 'member', 'moderator', or 'admin'"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,6 +635,80 @@ pub async fn remove_member(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a channel member as an authorized community moderator, bypassing the
|
||||
/// channel-role requirement of [`remove_member`].
|
||||
///
|
||||
/// This is the **sole** call site for moderator-initiated kicks (kind 9001
|
||||
/// third-party removal). It must only be invoked after
|
||||
/// `authorize_moderation_action(..., Kick)` has succeeded — the function name
|
||||
/// is the contract; there is no `skip_auth` boolean.
|
||||
///
|
||||
/// Acquires the same per-channel membership lock as [`remove_member`] so the
|
||||
/// last-owner check and the soft-deletion are serialized against concurrent
|
||||
/// membership writes. Re-checks target existence and last-channel-owner
|
||||
/// protection inside the transaction. The shared post-mutation path (cache
|
||||
/// invalidation, subscription eviction, etc.) must fire in the caller after
|
||||
/// this returns `Ok`.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(())` — member was soft-deleted.
|
||||
/// - `Err(DbError::MemberNotFound)` — target is not an active member.
|
||||
/// - `Err(DbError::AccessDenied)` — target is the last channel owner.
|
||||
pub async fn remove_member_as_community_moderator(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
channel_id: Uuid,
|
||||
target_pubkey: &[u8],
|
||||
actor_pubkey: &[u8],
|
||||
) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Serialize the last-owner check and the soft-delete against concurrent
|
||||
// membership writes on this channel (same advisory key as `remove_member`).
|
||||
acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?;
|
||||
|
||||
// Defense-in-depth: prevent removing the last owner regardless of the
|
||||
// community-level authority the caller holds.
|
||||
let target_role = get_active_role_tx(&mut tx, community_id, channel_id, target_pubkey).await?;
|
||||
if target_role.as_deref() == Some("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(&mut *tx)
|
||||
.await?;
|
||||
let owner_count: i64 = row.try_get("cnt")?;
|
||||
if owner_count <= 1 {
|
||||
return Err(DbError::AccessDenied(
|
||||
"cannot remove the last owner — transfer ownership first".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE channel_members
|
||||
SET removed_at = NOW(), removed_by = $1
|
||||
WHERE community_id = $2 AND channel_id = $3 AND pubkey = $4 AND removed_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(actor_pubkey)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(channel_id)
|
||||
.bind(target_pubkey)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::MemberNotFound(channel_id));
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` if the given pubkey is an active member of the channel.
|
||||
pub async fn is_member(
|
||||
pool: &PgPool,
|
||||
@@ -2684,4 +2758,182 @@ mod tests {
|
||||
.expect("read role after restore");
|
||||
assert_eq!(restored.as_deref(), Some("owner"));
|
||||
}
|
||||
|
||||
// ── remove_member_as_community_moderator ──────────────────────────────────
|
||||
|
||||
/// A relay-level moderator can kick an ordinary channel member even when
|
||||
/// the moderator holds no channel role. The test exercises the DB mutation
|
||||
/// path directly to verify that: (a) the member is removed, (b) the last-
|
||||
/// owner guard fires when needed, and (c) a non-member target returns
|
||||
/// `MemberNotFound`.
|
||||
///
|
||||
/// Discriminating: fails if the function falls back to the standard
|
||||
/// `remove_member` path (which requires a channel role) or if the
|
||||
/// last-owner protection is dropped.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_remove_member_as_community_moderator_removes_ordinary_member() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_test_community(&pool).await;
|
||||
let community = CommunityId::from_uuid(community_id);
|
||||
|
||||
let channel_owner_pk = random_pubkey();
|
||||
let target_pk = random_pubkey();
|
||||
let moderator_pk = random_pubkey(); // no channel membership
|
||||
ensure_user(&pool, community, &channel_owner_pk)
|
||||
.await
|
||||
.expect("ensure channel owner");
|
||||
ensure_user(&pool, community, &target_pk)
|
||||
.await
|
||||
.expect("ensure target");
|
||||
ensure_user(&pool, community, &moderator_pk)
|
||||
.await
|
||||
.expect("ensure moderator");
|
||||
|
||||
let channel = create_test_channel(
|
||||
&pool,
|
||||
community_id,
|
||||
"mod-kick-test",
|
||||
ChannelType::Stream,
|
||||
ChannelVisibility::Open,
|
||||
None,
|
||||
&channel_owner_pk,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create channel");
|
||||
|
||||
add_member(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
&target_pk,
|
||||
MemberRole::Member,
|
||||
Some(&channel_owner_pk),
|
||||
)
|
||||
.await
|
||||
.expect("add target as member");
|
||||
|
||||
// Moderator is NOT a channel member — the preauthorized path must still
|
||||
// succeed.
|
||||
remove_member_as_community_moderator(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
&target_pk,
|
||||
&moderator_pk,
|
||||
)
|
||||
.await
|
||||
.expect("moderator must be able to kick an ordinary member");
|
||||
|
||||
assert!(
|
||||
!is_member(&pool, community, channel.id, &target_pk)
|
||||
.await
|
||||
.expect("is_member check"),
|
||||
"target must no longer be a member"
|
||||
);
|
||||
}
|
||||
|
||||
/// The preauthorized mutation refuses to remove the last channel owner,
|
||||
/// preserving the same guard the standard `remove_member` path enforces.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_remove_member_as_community_moderator_blocks_last_owner_removal() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_test_community(&pool).await;
|
||||
let community = CommunityId::from_uuid(community_id);
|
||||
|
||||
let only_owner_pk = random_pubkey();
|
||||
let moderator_pk = random_pubkey();
|
||||
ensure_user(&pool, community, &only_owner_pk)
|
||||
.await
|
||||
.expect("ensure owner");
|
||||
ensure_user(&pool, community, &moderator_pk)
|
||||
.await
|
||||
.expect("ensure moderator");
|
||||
|
||||
let channel = create_test_channel(
|
||||
&pool,
|
||||
community_id,
|
||||
"mod-last-owner-test",
|
||||
ChannelType::Stream,
|
||||
ChannelVisibility::Open,
|
||||
None,
|
||||
&only_owner_pk,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create channel");
|
||||
|
||||
let err = remove_member_as_community_moderator(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
&only_owner_pk,
|
||||
&moderator_pk,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
matches!(err, Err(DbError::AccessDenied(_))),
|
||||
"must refuse to remove the last channel owner, got {err:?}"
|
||||
);
|
||||
|
||||
// The owner must still be a member after the refused attempt.
|
||||
assert!(
|
||||
is_member(&pool, community, channel.id, &only_owner_pk)
|
||||
.await
|
||||
.expect("is_member check"),
|
||||
"last owner must remain a member after a failed remove"
|
||||
);
|
||||
}
|
||||
|
||||
/// Attempting to remove a non-member returns `MemberNotFound`.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_remove_member_as_community_moderator_returns_not_found_for_non_member() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = make_test_community(&pool).await;
|
||||
let community = CommunityId::from_uuid(community_id);
|
||||
|
||||
let channel_owner_pk = random_pubkey();
|
||||
let non_member_pk = random_pubkey();
|
||||
let moderator_pk = random_pubkey();
|
||||
ensure_user(&pool, community, &channel_owner_pk)
|
||||
.await
|
||||
.expect("ensure channel owner");
|
||||
ensure_user(&pool, community, &non_member_pk)
|
||||
.await
|
||||
.expect("ensure target");
|
||||
ensure_user(&pool, community, &moderator_pk)
|
||||
.await
|
||||
.expect("ensure moderator");
|
||||
|
||||
let channel = create_test_channel(
|
||||
&pool,
|
||||
community_id,
|
||||
"mod-not-found-test",
|
||||
ChannelType::Stream,
|
||||
ChannelVisibility::Open,
|
||||
None,
|
||||
&channel_owner_pk,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create channel");
|
||||
|
||||
let err = remove_member_as_community_moderator(
|
||||
&pool,
|
||||
community,
|
||||
channel.id,
|
||||
&non_member_pk,
|
||||
&moderator_pk,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
matches!(err, Err(DbError::MemberNotFound(_))),
|
||||
"must return MemberNotFound for a non-member target, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2238,6 +2238,29 @@ impl Db {
|
||||
channel::remove_member(&self.pool, community_id, channel_id, pubkey, actor_pubkey).await
|
||||
}
|
||||
|
||||
/// Removes a channel member as an authorized community moderator, bypassing
|
||||
/// the channel-role requirement of [`Self::remove_member`].
|
||||
///
|
||||
/// Must only be called after `authorize_moderation_action(..., Kick)` has
|
||||
/// succeeded — the function name is the contract; no `skip_auth` flag.
|
||||
/// See [`channel::remove_member_as_community_moderator`].
|
||||
pub async fn remove_member_as_community_moderator(
|
||||
&self,
|
||||
community_id: CommunityId,
|
||||
channel_id: Uuid,
|
||||
target_pubkey: &[u8],
|
||||
actor_pubkey: &[u8],
|
||||
) -> Result<()> {
|
||||
channel::remove_member_as_community_moderator(
|
||||
&self.pool,
|
||||
community_id,
|
||||
channel_id,
|
||||
target_pubkey,
|
||||
actor_pubkey,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns `true` if the pubkey is an active member.
|
||||
pub async fn is_member(
|
||||
&self,
|
||||
@@ -4106,6 +4129,20 @@ impl Db {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Atomically removes a relay member from `community` only if their role
|
||||
/// is `'member'` or `'moderator'`.
|
||||
///
|
||||
/// Used by the admin 9031 path so an admin cannot remove a fellow admin
|
||||
/// or owner. Returns `RoleMismatch` when the target holds an elevated
|
||||
/// role. See [`relay_members::remove_relay_member_if_non_admin`].
|
||||
pub async fn remove_relay_member_if_non_admin(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
) -> Result<relay_members::RemoveResult> {
|
||||
relay_members::remove_relay_member_if_non_admin(&self.pool, community, pubkey).await
|
||||
}
|
||||
|
||||
/// Updates the role of an existing relay member in `community`. Returns `true` if updated.
|
||||
pub async fn update_relay_member_role(
|
||||
&self,
|
||||
|
||||
@@ -561,7 +561,7 @@ mod tests {
|
||||
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||
migrations.sort_by_key(|migration| migration.version);
|
||||
|
||||
assert_eq!(migrations.len(), 27);
|
||||
assert_eq!(migrations.len(), 28);
|
||||
assert_eq!(migrations[0].version, 1);
|
||||
assert_eq!(&*migrations[0].description, "initial schema");
|
||||
assert!(migrations[0]
|
||||
@@ -940,6 +940,30 @@ mod tests {
|
||||
desired_schema.contains("idx_channels_id_live"),
|
||||
"desired-state schema must carry the channel-id lookup index",
|
||||
);
|
||||
|
||||
// Relay-member moderator role (0028): extends the relay_members.role
|
||||
// CHECK to include 'moderator'. The constraint is dropped and
|
||||
// recreated by name (relay_members_role_check). Schema snapshot must
|
||||
// reflect the new value set; constraint in the migration must be safe
|
||||
// to run on any brownfield that already has the three-value CHECK.
|
||||
assert_eq!(migrations[27].version, 28);
|
||||
let moderator_role = migrations[27].sql.as_str();
|
||||
assert!(
|
||||
moderator_role.contains("relay_members_role_check"),
|
||||
"0028 must reference the constraint by its generated name",
|
||||
);
|
||||
assert!(
|
||||
moderator_role.contains("'moderator'"),
|
||||
"0028 must include 'moderator' in the new CHECK",
|
||||
);
|
||||
assert!(
|
||||
moderator_role.contains("DROP CONSTRAINT IF EXISTS"),
|
||||
"0028 must use DROP CONSTRAINT IF EXISTS for idempotency",
|
||||
);
|
||||
assert!(
|
||||
desired_schema.contains("'moderator'"),
|
||||
"desired-state schema must include 'moderator' in relay_members.role CHECK",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1182,7 +1206,7 @@ mod tests {
|
||||
run_migrations(&pool)
|
||||
.await
|
||||
.expect("retry succeeds after operator repair");
|
||||
assert_eq!(applied_versions(&pool).await.last().copied(), Some(27));
|
||||
assert_eq!(applied_versions(&pool).await.last().copied(), Some(28));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::CommunityId;
|
||||
pub struct RelayMember {
|
||||
/// 64-char lowercase hex pubkey.
|
||||
pub pubkey: String,
|
||||
/// Role: `"owner"`, `"admin"`, or `"member"`.
|
||||
/// Role: `"owner"`, `"admin"`, `"moderator"`, or `"member"`.
|
||||
pub role: String,
|
||||
/// Hex pubkey of who added this member, or `None` for bootstrap entries.
|
||||
pub added_by: Option<String>,
|
||||
@@ -309,6 +309,56 @@ pub async fn remove_relay_member_if_role(
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically removes a relay member from `community` only if their role is
|
||||
/// `'member'` or `'moderator'`. Used by the admin removal path so that an
|
||||
/// admin cannot remove a fellow admin; eliminates the TOCTOU race where the
|
||||
/// target could be promoted between a prior role read and the DELETE.
|
||||
///
|
||||
/// Returns [`RemoveResult::RoleMismatch`] when the target exists but holds a
|
||||
/// role that is not in the allowed set (e.g. `admin`).
|
||||
pub async fn remove_relay_member_if_non_admin(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
) -> Result<RemoveResult> {
|
||||
// Atomic single-statement delete: role IN ('member', 'moderator').
|
||||
// Using `= ANY(ARRAY[…])` keeps the parameter count low and avoids
|
||||
// dynamic SQL; the constant set is determined by the function contract.
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM relay_members \
|
||||
WHERE community_id = $1 AND pubkey = $2 \
|
||||
AND role = ANY(ARRAY['member','moderator'])",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
return Ok(RemoveResult::Removed);
|
||||
}
|
||||
|
||||
// Zero rows deleted: distinguish not-found from an admin/owner target.
|
||||
let row = sqlx::query("SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2")
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
None => Ok(RemoveResult::NotFound),
|
||||
Some(r) => {
|
||||
let role: String = r.try_get("role")?;
|
||||
if role == "owner" {
|
||||
Ok(RemoveResult::IsOwner)
|
||||
} else {
|
||||
// admin (or any future elevated role) — caller lacks authority.
|
||||
Ok(RemoveResult::RoleMismatch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the role of an existing relay member in `community`. Returns `true`
|
||||
/// if updated.
|
||||
pub async fn update_relay_member_role(
|
||||
|
||||
@@ -64,6 +64,8 @@ pub enum ModerationAuthority {
|
||||
CommunityOwner,
|
||||
/// Actor is community `admin` in `relay_members`.
|
||||
CommunityAdmin,
|
||||
/// Actor is community `moderator` in `relay_members`.
|
||||
CommunityModerator,
|
||||
/// Actor is channel owner/admin of the target's channel.
|
||||
ChannelRole,
|
||||
}
|
||||
@@ -100,8 +102,8 @@ pub async fn authorize_moderation_action(
|
||||
.map(|m| m.role);
|
||||
|
||||
// The target's community role is read only for the admin guard rail — i.e.
|
||||
// an admin actioning a pubkey with ban/timeout — so the owner and
|
||||
// channel-role paths stay at a single query.
|
||||
// an admin actioning a pubkey with ban/timeout — and for the moderator guard
|
||||
// rail — a moderator cannot Kick/Timeout a community owner or admin.
|
||||
let target_role = match (actor_role.as_deref(), action, target) {
|
||||
(Some("admin"), ModerationAction::Ban | ModerationAction::Timeout, target) => {
|
||||
match target {
|
||||
@@ -113,13 +115,23 @@ pub async fn authorize_moderation_action(
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
(Some("moderator"), ModerationAction::Kick | ModerationAction::Timeout, target) => {
|
||||
match target {
|
||||
ModerationTarget::Pubkey(pk) => state
|
||||
.db
|
||||
.get_relay_member(community, &hex::encode(pk))
|
||||
.await?
|
||||
.map(|m| m.role),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// The channel role is read only when community authority does not apply and
|
||||
// the action is channel-local (DeleteMessage/Kick within `channel_id`).
|
||||
let channel_role = match (actor_role.as_deref(), action, channel_id) {
|
||||
(Some("owner") | Some("admin"), _, _) => None,
|
||||
(Some("owner") | Some("admin") | Some("moderator"), _, _) => None,
|
||||
(_, ModerationAction::DeleteMessage | ModerationAction::Kick, Some(channel_id)) => {
|
||||
state
|
||||
.db
|
||||
@@ -168,8 +180,26 @@ fn decide_authority(
|
||||
}
|
||||
Ok(ModerationAuthority::CommunityAdmin)
|
||||
}
|
||||
// Not a community owner/admin: channel owner/admin keep channel-local
|
||||
// authority for DeleteMessage/Kick only.
|
||||
// Moderator: ViewQueue, ResolveReport, DeleteMessage, Kick, Timeout,
|
||||
// Untimeout — community-wide. Ban/Unban stay admin+ (only an admin or
|
||||
// owner may lift or apply a ban). Guard rail: a moderator cannot Kick
|
||||
// or Timeout the community owner or a fellow admin; only the owner can
|
||||
// action an admin. Target-role reads for the guard are loaded by the
|
||||
// caller in `authorize_moderation_action`; arriving here with a
|
||||
// `Some("owner") | Some("admin")` target means the caller resolved it.
|
||||
Some("moderator") => {
|
||||
if matches!(action, ModerationAction::Ban | ModerationAction::Unban) {
|
||||
anyhow::bail!("a moderator cannot ban or unban community members");
|
||||
}
|
||||
if matches!(action, ModerationAction::Kick | ModerationAction::Timeout)
|
||||
&& matches!(target_role, Some("owner") | Some("admin"))
|
||||
{
|
||||
anyhow::bail!("a moderator cannot kick or time out a community owner or admin");
|
||||
}
|
||||
Ok(ModerationAuthority::CommunityModerator)
|
||||
}
|
||||
// Not a community owner/admin/moderator: channel owner/admin keep
|
||||
// channel-local authority for DeleteMessage/Kick only.
|
||||
_ => match (action, channel_role) {
|
||||
(
|
||||
ModerationAction::DeleteMessage | ModerationAction::Kick,
|
||||
@@ -332,4 +362,141 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── moderator tests ───────────────────────────────────────────────────────
|
||||
|
||||
/// Moderator can take every action except Ban/Unban, against non-privileged
|
||||
/// targets (member, unknown, or no target).
|
||||
#[test]
|
||||
fn moderator_authorized_for_non_ban_actions_against_member_and_non_member() {
|
||||
const MODERATOR_ALLOWED: [ModerationAction; 6] = [
|
||||
ModerationAction::DeleteMessage,
|
||||
ModerationAction::Kick,
|
||||
ModerationAction::Timeout,
|
||||
ModerationAction::Untimeout,
|
||||
ModerationAction::ResolveReport,
|
||||
ModerationAction::ViewQueue,
|
||||
];
|
||||
for action in MODERATOR_ALLOWED {
|
||||
assert_eq!(
|
||||
ok(decide_authority(
|
||||
Some("moderator"),
|
||||
Some("member"),
|
||||
None,
|
||||
action
|
||||
)),
|
||||
ModerationAuthority::CommunityModerator,
|
||||
"moderator must be authorized for {action:?} against a member"
|
||||
);
|
||||
assert_eq!(
|
||||
ok(decide_authority(Some("moderator"), None, None, action)),
|
||||
ModerationAuthority::CommunityModerator,
|
||||
"moderator must be authorized for {action:?} against a non-member"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Moderator cannot ban or unban — those stay admin+.
|
||||
#[test]
|
||||
fn moderator_cannot_ban_or_unban() {
|
||||
for action in [ModerationAction::Ban, ModerationAction::Unban] {
|
||||
for target in [Some("member"), Some("moderator"), None] {
|
||||
assert!(
|
||||
decide_authority(Some("moderator"), target, None, action).is_err(),
|
||||
"moderator must not {action:?} (target={target:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moderator guard rail: cannot Kick or Timeout the community owner or an admin.
|
||||
#[test]
|
||||
fn moderator_cannot_kick_or_timeout_owner_or_admin() {
|
||||
for target in ["owner", "admin"] {
|
||||
for action in [ModerationAction::Kick, ModerationAction::Timeout] {
|
||||
assert!(
|
||||
decide_authority(Some("moderator"), Some(target), None, action).is_err(),
|
||||
"moderator must not {action:?} a community {target}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moderator guard rail is scoped to Kick/Timeout — not to Untimeout or
|
||||
/// other actions. Reversals on admin targets are always allowed.
|
||||
#[test]
|
||||
fn moderator_guard_rail_scoped_to_kick_and_timeout() {
|
||||
for action in [
|
||||
ModerationAction::Untimeout,
|
||||
ModerationAction::DeleteMessage,
|
||||
ModerationAction::ResolveReport,
|
||||
ModerationAction::ViewQueue,
|
||||
] {
|
||||
// Even against an admin target: the guard rail only protects
|
||||
// applying punitive actions, not reversals or reads.
|
||||
assert_eq!(
|
||||
ok(decide_authority(
|
||||
Some("moderator"),
|
||||
Some("admin"),
|
||||
None,
|
||||
action
|
||||
)),
|
||||
ModerationAuthority::CommunityModerator,
|
||||
"moderator must be authorized for {action:?} even against an admin target"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Moderator can Kick/Timeout plain members and non-members — the guard
|
||||
/// rail fires only on owner/admin targets.
|
||||
#[test]
|
||||
fn moderator_can_kick_and_timeout_plain_targets() {
|
||||
for action in [ModerationAction::Kick, ModerationAction::Timeout] {
|
||||
assert_eq!(
|
||||
ok(decide_authority(
|
||||
Some("moderator"),
|
||||
Some("member"),
|
||||
None,
|
||||
action
|
||||
)),
|
||||
ModerationAuthority::CommunityModerator,
|
||||
"moderator must be authorized for {action:?} against a member"
|
||||
);
|
||||
assert_eq!(
|
||||
ok(decide_authority(Some("moderator"), None, None, action)),
|
||||
ModerationAuthority::CommunityModerator,
|
||||
"moderator must be authorized for {action:?} against a non-member"
|
||||
);
|
||||
// Moderator targets are peers — no guard rail there.
|
||||
assert_eq!(
|
||||
ok(decide_authority(
|
||||
Some("moderator"),
|
||||
Some("moderator"),
|
||||
None,
|
||||
action
|
||||
)),
|
||||
ModerationAuthority::CommunityModerator,
|
||||
"moderator must be authorized for {action:?} against a fellow moderator"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A community moderator has no channel role loaded (the caller skips the
|
||||
/// channel_role DB read for community-level actors), so the channel_role
|
||||
/// fallthrough never fires for them.
|
||||
#[test]
|
||||
fn moderator_does_not_use_channel_role_path() {
|
||||
// Even if a channel_role value somehow arrived (misconfigured caller),
|
||||
// the moderator arm fires before the channel-role fallthrough.
|
||||
assert_eq!(
|
||||
ok(decide_authority(
|
||||
Some("moderator"),
|
||||
None,
|
||||
Some("owner"),
|
||||
ModerationAction::Kick
|
||||
)),
|
||||
ModerationAuthority::CommunityModerator,
|
||||
"moderator arm must win over the channel-role fallthrough"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ use nostr::Event;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::handlers::ingest::effective_message_author;
|
||||
use crate::handlers::moderation_authz::{
|
||||
authorize_moderation_action, ModerationAction, ModerationTarget,
|
||||
};
|
||||
@@ -363,6 +364,18 @@ async fn handle_untimeout(
|
||||
|
||||
// ── 9044: resolve report ─────────────────────────────────────────────────────
|
||||
|
||||
/// The resolved report-action value: built once from the stored report and the
|
||||
/// (tombstoned-or-live) target event, then used for authorization and close.
|
||||
struct ResolvedReportAction {
|
||||
/// The capability required to execute this action label.
|
||||
required_capability: ModerationAction,
|
||||
/// The effective author of the target (event reports) or the report target
|
||||
/// itself (pubkey reports). Feeds the target-role guard rail check.
|
||||
target_author: Vec<u8>,
|
||||
/// Channel id from the report row, required for delete/kick actions.
|
||||
channel_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
async fn handle_resolve(
|
||||
tenant: &TenantContext,
|
||||
state: &Arc<AppState>,
|
||||
@@ -396,17 +409,6 @@ async fn handle_resolve(
|
||||
));
|
||||
}
|
||||
|
||||
authorize_moderation_action(
|
||||
tenant,
|
||||
state,
|
||||
actor,
|
||||
None,
|
||||
ModerationTarget::Event(&report_event_id),
|
||||
ModerationAction::ResolveReport,
|
||||
)
|
||||
.await
|
||||
.map_err(authz_denial)?;
|
||||
|
||||
// Resolve the report row under this tenant only. The `report` tag carries
|
||||
// the signed 1984 event id (pinned contract); look the row up by it.
|
||||
let report = state
|
||||
@@ -429,6 +431,32 @@ async fn handle_resolve(
|
||||
));
|
||||
}
|
||||
|
||||
// Build the resolved report-action value once from the stored report and the
|
||||
// (tombstoned-or-live) event. This is the single source of truth for target
|
||||
// shape, effective author, channel, and required capability.
|
||||
let resolved = build_resolved_action(tenant, state, &action, &report).await?;
|
||||
|
||||
// Authorize using the action-specific capability and target author.
|
||||
// Log the authorization decision (attempt) now; the success log fires after
|
||||
// mutation commit below.
|
||||
let authority = authorize_moderation_action(
|
||||
tenant,
|
||||
state,
|
||||
actor,
|
||||
resolved.channel_id,
|
||||
ModerationTarget::Pubkey(&resolved.target_author),
|
||||
resolved.required_capability,
|
||||
)
|
||||
.await
|
||||
.map_err(authz_denial)?;
|
||||
tracing::debug!(
|
||||
action = %action,
|
||||
capability = ?resolved.required_capability,
|
||||
authority = ?authority,
|
||||
actor = %hex::encode(actor),
|
||||
"9044 authorization attempt"
|
||||
);
|
||||
|
||||
// Carry the report's own target into the audit row so `delete`/`kick`/`ban`
|
||||
// resolutions record what they acted on.
|
||||
let (target_pubkey, target_event_id) = match &report.target {
|
||||
@@ -456,7 +484,7 @@ async fn handle_resolve(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let resolved = state
|
||||
let resolved_db = state
|
||||
.db
|
||||
.resolve_moderation_report(
|
||||
tenant.community(),
|
||||
@@ -467,12 +495,21 @@ async fn handle_resolve(
|
||||
)
|
||||
.await
|
||||
.map_err(|e| error(format!("database error: {e}")))?;
|
||||
if !resolved {
|
||||
if !resolved_db {
|
||||
return Err(invalid(
|
||||
"report is not open (already resolved or dismissed)",
|
||||
));
|
||||
}
|
||||
|
||||
// Log after mutation commit (per the plan's authority-logging disposition).
|
||||
info!(
|
||||
report_id = %report.id,
|
||||
status = %status,
|
||||
action = %action,
|
||||
authority = ?authority,
|
||||
"report resolved"
|
||||
);
|
||||
|
||||
// Close the loop: DM the reporter that their report was reviewed.
|
||||
let summary = reason.clone().unwrap_or_else(|| match status.as_str() {
|
||||
"dismissed" => "Your report was reviewed and dismissed.".to_string(),
|
||||
@@ -493,10 +530,108 @@ async fn handle_resolve(
|
||||
info!(error = %e, "report-resolution notice DM delivery failed (report still resolved)");
|
||||
}
|
||||
|
||||
info!(report_id = %report.id, status = %status, action = %action, "report resolved");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the [`ResolvedReportAction`] from the stored report and the action
|
||||
/// label. This is the normalization matrix from plan v3.2:
|
||||
///
|
||||
/// - `delete` / `kick`: require an event target **with a channel_id**; the
|
||||
/// target event is resolved via `get_event_by_id_including_deleted` (the
|
||||
/// queue enforce-first, so the 9005 target is tombstoned by 9044 time — a
|
||||
/// live-only read would strand the report open); real author via
|
||||
/// `effective_message_author`.
|
||||
/// - `ban` / `timeout`: require a pubkey target or an event target whose
|
||||
/// effective author resolves; blob targets rejected.
|
||||
/// - `dismiss` / `escalate`: decision-only, target-agnostic; target_author is
|
||||
/// set to the report's own reporter pubkey as a placeholder (it is never used
|
||||
/// in the capability check, which only requires `ResolveReport`).
|
||||
async fn build_resolved_action(
|
||||
tenant: &TenantContext,
|
||||
state: &Arc<AppState>,
|
||||
action: &str,
|
||||
report: &buzz_db::moderation::ReportRecord,
|
||||
) -> Result<ResolvedReportAction, String> {
|
||||
use buzz_db::moderation::ReportTarget;
|
||||
|
||||
let required_capability = match action {
|
||||
"delete" => ModerationAction::DeleteMessage,
|
||||
"kick" => ModerationAction::Kick,
|
||||
"ban" => ModerationAction::Ban,
|
||||
"timeout" => ModerationAction::Timeout,
|
||||
"dismiss" | "escalate" => ModerationAction::ResolveReport,
|
||||
_ => return Err(invalid(format!("unknown action: {action}"))),
|
||||
};
|
||||
|
||||
match action {
|
||||
"delete" | "kick" => {
|
||||
// Require an event target with a channel.
|
||||
let event_id = match &report.target {
|
||||
ReportTarget::Event(id) => id.clone(),
|
||||
_ => {
|
||||
return Err(invalid(format!(
|
||||
"action `{action}` requires an event report target"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let channel_id = report.channel_id.ok_or_else(|| {
|
||||
invalid(format!(
|
||||
"action `{action}` requires a report with a channel (event must belong to a channel)"
|
||||
))
|
||||
})?;
|
||||
// Use the including-deleted variant: the queue may have already
|
||||
// executed a 9005 on this event before the 9044 arrived.
|
||||
let stored = state
|
||||
.db
|
||||
.get_event_by_id_including_deleted(tenant.community(), &event_id)
|
||||
.await
|
||||
.map_err(|e| error(format!("database error looking up target event: {e}")))?
|
||||
.ok_or_else(|| invalid("target event not found in this community"))?;
|
||||
let target_author =
|
||||
effective_message_author(&stored.event, &state.relay_keypair.public_key());
|
||||
Ok(ResolvedReportAction {
|
||||
required_capability,
|
||||
target_author,
|
||||
channel_id: Some(channel_id),
|
||||
})
|
||||
}
|
||||
"ban" | "timeout" => {
|
||||
// Require a pubkey target, or an event target whose effective author
|
||||
// resolves. Blob targets are rejected.
|
||||
let target_author = match &report.target {
|
||||
ReportTarget::Pubkey(pk) => pk.clone(),
|
||||
ReportTarget::Event(event_id) => {
|
||||
let stored = state
|
||||
.db
|
||||
.get_event_by_id_including_deleted(tenant.community(), event_id)
|
||||
.await
|
||||
.map_err(|e| error(format!("database error looking up target event: {e}")))?
|
||||
.ok_or_else(|| {
|
||||
invalid("target event not found — cannot resolve effective author")
|
||||
})?;
|
||||
effective_message_author(&stored.event, &state.relay_keypair.public_key())
|
||||
}
|
||||
ReportTarget::Blob(_) => {
|
||||
return Err(invalid(format!(
|
||||
"action `{action}` does not apply to blob targets"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(ResolvedReportAction {
|
||||
required_capability,
|
||||
target_author,
|
||||
channel_id: report.channel_id,
|
||||
})
|
||||
}
|
||||
// dismiss / escalate are decision-only and target-agnostic.
|
||||
_ => Ok(ResolvedReportAction {
|
||||
required_capability,
|
||||
target_author: report.reporter_pubkey.clone(),
|
||||
channel_id: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
fn resolution_audit_action(action: &str) -> &'static str {
|
||||
|
||||
@@ -326,7 +326,7 @@ async fn execute_relay_admin_command(
|
||||
if role == "admin" && sender_role != "owner" {
|
||||
return Err("actor not authorized: only owner can grant admin role".to_string());
|
||||
}
|
||||
if role != "admin" && role != "member" {
|
||||
if role != "admin" && role != "member" && role != "moderator" {
|
||||
return Err(format!("invalid role: {role}"));
|
||||
}
|
||||
|
||||
@@ -372,14 +372,14 @@ async fn execute_relay_admin_command(
|
||||
}
|
||||
|
||||
// Dispatch removal by sender role:
|
||||
// - Admins: atomic conditional delete, only removes 'member' targets.
|
||||
// This eliminates the TOCTOU race where the target could be promoted
|
||||
// between a prior role read and the delete.
|
||||
// - Owners: can remove admins and members, not other owners.
|
||||
// - Admins: atomic conditional delete that removes 'member' or
|
||||
// 'moderator' targets — covers the new moderator tier without a
|
||||
// TOCTOU race. Uses role = ANY($3) to match both atomically.
|
||||
// - Owners: can remove admins, moderators, and members, not other owners.
|
||||
let remove_result = if sender_role == "admin" {
|
||||
state
|
||||
.db
|
||||
.remove_relay_member_if_role(tenant.community(), &target_hex, "member")
|
||||
.remove_relay_member_if_non_admin(tenant.community(), &target_hex)
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?
|
||||
} else {
|
||||
@@ -400,7 +400,10 @@ async fn execute_relay_admin_command(
|
||||
return Err(format!("member not found: {target_hex}"));
|
||||
}
|
||||
RemoveResult::RoleMismatch => {
|
||||
return Err("actor not authorized: admins can only remove members".to_string());
|
||||
return Err(
|
||||
"actor not authorized: admins can only remove members or moderators"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,7 +442,7 @@ async fn execute_relay_admin_command(
|
||||
if new_role == "owner" {
|
||||
return Err("cannot set role to owner".to_string());
|
||||
}
|
||||
if new_role != "admin" && new_role != "member" {
|
||||
if new_role != "admin" && new_role != "member" && new_role != "moderator" {
|
||||
return Err(format!("invalid role: {new_role}"));
|
||||
}
|
||||
|
||||
@@ -896,4 +899,104 @@ mod tests {
|
||||
Some("https://example.com/closed.png")
|
||||
);
|
||||
}
|
||||
|
||||
// ── moderator role integration tests ─────────────────────────────────────
|
||||
|
||||
/// Admin may remove a moderator (atomic ANY-predicate path) but not an
|
||||
/// owner or a fellow admin. Tests the multi-role predicate from the plan's
|
||||
/// MINOR finding.
|
||||
///
|
||||
/// Discriminating: fails if the atomic predicate is split into two
|
||||
/// sequential removes or if the admin guard rail is omitted.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn admin_can_remove_member_and_moderator_but_not_owner_or_admin() {
|
||||
let host = format!("moderator-remove-{}.example", uuid::Uuid::new_v4().simple());
|
||||
let (state, tenant) = workspace_profile_test_state(&host, true).await;
|
||||
let owner_keys = Keys::generate();
|
||||
let admin_keys = Keys::generate();
|
||||
let mod_keys = Keys::generate();
|
||||
let member_keys = Keys::generate();
|
||||
|
||||
let community = tenant.community();
|
||||
|
||||
state
|
||||
.db
|
||||
.add_relay_member(community, &owner_keys.public_key().to_hex(), "owner", None)
|
||||
.await
|
||||
.expect("seed owner");
|
||||
state
|
||||
.db
|
||||
.add_relay_member(community, &admin_keys.public_key().to_hex(), "admin", None)
|
||||
.await
|
||||
.expect("seed admin");
|
||||
state
|
||||
.db
|
||||
.add_relay_member(
|
||||
community,
|
||||
&mod_keys.public_key().to_hex(),
|
||||
"moderator",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("seed moderator");
|
||||
state
|
||||
.db
|
||||
.add_relay_member(
|
||||
community,
|
||||
&member_keys.public_key().to_hex(),
|
||||
"member",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("seed member");
|
||||
|
||||
// Admin can remove a moderator (atomic ANY predicate covers both).
|
||||
let remove_mod = state
|
||||
.db
|
||||
.remove_relay_member_if_non_admin(community, &mod_keys.public_key().to_hex())
|
||||
.await
|
||||
.expect("should not error");
|
||||
assert_eq!(
|
||||
remove_mod,
|
||||
buzz_db::relay_members::RemoveResult::Removed,
|
||||
"admin must be able to remove a moderator"
|
||||
);
|
||||
|
||||
// Admin can remove a plain member.
|
||||
let remove_member = state
|
||||
.db
|
||||
.remove_relay_member_if_non_admin(community, &member_keys.public_key().to_hex())
|
||||
.await
|
||||
.expect("should not error");
|
||||
assert_eq!(
|
||||
remove_member,
|
||||
buzz_db::relay_members::RemoveResult::Removed,
|
||||
"admin must be able to remove a plain member"
|
||||
);
|
||||
|
||||
// Admin cannot remove another admin — role mismatch.
|
||||
let remove_admin = state
|
||||
.db
|
||||
.remove_relay_member_if_non_admin(community, &admin_keys.public_key().to_hex())
|
||||
.await
|
||||
.expect("should not error");
|
||||
assert_eq!(
|
||||
remove_admin,
|
||||
buzz_db::relay_members::RemoveResult::RoleMismatch,
|
||||
"admin must NOT be able to remove a fellow admin"
|
||||
);
|
||||
|
||||
// Admin cannot remove the owner — owner is separately protected.
|
||||
let remove_owner = state
|
||||
.db
|
||||
.remove_relay_member_if_non_admin(community, &owner_keys.public_key().to_hex())
|
||||
.await
|
||||
.expect("should not error");
|
||||
assert_eq!(
|
||||
remove_owner,
|
||||
buzz_db::relay_members::RemoveResult::IsOwner,
|
||||
"admin must NOT be able to remove the owner"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ use buzz_core::StoredEvent;
|
||||
use buzz_db::channel::{MemberRecord, MemberRole};
|
||||
|
||||
use super::event::dispatch_persistent_event;
|
||||
use crate::handlers::moderation_authz::{
|
||||
authorize_moderation_action, ModerationAction, ModerationTarget,
|
||||
};
|
||||
use crate::protocol::RelayMessage;
|
||||
use crate::state::AppState;
|
||||
use buzz_core::tenant::TenantContext;
|
||||
@@ -480,13 +483,52 @@ pub async fn validate_admin_event(
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("actor not authorized"))
|
||||
// Additive relay-role path: a community moderator
|
||||
// (or community owner/admin lacking a channel role)
|
||||
// may kick a member via 9001. `authorize_moderation_action`
|
||||
// resolves the target's relay role for the guard-rail
|
||||
// check inside the call.
|
||||
authorize_moderation_action(
|
||||
tenant,
|
||||
state,
|
||||
&actor_bytes,
|
||||
Some(channel_id),
|
||||
ModerationTarget::Pubkey(&target_pubkey),
|
||||
ModerationAction::Kick,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|_| anyhow::anyhow!("actor not authorized"))
|
||||
}
|
||||
}
|
||||
// Non-members fall here. We intentionally do NOT check
|
||||
// is_agent_owner for non-members — you must be in the channel
|
||||
// to remove anyone, even your own bot.
|
||||
_ => Err(anyhow::anyhow!("actor not authorized")),
|
||||
// Non-members: check relay-role authority before failing.
|
||||
// A community moderator who holds no channel membership can
|
||||
// still kick via the relay-role seam.
|
||||
_ => {
|
||||
if state
|
||||
.db
|
||||
.is_agent_owner(tenant.community(), &target_pubkey, &actor_bytes)
|
||||
.await?
|
||||
{
|
||||
// NOTE: agent-owner callers are also channel members
|
||||
// (agents join before humans chat with them), but
|
||||
// handle the edge case uniformly — agent owners who
|
||||
// lost channel membership can still remove their bot.
|
||||
return Ok(());
|
||||
}
|
||||
// Relay-level authority path.
|
||||
authorize_moderation_action(
|
||||
tenant,
|
||||
state,
|
||||
&actor_bytes,
|
||||
Some(channel_id),
|
||||
ModerationTarget::Pubkey(&target_pubkey),
|
||||
ModerationAction::Kick,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|_| anyhow::anyhow!("actor not authorized"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -709,9 +751,31 @@ pub async fn validate_admin_event(
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"must be event author or channel owner/admin"
|
||||
))
|
||||
// Additive relay-role path: community moderator (and
|
||||
// community owner/admin who lack a channel role) can delete
|
||||
// any message via 9005. The target author's role is loaded
|
||||
// inside `authorize_moderation_action` for the guard-rail
|
||||
// check; the author is already resolved above.
|
||||
authorize_moderation_action(
|
||||
tenant,
|
||||
state,
|
||||
&actor_bytes,
|
||||
Some(channel_id),
|
||||
ModerationTarget::Pubkey(&author),
|
||||
ModerationAction::DeleteMessage,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("must be event author or channel owner/admin"))
|
||||
.map(|_authority| {
|
||||
// authorization succeeded; log here rather than
|
||||
// persisting (per the plan's audit-attribution decision)
|
||||
tracing::debug!(
|
||||
actor = %hex::encode(&actor_bytes),
|
||||
target_author = %hex::encode(&author),
|
||||
channel = %channel_id,
|
||||
"9005 authorized via relay-role moderation seam"
|
||||
);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1381,10 +1445,42 @@ async fn handle_remove_user(
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
// Route through the preauthorized mutation when the actor is not an active
|
||||
// channel member (i.e., they arrived via the relay-role moderation seam —
|
||||
// community moderator/owner/admin without a channel role). The standard
|
||||
// `remove_member` path re-checks the channel role inside its transaction;
|
||||
// a relay-level moderator who is not a channel member would fail that check.
|
||||
let actor_has_channel_role = state
|
||||
.db
|
||||
.remove_member(tenant.community(), channel_id, &target_pubkey, &actor_bytes)
|
||||
.await?;
|
||||
.get_member_role(tenant.community(), channel_id, &actor_bytes)
|
||||
.await
|
||||
.map(|r| r.is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
if actor_has_channel_role || target_pubkey == actor_bytes {
|
||||
state
|
||||
.db
|
||||
.remove_member(tenant.community(), channel_id, &target_pubkey, &actor_bytes)
|
||||
.await?;
|
||||
} else {
|
||||
// Relay-role path: the validator already ran `authorize_moderation_action`
|
||||
// and passed — call the preauthorized mutation (no channel-role re-check).
|
||||
tracing::debug!(
|
||||
actor = %hex::encode(&actor_bytes),
|
||||
target = %hex::encode(&target_pubkey),
|
||||
channel = %channel_id,
|
||||
"9001 kick via relay-role moderation seam"
|
||||
);
|
||||
state
|
||||
.db
|
||||
.remove_member_as_community_moderator(
|
||||
tenant.community(),
|
||||
channel_id,
|
||||
&target_pubkey,
|
||||
&actor_bytes,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
state.invalidate_membership(tenant, channel_id, &target_pubkey);
|
||||
evict_live_channel_subscriptions(tenant, state, channel_id, &target_pubkey).await;
|
||||
disable_departed_member_workflows(tenant, state, channel_id, &target_pubkey).await;
|
||||
|
||||
@@ -1708,7 +1708,7 @@ async fn emit_db_usage_metrics(
|
||||
// Zero-fill across all (community, role) pairs; relay_members.role is a
|
||||
// CHECK constraint over {'owner', 'admin', 'member'}.
|
||||
{
|
||||
const RELAY_ROLES: &[&str] = &["owner", "admin", "member"];
|
||||
const RELAY_ROLES: &[&str] = &["owner", "admin", "moderator", "member"];
|
||||
let rows: HashMap<(Uuid, &str), i64> = relay_member_rows
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
|
||||
@@ -87,10 +87,10 @@ case "${1:-help}" in
|
||||
backup_hint
|
||||
;;
|
||||
add-member)
|
||||
docker compose exec relay /usr/local/bin/buzz-admin add-member --pubkey "${2:?Usage: ./run.sh add-member <npub-or-hex> [--role member|admin]}" "${@:3}"
|
||||
docker compose exec relay /usr/local/bin/buzz-admin add-member --pubkey "${2:?Usage: ./run.sh add-member <npub-or-hex> [--role member|moderator|admin]}" "${@:3}"
|
||||
;;
|
||||
remove-member)
|
||||
docker compose exec relay /usr/local/bin/buzz-admin remove-member --pubkey "${2:?Usage: ./run.sh remove-member <npub-or-hex> [--role member|admin]}" "${@:3}"
|
||||
docker compose exec relay /usr/local/bin/buzz-admin remove-member --pubkey "${2:?Usage: ./run.sh remove-member <npub-or-hex> [--role member|moderator|admin]}" "${@:3}"
|
||||
;;
|
||||
list-members)
|
||||
docker compose exec relay /usr/local/bin/buzz-admin list-members
|
||||
@@ -110,9 +110,9 @@ Commands:
|
||||
config Render merged compose config
|
||||
backup-hint Print the production backup checklist
|
||||
|
||||
add-member <npub-or-hex> [--role member|admin]
|
||||
add-member <npub-or-hex> [--role member|moderator|admin]
|
||||
Add a relay member (default role: member)
|
||||
remove-member <npub-or-hex> [--role member|admin]
|
||||
remove-member <npub-or-hex> [--role member|moderator|admin]
|
||||
Remove a relay member
|
||||
list-members List all relay members
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ mod media_upload_progress;
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
pub(crate) mod mesh_llm;
|
||||
mod messages;
|
||||
mod moderator;
|
||||
mod notifications;
|
||||
mod observer_archive;
|
||||
mod os_idle;
|
||||
@@ -91,6 +92,7 @@ pub use media_raw::*;
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
pub use mesh_llm::*;
|
||||
pub use messages::*;
|
||||
pub use moderator::*;
|
||||
pub use notifications::*;
|
||||
pub use observer_archive::*;
|
||||
pub use os_idle::*;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
use nostr::{EventBuilder, EventId, Kind, Tag};
|
||||
use tauri::State;
|
||||
|
||||
use crate::{app_state::AppState, relay::submit_event};
|
||||
|
||||
/// Delete a message as a relay-level moderator, owner, or admin (kind 9005).
|
||||
///
|
||||
/// Unlike `delete_message` (kind 5, author-only), this sends a Buzz-native
|
||||
/// moderator delete event validated by the relay against the actor's relay
|
||||
/// role via `authorize_moderation_action`.
|
||||
#[tauri::command]
|
||||
pub async fn moderator_delete_message(
|
||||
channel_id: String,
|
||||
event_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
|
||||
.map_err(|_| format!("invalid channel UUID: {channel_id}"))?;
|
||||
let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?;
|
||||
let tags = vec![
|
||||
Tag::parse(vec!["h", &channel_uuid.to_string()]).map_err(|e| format!("tag error: {e}"))?,
|
||||
Tag::parse(vec!["e", &target_eid.to_hex()]).map_err(|e| format!("tag error: {e}"))?,
|
||||
];
|
||||
let builder = EventBuilder::new(Kind::Custom(9005), "").tags(tags);
|
||||
submit_event(builder, &state).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -585,7 +585,7 @@ pub fn build_note(
|
||||
// ── Relay admin (NIP-43) ────────────────────────────────────────────────────
|
||||
|
||||
/// Allowed relay member roles for NIP-43 admin commands.
|
||||
const VALID_RELAY_ROLES: &[&str] = &["owner", "admin", "member"];
|
||||
const VALID_RELAY_ROLES: &[&str] = &["owner", "admin", "moderator", "member"];
|
||||
|
||||
fn check_relay_role(role: &str) -> Result<(), String> {
|
||||
if !VALID_RELAY_ROLES.contains(&role) {
|
||||
|
||||
@@ -715,6 +715,7 @@ pub fn run() {
|
||||
get_channel_messages_before,
|
||||
edit_message,
|
||||
delete_message,
|
||||
moderator_delete_message,
|
||||
add_reaction,
|
||||
remove_reaction,
|
||||
get_event,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Crown, MoreHorizontal, Search, Shield } from "lucide-react";
|
||||
import {
|
||||
Crown,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
Shield,
|
||||
ShieldHalf,
|
||||
} from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -103,10 +109,28 @@ function RelayMemberRow({
|
||||
const canRemove =
|
||||
!isSelf &&
|
||||
member.role !== "owner" &&
|
||||
(currentRole === "owner" || member.role === "member");
|
||||
const canPromote = currentRole === "owner" && member.role === "member";
|
||||
const canDemote = currentRole === "owner" && member.role === "admin";
|
||||
const hasActions = canRemove || canPromote || canDemote;
|
||||
(currentRole === "owner" ||
|
||||
(currentRole === "admin" &&
|
||||
(member.role === "member" || member.role === "moderator")));
|
||||
// Only the owner can change roles above member.
|
||||
const canMakeAdmin = currentRole === "owner" && member.role === "member";
|
||||
const canMakeModerator = currentRole === "owner" && member.role === "member";
|
||||
const canPromoteModeratorToAdmin =
|
||||
currentRole === "owner" && member.role === "moderator";
|
||||
const canDemoteAdminToModerator =
|
||||
currentRole === "owner" && member.role === "admin";
|
||||
const canDemoteModeratorToMember =
|
||||
currentRole === "owner" && member.role === "moderator";
|
||||
const canDemoteAdminToMember =
|
||||
currentRole === "owner" && member.role === "admin";
|
||||
const hasActions =
|
||||
canRemove ||
|
||||
canMakeAdmin ||
|
||||
canMakeModerator ||
|
||||
canPromoteModeratorToAdmin ||
|
||||
canDemoteAdminToModerator ||
|
||||
canDemoteModeratorToMember ||
|
||||
canDemoteAdminToMember;
|
||||
const displayName = formatDisplayName(member, profile?.displayName);
|
||||
|
||||
async function mutateWithToast(
|
||||
@@ -153,6 +177,9 @@ function RelayMemberRow({
|
||||
{member.role === "admin" ? (
|
||||
<Shield className="h-4 w-4 text-blue-500" />
|
||||
) : null}
|
||||
{member.role === "moderator" ? (
|
||||
<ShieldHalf className="h-4 w-4 text-green-500" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="shrink-0 capitalize">{member.role}</span>
|
||||
@@ -185,7 +212,7 @@ function RelayMemberRow({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canPromote ? (
|
||||
{canMakeAdmin ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
void mutateWithToast(
|
||||
@@ -201,7 +228,55 @@ function RelayMemberRow({
|
||||
Make admin
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canDemote ? (
|
||||
{canMakeModerator ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
void mutateWithToast(
|
||||
() =>
|
||||
changeRoleMutation.mutateAsync({
|
||||
pubkey: member.pubkey,
|
||||
role: "moderator",
|
||||
}),
|
||||
"Made community moderator",
|
||||
)
|
||||
}
|
||||
>
|
||||
Make moderator
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canPromoteModeratorToAdmin ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
void mutateWithToast(
|
||||
() =>
|
||||
changeRoleMutation.mutateAsync({
|
||||
pubkey: member.pubkey,
|
||||
role: "admin",
|
||||
}),
|
||||
"Made community admin",
|
||||
)
|
||||
}
|
||||
>
|
||||
Make admin
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canDemoteAdminToModerator ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
void mutateWithToast(
|
||||
() =>
|
||||
changeRoleMutation.mutateAsync({
|
||||
pubkey: member.pubkey,
|
||||
role: "moderator",
|
||||
}),
|
||||
"Made community moderator",
|
||||
)
|
||||
}
|
||||
>
|
||||
Make moderator
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canDemoteAdminToMember ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
void mutateWithToast(
|
||||
@@ -217,7 +292,29 @@ function RelayMemberRow({
|
||||
Make member
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canRemove && (canPromote || canDemote) ? (
|
||||
{canDemoteModeratorToMember ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
void mutateWithToast(
|
||||
() =>
|
||||
changeRoleMutation.mutateAsync({
|
||||
pubkey: member.pubkey,
|
||||
role: "member",
|
||||
}),
|
||||
"Made community member",
|
||||
)
|
||||
}
|
||||
>
|
||||
Make member
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canRemove &&
|
||||
(canMakeAdmin ||
|
||||
canMakeModerator ||
|
||||
canPromoteModeratorToAdmin ||
|
||||
canDemoteAdminToModerator ||
|
||||
canDemoteAdminToMember ||
|
||||
canDemoteModeratorToMember) ? (
|
||||
<DropdownMenuSeparator />
|
||||
) : null}
|
||||
{canRemove ? (
|
||||
|
||||
@@ -4,8 +4,12 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout";
|
||||
import type { buildVideoReviewContextForMessage } from "@/features/messages/lib/videoReviewContext";
|
||||
import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage";
|
||||
import { moderationCapabilities } from "@/features/moderation/lib/capabilities";
|
||||
import { useMyRelayMembershipQuery } from "@/features/community-members/hooks";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { moderatorDeleteMessage } from "@/shared/api/moderator";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
@@ -131,6 +135,25 @@ export function MessageRowItem({
|
||||
const canDelete = canManage && onDelete ? onDelete : undefined;
|
||||
const canEdit = canManage && onEdit ? onEdit : undefined;
|
||||
|
||||
// Moderator delete affordance: relay owners, admins, and moderators may
|
||||
// delete messages they do not own, using kind 9005 (moderator delete).
|
||||
// Self-deletions still flow through the normal `canDelete` path (kind 5).
|
||||
const relayMembershipQuery = useMyRelayMembershipQuery();
|
||||
const relayRole = relayMembershipQuery.data?.role;
|
||||
const caps = moderationCapabilities(relayRole);
|
||||
const isOwnMessage =
|
||||
currentPubkey != null &&
|
||||
message.pubkey != null &&
|
||||
normalizePubkey(message.pubkey) === normalizePubkey(currentPubkey);
|
||||
const canModeratorDelete =
|
||||
caps.canDelete && !isOwnMessage && channelId != null
|
||||
? (msg: TimelineMessage) => {
|
||||
void moderatorDeleteMessage(channelId, msg.id).catch(() => {
|
||||
// Failure is surfaced by the relay WebSocket rejection toast.
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (summary && onOpenThread) {
|
||||
const isHighlighted = message.id === highlightedMessageId;
|
||||
return (
|
||||
@@ -158,7 +181,7 @@ export function MessageRowItem({
|
||||
playEntrance={playEntrance}
|
||||
onEntranceComplete={onEntranceComplete}
|
||||
message={message}
|
||||
onDelete={canDelete}
|
||||
onDelete={canDelete ?? canModeratorDelete}
|
||||
onEdit={canEdit}
|
||||
onFollowThread={
|
||||
followThreadById ? () => followThreadById(message.id) : undefined
|
||||
@@ -211,7 +234,7 @@ export function MessageRowItem({
|
||||
playEntrance={playEntrance}
|
||||
onEntranceComplete={onEntranceComplete}
|
||||
message={message}
|
||||
onDelete={canDelete}
|
||||
onDelete={canDelete ?? canModeratorDelete}
|
||||
onEdit={canEdit}
|
||||
onMarkRead={onMarkRead}
|
||||
onMarkUnread={onMarkUnread}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { moderationCapabilities } from "./capabilities.ts";
|
||||
|
||||
// ── owner / admin ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("moderationCapabilities: owner gets all capabilities", () => {
|
||||
const caps = moderationCapabilities("owner");
|
||||
assert.strictEqual(caps.canDelete, true);
|
||||
assert.strictEqual(caps.canKick, true);
|
||||
assert.strictEqual(caps.canTimeout, true);
|
||||
assert.strictEqual(caps.canUntimeout, true);
|
||||
assert.strictEqual(caps.canBan, true);
|
||||
assert.strictEqual(caps.canUnban, true);
|
||||
assert.strictEqual(caps.canViewQueue, true);
|
||||
assert.strictEqual(caps.canResolve, true);
|
||||
});
|
||||
|
||||
test("moderationCapabilities: admin gets all capabilities", () => {
|
||||
const caps = moderationCapabilities("admin");
|
||||
assert.strictEqual(caps.canDelete, true);
|
||||
assert.strictEqual(caps.canKick, true);
|
||||
assert.strictEqual(caps.canTimeout, true);
|
||||
assert.strictEqual(caps.canUntimeout, true);
|
||||
assert.strictEqual(caps.canBan, true);
|
||||
assert.strictEqual(caps.canUnban, true);
|
||||
assert.strictEqual(caps.canViewQueue, true);
|
||||
assert.strictEqual(caps.canResolve, true);
|
||||
});
|
||||
|
||||
// ── moderator ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("moderationCapabilities: moderator gets delete/kick/timeout/untimeout/queue/resolve", () => {
|
||||
const caps = moderationCapabilities("moderator");
|
||||
assert.strictEqual(caps.canDelete, true);
|
||||
assert.strictEqual(caps.canKick, true);
|
||||
assert.strictEqual(caps.canTimeout, true);
|
||||
assert.strictEqual(caps.canUntimeout, true);
|
||||
assert.strictEqual(caps.canViewQueue, true);
|
||||
assert.strictEqual(caps.canResolve, true);
|
||||
});
|
||||
|
||||
test("moderationCapabilities: moderator does NOT get ban or unban", () => {
|
||||
const caps = moderationCapabilities("moderator");
|
||||
assert.strictEqual(caps.canBan, false);
|
||||
assert.strictEqual(caps.canUnban, false);
|
||||
});
|
||||
|
||||
// ── member / null / undefined ─────────────────────────────────────────────────
|
||||
|
||||
test("moderationCapabilities: member gets no capabilities", () => {
|
||||
const caps = moderationCapabilities("member");
|
||||
assert.strictEqual(caps.canDelete, false);
|
||||
assert.strictEqual(caps.canKick, false);
|
||||
assert.strictEqual(caps.canTimeout, false);
|
||||
assert.strictEqual(caps.canUntimeout, false);
|
||||
assert.strictEqual(caps.canBan, false);
|
||||
assert.strictEqual(caps.canUnban, false);
|
||||
assert.strictEqual(caps.canViewQueue, false);
|
||||
assert.strictEqual(caps.canResolve, false);
|
||||
});
|
||||
|
||||
test("moderationCapabilities: null gets no capabilities", () => {
|
||||
const caps = moderationCapabilities(null);
|
||||
assert.strictEqual(caps.canBan, false);
|
||||
assert.strictEqual(caps.canDelete, false);
|
||||
assert.strictEqual(caps.canViewQueue, false);
|
||||
});
|
||||
|
||||
test("moderationCapabilities: undefined gets no capabilities", () => {
|
||||
const caps = moderationCapabilities(undefined);
|
||||
assert.strictEqual(caps.canBan, false);
|
||||
assert.strictEqual(caps.canDelete, false);
|
||||
assert.strictEqual(caps.canViewQueue, false);
|
||||
});
|
||||
|
||||
// ── symmetry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test("moderationCapabilities: owner and admin return the same set", () => {
|
||||
const owner = moderationCapabilities("owner");
|
||||
const admin = moderationCapabilities("admin");
|
||||
assert.deepEqual(owner, admin);
|
||||
});
|
||||
|
||||
test("moderationCapabilities: moderator is a strict subset of admin capabilities", () => {
|
||||
const admin = moderationCapabilities("admin");
|
||||
const mod = moderationCapabilities("moderator");
|
||||
// Every capability a moderator holds, admin also holds.
|
||||
for (const key of Object.keys(mod)) {
|
||||
if (mod[key]) {
|
||||
assert.strictEqual(admin[key], true, `admin must also have ${key}`);
|
||||
}
|
||||
}
|
||||
// But admin has capabilities the moderator does not.
|
||||
assert.strictEqual(admin.canBan, true);
|
||||
assert.strictEqual(mod.canBan, false);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Relay-role–keyed moderation capability map.
|
||||
*
|
||||
* Single source of truth for which moderation actions each relay role may
|
||||
* perform. Both `MessageModerationMenuItems` and the moderation queue consume
|
||||
* this helper so the capability contract is never duplicated across surfaces.
|
||||
*
|
||||
* Capability grid (plan v3.2):
|
||||
* - owner / admin: all actions (Delete, Kick, Timeout, Untimeout, Ban, Unban,
|
||||
* Resolve, ViewQueue)
|
||||
* - moderator: Delete, Kick, Timeout, Untimeout, Resolve, ViewQueue — NOT
|
||||
* Ban/Unban; guard rails (cannot Kick/Timeout owner/admin) are enforced by
|
||||
* the relay seam, not this map.
|
||||
* - member / null: none
|
||||
*/
|
||||
|
||||
import type { RelayMemberRole } from "@/shared/api/types";
|
||||
|
||||
export type ModerationCapabilities = {
|
||||
/** May delete messages (9005 path for non-owned messages). */
|
||||
canDelete: boolean;
|
||||
/** May kick a user from a channel (9001). */
|
||||
canKick: boolean;
|
||||
/** May time out a user (9042). */
|
||||
canTimeout: boolean;
|
||||
/** May lift a timeout (9043). */
|
||||
canUntimeout: boolean;
|
||||
/** May ban a user from the community (9040). Admin+ only. */
|
||||
canBan: boolean;
|
||||
/** May lift a community ban (9041). Admin+ only. */
|
||||
canUnban: boolean;
|
||||
/** May view the moderation queue. */
|
||||
canViewQueue: boolean;
|
||||
/** May resolve/dismiss/escalate reports (9044). */
|
||||
canResolve: boolean;
|
||||
};
|
||||
|
||||
const ADMIN_CAPABILITIES: ModerationCapabilities = {
|
||||
canDelete: true,
|
||||
canKick: true,
|
||||
canTimeout: true,
|
||||
canUntimeout: true,
|
||||
canBan: true,
|
||||
canUnban: true,
|
||||
canViewQueue: true,
|
||||
canResolve: true,
|
||||
};
|
||||
|
||||
const MODERATOR_CAPABILITIES: ModerationCapabilities = {
|
||||
canDelete: true,
|
||||
canKick: true,
|
||||
canTimeout: true,
|
||||
canUntimeout: true,
|
||||
canBan: false,
|
||||
canUnban: false,
|
||||
canViewQueue: true,
|
||||
canResolve: true,
|
||||
};
|
||||
|
||||
const NO_CAPABILITIES: ModerationCapabilities = {
|
||||
canDelete: false,
|
||||
canKick: false,
|
||||
canTimeout: false,
|
||||
canUntimeout: false,
|
||||
canBan: false,
|
||||
canUnban: false,
|
||||
canViewQueue: false,
|
||||
canResolve: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the moderation capabilities for the given relay role.
|
||||
*
|
||||
* Pass `null` or `undefined` for unauthenticated/non-member callers.
|
||||
*/
|
||||
export function moderationCapabilities(
|
||||
role: RelayMemberRole | null | undefined,
|
||||
): ModerationCapabilities {
|
||||
if (role === "owner" || role === "admin") return ADMIN_CAPABILITIES;
|
||||
if (role === "moderator") return MODERATOR_CAPABILITIES;
|
||||
return NO_CAPABILITIES;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useUnbanMemberMutation,
|
||||
useUntimeoutMemberMutation,
|
||||
} from "@/features/moderation/hooks";
|
||||
import { moderationCapabilities } from "@/features/moderation/lib/capabilities";
|
||||
import { useMyRelayMembershipQuery } from "@/features/community-members/hooks";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { isTimedOut } from "@/features/moderation/lib/restrictionState";
|
||||
@@ -34,10 +35,11 @@ const TIMEOUT_PRESETS: { label: string; seconds: number }[] = [
|
||||
* kick from the current channel. Self-contained (wires its own hooks, no props
|
||||
* threaded from the message row), mirroring ReportMessageDialog.
|
||||
*
|
||||
* Renders nothing unless the viewer is a relay owner/admin, the message has a
|
||||
* real signer, and that signer is not the viewer. Actions target
|
||||
* `signerPubkey` — the raw signer, never a relay-delegated display author — per
|
||||
* the security note on TimelineMessage.
|
||||
* Renders nothing unless the viewer holds a moderating relay role (owner, admin,
|
||||
* or moderator), the message has a real signer, and that signer is not the
|
||||
* viewer. Ban/Unban are hidden for moderators (admin+ only per the capability
|
||||
* grid). Actions target `signerPubkey` — the raw signer, never a
|
||||
* relay-delegated display author — per the security note on TimelineMessage.
|
||||
*/
|
||||
export function MessageModerationMenuItems({
|
||||
channelId,
|
||||
@@ -48,7 +50,7 @@ export function MessageModerationMenuItems({
|
||||
}) {
|
||||
const relayMembershipQuery = useMyRelayMembershipQuery();
|
||||
const relayRole = relayMembershipQuery.data?.role;
|
||||
const canModerate = relayRole === "owner" || relayRole === "admin";
|
||||
const caps = moderationCapabilities(relayRole);
|
||||
|
||||
const identityQuery = useIdentityQuery();
|
||||
// Moderate the raw signer, never a relay-delegated display author. A message
|
||||
@@ -60,9 +62,13 @@ export function MessageModerationMenuItems({
|
||||
normalizePubkey(targetPubkey) ===
|
||||
normalizePubkey(identityQuery.data.pubkey);
|
||||
|
||||
const enabled = canModerate && targetPubkey != null && !isSelf;
|
||||
// At minimum, the viewer must be able to perform at least one action.
|
||||
const canActOnAny =
|
||||
(caps.canTimeout || caps.canKick || caps.canBan) &&
|
||||
targetPubkey != null &&
|
||||
!isSelf;
|
||||
|
||||
const restrictionsQuery = useModerationRestrictionsQuery(enabled);
|
||||
const restrictionsQuery = useModerationRestrictionsQuery(canActOnAny);
|
||||
const banMutation = useBanMemberMutation();
|
||||
const unbanMutation = useUnbanMemberMutation();
|
||||
const timeoutMutation = useTimeoutMemberMutation();
|
||||
@@ -101,60 +107,62 @@ export function MessageModerationMenuItems({
|
||||
[],
|
||||
);
|
||||
|
||||
if (!enabled || targetPubkey == null) return null;
|
||||
if (!canActOnAny || targetPubkey == null) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{timedOut ? (
|
||||
<DropdownMenuItem
|
||||
data-testid={`message-untimeout-${message.id}`}
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => untimeoutMutation.mutateAsync(targetPubkey),
|
||||
"Timeout lifted",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Lift timeout
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
data-testid={`message-timeout-${message.id}`}
|
||||
{caps.canTimeout ? (
|
||||
timedOut ? (
|
||||
<DropdownMenuItem
|
||||
data-testid={`message-untimeout-${message.id}`}
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => untimeoutMutation.mutateAsync(targetPubkey),
|
||||
"Timeout lifted",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
Time out author
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{TIMEOUT_PRESETS.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
data-testid={`message-timeout-${preset.seconds}-${message.id}`}
|
||||
disabled={isPending}
|
||||
key={preset.seconds}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() =>
|
||||
timeoutMutation.mutateAsync({
|
||||
pubkey: targetPubkey,
|
||||
expiresAt:
|
||||
Math.floor(Date.now() / 1000) + preset.seconds,
|
||||
}),
|
||||
"Author timed out",
|
||||
)
|
||||
}
|
||||
>
|
||||
{preset.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Lift timeout
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
data-testid={`message-timeout-${message.id}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
Time out author
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{TIMEOUT_PRESETS.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
data-testid={`message-timeout-${preset.seconds}-${message.id}`}
|
||||
disabled={isPending}
|
||||
key={preset.seconds}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() =>
|
||||
timeoutMutation.mutateAsync({
|
||||
pubkey: targetPubkey,
|
||||
expiresAt:
|
||||
Math.floor(Date.now() / 1000) + preset.seconds,
|
||||
}),
|
||||
"Author timed out",
|
||||
)
|
||||
}
|
||||
>
|
||||
{preset.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{channelId ? (
|
||||
{caps.canKick && channelId ? (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
data-testid={`message-kick-${message.id}`}
|
||||
@@ -171,36 +179,38 @@ export function MessageModerationMenuItems({
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
{isBanned ? (
|
||||
<DropdownMenuItem
|
||||
data-testid={`message-unban-${message.id}`}
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => unbanMutation.mutateAsync(targetPubkey),
|
||||
"Ban lifted",
|
||||
)
|
||||
}
|
||||
>
|
||||
<CircleSlash className="h-4 w-4" />
|
||||
Lift ban
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
data-testid={`message-ban-${message.id}`}
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => banMutation.mutateAsync({ pubkey: targetPubkey }),
|
||||
"Author banned",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
Ban author from community
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{caps.canBan ? (
|
||||
isBanned ? (
|
||||
<DropdownMenuItem
|
||||
data-testid={`message-unban-${message.id}`}
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => unbanMutation.mutateAsync(targetPubkey),
|
||||
"Ban lifted",
|
||||
)
|
||||
}
|
||||
>
|
||||
<CircleSlash className="h-4 w-4" />
|
||||
Lift ban
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
data-testid={`message-ban-${message.id}`}
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => banMutation.mutateAsync({ pubkey: targetPubkey }),
|
||||
"Author banned",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
Ban author from community
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,6 +248,43 @@ test("resolvableActions: timeout is never offered from one-click yet", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── canBan=false (moderator actor) ────────────────────────────────────────────
|
||||
|
||||
test("resolvableActions: canBan=false hides ban from event target with channel", () => {
|
||||
const actions = resolvableActions("event", true, false);
|
||||
assert.ok(!actions.includes("ban"), "ban must not appear for moderators");
|
||||
// delete and kick are still available (moderator can use them)
|
||||
assert.ok(actions.includes("delete"));
|
||||
assert.ok(actions.includes("kick"));
|
||||
assert.ok(actions.includes("escalate"));
|
||||
assert.ok(actions.includes("dismiss"));
|
||||
});
|
||||
|
||||
test("resolvableActions: canBan=false hides ban from event target without channel", () => {
|
||||
const actions = resolvableActions("event", false, false);
|
||||
assert.ok(!actions.includes("ban"));
|
||||
assert.ok(actions.includes("escalate"));
|
||||
assert.ok(actions.includes("dismiss"));
|
||||
});
|
||||
|
||||
test("resolvableActions: canBan=false hides ban from pubkey target", () => {
|
||||
const actions = resolvableActions("pubkey", false, false);
|
||||
assert.ok(!actions.includes("ban"));
|
||||
// pubkey targets have no delete/kick regardless
|
||||
assert.ok(!actions.includes("delete"));
|
||||
assert.ok(!actions.includes("kick"));
|
||||
assert.ok(actions.includes("escalate"));
|
||||
assert.ok(actions.includes("dismiss"));
|
||||
});
|
||||
|
||||
test("resolvableActions: canBan=true (default) still includes ban for event target with channel", () => {
|
||||
// Ensure the default backward-compat value is preserved for admins.
|
||||
const withDefault = resolvableActions("event", true);
|
||||
const withExplicit = resolvableActions("event", true, true);
|
||||
assert.deepEqual(withDefault, withExplicit);
|
||||
assert.ok(withDefault.includes("ban"));
|
||||
});
|
||||
|
||||
test("buildModerationQueue carries channelId from the report onto the group", () => {
|
||||
const t = "d".repeat(64);
|
||||
const [group] = buildModerationQueue([
|
||||
|
||||
@@ -232,7 +232,8 @@ export function groupTopReportType(group: ModerationQueueGroup): ReportType {
|
||||
}
|
||||
|
||||
/**
|
||||
* Which one-click resolutions can actually be *enforced* for a given target.
|
||||
* Which one-click resolutions can actually be *enforced* for a given target
|
||||
* and viewer capabilities.
|
||||
*
|
||||
* A 9044 resolve only records the decision + DMs the reporter; the client must
|
||||
* compose the paired enforcement event (delete→9005, ban→9040, kick→9001).
|
||||
@@ -244,6 +245,7 @@ export function groupTopReportType(group: ModerationQueueGroup): ReportType {
|
||||
* only event-target reports (a pubkey report is not tied to a channel).
|
||||
* - `ban` (9040) needs only the author pubkey — event reports resolve it from
|
||||
* the reported event's signer; pubkey reports carry it as the target.
|
||||
* Ban is **admin+ only** per the capability grid: hidden for moderators.
|
||||
* - `escalate` / `dismiss` are decision-only and always available.
|
||||
*
|
||||
* `timeout` is intentionally excluded until the resolve flow can collect a
|
||||
@@ -253,11 +255,14 @@ export function groupTopReportType(group: ModerationQueueGroup): ReportType {
|
||||
export function resolvableActions(
|
||||
targetKind: ReportTargetKind,
|
||||
hasChannel: boolean,
|
||||
canBan = true,
|
||||
): ResolutionAction[] {
|
||||
const actions: ResolutionAction[] = [];
|
||||
if (targetKind === "event" && hasChannel) actions.push("delete");
|
||||
// ban needs only the author; event reports look it up from the signer.
|
||||
if (targetKind === "event" || targetKind === "pubkey") actions.push("ban");
|
||||
// Gated by canBan so moderators never see the ban button.
|
||||
if (canBan && (targetKind === "event" || targetKind === "pubkey"))
|
||||
actions.push("ban");
|
||||
if (targetKind === "event" && hasChannel) actions.push("kick");
|
||||
actions.push("escalate", "dismiss");
|
||||
return actions;
|
||||
|
||||
@@ -12,11 +12,8 @@ import {
|
||||
} from "@/features/moderation/hooks";
|
||||
import { useMyRelayMembershipQuery } from "@/features/community-members/hooks";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import {
|
||||
deleteMessage,
|
||||
getEventById,
|
||||
removeChannelMember,
|
||||
} from "@/shared/api/tauri";
|
||||
import { getEventById, removeChannelMember } from "@/shared/api/tauri";
|
||||
import { moderatorDeleteMessage } from "@/shared/api/moderator";
|
||||
import {
|
||||
buildModerationQueue,
|
||||
groupTopReportType,
|
||||
@@ -30,6 +27,7 @@ import {
|
||||
type ReportType,
|
||||
type SeverityTier,
|
||||
} from "@/features/settings/lib/moderationQueue";
|
||||
import { moderationCapabilities } from "@/features/moderation/lib/capabilities";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -115,7 +113,9 @@ async function enforceResolution(
|
||||
case "delete":
|
||||
// Gated to event targets with a channel (resolvableActions).
|
||||
if (group.channelId == null) throw new Error("Report has no channel.");
|
||||
await deleteMessage(group.channelId, group.target);
|
||||
// Use kind 9005 (moderator delete) since the queue actor may not own
|
||||
// the message. The relay validates authority via the moderation seam.
|
||||
await moderatorDeleteMessage(group.channelId, group.target);
|
||||
return;
|
||||
case "ban":
|
||||
await ban({ pubkey: await resolveTargetAuthor(group) });
|
||||
@@ -280,11 +280,13 @@ function QueueGroupCard({
|
||||
reporterNames,
|
||||
onResolve,
|
||||
disabled,
|
||||
canBan,
|
||||
}: {
|
||||
group: ModerationQueueGroup;
|
||||
reporterNames: Record<string, string | null | undefined>;
|
||||
onResolve: (group: ModerationQueueGroup, action: ResolutionAction) => void;
|
||||
disabled: boolean;
|
||||
canBan: boolean;
|
||||
}) {
|
||||
const topType = groupTopReportType(group);
|
||||
const tier = severityTier(topType);
|
||||
@@ -321,6 +323,7 @@ function QueueGroupCard({
|
||||
allowed={resolvableActions(
|
||||
group.targetKind,
|
||||
group.channelId != null,
|
||||
canBan,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onResolve={(action) => onResolve(group, action)}
|
||||
@@ -361,6 +364,8 @@ function QueueTab() {
|
||||
const auditQuery = useModerationAuditQuery();
|
||||
const resolveMutation = useResolveReportMutation();
|
||||
const banMutation = useBanMemberMutation();
|
||||
const membershipQuery = useMyRelayMembershipQuery();
|
||||
const caps = moderationCapabilities(membershipQuery.data?.role);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const reports = (reportsQuery.data ?? []).map(toQueueReport);
|
||||
@@ -440,6 +445,7 @@ function QueueTab() {
|
||||
<div className="space-y-3">
|
||||
{groups.map((group) => (
|
||||
<QueueGroupCard
|
||||
canBan={caps.canBan}
|
||||
disabled={resolveMutation.isPending || banMutation.isPending}
|
||||
group={group}
|
||||
key={group.targetKey}
|
||||
@@ -543,7 +549,8 @@ function AuditTab() {
|
||||
export function ModerationQueueCard() {
|
||||
const membershipQuery = useMyRelayMembershipQuery();
|
||||
const role = membershipQuery.data?.role;
|
||||
const isModerator = role === "owner" || role === "admin";
|
||||
const isModerator =
|
||||
role === "owner" || role === "admin" || role === "moderator";
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Moderator-specific API commands.
|
||||
*
|
||||
* These commands are available to relay owners, admins, and moderators. They
|
||||
* use Buzz-native event kinds so the relay can validate authority via the
|
||||
* moderation seam (authorize_moderation_action).
|
||||
*/
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
|
||||
/**
|
||||
* Delete a message as a relay-level moderator, owner, or admin (kind 9005).
|
||||
*
|
||||
* Unlike the standard delete (kind 5, self-authored only), this sends a
|
||||
* Buzz-native moderator delete validated against the actor's relay role. Use
|
||||
* when deleting a message you did not author.
|
||||
*/
|
||||
export async function moderatorDeleteMessage(
|
||||
channelId: string,
|
||||
eventId: string,
|
||||
): Promise<void> {
|
||||
await invokeTauri("moderator_delete_message", { channelId, eventId });
|
||||
}
|
||||
@@ -15,7 +15,12 @@ const KIND_RELAY_ADMIN_CHANGE_ROLE = 9032;
|
||||
function isRelayMemberRole(
|
||||
value: string | undefined,
|
||||
): value is RelayMemberRole {
|
||||
return value === "owner" || value === "admin" || value === "member";
|
||||
return (
|
||||
value === "owner" ||
|
||||
value === "admin" ||
|
||||
value === "moderator" ||
|
||||
value === "member"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePubkey(pubkey: string): string {
|
||||
|
||||
@@ -258,7 +258,7 @@ export type {
|
||||
|
||||
// ── Relay Members ────────────────────────────────────────────────────────────
|
||||
|
||||
export type RelayMemberRole = "owner" | "admin" | "member";
|
||||
export type RelayMemberRole = "owner" | "admin" | "moderator" | "member";
|
||||
|
||||
export type RelayMember = {
|
||||
pubkey: string;
|
||||
|
||||
@@ -364,7 +364,7 @@ type E2eConfig = {
|
||||
relayRequiresMembership?: boolean;
|
||||
/** Delay EOSE for membership snapshots after delivering the event. */
|
||||
relayMembershipEoseDelayMs?: number;
|
||||
relayRole?: "owner" | "admin" | "member" | null;
|
||||
relayRole?: "owner" | "admin" | "moderator" | "member" | null;
|
||||
// Descriptors returned by the mocked `pick_and_upload_media` /
|
||||
// `upload_media_bytes` commands. Lets a spec drive the attachment flow
|
||||
// (e.g. a generic PDF) without a real upload pipeline. See
|
||||
@@ -555,7 +555,7 @@ type RawBlobDescriptor = {
|
||||
|
||||
type RawRelayMember = {
|
||||
pubkey: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
role: "owner" | "admin" | "moderator" | "member";
|
||||
added_by: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- ── Relay-level moderator role ────────────────────────────────────────────────
|
||||
-- Extends the `relay_members.role` CHECK to include 'moderator', enabling
|
||||
-- community moderators who hold a scoped subset of admin capabilities
|
||||
-- (ViewQueue, ResolveReport, DeleteMessage, Kick, Timeout, Untimeout) without
|
||||
-- full relay-admin rights.
|
||||
--
|
||||
-- Additive migration: previously applied files must not change checksum.
|
||||
--
|
||||
-- PostgreSQL does not support ALTER TABLE ... ALTER CONSTRAINT, so we drop the
|
||||
-- existing check constraint by name and add a new one. The constraint was
|
||||
-- generated by the initial schema CREATE TABLE statement, so its name follows
|
||||
-- the pg convention: relay_members_role_check.
|
||||
--
|
||||
-- Lock note: DROP/ADD CONSTRAINT takes an ACCESS EXCLUSIVE lock on
|
||||
-- relay_members for the duration. The table is not written to by live traffic
|
||||
-- during migrations (embedded startup serializes schema-first before serving),
|
||||
-- so this is safe in one-image deployments. Environments that separate
|
||||
-- migration from runtime should schedule this during low-traffic windows.
|
||||
|
||||
ALTER TABLE relay_members
|
||||
DROP CONSTRAINT IF EXISTS relay_members_role_check,
|
||||
ADD CONSTRAINT relay_members_role_check
|
||||
CHECK (role IN ('owner', 'admin', 'member', 'moderator'));
|
||||
+1
-1
@@ -572,7 +572,7 @@ CREATE TABLE pubkey_allowlist (
|
||||
CREATE TABLE relay_members (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
pubkey TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'moderator')),
|
||||
added_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
Reference in New Issue
Block a user