{"record":{"id":"e1f51f8a1c021c30","repo":"block/buzz","slug":"invalid-role-s","errorCode":null,"errorMessage":"invalid role: {s}","messagePattern":"invalid role: (.+?)","errorType":"validation","errorClass":"IngestError::Rejected","httpStatus":null,"severity":"error","filePath":"crates/buzz-relay/src/handlers/side_effects.rs","lineNumber":348,"sourceCode":"        && event.tags.iter().any(|t| {\n            let parts = t.as_slice();\n            parts.len() >= 2 && parts[0] == \"archived\" && parts[1] == \"false\"\n        });\n    if channel.archived_at.is_some() && !is_unarchive_request {\n        return Err(anyhow::anyhow!(\"channel is archived\"));\n    }\n\n    match kind {\n        9000 => {\n            // An absent role tag means \"no role change requested\": for an existing\n            // member that preserves the role they already hold, and only defaults\n            // to Member for a genuinely new member. Defaulting unconditionally to\n            // Member made a bare self-targeted PUT_USER silently demote an owner.\n            let role_str = extract_tag_value(event, \"role\");\n            let requested_role = match role_str {\n                Some(ref s) => match s.parse::<buzz_db::channel::MemberRole>() {\n                    Ok(r) => Some(r),\n                    Err(_) => return Err(anyhow::anyhow!(\"invalid role: {s}\")),\n                },\n                None => None,\n            };\n\n            let members = state.db.get_members(tenant.community(), channel_id).await?;\n            let actor_role: Option<buzz_db::channel::MemberRole> = members\n                .iter()\n                .find(|m| m.pubkey == actor_bytes)\n                .and_then(|m| m.role.parse().ok());\n            let target_pubkey =\n                extract_p_tag(event).ok_or_else(|| anyhow::anyhow!(\"missing p tag\"))?;\n\n            // PUT_USER: open channels allow any authenticated user; private channels\n            // require the actor to be an existing active member. Any active member may\n            // add an ordinary member, guest, or bot, but only owners/admins may grant\n            // an elevated role.\n            if channel.visibility == \"private\" {\n                if actor_role.is_none() {","sourceCodeStart":330,"sourceCodeEnd":366,"githubUrl":"https://github.com/block/buzz/blob/f956e6fe06a76e50cbd8fba1a162482e752e7f1a/crates/buzz-relay/src/handlers/side_effects.rs#L330-L366","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Use one of the five canonical values: owner, admin, member, guest, bot (lowercase)","Validate the role string against the allowed set before publishing the 9000 event","Map UI labels to wire values centrally (e.g. 'Moderator' → 'admin') instead of sending display strings"],"exampleFix":"// before\nTag::custom(TagKind::Custom(\"role\"), vec![\"moderator\"]) // → invalid role: moderator\n\n// after\nconst ROLES: [&str; 5] = [\"owner\", \"admin\", \"member\", \"guest\", \"bot\"];\nassert!(ROLES.contains(&role.as_str()), \"unknown role {role}\");\nTag::custom(TagKind::Custom(\"role\"), vec![role])","handlingStrategy":"type-guard","validationCode":"// Validate the role tag against the exact wire vocabulary before publish\nconst ROLES: [&str; 5] = [\"owner\", \"admin\", \"member\", \"guest\", \"bot\"];\nif let Some(role) = role_tag_value {\n    anyhow::ensure!(ROLES.contains(&role.as_str()), \"invalid role {role}\");\n}","typeGuard":"const MEMBER_ROLES = [\"owner\", \"admin\", \"member\", \"guest\", \"bot\"] as const;\nexport type MemberRole = typeof MEMBER_ROLES[number];\nconst isMemberRole = (v: string): v is MemberRole =>\n  (MEMBER_ROLES as readonly string[]).includes(v);","tryCatchPattern":"match validate_admin_event(&tenant, 9000, &event, &state).await {\n    Err(e) if e.to_string().starts_with(\"invalid role\") => {\n        // surface the allowed list to the user; never retry with a synonym\n        reject(\"role must be one of owner|admin|member|guest|bot\", &event.id)\n    }\n    other => other,\n}","preventionTips":["Define the five role strings as a shared constant/type used by both the UI dropdown and the event builder","Map display labels ('Moderator') to wire values ('admin') in one place","Reject unknown roles at parse time in your client so bad events never reach the relay"],"tags":["nostr","nip-29","roles","validation","enum"],"backgroundTag":"invalid-role-value","analyzedSha":"f956e6fe06a76e50cbd8fba1a162482e752e7f1a","analyzedAt":"2026-08-16T22:11:40.750Z","schemaVersion":2},"datasetVersion":"2026-08-16T23:17:17.608Z"}