You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
relay: on closed relays, a direct-member agent loses its NIP-OA identity (rate class, owner context, backfill) #4937
Bug: on closed relays, a direct-member agent loses its NIP-OA identity (rate class, owner context, backfill)
Component:buzz-relay — crates/buzz-relay/src/api/mod.rs (check_relay_membership), consumed by handlers/auth.rs and the HTTP bridge Severity: agents on closed relays are rate-limited as humans; owner-authorized control planes (drain, observer control) silently refuse
Symptom cluster (one cause, three faces)
On a relay with require_relay_membership = true, an agent that is a relay member in its own right and presents a valid NIP-OA auth tag at NIP-42 AUTH:
is rate-limited under human_messages_per_min (default 60/min) instead of agent_standard_messages_per_min — one working turn's observer telemetry (~90 msg/min) exceeds it, producing a permanent rate-limited: quota exceeded storm on the WS path;
gets no agent_owner_pubkey in its connection auth context;
never receives the users.agent_owner_pubkey backfill, so owner-signed NIP-AO control frames (drain, observer control) are refused with restricted: observer frame is not authorized for this agent owner until the mapping is inserted by hand.
Root cause
check_relay_membership short-circuits to MembershipDecision::Member for any direct member and consults the NIP-OA tag only as a membership fallback for non-members (ViaOwner). The open-relay path has an explicit opportunistic-extraction branch for exactly these consumers (extract_nip_oa_owner, gated on !require_relay_membership) — the closed-relay member arm has no equivalent. So agent-ness downstream (ctx.agent_owner_pubkey.is_some() in connection.rs) is derivable only for agents that are NOT members, which inverts the natural provisioning path of enrolling agents as members.
Fix that worked for us
In the member arm, when allow_nip_oa_auth is on and a tag is presented, extract the (cryptographically self-proving) owner and return ViaOwner(owner) instead of Member. Membership stops being a reason to ignore the tag. All existing consumers either discard the owner or feed it to the intended backfill, so no behavior is lost.
Reproduction
Closed relay; add an agent pubkey as a direct relay member.
Give the agent a valid NIP-OA auth tag (owner also a member); connect via WS and AUTH with the tag.
Publish >60 events/min (any working agent with observer telemetry does this naturally) → quota storm despite BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN being far higher.
Send an owner-signed kind-24200 control frame → refused until users.agent_owner_pubkey is set manually.
PR up: #5581 — it implements the fix you describe ("in the member arm ... extract the (cryptographically self-proving) owner"), placed in a shared helper both the HTTP submit path and the NIP-42 AUTH path call, so the two can't drift apart again.
Your framing of the symptom cluster is what shaped the change: because connection.rs:632 derives is_agent from ctx.agent_owner_pubkey and the observer fast path reads the same session field, fixing only the HTTP bridge (#4260) leaves the rate class and the kind-24200 refusals exactly as they are. Independent confirmation of both on our own closed relay, with details in #4223.
One implementation note against your suggested shape: rather than returning ViaOwner from the member arm, I left MembershipDecision alone and resolve the owner after the gate. Same outcome for the consumers, but ViaOwner keeps meaning "membership was granted through the owner", which is still a distinct fact from "this caller has a proven owner" — worth not conflating if membership-source ever gets audited or logged.
Activity
rmichelena commentedon Aug 11, 2026
PR up: #5581 — it implements the fix you describe ("in the member arm ... extract the (cryptographically self-proving) owner"), placed in a shared helper both the HTTP submit path and the NIP-42 AUTH path call, so the two can't drift apart again.
Your framing of the symptom cluster is what shaped the change: because
connection.rs:632derivesis_agentfromctx.agent_owner_pubkeyand the observer fast path reads the same session field, fixing only the HTTP bridge (#4260) leaves the rate class and the kind-24200 refusals exactly as they are. Independent confirmation of both on our own closed relay, with details in #4223.One implementation note against your suggested shape: rather than returning
ViaOwnerfrom the member arm, I leftMembershipDecisionalone and resolve the owner after the gate. Same outcome for the consumers, butViaOwnerkeeps meaning "membership was granted through the owner", which is still a distinct fact from "this caller has a proven owner" — worth not conflating if membership-source ever gets audited or logged.fix(relay): trust and time-bound the NIP-OA owner before recording it
test(relay): cover NIP-OA owner materialization through both real paths
fix(relay): record the NIP-OA owner for direct members on closed relays
fix(relay): trust and time-bound the NIP-OA owner before recording it
test(relay): cover NIP-OA owner materialization through both real paths
fix(relay): record the NIP-OA owner for direct members on closed relays