block/buzz · error · IngestError::Rejected

actor not authorized

Error message

actor not authorized

What it means

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.

Source

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

                },
                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() {
                    return Err(anyhow::anyhow!("actor not authorized"));
                }

                if requested_role.is_some_and(|role| role.is_elevated())
                    && !actor_role.is_some_and(|role| role.is_elevated())
                {
                    return Err(anyhow::anyhow!(
                        "only owners/admins may grant elevated roles"
                    ));
                }
            }

            // Changing an ACTIVE existing member's role is privileged in both
            // directions, on every visibility. `get_members` filters
            // `removed_at IS NULL`, so a soft-removed row is deliberately not an
            // "existing member" here: its stored role is history, not live
            // authority, and reactivation is governed by the elevated-granter
            // check above rather than by the role the row remembers.
            //

View on GitHub (pinned to f956e6fe06)

Solutions

  1. Have an existing active member (or the channel owner/admin) perform the add
  2. If you were removed, get re-added by a member first — a removed row is deliberately not live authority
  3. Verify your signing pubkey matches the pubkey in channel_members (get_members output) before retrying
  4. For open channels, no membership is needed — confirm the channel's visibility is actually private before assuming a bug

Example fix

// before: non-member adds a user to a private channel
client.publish(put_user(private_channel, target)).await?; // actor not authorized

// after: route the add through an active member/owner
let member_client = login(channel_owner_key).await?;
member_client.publish(put_user(private_channel, target)).await?;
Defensive patterns

Strategy: validation

Validate before calling

// For private channels, verify active membership before publishing 9000
if channel.visibility == "private" {
    let members = client.get_members(community, channel_id).await?;
    let active = members.iter().any(|m| m.pubkey == actor_bytes /* removed rows already filtered */);
    anyhow::ensure!(active, "join the private channel (or get re-added) before adding others");
}

Type guard

const isActiveMember = (members: {pubkey: string}[], actor: string): boolean =>
  members.some(m => m.pubkey === actor); // get_members filters removed_at IS NULL

Try / catch

match validate_admin_event(&tenant, 9000, &event, &state).await {
    Err(e) if e.to_string().contains("actor not authorized") => {
        // Escalate to an existing member; retrying the same signer is futile
        request_member_invite(channel_id, target_pubkey).await
    }
    other => other,
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


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