Skip to content

fix(relay): materialize NIP-OA owner for agents that are direct relay members - #6078

Open
rclod wants to merge 1 commit into
block:mainfrom
rclod:fix/nip-oa-owner-for-direct-members
Open

fix(relay): materialize NIP-OA owner for agents that are direct relay members#6078
rclod wants to merge 1 commit into
block:mainfrom
rclod:fix/nip-oa-owner-for-direct-members

Conversation

@rclod

@rclod rclod commented Aug 16, 2026

Copy link
Copy Markdown

Fixes #6072.

Problem

On a closed relay (require_relay_membership = true), an agent enrolled
directly in relay_members never gets users.agent_owner_pubkey populated,
however valid its NIP-OA attestation.

Two correct-in-isolation pieces combine badly. handlers/auth.rs runs the
backfill only on open relays, on the stated grounds that closed relays are
covered by enforce_relay_membership. But that helper reports an owner only
when it used the delegation to admit the agent:

Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None),
Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)),

An agent that is also a direct member takes ::Member, gets Ok(None), and
its attestation is discarded. On a closed relay it therefore hits neither path.

Why it matters

agent_owner_pubkey drives at least three behaviours, so the NULL is not
cosmetic:

derived from the column effect when NULL
is_agent in connection.rs limited at human_messages_per_min (60) instead of agent_standard_messages_per_min (120)
owner-managed agent lists agent is absent from them while still being DM-able
channel_add_policy = "owner_only" add-member refused for every actor, including the real owner, with policy:owner_only — agent has no owner set

The third is the one that looks like a bug in the client: the agent cannot be
added to any channel by anyone, and no error explains why.

Change

Run the extraction whenever an auth tag is present, independent of how
membership was satisfied. The security boundary is unchanged — it remains the
tag's own verification in extract_nip_oa_owner, whose doc comment already
makes this argument:

The NIP-OA signature is cryptographically self-proving, so no feature flag is
needed — if the tag verifies, the owner relationship is authentic.

Membership and the owner relationship are separate questions; the bug was
treating the second as a by-product of the first.

Also corrects two doc comments that described the old open-relay-only behaviour.

Tests

Two regression tests in handlers/auth.rs:

  • a valid attestation yields its owner regardless of membership path
  • an attestation naming a different agent yields None — extraction is
    unconditional now, so its own verification is the only thing between a forged
    tag and an owner mapping

Verification

Found on a self-hosted closed relay: "NIP-42 auth successful" logged, no
materialization attempted, no warning, and agent_owner_pubkey NULL for an
agent sending a valid tag.

Full CI green on a fork run (21 passed, Mobile/Web skipped by path filters),
including Rust Lint and Unit Tests. Noting for transparency: I have no Rust
toolchain on the machine I authored this on, so just ci was run via that CI
rather than locally.

@themiguelamador themiguelamador left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one remaining functional gap and one test gap:

  • The fix was limited to WebSocket AUTH. POST /events had the same closed-relay direct-member branch and still discarded a valid x-auth-tag, so an agent using HTTP ingestion could remain without agent_owner_pubkey.
  • Both new regression tests called the pre-existing extract_nip_oa_owner crypto helper directly. They pass on the unpatched base and therefore did not exercise the membership-path behavior that regressed.

I fixed this in commit 03ba80e3e on Complear:review/pr-6078-fix. The shared enforce_relay_membership result now reports a verified owner after any successful admission (open relay, direct member, or owner delegation). WebSocket AUTH and POST /events consume that shared result, and the regression tests now exercise the direct-member decision logic itself.

Verification:

  • cargo test -p buzz-relay api::relay_members::tests --quiet — 5 passed
  • cargo test -p buzz-relay handlers::auth::tests --quiet — 3 passed
  • cargo clippy -p buzz-relay --all-targets -- -D warnings
  • cargo fmt --all -- --check
  • git diff --check

The full relay unit run reached 859 passes, 40 ignored, and 10 unrelated environment/global-state failures: the local Postgres instance lacks the buzz role, the mesh sidecar test timed out, and a tracing-global-state assertion fired. The changed-path tests and full strict compile/lint gate are green.

@rclod
rclod force-pushed the fix/nip-oa-owner-for-direct-members branch from 1ecb6ec to a24e243 Compare August 16, 2026 20:35
@rclod

rclod commented Aug 16, 2026

Copy link
Copy Markdown
Author

Both points were right, and the second more so than stated — thank you.

