Skip to content

feat(nip-fi): bind assertions to the request Host's community - #8028

Merged
wpfleger96 merged 41 commits into
mainfrom
duncan/nip-fi-community-binding
Oct 1, 2026
Merged

wpfleger96 merged 41 commits into
mainfrom
duncan/nip-fi-community-binding

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

🤖 Stack: #8028 → #8034

Binds every enforce-mode NIP-FI assertion to the community served at the request's Host. Before this, an assertion was accepted if its aud matched any value in a deployment-wide set. That meant an issuer trusted by one community could mint an assertion another community accepted.

Rollout consequence: an enforce relay will not start without BUZZ_NIP_FI_COMMUNITIES. off and deny_protected do not read it, so repair mode still boots.

What changes

  • Host resolution runs first. In enforce mode, right after the off and deny_protected checks, the Host must map to a configured community. An unmapped or missing Host returns 503 authorization unavailable. This happens at all three call sites: the router guard, the upgrade (WS root h1/h2 and audio), and HTTP admission. Off mode behaves exactly as before.

  • The allowlist is checked before JWKS. VerifyAssertion::verify_assertion(token, &CommunityBinding) rejects an issuer the community does not authorize before any key-source lookup. Clients can't tell this rejection apart from an unknown iss. aud must exactly equal the community's canonical URI.

  • New config: BUZZ_NIP_FI_COMMUNITIES. It is a JSON array of {canonical_uri, authorized_issuers}. Startup fails if:

    • a canonical_uri is anything other than a canonical https://<host>[:port]: the URL parser must reserialize the configured authority byte-for-byte, so whitespace, control characters, backslashes, uppercase, a redundant :443, and non-canonical IPv6 are rejected rather than repaired. The authority must also already be Host-normalized, so a trailing dot or :80 is rejected and the Host map key always equals the aud authority exactly (never empty);
    • two communities share a Host;
    • an allowlisted issuer is missing from BUZZ_NIP_FI_ISSUERS;
    • an allowlist is empty;
    • a configured issuer belongs to no community.

    Errors name entries by index only.

  • IssuerPolicy.audiences is removed. Command JWTs use the new per-issuer command_audiences, so feat(nip-fi): admin disconnect/deny API with in-memory deny-until-TTL map (S4) #7977's admin-disconnect command behavior is unchanged. A config that still sets audiences, even to null, fails with a migration message instead of being silently ignored.

  • AssertionPolicyId now covers the community allowlist. It hashes the aud of each community that authorizes the issuer, so changing the allowlist changes the ID. This is code only; no spec file changes here.

Duncan and others added 30 commits September 29, 2026 15:15
… map (S4)

Adds the NIP-FI admin command endpoint, a bounded per-issuer deny-until-TTL
map, and cross-pod disconnect fan-out. The deny check runs after each root
and audio socket registers its proven identity, so a concurrent disconnect
either finds the socket in its close scan or the check finds the entry.
Hooks attach at the S3 admission and pairing points without changing S3
terminal-frame or close-code behavior.

Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The root-AUTH witness could pass with the handler's deny check removed; add a pre-registration case the close scan misses and parse OK frames. Restore the clippy allowance to the eight-argument audio handler.

Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ents

Constrain the no-map baseline to 200/404 so identical upstream failures cannot satisfy the equality check.

Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The full expiry sweep ran on every write while holding the shard mutex that
every admission's is_denied also takes. It now runs only when a write would
otherwise hit capacity; reads already compare against now, and replay checks
treat a lingering expired jti as absent. Cross-pod merges share the local merge
path, removing an unreachable! that could kill the consumer task. jti is capped
at 512 bytes so the 2x-capacity reservation budget bounds memory.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…criber starts

The broadcast receiver was created only after AppState construction and JWKS
warm, so disconnects relayed during boot hit a receiverless channel and were
dropped. Subscribing first lets the receiver buffer them until the consumer runs.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…jecting

A pod whose clock trailed the origin rejected at-ceiling commands the origin had
already accepted with 200, leaving the target's sessions open on that pod. The
consumer now clamps until to its own ceiling and still merges and closes;
malformed pubkey, unknown issuer, and unrepresentable timestamps stay rejected.

