{"record":{"id":"21d5b4f07d970304","repo":"block/buzz","slug":"actor-not-authorized","errorCode":null,"errorMessage":"actor not authorized","messagePattern":"actor not authorized","errorType":"validation","errorClass":"IngestError::Rejected","httpStatus":null,"severity":"error","filePath":"crates/buzz-relay/src/handlers/side_effects.rs","lineNumber":367,"sourceCode":"                },\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() {\n                    return Err(anyhow::anyhow!(\"actor not authorized\"));\n                }\n\n                if requested_role.is_some_and(|role| role.is_elevated())\n                    && !actor_role.is_some_and(|role| role.is_elevated())\n                {\n                    return Err(anyhow::anyhow!(\n                        \"only owners/admins may grant elevated roles\"\n                    ));\n                }\n            }\n\n            // Changing an ACTIVE existing member's role is privileged in both\n            // directions, on every visibility. `get_members` filters\n            // `removed_at IS NULL`, so a soft-removed row is deliberately not an\n            // \"existing member\" here: its stored role is history, not live\n            // authority, and reactivation is governed by the elevated-granter\n            // check above rather than by the role the row remembers.\n            //","sourceCodeStart":349,"sourceCodeEnd":385,"githubUrl":"https://github.com/block/buzz/blob/f956e6fe06a76e50cbd8fba1a162482e752e7f1a/crates/buzz-relay/src/handlers/side_effects.rs#L349-L385","documentation":"PUT_USER on a private channel requires the actor to already be an active member, and get_members() (which filters removed_at IS NULL) contains no row for the actor's pubkey. Open channels let any authenticated user add members; private channels require membership before you can invite. A soft-removed member also counts as absent — their stored role is history, not live authority.","triggerScenarios":"A non-member (or never-invited user) publishes 9000 against a private channel; an actor who was soft-removed from the private channel tries to re-invite themselves or others; the actor's pubkey has no membership row because they only ever had community-level (relay_members) status, not channel membership.","commonSituations":"Community admins assume their relay-wide role grants private-channel add rights — it does not at this validator seam; users removed from a private channel reusing an old invite flow; signing key rotated so the membership row's pubkey no longer matches.","solutions":["Have an existing active member (or the channel owner/admin) perform the add","If you were removed, get re-added by a member first — a removed row is deliberately not live authority","Verify your signing pubkey matches the pubkey in channel_members (get_members output) before retrying","For open channels, no membership is needed — confirm the channel's visibility is actually private before assuming a bug"],"exampleFix":"// before: non-member adds a user to a private channel\nclient.publish(put_user(private_channel, target)).await?; // actor not authorized\n\n// after: route the add through an active member/owner\nlet member_client = login(channel_owner_key).await?;\nmember_client.publish(put_user(private_channel, target)).await?;","handlingStrategy":"validation","validationCode":"// For private channels, verify active membership before publishing 9000\nif channel.visibility == \"private\" {\n    let members = client.get_members(community, channel_id).await?;\n    let active = members.iter().any(|m| m.pubkey == actor_bytes /* removed rows already filtered */);\n    anyhow::ensure!(active, \"join the private channel (or get re-added) before adding others\");\n}","typeGuard":"const isActiveMember = (members: {pubkey: string}[], actor: string): boolean =>\n  members.some(m => m.pubkey === actor); // get_members filters removed_at IS NULL","tryCatchPattern":"match validate_admin_event(&tenant, 9000, &event, &state).await {\n    Err(e) if e.to_string().contains(\"actor not authorized\") => {\n        // Escalate to an existing member; retrying the same signer is futile\n        request_member_invite(channel_id, target_pubkey).await\n    }\n    other => other,\n}","preventionTips":["Pre-check channel visibility and actor membership in the invite UI before showing an add button","Community-level roles do not bypass private-channel membership at this seam — grant channel membership explicitly","Soft-removed members lose add rights immediately; re-invite them before they can invite others"],"tags":["nostr","nip-29","group-admin","authorization","membership","private-channel"],"backgroundTag":"not-channel-member","analyzedSha":"f956e6fe06a76e50cbd8fba1a162482e752e7f1a","analyzedAt":"2026-08-16T22:11:40.750Z","schemaVersion":2},"datasetVersion":"2026-08-16T23:17:17.608Z"}