POST /events gap. Confirmed: bridge.rs carried the identical
open-relay-only .or_else(...) workaround, so HTTP ingestion had the same hole.
Rather than patch it there too, I took your approach and moved the fix into the
shared helper: the attestation is verified once before the admission branches,
MembershipDecision::OpenRelay and ::Member carry it, and
enforce_relay_membership reports it from every admitted variant. Both
transports now just consume that result, and both workarounds are deleted
rather than duplicated. That also covers the other consumers — media, git
transport, audio — which would have had the same gap the moment they needed an
owner.

Test gap. Worse than you described: my attestation_yields_owner_... test
was a near-duplicate of the existing valid_nip_oa_returns_owner, three lines
away in the same module. It exercised the crypto helper, which never changed. It
would have passed on the base, on the fix, and on any future regression of the
thing it was supposedly guarding.

Replaced with tests over the behaviour that actually regressed. I split the
decision→owner mapping into owner_from_decision so it is reachable without a
database, and the key case is Member(Some(owner)) -> Some(owner) — which is
exactly what previously returned None.

I could not run cargo locally (no Rust toolchain on the machine I authored
this on), so verification is a full CI run on my fork rather than just ci
locally. Flagging that explicitly since it is why the vacuous tests survived my
own review — they compiled and passed, and passing told me nothing.

Credited you as co-author on the commit. Happy to squash or reword if you'd
prefer it attributed differently.

@themiguelamador themiguelamador left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed a24e243. The substantive fix now covers both WebSocket AUTH and POST /events through the shared admission result, including direct-member and open-relay attestations. One merge gate still fails: cargo fmt --check --all reformats the new MembershipDecision match arm in crates/buzz-relay/src/api/mod.rs. I applied that formatter-only correction and pushed signed commit cc5275ef1 to Complear:review/pr-6078-latest-fix. Verification on the fix: formatting passes; all 6 relay_members tests pass; strict cargo clippy -p buzz-relay --all-targets -- -D warnings passes. The broader relay unit run reached 860 passing, with 10 unrelated environment-dependent failures (missing local buzz Postgres role, mesh demo timeout, and the global telemetry subscriber assertion). Please cherry-pick cc5275ef1; after that this is approvable.

…ission

The agent→owner backfill ran only when `require_relay_membership` was false.
On a closed relay it never ran, and `enforce_relay_membership` did not fill the
gap: it reported an owner only when it had USED the delegation to admit the
agent (`MembershipDecision::ViaOwner`). An agent that is also a direct relay
member took the `::Member` branch, which returned `Ok(None)` and discarded a
valid, cryptographically self-proving attestation.

So on a closed relay, any agent enrolled directly in `relay_members` had
`users.agent_owner_pubkey` left NULL however valid its attestation. That column
is not cosmetic — `is_agent` in the admission path derives from it (so such
agents are limited at `human_messages_per_min` rather than
`agent_standard_messages_per_min`), owner-managed agent lists have no
relationship to display, and `channel_add_policy = "owner_only"` has no owner to
compare against, refusing `add-member` for every actor including the real owner.

Fixed in the shared helper rather than per call site. The attestation is now
verified once, before the admission branches, and `MembershipDecision::OpenRelay`
and `::Member` carry it; `enforce_relay_membership` reports it from every
admitted variant. Both WebSocket AUTH and `POST /events` consume that shared
result, so the open-relay-only workaround each carried is deleted rather than
duplicated — the HTTP path had the same gap and would otherwise have kept it.

Membership answers "may this caller connect"; the attestation answers "who owns
this agent". Deriving the second from the first was the bug. The security
boundary is unchanged: the tag's own verification in `extract_nip_oa_owner`.

The decision→owner mapping is split into `owner_from_decision` so the behaviour
that regressed is testable without a database, with three tests over it —
notably that a direct member reports its owner, which is what previously
returned None.

Co-authored-by: themiguelamador <themiguelamador@users.noreply.github.com>
Signed-off-by: rclod <3385524+rclod@users.noreply.github.com>
@rclod
rclod force-pushed the fix/nip-oa-owner-for-direct-members branch from a24e243 to f0fbbfc Compare August 16, 2026 21:25
@rclod

rclod commented Aug 16, 2026

Copy link
Copy Markdown
Author

Reworked commit is verified green — full CI on a fork run: 21 passed, 2 skipped
(Mobile/Web, path-filtered), 0 failed
, including Rust Lint, Unit Tests, Relay
E2E, Backend Integration (relay e2e) and Desktop E2E Integration.

Two notes for whoever picks this up:

  • The upstream checks here are still action_required (first-time fork
    contributor gating), so the fork run is the only verification available until
    a maintainer releases them.
  • One iteration in between was a cargo fmt wrap of the new match arm, now
    applied and amended into the same commit. Worth mentioning only because it is
    why the branch was force-pushed once.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NIP-OA owner never materialized for agents that are direct relay members (closed relay)

2 participants