Also documents that enforce-mode issuers must carry maximum_command_age_seconds
and authorized_principals (startup already required them), and drops a 4s
wall-clock admission test whose expiry behavior is pinned with an injected clock.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
FI-TRACE-DENIAL-ORACLE requires every authorization_denied row to be
byte-identical, but S3 key mismatch and lease expiry closed with Close(None)
while S4 deny-set hits and admin disconnects closed 1008. Every root and audio
authorization_denied now goes through one first-writer-wins transition that
enqueues the denial frame only for the winner and closes 1008, so a concurrent
registry scan can no longer queue a second audio denial. OK(true) is also
skipped when a concurrent disconnect already cancelled the session.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…i-port

* origin/main:
  test(desktop): fix flaky forum empty-draft replacement e2e (#7981)
  chore(release): release Buzz Desktop version 0.5.26 (#7980)
  fix(desktop): correct Grok Build setup guide URL (#4846)
  fix(db): audit partition catalog before creation (#6515)
  feat(mobile): show contextual names in channel conversations (#7895)

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Pins the current contract of the three assertion-verification sites (HTTP
guard, WS upgrade, HTTP admission) and the two key-equality checks before
they are consolidated: verifier error classes, transport-before-verifier
precedence, NIP-98-before-assertion ordering, mode short-circuits, and
claimless-assertion pairing denials.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The HTTP guard, WS upgrade check and HTTP admission each carried their own
copy of extract-then-verify-then-map, and HTTP admission and WS pairing each
carried their own key-equality check. A relay-private nip_fi_core now owns
evaluation, denial rendering and the pairing predicate so later changes
(community binding, shadow mode) land in one place. Behavior is unchanged.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…cation

A routed request with a valid NIP-98 proof now counts both assertion
verifications (the HTTP guard, then handler admission) and shows that
admission's own failure decides the response. Also moves test-only imports
out of production scope in nip_fi_http and points the router ownership
comment at nip_fi_core.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… claim

The (iss, jti) reservation lived only in the receiving pod's deny map, so one captured command JWT was accepted once per pod, each acceptance re-publishing a cluster-wide disconnect. The verifier now takes an atomic Redis SET NX EX claim after authentication, mirroring the NIP-98 guard, and fails closed when Redis errors. The claim is released when the local insert hits capacity so a 503 deny set full stays retryable.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…i-port

* origin/main:
  fix(agents): stop built-in prompts from teaching sleep polling (#7992)
  feat(relay): add direct staff ban/timeout/delete with staff guard (#7883)
  fix(ci): gate security review on repo write access (#7986)
  feat(acp): wrap workers at the subprocess launch boundary (#7985)
  feat(buzz-relay): idempotent owner community deletion with quota reservation (#7969)
  feat(mobile): show contextual names in lists, Search and Pulse (#7896)
  Add Kimi Code's default install path to managed-agent binary discovery (#5997)

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Truncating the remaining validity let the shared claim expire up to a second before a verifier trailing by the full skew stopped accepting the JWT; with skew above the guard's 120s floor the floor did not hide it. Also gate the local-only verify_at behind test-utils and state the fail-closed guarantee precisely.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…blishes

A forged command carrying a real command's (iss, jti) must be rejected without touching the shared claim, so the legitimate command still succeeds on another pod. The Redis variants subscribe to the disconnect channel to prove rejected replays and guard errors publish nothing while the accepted command publishes once.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
A fixed sleep could miss a late prohibited publish or fail on a slow legitimate one, and a dead collector read as zero. Each count now waits for an accepted sentinel command's publication, giving every case a positive control, and fails loudly if the collector stops. Also split the verify() doc into the normal claim lifecycle and the separate ways a claim can outlive a failure.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Pins exact status, content type, challenge and body bytes for every
pre-success rejection and the header -> body -> pubkey -> verifier order
before the route is moved onto the shared parser and CommandError rendering.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…I disconnect

The disconnect route kept its own copy of the Bearer parser and hand-typed
denial bodies. It now uses nip_fi_core::extract_bearer_token/http_denial and
renders every command rejection through CommandError, so the strings live in
one place. Reachable header bytes are visible ASCII or tab (HeaderValue::to_str),
where the old Unicode and new ASCII whitespace checks agree.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…nip-fi-auth-consolidation

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Route transport rows reached the verifier, which returned the same 403,
so dropping a transport check went unnoticed. Send them with a malformed
body, and pin a comma-joined value ahead of the missing-verifier check.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A capacity failure after the Redis claim relied on a DEL to keep the
command retryable; a failed DEL left the jti burned. Reserving a pending
slot under the shard lock first means DenySetFull never touches Redis, and
an RAII Reservation frees the slot on replay, guard error, or cancellation.
CommandReplayGuard::release is removed as it has no callers.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Disconnect publishes are detached tasks, so a sentinel publish alone did
not prove earlier ones had landed. Spawning them through a TaskTracker on
AppState lets the test wait for completion first; production still never
waits on it.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
merge_entry only exempted keys already in entries, so with a full shard a
remote deny for a key this pod had reserved was dropped, and lost entirely
if the local attempt then lost the shared claim. The publish-count test now
drains every pod that could publish, not just the sentinel pod.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The nip_fi_core doc line landed under nip_fi_gate's, leaving the gate
undocumented, and two comments still pointed at nip_fi_http for logic that
moved to nip_fi_core. The NIP-98-order test now notes where it departs from
the spec's admission order and why routed clients don't observe it.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
http_denial still used expect() while the disconnect route's command_denial
fell back to 500, and plain_response fell back to an empty 200. All three
branches are unreachable with constant inputs; they now fall back to a bare
denial status so the disconnect route fails one way, closed. The module doc
also names the disconnect route as a second consumer of the shared helpers.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
The core, HTTP, upgrade, and router tests each carried their own copy of the same fixed-result, call-counting verifier stub, so a VerifyAssertion change had to be repeated four times. They now share one in nip_fi_core::tests.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…ed builder

A ConnectionState field change should touch one test builder, not three. The shared builder now accepts a NIP-FI assertion and returns every outbound receiver, so the session tests stop hand-building the struct.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
The capacity-rejection counter on the DenySetFull arm was untested: deleting it left the suite green. Pin that it counts DenySetFull exactly once and never counts other command-error arms.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
An issuer trusted by one community could mint an assertion another community
accepted, because acceptance keyed only on a deployment-wide audience set.
Enforce mode now resolves the Host to a configured community first (unmapped
is 503), checks the issuer against that community's allowlist before any JWKS
lookup, and matches aud exactly against its canonical URI.

BUZZ_NIP_FI_COMMUNITIES is required in enforce; an issuer in no community is
a startup error. IssuerPolicy.audiences is replaced by command_audiences for
S4 command JWTs, and the policy ID now covers the community allowlist.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Duncan and others added 7 commits October 1, 2026 15:36
…into duncan/nip-fi-community-binding

* origin/hayt/nip-fi-auth-consolidation:
  test(buzz-relay): pin the NIP-FI disconnect deny-set-full metric
  test(buzz-relay): build NIP-FI session test connections with the shared builder
  test(buzz-relay): share one scripted NIP-FI verifier across test modules
  refactor(buzz-relay): render NIP-FI denials without panicking
  docs(buzz-relay): fix NIP-FI module docs and stale cross-references

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

# Conflicts:
#	crates/buzz-relay/src/nip_fi_core.rs
#	crates/buzz-relay/src/nip_fi_http.rs
#	crates/buzz-relay/src/nip_fi_upgrade.rs
#	crates/buzz-relay/src/router.rs
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ries

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…into duncan/nip-fi-community-binding

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The URL parser trims whitespace, drops tabs and newlines, and treats
backslash as a path separator, so such URIs booted with an unmatchable
aud or Host key. Require the parser to reserialize the configured
authority byte-for-byte. Also detect a removed "audiences" field even
when null, and gate the latent h2 extended-CONNECT upgrade before the
tenant lookup like h1 and audio.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
https://. survived the URL round trip but normalize_host turned it into an
empty Host key, so blank Hosts matched a community instead of getting 503.
Trailing-dot and :80 aliases also kept a different aud than their Host key.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 marked this pull request as ready for review October 1, 2026 22:26
@wpfleger96
wpfleger96 requested a review from a team as a code owner October 1, 2026 22:26
Reword canonical_authority() doc to the normalize_host() invariant instead of claiming a unique aud spelling.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96 added a commit that referenced this pull request Oct 1, 2026
…rs (#7990)

🤖 Stack: #7990 → #8028

The three production NIP-FI assertion-verification sites (the HTTP
assertion guard in `router.rs`, `check_nip_fi_at_upgrade` in
`nip_fi_upgrade.rs`, and `admit_nip_fi_http` in `nip_fi_http.rs`) each
carried their own extract → verify → map-to-denial sequence, and HTTP
admission and `enforce_nip_fi_key_pairing` each carried their own
key-equality check. The admin disconnect route (`POST
/api/nip-fi/disconnect`) kept a second copy of the header parser and
hand-typed its denial bodies. Community binding and shadow mode both
need to change exactly those decisions, so they now live in one place.

**No behavior change.** Every denial class, status code, body, header
and log line is preserved, including `deny_protected` (503), the
startup-race 503, transport-before-verifier precedence,
NIP-98-before-assertion ordering, and guard + handler double
verification.

- New relay-private `nip_fi_core` owns `extract_bearer_token` and
`http_denial` (moved from `nip_fi_http`), `evaluate_attached_assertion`
(the single `verify_assertion` call for all three adapters, returning a
typed `AssertionRejection`), and `asserted_key_matches` (the pairing
predicate; a claimless assertion never matches). `http_denial` and the
disconnect route's plain-text renderer fall back to the bare denial
status instead of panicking or answering an empty 200; both fallbacks
are unreachable because every status and header value is a constant.
- Adapters keep their own mode short-circuits, exemptions, ordering,
metrics and `code()` debug logs; the guard still logs nothing.
- The disconnect route parses its header with `extract_bearer_token`,
renders header failures with `http_denial`, and renders every other
rejection (malformed body, bad pubkey, no command verifier, every
`CommandError`) through `CommandError::http_status()`/`response_body()`.
Its check order (header → body → pubkey → verifier present → verify),
`DenySetFull` warning and metric, success body and cross-pod publish are
unchanged. The old Unicode whitespace check and the shared ASCII one
agree on every reachable input, because `HeaderValue::to_str` only
admits visible ASCII and tab.
- The 12 HTTP admission callers, 3 WS upgrade placements, deny-map
checks, expected-URL constructors and the command/admin proof path are
untouched.
- Characterization tests land before each refactor, green against the
unmodified code, and pass unchanged after it. A routed test counts both
verifications on one request (guard, then handler admission) and shows
admission's own failure decides the response. Disconnect-route tests pin
exact status, content type, challenge and body for every rejection, the
observable check order, and that only `DenySetFull` increments
`buzz_nip_fi_disconnect_capacity_rejections_total`. The test modules
share one counting `ScriptedVerifier` from `nip_fi_core::tests`, and the
session tests build connections through `connection::tests::test_conn`.
`nip_fi_core::tests::` is added to the unit selectors in `Justfile` and
`scripts/run-tests.sh`.

🤖 Implemented by Hayt.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Base automatically changed from hayt/nip-fi-auth-consolidation to main October 1, 2026 22:52
Duncan and others added 2 commits October 1, 2026 19:08
* commit '9b083957f^':
  Include thread roots in agent activity events (#8029)
  fix(relay): gate owner-only kinds in shared fan-out access filter (#8006)
  feat(acp): add BUZZ_GIT_IDENTITY switch for agent commit identity (#8024)
  fix(relay): deny channel writes when the channel lookup fails (#8007)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 enabled auto-merge (squash) October 1, 2026 23:30
@wpfleger96
wpfleger96 merged commit 2ebfde9 into main Oct 1, 2026
79 of 80 checks passed
@wpfleger96
wpfleger96 deleted the duncan/nip-fi-community-binding branch October 1, 2026 23:56
wpfleger96 added a commit that referenced this pull request Oct 2, 2026
#8005 added a `NipFiMode::Off => legacy, _ => 503` match in Git auth that
put shadow on the enforce side; it now uses `restricts()`. The mode guard
test also rejects wildcard arms after a mode arm, and new tests pin shadow's
NIP-11 bytes, metric community label, and strict NIP-98 side check. Also
repairs the two #8005/#8028 lines that broke the build on main.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96 added a commit that referenced this pull request Oct 2, 2026
🤖 `main` at `2ebfde914` doesn't compile `buzz-relay`. #8005 and #8028
each passed CI alone and git merged them cleanly, but they conflict in
meaning:

- #8005 added a call to `crate::nip_fi_http::http_denial` in
`api/git/transport.rs`. #8028 turned that path into a private re-import
of `nip_fi_core::http_denial`, so the call fails with E0603. It now
calls `crate::nip_fi_core::http_denial` directly. Behavior is unchanged.
- #8005's test `failed_restriction_lookup_is_503_not_403` implements
`VerifyAssertion::verify_assertion` with one argument. #8028 added a
`community: &CommunityBinding` parameter to the trait, so the test fails
with E0050. The test stub now accepts and ignores `_community`.

Each PR's CI ran against a base without the other's change, so neither
CI run could catch this.

🤖 The local `rust-tests` pre-push hook was skipped for this push. It
kept failing on an unrelated `buzz-acp` timing test,
`keepalive_resets_idle_past_deadline`, which passes when run alone. A
serial `just test-unit` run was fully green. CI's Unit Tests job is the
gate for this PR.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wpfleger96 pushed a commit that referenced this pull request Oct 2, 2026
* origin/main:
  fix(relay): restore buzz-relay build after #8005 and #8028 (#8036)
  fix(mobile): preserve exact mention recipients in saved drafts (#7387)

Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>

# Conflicts:
#	crates/buzz-relay/src/api/git/transport.rs
tlongwell-block pushed a commit that referenced this pull request Oct 2, 2026
Main added nine commits since the previous merge. The ones that matter
here rework NIP-FI admission under /buzz/v1: shared assertion evaluation
across adapters (#7990), binding assertions to the request Host's
community (#8028), and the community ban gaps (#8005, #8006, #8007,
#8036). Main changes 55 files and adds no migration.

Git merged the three files both sides change without conflict:
buzz-relay's api/bridge.rs, api/mod.rs and router.rs. No hand edit was
needed. This branch's diff against main is line for line the same before
and after the merge: 31 files, +5,726 / -81.

/buzz/v1 still authenticates through admit_nip_fi_http_on_state. Its
signature is unchanged and it now resolves the Host's community itself,
so the accessory routes take the new binding from the same shared
admission as the bridge routes. The three /buzz/v1 NIP-FI wire-contract
tests pass on the merged tree.

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
wpfleger96 added a commit that referenced this pull request Oct 2, 2026
🤖 Follows #8036 (merged).

The Postgres test `failed_restriction_lookup_is_503_not_403` in
`crates/buzz-relay/src/api/git/transport.rs` sets `nip_fi.mode =
Enforce` but configured no NIP-FI communities. Since #8028, enforce mode
refuses that request at Host-to-community lookup with the same `503
authorization unavailable` the test expects, so the enforce half never
reached the ban lookup it is meant to guard. Deleting the `unavailable`
mapping on the ban-lookup error in `GitAuth::from_request_parts` left
the test passing.

The fixture now binds a community with `test_support::any_host`, the
same way #8028 fixed the other enforce fixtures in this file. With the
mapping deleted, the enforce half now fails.

Found by @loganj's review comment on #8036.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wpfleger96 added a commit that referenced this pull request Oct 2, 2026
)

Stack: this PR → #8062

🤖 Follows #8028 (merged).

Adds `BUZZ_NIP_FI_MODE=shadow`. The relay loads and validates the full
enforce configuration (issuers, communities, lifetimes, command
issuers), evaluates NIP-FI evidence wherever enforce would decide, and
records what enforce would have done, while every request, upgrade and
session behaves exactly as in `off`. The one approved exception is admin
disconnect: shadow verifies the command and returns `200`, where Off has
no verifier and returns `503`. It lets an operator measure the
would-deny rate, including Hosts missing from `BUZZ_NIP_FI_COMMUNITIES`,
before switching to `enforce`.

- **Mode predicates.** `NipFiMode` gains `is_off`, `restricts`,
`evaluates`, `enforces`, `denies_unconditionally` and `observes_only`,
built on two exhaustive `match` bases (`restricts`, `evaluates`);
`is_off` is a `matches!` and the rest derive from the bases, so a new
mode cannot silently fall into one side. Every call site uses them,
including the Git membership-lookup failure from #8005, which uses
`restricts()` so shadow keeps Off's body. A test scans each crate's
`src/` and fails on any `NipFiMode::<variant>` in production code
outside the predicate definitions and the env parser, which catches
`==`, `matches!` on any number of lines, `if let` and or-patterns.
- **Startup.** Shadow goes through enforce's path, so every enforce
startup refusal applies, including the required connection lifetime.
Missing-setting errors name the configured mode through one formatter
(`mode_requires`); the enforce text is byte-identical. Shadow builds the
assertion verifier and claims command replays under its own Redis
prefix, both through the production `AppState::new`.
- **HTTP and Blossom.** `admit_nip_fi_http` keeps Off's result in shadow
and additionally replays enforce in enforce's order: first the router
guard's steps (community, then assertion), then the handler's steps
(NIP-98, pairing, deny set) on the same NIP-98 proof, through
`evaluate_enforce_steps`, which enforce shares. A Host mapped in config
but missing from the communities table, with a valid assertion, records
an admit, because enforce's guard passes and its 404 is not a NIP-FI
denial. A dev-mode `X-Pubkey` identity is marked unsigned and is never
an enforce proof, so shadow records it as a `nip98` would-deny. On
bridge and Blossom routes, the strict proof check (payload tag, Blossom
`Strict`) runs as a separate pure check that consumes no replay entry
and records on its own series, so it never counts as an admission
verdict. Shadow records exactly one verdict for every identity,
community or credential refusal enforce would make, with Off's status,
body and challenge unchanged: bridge, workflows, Blossom, Git transport
and settings, GIF search and share, and invite minting record the
`community` would-deny when the Host is unmapped. Git's missing or
malformed credentials still reject before any database work, and shadow
records the enforce verdict for that failed proof. The router guard
stays transparent in shadow.
- **WebSocket, root and audio.** The upgrade check evaluates the
attached assertion once. An upgrade enforce would refuse records that
would-deny; otherwise the assertion goes into a shadow-only session
object and the upgrade returns as in Off. The assertion never reaches
the connection, the identity registries, the admission gate, terminal
frames or cancellation. Each session records at most one admission
verdict. **Pairing** is recorded on both ingresses right after a valid
NIP-42 proof, before ordinary relay policy, where enforce pairs: a key
mismatch or claimless assertion is a `pairing` denial. **The deny-set
check** runs where enforce runs it, after registration: a hit is a
`deny_set` denial, and a clean read keeps the session pending and
registered. **The admit** is recorded only when the connection is
actually admitted: on root at the AUTH commit, after ordinary policy; on
audio when the participant join transaction commits, after relay
membership, channel membership, the final ban and membership decision,
mesh routing and the join's own checks, and before publication. A NIP-42
failure, which comes before pairing, and any refusal between pairing and
the admit (ordinary policy, mesh, or a join refusal inside the join
transaction) retires the observer when the refusal is decided, before
any refusal frame, rollback or cleanup is awaited, so it leaves no
record even if the deadline passes during that cleanup. A record-only
deadline task, armed at upgrade with enforce's
`compute_session_deadline`, records `expired` when enforce would have
closed an admitted session. If the deadline passes while the session is
still pending, that is its single admission verdict, a denial with
`stage=deadline`, and a later AUTH records nothing. A deny for the key
(admin or cross-pod) records `revoked` on an admitted session; if it
arrives after the deny-set check but before the admit, including a deny
refused for capacity, the session keeps a pending-revocation mark and
records the admit followed by exactly one `revoked` end, matching
enforce's phase-independent close, and the deadline cannot take that
end. The phase, the mark and retirement change together under one
session lock. Each session records at most one end. The session holds
the socket's cancellation token for recording only: once the socket is
cancelled, no transition records anything, even while connection
references remain (a cancel fence; the token never feeds a close
action). Upgrade would-denies carry the ingress route, `ws` or `audio`.
- **Admin disconnect.** Shadow verifies the command and records the deny
entry, so later admissions record would-denies, but closes no session;
the response stays `{"disconnected": true}`. Shadow publishes cross-pod
on its own channel, `buzz:nip-fi:shadow-disconnect`, which only shadow
pods subscribe to, so no enforce pod (including older builds that know
only `buzz:nip-fi:disconnect`) acts on a shadow command. Shadow pods
also keep listening on `buzz:nip-fi:disconnect`, so their deny record
includes enforce's real disconnects. Shadow claims command replays under
`buzz:nip-fi:shadow-command:{hash}`: replays are still rejected within
shadow, and a shadow accept never uses up the enforce claim. Enforce and
Off keep the same channel, keys and close behavior.
- **NIP-11.** Shadow serves the same document as enforce, including
`limitation.federated_identity`, because clients only attach evidence to
a relay that advertises it.

## Recording

| Series | Labels | One increment per |
|---|---|---|
| `buzz_nip_fi_shadow_total` | `route` (`http`, `ws`, `audio`), `stage`,
`outcome`, `community` | decision enforce would make: an HTTP admission,
a refused upgrade, a session's NIP-FI AUTH decision, or its deadline
passing before AUTH (`stage=deadline`) |
| `buzz_nip_fi_shadow_strict_proof_total` | `route` (`bridge`,
`blossom`), `outcome` (`pass`, `rejected`), `community` | strict NIP-98
side check |
| `buzz_nip_fi_shadow_session_end_total` | `route` (`ws`, `audio`),
`reason` (`expired`, `revoked`), `community` | admitted session enforce
would have ended (admitted sessions only) |
| `buzz_nip_fi_shadow_disconnect_total` | `route` (`admin`,
`cross_pod`), `outcome` | disconnect-path event, in place of the real
disconnect, lag, capacity and poison counters |

`community` is the configured canonical URI or `unmapped`, never the raw
`Host`; an unmapped or empty Host records `stage=community,
outcome=unavailable`. A shadow pod never moves an enforce disconnect or
failure counter. Strict-proof rates use their own `pass + rejected`
total, and session-end rates divide by the `admit` count for the same
route. There is no per-verdict `info` log; a would-deny writes a `debug`
line with no token, claim or issuer. The pre-existing enforce `debug`
line for the proven NIP-98 key is shared and also fires in shadow. Admin
commands rejected for a bad header, body or proof, a replay, or a
dependency error record no shadow verdict; only capacity rejection is
counted.

## Not observed

- Handler-side path-validation exits: an invalid Git owner or repo name
on default-branch, a media path that is not a valid hash, and a Git pack
URL with a query string record nothing from the handler. The router
guard still records what enforce would refuse at the guard (for example
a missing assertion), because enforce refuses those requests before it
validates the path.
- A refusal that is not a NIP-FI decision, such as a tenant-binding 404
behind a passing guard, or an ordinary WebSocket refusal after pairing
(a ban, membership or join refusal before admission).

## Follow-ups

- A bounded `route` label per HTTP surface (bridge, Blossom, Git, …) on
`buzz_nip_fi_shadow_total`; today every HTTP verdict carries
`route=http`.
- One shared helper for the ten copied `observe_unbound` recording
hooks, stacked on this PR.

The shadow recorder and session suites and the root pairing witness run
in `just test-unit` and the `scripts/run-tests.sh` fallback; the
`AppState::new` shadow witness, which exercises the command verifier
against real Redis, runs in the `external_infra_redis` lane. NIP-FI env
tests recover a poisoned env lock so one panic cannot cascade.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>

This branch was successfully deployed

No deployments
codex-review — 299a1602 Deployed Oct 1, 2026 by wpfleger96 via Run Codex Security Review #6468
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.

2 participants