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:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-08-04 15:13:24 -04:00
co-authored by Will Pfleger
parent cb4a73e17d
commit 85782fc71a
29 changed files with 1461 additions and 158 deletions
+3 -3
View File
@@ -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'"
)),
}
}
+252
View File
@@ -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:?}"
);
}
}
+37
View File
@@ -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,
+26 -2
View File
@@ -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]
+51 -1
View File
@@ -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 {
+111 -8
View File
@@ -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"
);
}
}
+107 -11
View File
@@ -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;
+1 -1
View File
@@ -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| {
+4 -4
View File
@@ -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
+2
View File
@@ -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(())
}
+1 -1
View File
@@ -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) {
+1
View File
@@ -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
+22
View File
@@ -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 });
}
+6 -1
View File
@@ -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 {
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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;
};
+23
View File
@@ -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
View File
@@ -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(),