block/buzz · error · IngestError::Rejected

invalid role: {s}

Error message

invalid role: {s}

What it means

A kind 9000 PUT_USER carried a 'role' tag whose value failed MemberRole::from_str. Buzz's channel role vocabulary is exactly five canonical lowercase strings — owner, admin, member, guest, bot — matching the DB enum and Nostr tags. Any other spelling (including case variants like 'Admin', abbreviations like 'mod', or plural forms) is rejected before the membership change is stored.

Source

Thrown at crates/buzz-relay/src/handlers/side_effects.rs:348

        && event.tags.iter().any(|t| {
            let parts = t.as_slice();
            parts.len() >= 2 && parts[0] == "archived" && parts[1] == "false"
        });
    if channel.archived_at.is_some() && !is_unarchive_request {
        return Err(anyhow::anyhow!("channel is archived"));
    }

    match kind {
        9000 => {
            // An absent role tag means "no role change requested": for an existing
            // member that preserves the role they already hold, and only defaults
            // to Member for a genuinely new member. Defaulting unconditionally to
            // Member made a bare self-targeted PUT_USER silently demote an owner.
            let role_str = extract_tag_value(event, "role");
            let requested_role = match role_str {
                Some(ref s) => match s.parse::<buzz_db::channel::MemberRole>() {
                    Ok(r) => Some(r),
                    Err(_) => return Err(anyhow::anyhow!("invalid role: {s}")),
                },
                None => None,
            };

            let members = state.db.get_members(tenant.community(), channel_id).await?;
            let actor_role: Option<buzz_db::channel::MemberRole> = members
                .iter()
                .find(|m| m.pubkey == actor_bytes)
                .and_then(|m| m.role.parse().ok());
            let target_pubkey =
                extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?;

            // PUT_USER: open channels allow any authenticated user; private channels
            // require the actor to be an existing active member. Any active member may
            // add an ordinary member, guest, or bot, but only owners/admins may grant
            // an elevated role.
            if channel.visibility == "private" {
                if actor_role.is_none() {

View on GitHub (pinned to f956e6fe06)

Solutions

  1. Use one of the five canonical values: owner, admin, member, guest, bot (lowercase)
  2. Validate the role string against the allowed set before publishing the 9000 event
  3. Map UI labels to wire values centrally (e.g. 'Moderator' → 'admin') instead of sending display strings

Example fix

// before
Tag::custom(TagKind::Custom("role"), vec!["moderator"]) // → invalid role: moderator

// after
const ROLES: [&str; 5] = ["owner", "admin", "member", "guest", "bot"];
assert!(ROLES.contains(&role.as_str()), "unknown role {role}");
Tag::custom(TagKind::Custom("role"), vec![role])
Defensive patterns

Strategy: type-guard

Validate before calling

// Validate the role tag against the exact wire vocabulary before publish
const ROLES: [&str; 5] = ["owner", "admin", "member", "guest", "bot"];
if let Some(role) = role_tag_value {
    anyhow::ensure!(ROLES.contains(&role.as_str()), "invalid role {role}");
}

Type guard

const MEMBER_ROLES = ["owner", "admin", "member", "guest", "bot"] as const;
export type MemberRole = typeof MEMBER_ROLES[number];
const isMemberRole = (v: string): v is MemberRole =>
  (MEMBER_ROLES as readonly string[]).includes(v);

Try / catch

match validate_admin_event(&tenant, 9000, &event, &state).await {
    Err(e) if e.to_string().starts_with("invalid role") => {
        // surface the allowed list to the user; never retry with a synonym
        reject("role must be one of owner|admin|member|guest|bot", &event.id)
    }
    other => other,
}

Prevention

When it happens

Trigger: role tag value "moderator", "mod", "Member" (capitalized), "admin " (trailing space), or a numeric role like "3"; role taken from a UI dropdown whose labels differ from the wire values; templating that uppercases tag values.

Common situations: Design docs introduce a 'moderator' tier that the enum does not have (v1 hierarchy is Owner > Admin > Member > Guest with Bot separate); locale/casing transformations in the client; role strings sourced from a different product's vocabulary.

Related errors


AI-assisted analysis of block/buzz@f956e6fe06 (2026-08-16). Data as JSON: /api/errors/e1f51f8a1c021c30. Report an issue: GitHub.