feat(nip-fi): admin disconnect/deny API with in-memory deny-until-TTL map (S4) - #7265
feat(nip-fi): admin disconnect/deny API with in-memory deny-until-TTL map (S4)#7265wpfleger96 wants to merge 31 commits into
Conversation
buzz-auth gains two new modules for the NIP-FI admin disconnect API: deny_map.rs — NipFiDenyMap: per-issuer DashMap shards, merge rule max(existing_until, incoming_until), past-until commands close sessions but never create/shorten entries, per-issuer capacity cap with fail-closed DenySetFull error, self-evicting TTL entries, is_denied interface for S5 HTTP enforcement. Atomic jti-reservation + deny-entry insertion within one shard lock (both-or-neither). command.rs — CommandVerifier<S>: verifies typ=nip-fi-command+jwt tokens, validates method/path/target/aud/iat/exp/jti/body-hash claims, jti reserved at the final admission step (after authz + body-match) to close the DoS seam identified in the spec review, CommandIssuerPolicy carries maximum_command_age_secs + authorized_principals + capacity bound. verifier.rs — promotes six parsing/validation helpers to pub(super) so command.rs can reuse the same cryptographic primitives without duplication. Closes items 1 and 3 of the S4 scope. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
buzz-relay gains the POST /api/nip-fi/disconnect endpoint (item 2 of S4): api/nip_fi.rs — disconnect handler (axum): extracts command JWT from Nostr-Federated-Identity Bearer header, parses + validates the JSON body pubkey, delegates to CommandVerifier::verify, inserts the deny entry, and closes all live sessions via ConnectionManager::disconnect_nip_fi. Response contract matches the spec rejection table (200/400/401/403/503). Also exposes build_nip_fi_command_components for main.rs startup wiring. state.rs — adds nip_fi_deny_map and nip_fi_command_verifier fields to AppState (None-initialized; 503 before startup init). Adds disconnect_nip_fi to ConnectionManager: issuer-global scan (unfenced, spec requirement) that closes all sessions for a target pubkey across all communities and sends a NOTICE before close. router.rs + api/mod.rs — wires POST /api/nip-fi/disconnect into the app router. The endpoint sits outside all NIP-98 middleware layers; auth is entirely by the signed command JWT inside the handler. Item 5 (clean is_denied interface for S5) is provided by NipFiDenyMap in the prior commit. Item 4 (WS-admission deny-check seam) is a thin separate commit held until S3 (#7224) merges to avoid file conflicts. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
F1 — Gate GIF search/share, workflow runs/approvals, and moderation reads through check_nip_fi_http_on_state. authenticate() in gifs.rs, authorize_workflow_read() in workflows.rs, and authorize_moderation_read() in bridge.rs all now call the NIP-FI gate after NIP-98 verification. Route inventory with protected/exempt classification added to the F4 seam-test block so new authenticated routes must be explicitly classified. F2 — Kill X-Pubkey fallback in NIP-FI enforce/deny-protected mode. Bridge POST /events, /query, /count now pass require_auth_token = config.require_auth_token || nip_fi_active to verify_bridge_auth_with_options. When NIP-FI is not Off, a real NIP-98 event is mandatory; X-Pubkey dev-mode fallback is disabled. [NIP-FI.md:547-567, FI-TRACE-HTTP-INGRESS] F3 — Require NIP-98 payload tag for bridge POST bodies in enforce mode. POST /events, /query, /count pass require_payload = nip_fi_enforce (Enforce mode only; off/deny-protected unchanged). Every POST body on these routes is authorization-relevant per spec §579-597. F4 — Production-seam tests per surface. Six handler-level tests added to bridge.rs postgres_tests: events, query, count, moderation_reports (shared witness for all three moderation routes), gif_search (shared witness for both GIF routes), workflow_runs (shared witness for both workflow routes). Each test drives the real router in Enforce mode with valid NIP-98 but no assertion → expects 401. The test fails if the check_nip_fi_http_on_state call is deleted from the production code. Marked #[ignore = "requires Postgres"]. F5 — Reshape HttpDenyMap trait to match S4 NipFiDenyMap signature. is_denied now takes (issuer: &str, pubkey: &PublicKey, now: DateTime<Utc>) matching NipFiDenyMap::is_denied from PR #7265 (S4). The check_nip_fi_http call site passes assertion.identity().issuer() and Utc::now() so integration is a one-liner. Rename FailClosedStubDenyMap → AlwaysAdmitStubDenyMap to accurately describe the stub phase semantics. CI — Fix main.rs:530 clippy::redundant_pattern_matching warning: if let None = ... → .is_none(). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Startup wiring (F1): add nip_fi_config.rs with NipFiRelayConfig that
parses BUZZ_NIP_FI_MODE + BUZZ_NIP_FI_ISSUERS; rejects startup in
enforce mode on malformed/missing command policy (fail-closed). Wire
build_nip_fi_command_components in main.rs before Arc::new so fields
can be assigned directly; Config::from_env() calls from_env() on the
new config, giving a hard startup gate.
Cross-pod propagation (F2): add NipFiDisconnect struct and
NIP_FI_DISCONNECT_CHANNEL to buzz-pubsub conn_control; add
run_nip_fi_disconnect_subscriber + connect_and_subscribe_nip_fi;
add nip_fi_disconnect_tx broadcast sender and associated
run/subscribe/publish methods to PubSubManager. Wire subscriber
spawn in main.rs; add cross-pod consumer loop that calls
merge_cross_pod_deny + disconnect_nip_fi on each message.
Fail-closed deny-map reads (F3): is_denied already returns true on a
poisoned shard lock (unwrap_or(true)); add oracle test
poisoned_shard_is_denied_fails_closed. Add public
merge_cross_pod_deny on NipFiDenyMap for cross-pod use so
atomic_reserve_and_insert stays pub(crate) and the jti
burn-on-503 invariant is not reachable from outside the crate.
Raw iss removed from logs (F4): handler logs only a session count;
no iss or pubkey appear in any log line per FI-TRACE-PRIVACY-NONPUBLIC.
HTTP contract (F5): auth_required_response adds WWW-Authenticate:
Nostr on 401; disconnected_response produces byte-exact spec literal
'{"disconnected": true}'; all response helpers unit-tested including
header assertions. disconnect_nip_fi sends a NOTICE frame before
cancel so the client learns why.
Full-path CommandVerifier tests (F6): add ~425 lines of verify_at
tests using real ES256 key material covering all VerifyCommandJwt
steps; mutation anchors named per-test; 503-does-not-burn-jti oracle
verifies retry semantics; deny-set-both-delivery-orders oracle verifies
max(until) rule.
Clippy doc_lazy_continuation (F7/F8): fix continuation indent on
command.rs:10-11 from 4 spaces (Markdown code-block boundary) to 3.
Reuse parse_numeric_date from verifier.rs (was already done).
Compile fixes: re-export JwtAlgorithm from buzz-auth so buzz-relay
does not need a direct jsonwebtoken dep; fix duplicate NipFiDisconnect
import in buzz-pubsub lib.rs; add Clone to CommandIssuerEnvConfig;
add mut to app_state binding; use DateTime::from_timestamp_secs
(chrono API); derive PartialEq+Eq on CommandResult for test assertions;
add IssuerCapacity to test module top-level import.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested
Reviewed head 2a42ddff049aaaf4bcfdf994680c0b1b4e2785d7 against base ac5a18697c8294e9237f505a2e01ec6fc374849a, source-only. No PR code was checked out, built, tested, or executed.
The review preserves the accepted RAM-only/restart-amnesia design. Startup wiring, WS admission deny checks, and S5 HTTP enforcement are explicitly deferred, not blockers by themselves. The issues below are in the delivered callable components; main.rs currently leaves the endpoint unconfigured.
P2: Close existing huddle sockets as well as Nostr sockets
Handler close call / new close scan.
The success path scans only ConnectionManager. Huddle audio sockets instead register with community_connections, have their own cancellation token, and prove their key through a separate NIP-42 handshake (audio handler, proof). They never enter this scan.
With a configured verifier, a target already connected to chat and a huddle receives a successful disconnect, loses the chat socket, but retains the independently authenticated audio socket and its audio access. NIP-FI's delivered disconnect contract closes all live WebSockets whose proven key matches (spec). This is not the deferred new-admission check. Include every existing key-proven socket type in targeted cancellation, and cover a target with simultaneous Nostr/audio sockets plus an unaffected other key.
P2: Preserve fractional NumericDate deadlines
The new parser floors every fractional NumericDate and reconstructs it with zero nanoseconds. For an otherwise valid signed command with until = T + 0.9, the stored deadline becomes T, so the deny map reports the key allowed at T + 0.1, before the issuer's signed deadline. Flooring iat can also admit evidence beyond the allowed future-skew boundary. The existing assertion parser already preserves nanoseconds (verifier.rs:916–952). Reuse that semantics and add signed-command tests through verify_at, asserting denial just before fractional until and expiry at equality, plus future-iat and ceiling boundaries.
P2: Keep issuer identities out of logs
api/nip_fi.rs:144–149 and configuration warnings.
A successful close emits the exact signed iss as caller_iss when debug logging is enabled; invalid configuration emits the issuer URI at warning level. This exports deployment-private identity coordinates to logs, contrary to the explicit NIP-FI privacy contract. Remove the issuer fields, retain fixed reason/count diagnostics, and bind a privacy regression to the actual success/configuration logging paths.
P2: Bound the replay map independently of deny-entry capacity
Even with issuer deny capacity 1, repeated valid commands updating one active key bypass the entry-capacity guard and retain a new jti string each time. Every mutation then scans that growing replay map under the shard lock. There is no replay-count/byte budget or command rate bound in this path. A buggy or abusive authorized issuer can exceed its intended memory budget and increase lock-held work; this is not an anonymous attack or a measured outage. Large JTIs are bounded only by the total 64-KiB token limit, and accepted clock skew can extend retention to 360 seconds.
Add an explicit per-issuer replay-resource bound checked in the same atomic admission step. Exhaustion must leave both the new deny mutation and JTI reservation unapplied, without evicting unexpired replay identities. Test repeated same-key updates at the budget, concurrent reservations, and reuse of a rejected still-valid JTI after capacity frees.
Validation and smaller compatibility note
The changed tests exercise policy constructors, error helpers, header/pubkey parsing, and sequential deny-map operations. They do not exercise the new command verification or disconnect handler end to end; the referenced command/tests.rs is absent from the complete pinned head tree. The regression cases above need the production seams, not copies of predicates or response constants.
Non-blocking: missing-header 401 is missing WWW-Authenticate: Nostr required by the rejection table; plain_response only adds Content-Type. Existing DenialClass already provides the challenge value.
F1 (production wiring): build_nip_fi_command_components returns Result<Option<...>,String>; warn-and-skip replaced with hard errors. nip_fi_config.rs validates maximum_command_age_seconds [1,60], deny_set_capacity (non-zero), and calls validate_command_issuer_config() at from_env() time. main.rs constructs key source with ?, warms JWKS snapshots via get_snapshot() for each issuer, spawns background refresh loop using AtomicBool::load(Ordering::Acquire) for shutdown check. F2 (hostile consumer): deny_map.rs adds CrossPodMergeResult enum and remote_merge() method on IssuerShard (no jti allocation, idempotent via max-merge). merge_cross_pod_deny only operates on pre-configured shards (unknown issuer = reject, no shard allocation). Capacity/poison return fail-closed enum variants. main.rs consumer validates pubkey bytes, issuer (policy_for_issuer), timestamp representability (from_timestamp), until ceiling (until <= now + skew + maximum_assertion_age), dispatches on CrossPodMergeResult with fail-closed session-close on capacity/poison. CrossPodMergeResult exported from lib.rs. F3 (atomicity + poison oracle): IssuerShard::atomic_reserve_and_insert prebuilds both key strings and effective_until before any write, then executes both HashMap inserts atomically. Poison test poisons a real IssuerShard via std::thread::spawn + panic; mutation anchor: reverting unwrap_or(true) -> false makes the test fail. Added 5 new remote_merge oracle tests: shorter-after-longer, replay-idempotent, unknown-issuer-rejected, capacity-exceeded, poisoned-shard. F4 (log redaction): nip_fi_config.rs error messages use issuer [index N] instead of raw issuer URIs. api/nip_fi.rs build_nip_fi_command_components uses bounded index in all error paths. warn import restored (used by deny-set-full capacity log, no issuer field). F6 (route integration): buzz-auth/jwks/mod.rs adds seed_snapshot_for_test() on ProductionJwksSource<F> under cfg(any(test, feature = "dev")) - seeds CachedSnapshot directly without HTTP. api/nip_fi.rs adds mod route_integration_tests with 6 tests: absent-verifier gives 503, absent-header gives 401+WWW-Authenticate, bad-signature gives 403, capacity-503 does not burn jti, success gives spec-exact bytes, deny entry recorded and visible to is_denied. buzz-relay Cargo.toml adds jsonwebtoken dev-dep with use_pem. F7 (lint): command.rs:11 doc comment indented to 4 spaces fixing doc_lazy_continuation. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
F1 — Gate GIF search/share, workflow runs/approvals, and moderation reads through check_nip_fi_http_on_state. authenticate() in gifs.rs, authorize_workflow_read() in workflows.rs, and authorize_moderation_read() in bridge.rs all now call the NIP-FI gate after NIP-98 verification. Route inventory with protected/exempt classification added to the F4 seam-test block so new authenticated routes must be explicitly classified. F2 — Kill X-Pubkey fallback in NIP-FI enforce/deny-protected mode. Bridge POST /events, /query, /count now pass require_auth_token = config.require_auth_token || nip_fi_active to verify_bridge_auth_with_options. When NIP-FI is not Off, a real NIP-98 event is mandatory; X-Pubkey dev-mode fallback is disabled. [NIP-FI.md:547-567, FI-TRACE-HTTP-INGRESS] F3 — Require NIP-98 payload tag for bridge POST bodies in enforce mode. POST /events, /query, /count pass require_payload = nip_fi_enforce (Enforce mode only; off/deny-protected unchanged). Every POST body on these routes is authorization-relevant per spec §579-597. F4 — Production-seam tests per surface. Six handler-level tests added to bridge.rs postgres_tests: events, query, count, moderation_reports (shared witness for all three moderation routes), gif_search (shared witness for both GIF routes), workflow_runs (shared witness for both workflow routes). Each test drives the real router in Enforce mode with valid NIP-98 but no assertion → expects 401. The test fails if the check_nip_fi_http_on_state call is deleted from the production code. Marked #[ignore = "requires Postgres"]. F5 — Reshape HttpDenyMap trait to match S4 NipFiDenyMap signature. is_denied now takes (issuer: &str, pubkey: &PublicKey, now: DateTime<Utc>) matching NipFiDenyMap::is_denied from PR #7265 (S4). The check_nip_fi_http call site passes assertion.identity().issuer() and Utc::now() so integration is a one-liner. Rename FailClosedStubDenyMap → AlwaysAdmitStubDenyMap to accurately describe the stub phase semantics. CI — Fix main.rs:530 clippy::redundant_pattern_matching warning: if let None = ... → .is_none(). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… (F9) A NIP-FI targeted disconnect only scanned ConnectionManager (Nostr relay WebSocket connections). Huddle audio sockets register with the separate CommunityConnectionRegistry and were never reached, so a booted target could lose chat while keeping their audio session alive. Fix: - Add AuthorizationDenied variant to CommunityDisconnectReason, which the audio send_loop turns into a 1008 POLICY close frame. - Add proven_pubkey: Arc<RwLock<Option<Vec<u8>>>> to CommunityConnectionControl. The audio handler calls set_proven_pubkey immediately after NIP-42 auth succeeds. - Add disconnect_nip_fi() to CommunityConnectionRegistry: scans proven_pubkey, fires AuthorizationDenied reason + cancels. Pre-auth sockets (no proven pubkey) are not matched. - Update all three disconnect call sites — api/nip_fi.rs (HTTP handler) and main.rs cross-pod consumer (Merged / CapacityExceeded / ShardPoisoned) — to also call community_connections.disconnect_nip_fi. Tests (4): proven-key closes + reason is AuthorizationDenied; pre-auth socket not touched; different-key socket not touched; collocated peer preserved when target is closed. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
F7: fix clippy::redundant_closure in nip_fi_config.rs (.map_err(ConfigError::InvalidValue)). F2a (cross-pod capacity fail-closed): add blocked_issuers DashSet to NipFiDenyMap. merge_cross_pod_deny now inserts the issuer into blocked_issuers on CapacityExceeded or ShardPoisoned, and is_denied checks blocked_issuers first. This transitions the issuer to deny-all-keys-until-restart when the shard cannot record a required deny entry, satisfying NIP-FI.md:328-336. Adds oracle: is_denied returns true for the targeted key AND unrelated keys after remote CapacityExceeded. F2b (Carl's bounded-replay finding): add max_jti_count (= capacity * 2) to IssuerShard and check it in atomic_reserve_and_insert before the entry-capacity check. Bounds the JTI table at 2x the entry ceiling so an issuer cannot accumulate replay state faster than entries expire. Returns CapacityExceeded (503, replayable) on exhaustion. Adds oracle: five JTIs across two keys exhaust max_jti_count=4 and the fifth is rejected. F3 (fractional until truncation on cross-pod): add until_unix_nanos: u32 to NipFiDisconnect (serde default=0 for backward compat with old pods). Publisher now sets cmd.until.timestamp_subsec_nanos(); consumer uses from_timestamp(unix, nanos) instead of from_timestamp(unix, 0). Cross-pod round-trip now preserves the full sub-second precision of the signed until. Adds serde backward-compat oracle and nanos-roundtrip test. F1/F6 (production assembly oracle): add production_assembly_build_nip_fi_command_components_wires_both_fields test that calls build_nip_fi_command_components directly (same call path as main.rs), verifies Some returned for valid config, and confirms the returned deny_map records the deny entry from the returned verifier. Deletion of either nip_fi_* state assignment in main.rs breaks this test. F1/F6 (orphan S4 config): reject authorized_principals and deny_set_capacity when maximum_command_age_seconds is absent. Adds two config-validation tests. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested
Reviewed head f03efb3f472600ba9ab2061a5136d22277a9fbe3 against base ac5a18697c8294e9237f505a2e01ec6fc374849a, source-only. No checkout, build, tests, imports, or PR-code execution. All three independent review lanes returned; findings below were independently reconciled against the source.
The original audio-scan omission, fractional NumericDate parser, explicit issuer-log sites, and unbounded replay-map implementation are corrected. The current source also delivers startup and cross-pod wiring despite the stale PR description. Three defects remain in that delivered integration.
P2: Do not turn remote capacity pressure into permanent issuer-wide denial
deny_map.rs:277–290,341–367, called by main.rs:1215–1258.
Source-derived witness: configure two maps/pods with issuer capacity 1. Before propagation, each accepts a command for a different key. Deliver the remote messages: each new-key merge hits capacity and inserts the issuer into blocked_issuers. Every subsequent is_denied call for that issuer returns true, including unrelated keys and times after both signed TTLs have expired. No expiry or recovery clears this flag; restart is required. A delayed, already-expired remote command against a full shard takes the same path.
This breaks the delivered shared deny interface’s targeted, self-expiring contract. It is not a claim of an already-wired admission outage: WS/S5 admission consumers remain deferred. The spec explicitly permits asynchronous propagation loss with issuer re-push; it does not require permanent denial of unrelated users. Remove the sticky issuer-wide transition on ordinary capacity exhaustion, retain existing live entries and observable recovery, and preserve target session closure for past-until delivery without creating future denial. Do not add persistence. Replace the test that codifies deny-all (deny_map.rs:831–879) with the two-map/capacity, unrelated-key, and post-TTL cases. Poisoned-lock handling is separate from normal capacity pressure.
P2: Sanitize configuration parse errors before they enter logs
nip_fi_config.rs:159–162 forwards the raw serde error into ConfigError; main.rs:163–165 logs that error.
An otherwise complete issuer entry containing "authorized_principals": "admin@private.example" instead of an array produces a type-error diagnostic containing the supplied principal string. This happens before the new index-only policy validation. Startup fails closed, but private principal/email data is exported to logs, violating NIP-FI’s explicit privacy contract. Keep a fixed error category and safe location information rather than {e}. Add a malformed-sensitive-value regression through the actual configuration/error-reporting path. The old explicit issuer log sites are fixed; this is a newly introduced path.
P2: Isolate the new route fixtures from process-environment mutation
api/nip_fi.rs:634–640 and the absent-verifier fixture at line 742 call Config::from_env().expect(...). That now reads NIP-FI environment configuration. In the same test binary, nip_fi_config.rs:399–462 sets the mode to permissive, or to enforce without issuers, under a mutex private to that module.
If a route fixture reads during either interval, configuration returns an error and the fixture panics before reaching the route under test. The private mutex does not protect those readers. Construct fixture configuration without process-env reads, or coordinate every relevant reader/writer using shared synchronization. Do not rely on serial execution of this test module. This is a source-derived parallel-test failure, not a claimed local reproduction.
Regression coverage and stable exit criteria
Keep the real signed-verifier and real-router tests; they are a substantial improvement. Finish the already-requested corrective witnesses: signed verify_at fractional until just-before/equality, future-iat and ceiling boundaries; an actual audio-handler registration plus target chat/audio and unaffected-peer disconnect; and privacy-path capture. Existing registry tests manually populate the key, so removing audio/handler.rs:252 survives them. Integer-only command fixtures do not catch reintroducing timestamp flooring. The “production assembly” test calls only the component builder; its comment at api/nip_fi.rs:990–995 incorrectly claims deleting main’s state assignments makes it fail. Correct that claim or bind the actual assembly seam. These are source-inspected coverage limits, not test-run results.
Exit criteria are the three bounded defects above and regression witnesses for the corrective paths. Preserve RAM-only/restart amnesia, asynchronous propagation/re-push, and the separately deferred WS admission/S5 HTTP work. No broader session-lifecycle rewrite, distributed completion guarantee, or persistence requirement is added. The missing-header WWW-Authenticate: Nostr note is also resolved.
Blocker 1 — cross-pod capacity/poison fail-closed (race fixed): Replace `blocked_issuers` DashSet with `IssuerShard.blocked` bit set under the shard lock on capacity exhaustion. `is_denied` checks `is_denied_or_blocked` while holding the lock — admission and blocked-check share one lock boundary, eliminating the window between DashSet read and lock acquisition. The `pre_lock_hook` (`#[cfg(test)]`, inert in prod) parks an in-flight admission between shard-resolve and lock so the concurrent oracle can race a real `merge_cross_pod_deny` against a real `is_denied` call. Three oracles: `remote_merge_capacity_exceeded_marks_issuer_blocked_ and_denies_all_keys`, `capacity_exhaustion_blocks_targeted_and_unrelated_ keys`, `remote_capacity_transition_linearizes_before_waiting_admission`. Blocker 2 — per-issuer JTI budget boundary oracles: Add `concurrent_same_issuer_reservations_do_not_exceed_jti_budget` (8 threads vs 3 open slots, asserts successes ≤ ceiling) and `jti_budget_rejection_is_unapplied_and_exact_jti_retries_after_expiry` (fill 4/4 slots, reject, advance clock past one expiry, retry successfully, verify deny deadline not extended by the rejected command). Blocker 3 — fractional `until` across the bus: Extract `encode_nip_fi_disconnect` / `decode_nip_fi_disconnect` seams in `buzz-pubsub`, `nip_fi_disconnect_message` publisher seam and `apply_nip_fi_disconnect` consumer seam in `nip_fi.rs`. The consumer seam returns `NipFiDisconnectApplyResult` and replaces the entire `main.rs` receive loop body with a single call. Oracle `fractional_deadline_survives_publisher_wire_and_consumer_equality_ boundary`: T+500ms deadline, denied at T+1ns, admitted at exact equality. Blocker 4a — enforce issuer without command fields rejected: `build_nip_fi_command_components` now returns `Err` for every configured issuer missing `maximum_command_age_seconds` in enforce mode. Orphan field checks preserved. Oracle: `enforce_issuer_without_command_fields_is_rejected`. Blocker 4b — production assembly oracle wires `main.rs`: `install_nip_fi_command_components` owns JWKS warmup, background refresh, `build_nip_fi_command_components`, and both `AppState` assignments. `main.rs` startup block replaced by a single call. Oracle `production_install_warms_and_populates_both_app_state_fields` reds on either field deletion. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…e alias, fix fractional clock assertion, tighten JTI concurrent counts Move the #[cfg(test)] pre_lock_hook invocation from before shards.get() into the Some(shard) arm immediately before shard.lock(). The hook now fires after the shard is resolved, correctly parking admission at the mutex boundary as specified by the authored contract. Introduce a #[cfg(test)] PreLockHook type alias for the raw Arc<dyn Fn> field, silencing the clippy::type_complexity lint that failed CI at the prior head. Fix the fractional round-trip oracle: add a distinct now_after_t = t_whole + 1ns assertion for the "immediately after T" point (previously both assertions used t_frac - 1ns = T+499_999_999ns, never testing the mandated T+1ns point). Tighten the concurrent JTI oracle: use assert_eq!(successes, 3) and assert_eq!(live_jtis, 4) instead of <= bounds so the oracle also catches accidental under-admission, matching the exact ceiling semantics. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested
Reviewed head b6f5ba30de70c06f581fc9201c090726210d9679 against base ac5a18697c8294e9237f505a2e01ec6fc374849a, focused on the two corrective commits since f03efb3f472600ba9ab2061a5136d22277a9fbe3 and the prior exit criteria. Source/metadata review only: no checkout, build, tests, imports, or execution of PR code.
The same three P2 findings remain. Moving the blocked flag under the shard mutex improves serialization, but preserves the incorrect permanent issuer-wide denial. The new publisher/consumer and startup seams improve testability; they do not close the policy/privacy/fixture findings below.
1. P2: Ordinary remote capacity exhaustion still permanently denies unrelated keys
deny_map.rs:401–409, is_denied:316–327, consumer:317–343
Source-derived witness: two pods each have issuer capacity 1 and independently accept different target keys before propagation. Deliver the remote entries: the new-key capacity miss sets IssuerShard.blocked = true. is_denied_or_blocked returns that bit for every key (deny_map.rs:115–119), and TTL eviction never clears it (101–105). Both unrelated keys and the missed target remain denied even after all signed until values expire. A delayed, already-expired remote entry against a full shard causes the same transition.
This is the same targeted/self-expiring shared-interface defect as before, not a claimed live outage in the deferred WS/S5 admission consumers. The contract permits asynchronous loss and issuer re-push; it does not authorize permanent denial of unrelated users. Holding the flag under a mutex makes the wrong state transition atomic, not correct.
On ordinary CapacityExceeded, retain existing live entries, return the capacity outcome, close only the delivered target’s sessions through the consumer, and leave recovery to the accepted re-push policy. Remove the sticky issuer-wide transition; poisoned-lock handling can remain separately fail-closed. Replace the tests that require unrelated-key denial (deny_map.rs:889–938,1099–1141; api/nip_fi.rs:1241–1354) with two-map capacity, unrelated-key, and post-TTL cases. No persistence or distributed completion guarantee is requested.
2. P2: Configuration parsing still exports private principal values to startup logs
nip_fi_config.rs:159–162, main.rs:163–165
An otherwise complete issuer entry with "authorized_principals": "admin@private.example" instead of an array still produces a serde type error containing the supplied string. The parser interpolates that raw error into ConfigError; startup logs it verbatim. This happens before the new index-only missing-command-field validation. Startup rejects the configuration, but leaks the principal/email in doing so, contrary to the explicit privacy contract.
Keep a fixed error category and safe location information instead of raw {e}. Add a malformed-sensitive-value regression through the actual configuration/error-reporting path. The new missing-command-fields test does not cover this failure mode.
3. P2: Existing and new route fixtures still race process-environment writers
api/nip_fi.rs:895–902, new consumer fixture:1261, nip_fi_config.rs:459–477
The route fixtures still call Config::from_env().expect(...); the corrective commits add more copies at api/nip_fi.rs:1261,1378,1513 alongside 899,1003. That loader reads NIP-FI config (config.rs:1266). In the same test binary, NIP-FI tests set BUZZ_NIP_FI_MODE=permissive, or Enforce with issuers absent, under a mutex private to that module (nip_fi_config.rs:413). The route readers do not take it. A concurrent read returns a configuration error and panics before the intended route/consumer/startup assertion.
Construct fixture configuration without process-environment reads, or coordinate all relevant readers/writers with shared synchronization. Overriding DB/Redis fields after loading does not fix the fallible ambient read. This is a source-derived parallel-test failure, not a claimed local reproduction.
Corrective coverage and scope
The new fractional_deadline_survives_publisher_wire_and_consumer_equality_boundary test (api/nip_fi.rs:1371–1490) exercises the real publisher mapping, encode/decode, apply helper and map equality comparison. Credit that wire-path regression. It starts from a synthetic CommandResult, so it does not close the previously requested signed fractional-NumericDate verify_at boundary witness. Integer future-iat and until ceiling cases already exist; the remaining gap is fractional parsing and boundary preservation on the signed path. command.rs is unchanged from the previous reviewed head.
The replacement installer test (api/nip_fi.rs:1508–1613) now invokes the production helper that owns both AppState assignments and verifies that command verification writes to the same map. The former builder-only assembly criticism is resolved. It seeds the key snapshot and checks the warmup result; the final shutdown store is not an observed refresh-task exit, so do not claim that lifecycle was tested.
The actual audio-handler registration plus targeted chat/audio and unaffected-peer witness remains uncovered in the inspected tests. The registry tests manually set the proven key (state.rs:2650–2742), so they bypass the production registration at audio/handler.rs:250–252; the new consumer tests assert map state without live sessions on both transports. Finish the previously requested registration-to-dual-transport-close witness. This is a carried-forward coverage gap, not a new production regression. All three independent lanes have returned and been reconciled. No runtime or mutation-test results are claimed here.
Stable exit criteria remain these three bounded defects and the previously requested corrective witnesses. Preserve RAM-only restart amnesia, asynchronous propagation/issuer re-push, and separately deferred WS admission/S5 HTTP. No broad session-lifecycle rewrite or additional persistence requirement is added. Existing HTTP status/body classes, pubkey-targeted close behavior, and command/JWKS verification contracts were traced through the changed wiring; unchanged cryptographic internals were not reopened as a fresh audit.
…d dual-transport witness, fix privacy leak, hermetic config, fractional verify_at, claim cleanups (S4 R5) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: 🤖 fix(desktop): harden smoke E2E tests against Bestie overlay and toast timing (#7270) Show status and huddle indicators beside names (#7112) Add mobile voice notes (#7121) perf(desktop): publish mention sends before waking agents (#7154) fix(desktop): unify owned-agent cloud provenance markers (#7129) fix(desktop): derive agent availability from relay presence (#7127) fix(desktop): preserve spacing after multi-word mentions (#7128) docs(nip-fi): document Git smart-HTTP credential exemption (#7268) feat(cli): add buzz gifs command group and NIP-30 emoji tags on messages (#7259) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Remove the transitive Config::from_env() call from hermetic_for_test(). The previous implementation called Self::from_env() internally and patched fields afterward, leaving the constructor racy against concurrent NIP-FI config tests that mutate BUZZ_NIP_FI_* and RELAY_OWNER_PUBKEY under a module-private mutex these callers did not hold. Replace the body with a direct struct literal using the same hard-coded development defaults that from_env() selects when all variables are absent. Zero direct or transitive process-environment reads, no locks required. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…rminism regression Replace the rand::random HMAC secret in hermetic_for_test() with a fixed test-only 64-hex literal so two calls always produce an identical Config. The random expression made the constructor non-deterministic and falsified its doc comment. Add hermetic_for_test_is_deterministic: calls the constructor twice and asserts git_hook_hmac_secret is equal across calls. Restoring the random expression causes the two values to diverge and the test fails. Update the constructor doc comment to accurately describe the one field that intentionally differs from the from_env() default. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…paths Gurney's live pass observed 1005/1006 generic close codes on NIP-FI denial/disconnect. This folds in explicit policy close frames so client libraries that branch on close codes see a clear, actionable signal. Mechanism: reuse CommunityDisconnectReason::AuthorizationDenied and its close_message() (1008 POLICY, 'authorization denied'). Three paths: 1. disconnect_nip_fi() (root WS admin-disconnect): ConnEntry now carries nip_fi_reason_tx (shared watch::Sender with ConnectionState and CommunityConnectionControl). Setting it to AuthorizationDenied before cancel() lets the existing send_loop cancel branch emit the policy frame via disconnect_reason.borrow(). 2. NIP-FI expiry task (root WS and audio): the deny_reason_tx param (same shared channel) is set inside the gate.expire() terminal closure before terminal_ctrl_tx.try_send() and cancel.cancel(). 3. Audio pre-send_loop paths (deny-set hit, already-expired deadline, enforce_nip_fi_key_pairing Audio arm): send_loop is not yet started so ws_send is directly owned; explicit Close(Some(POLICY, ...)) is sent before cancel.cancel(). [FI-TRACE-CLOSE-CODE] tag on all new call sites. [FI-TRACE-PRIVACY-NONPUBLIC]: close reason is static 'authorization denied' — no iss/pubkey. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…writer-wins reason publication Fix 1 (Thufir IMPORTANT): audio pre-send-loop expiry exits now send the 1008 POLICY close frame after draining terminal_ctrl_rx. All three check_cancel!() arms and the four manual expiry exits (post-dial, SessionExpired acquire_effect, post-add_peer, JoinCommitError::Expired) lacked the close step — they drained the denial payload but returned without the frame, leaving clients with 1005/1006. Copies the watch value before any await to satisfy Send bounds. Fix 2 (Thufir IMPORTANT): replace all unconditional send_replace calls on the shared disconnect-reason watch with a first-writer-wins publish_disconnect_reason helper (send_if_modified that writes only when current is None). Routes all writers through it: CommunityConnectionControl::disconnect_community, disconnect_nip_fi, ConnectionManager::disconnect_nip_fi, key-pairing Root arm, and the expiry task terminal closure. Prevents concurrent CommunityDeleted and AuthorizationDenied from misattributing the close frame. Tests: W_FIX1 (pre-send-loop drain emits restricted JSON then 1008 close), community_disconnect_then_nip_fi_keeps_community_deleted_reason, nip_fi_disconnect_then_community_keeps_authorization_denied_reason. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…iter-wins handlers/auth.rs:363 was still calling send_replace unconditionally on nip_fi_reason_tx, making it the last surviving path that could clobber a concurrent CommunityDeleted reason. Convert to the same send_if_modified None-gated pattern used at nip_fi_session.rs:111-120 and state.rs:521-528. All six writers now go through first-writer-wins publication. No new witness needed: the two precedence tests in state.rs already pin the contract; this routes the last writer through it. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The previous W_FIX1 built its own terminal channel / reason watch and reimplemented the drain-then-close statements inline (a test-only copy). Deleting the production close blocks left the test green -- TESTING.md:25-29 prohibited pattern; not a valid mutation transcript. Replacement (pre_send_loop_check_cancel_emits_restricted_json_then_policy_close): - Drives the real handle_active_audio_connection via a WS server. - Arms a new before_first_audio_check_cancel hook (fires after the expiry task is spawned at ~line 426 but before check_cancel!() at ~line 554). - Holds the handler at that seam while the real expiry task fires naturally (100 ms deadline), queuing the denial frame on the internal terminal channel, publishing AuthorizationDenied on the real disconnect_reason watch, and cancelling. - Releases the hook; handler hits the actual check_cancel!() arm, drains the denial frame, sends reason.close_message(). - Client asserts: Text(restricted JSON) -> Close(1008, 'authorization denied'). Deleting the block from the production check_cancel!() arm leaves this test red (close frame absent). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Replace stale `after_deny_set_check_passed` narrative with the correct `before_first_audio_check_cancel` hook name in the W_FIX1 witness setup description (handler.rs:3578). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Active audio sockets closed via the admin disconnect path (CommunityConnectionRegistry::disconnect_nip_fi) were receiving only the 1008 POLICY close frame with no preceding protocol payload. ConnectionManager::disconnect_nip_fi (root path) already enqueued the denial frame on ctrl_tx before cancelling; the audio path only published AuthorizationDenied and cancelled. Register the audio terminal-frame sender on CommunityConnectionControl after the terminal channel is created in handle_active_audio_connection. In CommunityConnectionControl::disconnect_nip_fi, try_send the authorization_denied_frame(Audio) before publish_disconnect_reason + cancel. The send loop (or pre-send-loop drain) drains terminal_ctrl_rx before emitting the close, satisfying the payload-then-close contract. Capacity-1 contention with the expiry task is benign: both enqueue the same canonical denial frame, and first-frame-wins mirrors first-writer-wins on the reason. Root relay connections leave terminal_frame_tx unset; their deny path is unchanged. Adds W_admin_disconnect: production-bound witness drives the real handle_active_audio_connection through before_first_audio_check_cancel hook, calls the real registry disconnect_nip_fi scan, and asserts client observes restricted JSON then 1008. Mutation A (remove set_terminal_frame_sender) → RED (only close observed); restore → PASS. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…rst-writer-wins Fix 1: Create the terminal channel and call set_terminal_frame_sender BEFORE audio_post_auth_register makes the pubkey scan-visible in the registry. Previously, set_terminal_frame_sender was called after audio_post_auth_register, leaving a window where disconnect_nip_fi could scan a proven pubkey, find terminal_frame_tx = None, and enqueue nothing — producing close-only with no restricted JSON payload. The fix establishes the ordering invariant: sender is registered before scan-visibility, closing the window unconditionally. New witness W_addc (w_admin_disconnect_at_deny_check_delivers_payload_then_close) holds at before_deny_set_check — the exact old-gap window — fires disconnect_nip_fi there, and asserts Text(restricted JSON) → Close(1008). Mutation: move set_terminal_frame_sender to after the hook window → RED (only 1008, no Text). Restore → PASS. Fix 2: Couple the denial payload enqueue to first-terminal-writer-wins by performing send_if_modified and the conditional try_send atomically inside the terminal_frame_tx Mutex lock. Previously, disconnect_nip_fi enqueued unconditionally before knowing whether it won reason publication, so a losing deny could queue an authorization_denied frame against a community-deleted close. New tests (both ordered cause directions): - disconnect_nip_fi_wins_reason_enqueues_frame_then_losing_delete_does_not - disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame Mutation: remove the won gate → disconnect_community_wins test RED. Restore → PASS. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ancel ordering disconnect_community was calling cancel.cancel() outside the terminal_frame_tx lock. A concurrent disconnect_nip_fi that won the reason slot could be descheduled between send_if_modified and try_send; community's cancel would then fire before the denial frame was enqueued, waking any consumer on an empty terminal channel and causing close-only without the restricted-JSON payload. Fix: disconnect_community acquires the terminal_frame_tx lock before send_if_modified and drops it before cancel.cancel(), matching the critical-section shape of disconnect_nip_fi. The losing community-delete must take the same lock; the winner holds it across win+enqueue, so the loser's cancel is serialized after the enqueue completes. Expiry task and key-pairing writers enqueue their payload inside gate.expire()'s terminal closure before cancel fires — they cannot reproduce the empty-drain shape. Adds cancel_race_test_hook: a cfg(test)-only static hook that fires inside disconnect_nip_fi after winning reason but before try_send, while the lock is held. Allows a deterministic concurrent witness without production overhead. Adds w_cancel_race_deny_payload_precedes_community_cancel: consumer thread wakes on first cancel and immediately try_recvs. With the fix, community's cancel blocks until deny's try_send completes — consumer always sees the frame. Mutation (revert to unserialized disconnect_community) → community cancel fires while deny is paused → consumer sees empty channel → RED. Restore → PASS. Poison recovery: both lock() calls use unwrap_or_else(PoisonError::into_inner) so a poisoned mutex does not silently skip reason publication. publish_disconnect_reason removed (dead code after inlining into both disconnect_community and disconnect_nip_fi). [FI-TRACE-CANCEL-RACE] Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… lock Thufir identified that expiry and root key-pairing writers (spawn_nip_fi_expiry_task, enforce_nip_fi_key_pairing) published reason + enqueued denial frames WITHOUT holding the CommunityConnectionControl terminal_frame_tx lock. A concurrent disconnect_community that lost the reason race could still fire cancel.cancel() before the winning writer finished its try_send — recreating the original close-only defect for expiry/delete and root-key-pairing/delete races. Fix: - Add CommunityConnectionControl::expiry_deny_terminal(frame_tx, route): acquires the shared transition lock, publishes AuthorizationDenied via first-writer-wins, enqueues the denial frame (on win only), then releases. No cancel — caller handles that. - Change spawn_nip_fi_expiry_task to accept CommunityConnectionControl instead of a bare deny_reason_tx watch sender. Terminal closure calls control.expiry_deny_terminal inside gate.expire, so the lock is held while the frame is enqueued; any concurrent disconnect_community must acquire the same lock and cannot cancel until the enqueue completes. - Update all call sites (connection.rs root path, audio/handler.rs audio path, all test call sites in nip_fi_session.rs, connection.rs, and audio/handler.rs). Witnesses and mutation evidence (executed): - expiry_wins_reason_enqueues_frame_then_losing_delete_does_not: sequential, PASS - delete_wins_reason_losing_expiry_does_not_enqueue_frame: sequential, PASS - w_expiry_cancel_race_payload_precedes_community_cancel: concurrent barrier witness; hooks expiry_deny_terminal after reason-win while lock held, races disconnect_community. PASS on clean fix. Remove lock from disconnect_community -> RED (consumer sees Empty on community's premature cancel). Restore -> PASS. - All 41 prior state tests + 1151 relay lib tests pass. Pre-existing api::mesh_demo::demo_join_forwarded_arm_round_trips_echo failure confirmed on origin/main (unrelated). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested
Reviewed head 26099bc63bb15934ce08296964112db6bd55def1 against base 2ac0aa1dd18c0b9d4fa722658c4692b0c79a496f, following the prior corrective exit criteria and the newly added session/admission code. Source-only: no PR checkout, build, tests, imports, runtime reproduction, or mutation execution. All three independent lanes returned; the findings below were independently checked against the pinned source.
The sticky issuer-wide capacity denial and sensitive configuration-error leak are fixed. The signed fractional verify_at witness is now present. The remaining issues are one carried fixture failure and defects in the expanded session/lifecycle integration. RAM-only restart amnesia, asynchronous propagation/issuer re-push, and separately deferred S5 HTTP enforcement remain accepted scope, not blockers. Off-mode huddle lifecycle compatibility must remain intact.
1. P1: Include observer EVENT publication in the session admission gate
handlers/event.rs:682–693, publication:1116–1136.
Kind 24200 returns through handle_agent_observer_event before either new EVENT permit. Its helper awaits signature verification and possibly an owner lookup, then publishes to Redis and local subscribers without acquiring a permit. Source-derived witness: submit a valid owner-to-agent observer control frame, hold validation/owner lookup, expire or admin-disconnect the publisher, then resume validation. The detached task still delivers the control frame to a live agent after the publisher's session authority has ended. Dispatch's earlier cancellation check cannot fence an already-spawned handler; recipient access checks do not validate the publisher's session.
Acquire the existing effect permit after validation and before observer publication, holding it through fan-out. Add a barrier witness proving expiry/disconnect before acquisition prevents delivery to the live recipient. This preserves the accepted rule that an effect which already acquired a permit may finish; this branch currently acquires none.
2. P2: Route every root denial producer through the reserved, serialized terminal transition
state.rs:623–642, handlers/auth.rs:365–380, nip_fi_session.rs:108–126.
The dedicated capacity-1 terminal queue and transition lock are not wired to all root producers. Admin disconnect and post-registration deny still try_send on ordinary capacity-8 ctrl_tx; a full queue drops the required NOTICE, then cancellation produces only the policy close. Pairing uses the reserved queue but writes the raw reason sender outside the transition lock and ignores whether it won. The post-registration deny does the same outside-lock reason write.
A second source-derived schedule: pairing wins AuthorizationDenied, pauses before enqueue, and concurrent community deletion acquires its separate transition lock and cancels. The send loop can drain an empty terminal queue and close before pairing queues the NOTICE. Conversely, if CommunityDeleted wins, pairing still queues an authorization-denied payload, which can be emitted alongside the community-deleted close reason. The new expiry/audio serialization does not cover these callers.
Pass the shared terminal control and reserved root sender to these paths; serialize winning reason plus cause-specific enqueue, and enqueue only for the winner before cancellation. Cover root admin/post-auth denial with saturated ordinary control traffic and pairing/post-auth versus community-delete in both orders. No new queue abstraction is needed beyond consistently using the one introduced here.
3. P2: Preserve generation on the transaction-owned huddle JOIN event
audio/handler.rs:2258–2262, base JOIN:707–718.
The new kind-48101 builder drops generation, although the handler still computes lifecycle_generation and includes it in LEFT/ENDED. This affects NIP-FI Off as well as Enforce. An already-hydrated desktop observer receiving a new START/JOIN assigns that room the fallback "pending" generation (consumer:264–272). At the next authoritative liveness refresh, the real generation differs, so reconcileLiveness:469–480 clears participant admissions. In-huddle indicators disappear while audio remains connected; polling does not emit another JOIN.
Pass the existing lifecycle generation into commit_participant_join and preserve it in persisted/fanned-out JOIN content. Test the production event and JOIN-to-liveness consumer sequence, including Off mode.
4. P2: Do not cancel the audio deadline producer before it publishes the denial
audio/handler.rs:918–938, commit rejection:1220–1246, nip_fi_session.rs:211–238.
acquire_effect can detect an elapsed wall-clock deadline before the spawned expiry task runs. The add-peer and commit rejection paths then cancel the token before awaiting that task. Its unbiased select may choose the now-ready cancellation arm and return without calling expiry_deny_terminal. The handler drains an empty terminal queue, finds no reason, and returns without restricted JSON or an explicit 1008 policy close. Awaiting task completion is not evidence that the task produced a denial.
When the deadline fast path wins, publish the serialized terminal cause before self-cancellation, preserving any already-winning cause. Add a delayed-expiry-task wire witness at these rejection boundaries. Admission still fails; this is a denial-delivery defect, not an authorization bypass.
5. P2: Quiesce permitted root effects on admin cancellation before cleanup
nip_fi_session.rs:238, connection.rs:461–483, handlers/req.rs:307–351.
Only the timer arm waits for outstanding effect permits. With a future deadline, admin disconnect wakes the cancellation arm, which returns immediately; root teardown awaits it and then removes subscriptions without joining detached REQ tasks. Source-derived witness: a REQ acquires a permit, pauses before subscription registration, admin disconnect completes connection cleanup, then the REQ resumes. register_with_scope recreates the removed connection entry (subscription.rs:152–155) and retains topics after cleanup. No later cleanup releases those orphan entries/references. Repetition accumulates leaked state.
The newly delivered cancellation path needs the existing quiescence guarantee too, without manufacturing a second reason/frame. Wait for permitted effects before removing their resources on this path. Add a held-permit/admin-cancel witness asserting cleanup occurs after the effect and leaves no subscription/topic state. Socket cancellation should remain immediate; only resource cleanup waits. This is incomplete integration of the new gate, not a claim that the prior detached-task architecture was race-free.
6. P2: Finish isolating route/session fixtures from NIP-FI environment writers
router.rs:1512–1515, state.rs:1910–1911, writers:478–497.
The API fixtures now use hermetic_for_test, but nip_fi_enforce_state still calls Config::from_env().expect(...); new handler witnesses also use the ambient state::tests::test_state helper. In the same binary, NIP-FI tests temporarily set BUZZ_NIP_FI_MODE=permissive, or Enforce without issuers, under their module-private mutex. A fixture reading in either interval errors and panics before its intended assertion. Overriding config.nip_fi afterward cannot repair that fallible read. The comment claiming the environment read is irrelevant is incorrect.
Use the existing hermetic constructor for these fixtures/shared helpers, or synchronize all relevant readers and writers. Preserve normal parallel test execution. This carries the prior bounded fixture finding forward; no local test failure is claimed.
Coverage, dispositions, and exit criteria
The source review covered command/deny-map corrections, startup/config privacy, publisher/consumer wiring, root and audio registration/denial/deadline/cleanup transitions, EVENT/REQ/search/COUNT permit paths, changed audio transaction boundaries, and the unchanged desktop consumer of the rewritten JOIN event. All 47 cached head and 9 base blobs matched their pinned Git trees at final source verification.
Credit the real signed fractional verifier test, production audio registration witness, target root/audio registry cancellation with unrelated peers, and handler-driven audio payload/close tests. Registry cancellation alone is not simultaneous dual-socket wire proof. Comments describing mutations are not executed mutation evidence. Unchanged crypto internals, S5 HTTP admission, and broad mesh behavior were not reopened; no CI or live-runtime pass is claimed.
A suggested parent-membership revocation blocker was withdrawn after base comparison: the old ensure_membership already separated parent membership check from child add. The new transaction still does not serialize parent revocation, but that pre-existing TOCTOU is deferred rather than added to this corrective review's exit criteria. Huddle-liveness REQ gate coverage also remains a noted uncertainty, not an additional claimed post-expiry delivery defect.
Exit criteria are the six bounded issues above and their production-bound regression witnesses. Prefer completing the existing control/gate wiring and restoring the dropped field over expanding the machinery. No persistence, synchronous cross-pod completion, or broader authorization rewrite is requested.
…l ordering Root key-pairing's PairingDenialTarget::Root branch in enforce_nip_fi_key_pairing previously published AuthorizationDenied and enqueued the denial frame directly on conn.terminal_ctrl_tx outside the shared transition lock. A concurrent disconnect_community could therefore cancel the connection token before the winning pairing path's frame enqueue completed, leaving the consumer to drain an empty terminal channel and emit a close-only 1008. Fix: add pairing_deny_terminal() on CommunityConnectionControl with the same critical-section shape as disconnect_nip_fi and expiry_deny_terminal — acquires terminal_frame_tx lock, first-writer-wins send_if_modified, winner-only try_send, then drops lock before cancel. Add community_control: CommunityConnectionControl field to ConnectionState, sharing the cancel token and reason sender. Root branch now calls conn.community_control.pairing_deny_terminal(). All ten ConnectionState test constructors updated. Also fold clippy::type_complexity fix from CI at 26099bc: introduce HookSlot and HookCell type aliases shared by all three #[cfg(test)] hook modules, removing the bare OnceLock<Mutex<Option<Arc<dyn Fn() + Send + Sync>>>> expansions that triggered the lint on Rust Lint and Windows Rust jobs. Adds three deterministic state tests: - pairing_wins_reason_enqueues_frame_then_losing_delete_does_not - delete_wins_reason_losing_pairing_does_not_enqueue_frame - w_pairing_cancel_race_payload_precedes_community_cancel (barrier witness: pauses after reason win while lock held; concurrent disconnect_community blocks until try_send completes — Mut-PairingRace: removing lock makes community cancel fire before enqueue → RED; restore → PASS) Collective invariant proof: all terminal writers (disconnect_nip_fi, expiry_deny_terminal, pairing_deny_terminal, disconnect_community) now go through the same transition lock. Enumeration is total — no other writer exists on this reason/cancel pair. [FI-TRACE-CANCEL-RACE] Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: chore(release): release Buzz Desktop version 0.5.23 (#7381) fix(desktop): keep packaged frontendDist relative so Windows embeds assets (#7177) fix(sidebar): simplify unread indicators and emphasize priority activity (#7134) Add generic information-flow control core (#7293) feat(buzz-acp): update base prompt; add buzz context and skills to Pi agents (#7335) fix(desktop): restore mention chip identity icons (#7338) Persist video playback speed preference (#7336) Verify ACP relay events before prompt routing (#7010) fix(buzz-acp): bound busy-owner hold to prevent cross-channel starvation (#7337) feat(desktop): invite owned agents from standalone forums (#7125) fix(desktop): authorize remote mentions at publication (#7124) fix(acp): rename system tag to agent-instructions (#7332) fix(desktop): bind duplicate mention selections to exact recipients (#7133) refactor(relay): extract NIP-29 membership authorization (#7285) chore(release): release Buzz Desktop version 0.5.22 (#7308) feat(desktop): preserve mentions across copy and paste (#7228) test(desktop): await Bestie drag and profile hover endpoints (#7294) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…ough terminal lock
Routes the two remaining terminal-writer bypasses through the shared
CommunityConnectionControl transition primitive:
1. ConnectionManager::disconnect_nip_fi — replaced direct nip_fi_reason_tx
+ ctrl_tx + cancel with entry.community_control.manager_disconnect_nip_fi,
which acquires the terminal_frame_tx lock before reason/enqueue/cancel.
2. handlers/auth.rs deny-set hit — replaced direct nip_fi_reason_tx +
ctrl_tx + cancel with conn.community_control.auth_deny_terminal, which
acquires the same lock before reason/enqueue, then cancel follows outside.
Both call sites previously bypassed the transition lock, enabling a
concurrent disconnect_community to fire cancel before the winning
writer's payload enqueue completed — leaving the consumer with an empty
terminal channel and a close-only 1008. [FI-TRACE-CANCEL-RACE]
New methods on CommunityConnectionControl (state.rs):
- auth_deny_terminal: same critical-section shape as pairing_deny_terminal
and expiry_deny_terminal.
- manager_disconnect_nip_fi: same shape, cancels internally after drop.
- auth_race_test_hook / manager_race_test_hook: #[cfg(test)] barrier hooks
mirroring cancel_race_test_hook and expiry_race_test_hook.
ConnEntry restructured: removed standalone cancel + nip_fi_reason_tx fields;
added terminal_ctrl_tx; community_control exclusively owns the reason sender
and cancel token. All entry.cancel references updated to
entry.community_control.cancellation_token().cancel().
Collective invariant: all four terminal writers now participate in the same
lock — disconnect_nip_fi, expiry_deny_terminal, pairing_deny_terminal,
and disconnect_community. No fifth writer exists.
6 new tests (state.rs):
- auth_wins_reason_enqueues_frame_then_losing_delete_does_not
- delete_wins_reason_losing_auth_does_not_enqueue_frame
- w_auth_cancel_race_payload_precedes_community_cancel (barrier witness)
- manager_wins_reason_enqueues_frame_then_losing_delete_does_not
- delete_wins_reason_losing_manager_does_not_enqueue_frame
- w_manager_cancel_race_payload_precedes_community_cancel (barrier witness)
Mutation transcript (executed): removed lock from disconnect_community ->
w_auth_cancel_race and w_manager_cancel_race both RED (Empty); restored ->
both PASS. All 5 existing witnesses (cancel, expiry, pairing, auth, manager)
also RED under mutation; all PASS at restored head.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
All paths that cancel a connection's lifecycle token — graceful drain (drain_all, drain_all_jittered, late-registration self-signal), backpressure eviction (ConnectionState::send, fan-out), heartbeat failure, auth timeout, and recv-loop teardown — previously called cancel.cancel() directly, outside the terminal_frame_tx transition lock. A concurrent terminal writer (disconnect_nip_fi, pairing/auth/ expiry _deny_terminal) that had already won the reason slot but not yet executed its try_send could have its payload frame lost: the external cancel woke the send loop, which tried_recv on an empty terminal channel and sent the close frame with no preceding NOTICE. Fix: add lifecycle_cancel() to CommunityConnectionControl. It acquires the terminal_frame_tx lock (blocking any in-progress terminal enqueue), drops it, then calls cancel.cancel(). All external cancel sites now route through lifecycle_cancel(). The five existing terminal-writer methods already drop the lock before returning, so any lifecycle_cancel racing them either: (a) acquires the lock after the enqueue — frame is in the channel; or (b) blocks on the lock during the enqueue — frame is enqueued before cancel fires. Both orderings preserve the invariant. Heartbeat failure now takes CommunityConnectionControl instead of bare CancellationToken. Auth timeout and recv-loop teardown similarly updated to lifecycle_cancel(). Two new tests: - lifecycle_cancel_does_not_enqueue_frame_but_cancels_token: ordered proof of the no-payload contract. - W_lifecycle_cancel_race: barrier witness using manager_race_test_hook — lifecycle_cancel blocks until manager_disconnect_nip_fi drops the lock after try_send; mutation (remove lock) → consumer wakes on empty channel → RED; restore → PASS. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…aths audio/handler.rs still called bare cancel.cancel() in five production families after the root-relay fix (c98f897): - heartbeat_loop: missed-pong and tx-error exits - audio_forward_loop: backpressure/roster-stale and peer-close exits - teardown_remote_huddle: remote-owner shutdown exit - owner_teardown_task: owner-draining and owner-lost exits - recv-loop return at L1497 Each of these cancels the connection token consumed by audio send_loop, so a concurrent disconnect_nip_fi winning the reason slot could be preempted between its try_send and cancel.cancel() — the consumer waking on a bare cancel would drain an empty terminal channel and deliver close-only 1008, no preceding NOTICE. Fix: pass CommunityConnectionControl into heartbeat_loop, audio_forward_loop, and teardown_remote_huddle; add owner_control and reader_control clones for the inline async blocks. Replace every post-send_loop-spawn cancel.cancel() on the connection token with control.lifecycle_cancel(). Pre-spawn bare cancel.cancel() calls (L396-L1314) are intentionally left as-is: send_loop has not started at those points, so no concurrent drain consumer exists. The heartbeat_loop watch arm uses a child token (control.cancellation_token() materialized as a local) so the select can still observe external cancellation without calling cancel() itself. Two production-wiring witnesses added to state.rs: w_root_manager_drain_race_payload_precedes_drain_lifecycle_cancel: real ConnectionManager::register + set_authenticated_pubkey + disconnect_nip_fi vs drain_all() through full registration wiring. w_audio_registry_lifecycle_cancel_race_payload_precedes_audio_teardown_cancel: real CommunityConnectionRegistry::register + set_proven_pubkey + set_terminal_frame_sender + disconnect_nip_fi vs lifecycle_cancel() on the same control (audio teardown path). Both witnesses go RED when lifecycle_cancel drops the lock acquisition (bare cancel.cancel() mutation) and PASS when restored. [FI-TRACE-CANCEL-RACE] Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested
Reviewed head 5e41fffaf995d53b6eac7aaa5a440f425be7f778 against exact base 3c7f288c60d67df78577b237e27c3dfc8831aaa1, using the previous six-finding review as the corrective contract. Source-only: no checkout, build, test, import, runtime reproduction, or mutation execution.
Credit: root pairing, post-registration deny-set, and admin-disconnect producers now use the shared winner-only transition and reserved terminal sender. This fixes the prior ordinary-queue saturation and pairing/post-auth versus community-delete schedules. Five prior findings remain unchanged; F2 is partially fixed with a reverse-order race in the newly added lifecycle helper. The corrective tests also introduce a parallel-run hang described separately below.
F1 · P1 · Observer EVENT still bypasses session authority
crates/buzz-relay/src/handlers/event.rs:682–693, crates/buzz-relay/src/handlers/event.rs:990–1138.
Kind 24200 returns through handle_agent_observer_event before either EVENT permit. Its complete helper awaits signature verification and potentially owner lookup, then publishes to Redis and local subscribers without acquiring a session effect permit. Source-derived schedule: hold validation/owner lookup for a valid owner-to-agent control frame, expire or admin-disconnect the publisher, then resume. The already-spawned task can still deliver that control to a live agent. The dispatch-time cancellation check cannot fence this resumed task, and recipient access checks do not validate publisher authority.
Acquire the existing permit after validation and before publication, hold it through fan-out, and add a production barrier witness for cancellation before acquisition. Preserve already-permitted bounded effects.
F2 · P2 · Finish terminal ordering in the new lifecycle cancellation helper
crates/buzz-relay/src/state.rs:347–357, crates/buzz-relay/src/state.rs:279–305, crates/buzz-relay/src/connection.rs:560–587.
The root producers requested previously are wired correctly now, but lifecycle_cancel releases the transition lock before cancelling and leaves the reason unset. Concrete reverse-order schedule: lifecycle cancellation acquires/releases the lock and pauses at line 356; a manager denial then acquires it, wins AuthorizationDenied, and pauses before try_send; lifecycle resumes at 357 and wakes the send loop. It drains an empty terminal queue and emits a 1008 authorization-denied close before the NOTICE is enqueued. No stalled writer is required. Heartbeat/backpressure/teardown now call this helper in production.
Serialize lifecycle cancellation itself with terminal publication and prevent a later writer from installing a new cause after lifecycle cancellation has already won. This can be completed with the existing lock/token rather than another queue abstraction. The new lifecycle/manager witnesses force denial-first ordering (crates/buzz-relay/src/state.rs:4408–4469); add the reverse order and verify the actual writer observes one consistent terminal result.
F3 · P2 · Transactional JOIN still drops the lifecycle generation
crates/buzz-relay/src/audio/handler.rs:1159–1172, crates/buzz-relay/src/audio/handler.rs:2245–2265.
The handler computes lifecycle_generation but does not pass it into commit_participant_join; the persisted/fanned-out kind 48101 content still omits it while LEFT/ENDED include it. An already-hydrated Desktop observer receiving a new START/JOIN assigns "pending" (desktop/src/features/huddle/lib/huddlePresenceRuntime.ts:264–272). The next authoritative liveness response has the real generation, so reconciliation clears admissions (desktop/src/features/huddle/lib/huddlePresence.ts:464–483). In-huddle indicators disappear while audio remains connected. This affects Off as well as Enforce.
Pass the existing generation through the transaction-owned JOIN builder and test the producer-to-JOIN-to-liveness sequence, including Off mode.
F4 · P2 · Audio deadline rejection still cancels its denial producer
crates/buzz-relay/src/audio/handler.rs:918–938, crates/buzz-relay/src/audio/handler.rs:1220–1246, crates/buzz-relay/src/nip_fi_session.rs:200–228.
acquire_effect can observe an elapsed wall-clock deadline before the expiry task runs. Both audio rejection paths still call raw cancel.cancel() before awaiting that task. Its unbiased select can take the now-ready cancellation arm and return without publishing expiry_deny_terminal. The handler then drains no restricted JSON, finds no disconnect reason, and returns without an explicit 1008 policy close. Awaiting task completion does not prove denial publication. The new lifecycle wiring does not change these rejection branches.
Publish the serialized deadline cause before self-cancellation, respecting an already-winning cause. Add delayed-expiry-task wire witnesses at add-peer and commit rejection. Admission is denied already; this is a denial-delivery defect, not an authorization bypass.
F5 · P2 · Admin cancellation still skips the root quiescence barrier
crates/buzz-relay/src/nip_fi_session.rs:227, crates/buzz-relay/src/connection.rs:457–479, crates/buzz-relay/src/handlers/req.rs:307–351.
Only the timer arm waits for existing effect permits. With a future deadline, admin disconnect wakes the cancellation arm, which returns immediately; root cleanup awaits it and removes subscriptions without joining detached REQ handlers. Hold a REQ after permit acquisition but before registration, let admin cancellation finish cleanup, then resume it. Registration recreates the removed connection entry (crates/buzz-relay/src/subscription.rs:150–155) and retains topics after the only cleanup pass. Repeating this schedule accumulates orphan registry entries/topic references.
Keep socket cancellation immediate, but wait for already-permitted bounded effects before resource removal on admin cancellation, without producing a second terminal frame. Add a held-permit/admin-cancel witness proving cleanup follows the effect and leaves no subscription/topic state.
F6 · P2 · Ambient NIP-FI fixture races remain
crates/buzz-relay/src/router.rs:1508–1527, crates/buzz-relay/src/state.rs:2183–2187, crates/buzz-relay/src/nip_fi_config.rs:478–497.
nip_fi_enforce_state and shared state::tests::test_state still use Config::from_env().expect(...). Concurrent tests set an invalid mode or Enforce without issuers under a module-private mutex; those fixture readers do not share it. They can fail before their intended assertion. Overriding config.nip_fi afterward does not repair the fallible read. The same ambient reads remain in the bounded root-auth fixtures (handlers/auth.rs:468,520), fanout fixture (handlers/event.rs:2060), and audio transaction fixture (audio/handler.rs:5039). The API fixture's existing hermetic fix remains credited.
Use the existing Config::hermetic_for_test constructor for these fixtures, or synchronize every relevant reader/writer while preserving normal parallel execution. No local failure execution is claimed.
New corrective-test regression · P2 · Global race hooks can deadlock parallel tests
crates/buzz-relay/src/state.rs:2109–2136, arm sites crates/buzz-relay/src/state.rs:4320–4323, crates/buzz-relay/src/state.rs:4431–4438, crates/buzz-relay/src/state.rs:4566–4569.
The three new manager race tests share one unkeyed process-global callback, but each installs a different blocking Barrier. Under parallel libtest, test B can replace A's callback before A's worker calls manager_disconnect_nip_fi; A's worker then enters B's barrier while A's main thread waits indefinitely on A's never-invoked barrier. An unrelated manager-disconnect test can also invoke the armed callback. The mutex protects individual slot reads/writes, not callback ownership for a whole test, and the barriers have no timeout.
Make hooks connection/control-scoped (or otherwise isolate all arming and invoking tests), with bounded rendezvous. Per-test-process nextest isolation can mask this, but ordinary parallel cargo test remains a supported path. Do not solve it by requiring the whole package to run serially.
Scope and exit criteria
The six prior boundaries were rechecked across root/audio registration, denial, effect admission and cleanup, the JOIN producer/Desktop consumer, and fixtures. The command verifier, deny map, JWKS/config, router, and pubsub blobs are unchanged from the previously reviewed head; the API delta is registration-fixture wiring only. Their previously credited capacity, privacy, fractional-time, and propagation fixes remain credited. All independent review lanes are integrated; conclusions above are checked against pinned source, not inferred from claimed test results.
RAM-only restart amnesia, asynchronous propagation/issuer re-push, and deferred S5 HTTP enforcement remain accepted. The previously withdrawn parent-revocation TOCTOU, uncertain huddle-liveness gate claim, unrelated rebase features, and separate #7224 findings are not added to these exit criteria. No persistence, synchronous cross-pod completion, generic stalled-writer overhaul, or broader auth rewrite is requested.
Exit criteria: close the five unchanged findings, finish F2's new lifecycle ordering, and isolate the newly introduced race hooks, with production-bound regression witnesses. No CI or runtime pass is claimed.
Implements S4 of the buzz-enterprise-identity program: the NIP-FI admin disconnect API with deny-until-TTL semantics.
Merge order: This branch carries a pre-#7224 S3 snapshot (merge-base
88687876f) and merges after #7224; base reconciliation follows.What this adds
buzz-auth
crates/buzz-auth/src/nip_fi/deny_map.rs—NipFiDenyMapIn-memory deny set, no persistence (Option B, relay restart amnesia is accepted and documented as issuer-re-push).
DashMapshards — cross-issuer capacity starvation is impossiblemax(existing_until, incoming_until)on same-key collision — a delayed shorter-untilcommand never shortens an active deny [FI-TRACE-DENY-SET]untilcommands: never create or shorten entries; atomically calls the session-close path but skips the deny-entry write [FI-TRACE-DENY-SET]DenySetFull) — spec requires503and the jti is not consumed on full [VerifyCommandJwt step 7]is_denied(issuer, pubkey, now)— the clean interface S5 (HTTP enforcement) consumescrates/buzz-auth/src/nip_fi/command.rs—CommandVerifier<S>Verifies
typ=nip-fi-command+jwttokens against the same issuer JWKS as assertions.method,path,target_pubkey,aud,iat,exp,jti, and body hash claimsCommandIssuerPolicycarriesmaximum_command_age_seconds(normative ≤ 60),authorized_principals(suballowlist), and per-issuerdeny_set_capacitypub(super)helper promotion — no crypto duplicationbuzz-relay
crates/buzz-relay/src/api/nip_fi.rs—POST /api/nip-fi/disconnectNostr-Federated-Identity: Bearer <token>headerpubkey(lowercase hex, exactly 32 bytes)CommandVerifier::verify— jti + deny entry written atomically on successConnectionManager::disconnect_nip_fi200 {"disconnected": true}on success;400/401/403/503per the spec rejection tableCommandVerifier::verifybuzz_nip_fi_disconnect_propagation_failures_total(no iss/pubkey in labels [FI-TRACE-PRIVACY-NONPUBLIC])build_nip_fi_command_components— startup builder callable frommain.rscrates/buzz-relay/src/state.rsConnectionManager::disconnect_nip_fi— issuer-global (unfenced) cross-community session close, sendsNOTICEbefore cancel; setsAuthorizationDeniedvia first-writer-winspublish_disconnect_reason(writes only when slot isNone) so the send loop's cancel branch emits a1008 POLICYclose frame with reason"authorization denied"[spec: deny applies across all communities under the issuer]CommunityConnectionControl::publish_disconnect_reason— atomic first-writer-wins helper; all writers (disconnect_community,disconnect_nip_fi, the expiry task, key-pairing) route through it to prevent concurrentCommunityDeleted/AuthorizationDeniedcauses from misattributing the close frameAppState::nip_fi_deny_mapandnip_fi_command_verifierfields —None-initialized; endpoint returns503until startup wires them incrates/buzz-relay/src/router.rs+src/api/mod.rsPOST /api/nip-fi/disconnectsits outside all NIP-98 middleware layerscrates/buzz-relay/src/handlers/auth.rs+audio/handler.rsis_deniedchecked at WS admission after pubkey registration (spec steps 5+6 ordering): root WS (handlers/auth.rs:346), audio (audio/handler.rs:349), and the pre-upgrade early-bounce path —authorization_deniedframe + explicit1008 POLICYclose frame + close on match; NIP-FI expiry sends the same policy close on all paths including the audio pre-send-loop window (check_cancel!()arms andJoinCommitError::Expiredexit)crates/buzz-relay/src/main.rsinstall_nip_fi_command_componentswired at startup (main.rs:543) with shared-Arc JWKS sourceTest coverage
20 new tests in
nip_fi::deny_mapand 26 new tests innip_fi::command:max(until)[FI-TRACE-DENY-SET oracle]untilcommands: absent entry inserts expired; active entry left unchangedCommandIssuerPolicyconstruction validation (zero age, >60 age, empty principals, zero capacity, empty issuer)22 unit tests in
api::nip_fi: header extraction contract (401/403), pubkey parsing (uppercase rejected, wrong length). 4 additionalConnectionManager/CommunityConnectionControltests:conn_manager_disconnect_nip_fi_sets_authorization_denied_reason,conn_manager_disconnect_nip_fi_ignores_unproven_connection,community_disconnect_then_nip_fi_keeps_community_deleted_reason(first-writer-wins: CommunityDeleted not clobbered), andnip_fi_disconnect_then_community_keeps_authorization_denied_reason(first-writer-wins: AuthorizationDenied not clobbered).W_FIX1: pre-send-loop drain emits restricted JSON then 1008 POLICY close.All spec oracle cases are falsifiable: a mutation that violates the merge rule, the past-until invariant, or the capacity semantics will break the corresponding test.