Skip to content

feat(relay): add NIP-FI shadow mode for HTTP, WebSocket and admin - #8034

Merged
wpfleger96 merged 71 commits into
mainfrom
hayt/nip-fi-shadow-mode
Oct 2, 2026
Merged

wpfleger96 merged 71 commits into
mainfrom
hayt/nip-fi-shadow-mode

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

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 fix(relay): close community ban gaps across audio, agents and HTTP #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.

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>
Hayt and others added 16 commits October 2, 2026 13:48
A shadow pod moved the enforce disconnect, capacity, poison, propagation
and lag counters, so production shadow runs would inflate the enforce
series. Those events now count on buzz_nip_fi_shadow_disconnect_total
in shadow, and the failsafe logs no longer claim sessions were closed.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Every shadow record wrote a synchronous info line, one or two per HTTP
request, which is a log-volume risk for a production shadow run. The
counter is the record; only a would-deny now logs, at debug.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A typed Stage enum replaces free-form stage strings, the mode predicates
match exhaustively so a new mode cannot fall through, and strict-proof
side checks get their own series instead of sharing buzz_nip_fi_shadow_total.
The predicate-only scan now walks crate source roots, fails on I/O errors
and flags any raw NipFiMode variant outside the allowlisted definitions.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Builds a shadow pod through AppState::new and checks it gets the assertion
verifier and the shadow-prefixed command replay guard, pins the install
pre-flight refusal for shadow, and pins the exact lifetime startup error.

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

Each row runs on a seeded, mapped Host in Off and Shadow and must match
byte for byte with one shadow verdict and the exact strict-proof side
checks. A test-only hook before the Git membership lookup lets the Git row
fail that lookup after the tenant binds, pinning the legacy 503 in shadow.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Covers bridge query, count and moderation, media HEAD and GET, Git
settings, GIFs, invites and workflows on a seeded Host.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Several doc blocks still described only Off/Enforce/DenyProtected, so
readers would miss that shadow evaluates, records, and admits as Off.

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 deny landing between the clean deny-map read and the admit was lost, so
the session now carries a revocation mark that the admit resolves. Expiry
before AUTH becomes that session's admission verdict on a typed `deadline`
stage, keeping session ends to admitted sessions. A root AUTH that ends for
a non-NIP-FI reason, or the socket's cancellation, retires observation.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The shared upgrade check recorded every rejection as `ws`, so missing or
rejected audio assertions inflated root's would-deny rate and vanished from
audio's. Callers now pass their ingress.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The witness drives the production replay guard, which needs Redis, so it
runs with the explicit test Redis URL in `external_infra_redis`. It now also
installs and exercises the shadow command verifier on the shared key source.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The shadow recorder and session suites and the root shadow AUTH witnesses
were not in either unit runner, so CI never ran them.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Shadow reaches the key-pairing debug line enforce shares, so the module no
longer promises that no key is logged, and the disconnect series now says
which admin refusals record nothing. Test-only: drop a leftover debug print,
and recover the NIP-FI env lock from poisoning so one panic fails one test.

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

The phase and pending-revocation mark were separate atomics, so expiry
could end an admitted session as expired between the admit and the mark
check. One session lock now guards both, and every recording transition
checks the socket's cancel token under it, so nothing records after
cancellation. Timer tests use paused Tokio time; AUTH witnesses fire the
deadline explicitly once the refusal finished.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Audio recorded its shadow admit right after the deny-set check, before
relay membership, channel membership, mesh routing and the join's own
refusals, so a refused connection counted as admitted. The deny-set
check still registers the session early, keeping a pending revocation
across the longer window; the admit now lands when the join transaction
commits, and each refusal retires the observer where it is decided.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
An audio NIP-42 failure and the join refusals inside
commit_participant_join awaited a frame write or rollback while the shadow
observer was still pending, so a deadline in that window recorded a false
deadline/rejected. Retire at the decision; tests hold each window open.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 changed the title feat(relay): add NIP-FI shadow mode for HTTP, upgrade and admin feat(relay): add NIP-FI shadow mode for HTTP, WebSocket and admin Oct 2, 2026
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 2, 2026
shadow_state used the default DATABASE_URL with sqlx's 30s acquire timeout, so the audio join case outlived its 5s wait on CI runners without Postgres and the root auth shadow tests stalled 30s. An unreachable URL with a 100ms timeout makes them fail fast the same way everywhere.

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 2, 2026 22:05
@wpfleger96
wpfleger96 disabled auto-merge October 2, 2026 22:32
@wpfleger96
wpfleger96 merged commit df3f28a into main Oct 2, 2026
80 of 81 checks passed
@wpfleger96
wpfleger96 deleted the hayt/nip-fi-shadow-mode branch October 2, 2026 22:32
tlongwell-block pushed a commit that referenced this pull request Oct 3, 2026
Main added twelve commits since the previous merge. Two matter here:
NIP-FI shadow mode for HTTP, WebSocket and admin (#8034), and the
shadow-aware bind_tenant helper every protected HTTP handler now binds
its tenant through (#8062). Main changes 90 files and adds no migration.

Git merged the seven files both sides change without conflict:
Cargo.lock, buzz-db's store/deletion.rs, and buzz-relay's Cargo.toml,
api/bridge.rs, config.rs, nip11.rs and router.rs. This commit is that
merge with no hand edit. This branch's diff against main is line for
line the same before and after it: 31 files, +5,811 / -81.

The merged tree is not yet correct on its own. /buzz/v1's admission
adapter still compares the NIP-FI mode to a named variant and binds its
tenant directly, which main's shadow contract forbids and its
nip_fi_mode_is_inspected_only_through_predicates test rejects. The next
commit adapts the adapter and pins it with a test.

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
ArnaudLafosse92100 added a commit to ArnaudLafosse92100/buzz that referenced this pull request Oct 4, 2026
Brings 16 upstream block/buzz commits (a14107a) into the fork
integration branch: ACP mention/edit steering (block#6131, block#6132), quiet-host
recovery wakes (block#7459), relay NIP-FI shadow mode (block#8034, block#8062), writer
lock foundations (block#7706), Goose MCP handshake (block#8037), Claude model names
(block#8053), summarized thinking (block#8051) and mobile iOS changes.

Merged cleanly without textual conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Arnoldinh0 <arnaudlafosse92100@gmail.com>

This branch was successfully deployed

No deployments
codex-review — 902b3526 Deployed Oct 2, 2026 by wpfleger96 via Run Codex Security Review #6640
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