From d402ba6f4240302ba50c4b92218bafc6608dd3d0 Mon Sep 17 00:00:00 2001 From: seiler Date: Mon, 5 Oct 2026 21:15:07 +0000 Subject: [PATCH 01/18] test(relay): map community ban routes and regressions Signed-off-by: seiler Co-authored-by: Codex (cherry picked from commit dcb83eaec669ed044ab2f16c8b7612bd54ed1d9c) Signed-off-by: Brad Seiler --- .github/workflows/_ci-relay.yml | 6 + Justfile | 1 + TESTING.md | 78 ++ crates/buzz-relay/src/api/bridge.rs | 181 ++++- crates/buzz-relay/src/audio/handler.rs | 128 ++++ .../tests/community_ban_routes.rs | 693 ++++++++++++++++++ .../fixtures/community-ban-regressions.tsv | 20 + .../community-ban-route-inventory.tsv | 69 ++ .../check-community-ban-route-inventory.py | 556 ++++++++++++++ scripts/run-tests.sh | 3 + 10 files changed, 1732 insertions(+), 3 deletions(-) create mode 100644 crates/buzz-test-client/tests/community_ban_routes.rs create mode 100644 crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv create mode 100644 crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv create mode 100644 scripts/check-community-ban-route-inventory.py diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index bf57fb424ba..b3b1ac5588f 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -660,6 +660,8 @@ jobs: - name: Relay E2E tests run: | cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_thread_roots --test e2e_project -- --ignored --nocapture + cargo test -p buzz-test-client --test community_ban_routes -- --ignored --nocapture + cargo test -p buzz-test-client --test regression_relay_admin_ban_gate -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_departure_wire -- --ignored --nocapture @@ -672,6 +674,10 @@ jobs: cargo test -p buzz-test-client --test e2e_relay nip29_relay_rejects_role_change_by_unprivileged_actor -- --ignored --nocapture env: RELAY_URL: ws://localhost:3000 + RELAY_HTTP_URL: http://localhost:3000 + REPRO_RELAY_HTTP: http://localhost:3000 + REPRO_HOST: localhost:3000 + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr - name: Media read-auth e2e # Reads require kind:24242 `t=get` auth, so these binaries are the only diff --git a/Justfile b/Justfile index 0ebbc07c8ed..40d2af619e4 100644 --- a/Justfile +++ b/Justfile @@ -399,6 +399,7 @@ test-unit: ./scripts/test-ensure-local-relay-key.sh # Shared Huddle transport, codec, and loopback authentication regressions. cargo test -p buzz-audio-client + python3 scripts/check-community-ban-route-inventory.py if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib # Mesh JWKS proxy-bypass regression: infra-free loopback I/O with diff --git a/TESTING.md b/TESTING.md index fa3a9a3a82d..4bf29e2c827 100644 --- a/TESTING.md +++ b/TESTING.md @@ -31,6 +31,84 @@ combination space (PR #6807). Scope Playwright locators — unscoped `getByText` in a required smoke test is a strict-mode flake (PR #6980). (PRs #6807, #6980, #6996, #7013) +### Community-ban route coverage + +`crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv` +classifies the relay's production Axum routes by listener, method, path, +authentication plane, membership behavior, and community-restriction behavior. +The inventory guard finds direct `.route(...)` calls in production functions +named `router` or `*_router`, resolves literal or declared `&str` paths, and +applies direct literal `.nest(...)` prefixes from the relay router. It fails +when one of those registrations is missing from the table or a stale row +remains. Run it directly with: + +```bash +python3 scripts/check-community-ban-route-inventory.py +``` + +`just test-unit` and `just test` run the guard; CI also runs the ignored HTTP +and root-WebSocket behavior matrix in the Relay E2E lane: + +```bash +cargo test -p buzz-test-client --test community_ban_routes -- --ignored --nocapture +``` + +Use a fresh disposable local relay, PostgreSQL, Redis, and MinIO stack for that +command, and set `DATABASE_URL` or `BUZZ_TEST_DATABASE_URL` to the disposable +PostgreSQL database. The matrix creates unique tenant hosts and identities; GIF +requests stop at malformed local input, and Git/workflow requests use absent resources. +Do not point these tests at a shared, development, or hosted database or relay. + +The inventory keeps membership separate from restriction policy. Public NIP-11, +NIP-05, policy documents, and health probes have no authenticated community +principal. Anonymous policy-receipt acceptance is pre-membership and has no +principal to restrict. Invite claim is also pre-membership, but it carries a +verified NIP-98 principal and remains restriction-enforced: it is the sole +row marked pending in the inventory until BUZZ-268 qualifies the updated +v1/v2 lifecycle: +retain valid unexpired v1 codes, mint only v2, reject banned claimants and +owners transactionally, permanently invalidate v2 on issuer ban, and honor the +optional absolute invalid-after boundary for v1. Legacy v1 codes have no issuer +identity, so issuer-specific revocation remains limited until a verified fleet +drain or security approval. Relay-operator APIs, deployment-admin APIs, +NIP-FI commands, the localhost Git hook, and the gated mesh test endpoint use +separate auth planes; their exemption reason is recorded on each route row. +Workflow webhooks also use a secret-authenticated caller with no user principal. +Before creating a run, the handler separately checks the saved workflow owner's +channel membership and role, but it does not check that owner's community-ban +state. The inventory's `not_applicable:webhook_secret_auth` entry is a +provisional caller-auth classification, not proof that an owner ban is +irrelevant. Whether the owner's ban should gate webhook trigger and run +creation remains an explicit policy question. + +Root and huddle admission, final-admission races, fail-closed reads, owner-to- +agent restriction, timeout behavior, and tenant-scoped eviction point to their +existing regression tests in `community-ban-regressions.tsv`. The local socket +eviction tests establish pod-local closure. Delivery to sessions on other pods +depends on the Redis pub/sub path and is not inferred from those local tests. +The moderation-read matrix also checks a moderator-privileged agent against a +fresh signed owner proof and a stored owner link; the bridge harness verifies +that the ban denial uses the NIP-98 signed creation time, with a clear-owner +control that reaches the queue. + +This scanner is a source-level guard, not a Rust router interpreter. It reads +direct `.route(...)` calls inside production functions named `router` or ending +in `_router`; a `.route(...)` elsewhere is a scanner error. It resolves a +`.nest(...)` prefix only when the relay's `build_router` passes a literal +prefix and a direct call to another recognized router function. It does not +expand macros, discover `.route_service(...)` or `.nest_service(...)`, resolve +dynamic path/prefix expressions, or interpret inline nested routers and +conditional/opaque router composition. It also does not derive auth or +restriction policy from handler code, or prove that the named regression +function contains the expected assertions or is selected by CI. If production +routes use an unrecognized registration shape, extend the scanner and bind +that shape to the inventory before treating the table as complete. + +The method column records explicitly declared methods. Axum's `get(...)` helper +also serves `HEAD` through the GET handler unless an explicit `.head(...)` +handler is attached; the scanner does not add that implicit `HEAD` behavior as a +separate inventory row or exercise it separately. + --- ## Live Local Relay diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 24b2c3136b5..fca6517456c 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -3165,7 +3165,7 @@ async fn authorize_moderation_read( None, state.config.require_auth_token || nip_fi_active, )) - .map(|auth| auth.proof((auth.event_id_bytes, None))) + .map(|auth| auth.proof((auth.event_id_bytes, auth.signed_created_at))) .map_err(|e| e.into_response()) }) .await?; @@ -3182,7 +3182,7 @@ async fn authorize_moderation_read( &tenant, &pubkey_bytes, super::relay_members::extract_auth_tag_header(headers), - None, + caller.signed_at, caller.token_owner, ) .await; @@ -3426,7 +3426,7 @@ mod typing_postgres_tests; #[cfg(test)] pub(crate) mod postgres_tests { use super::*; - use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag}; + use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag, Timestamp}; use std::sync::Mutex; fn redis_pool() -> deadpool_redis::Pool { @@ -5960,6 +5960,16 @@ pub(crate) mod postgres_tests { url: &str, method: &str, body: &[u8], + ) -> axum::http::HeaderMap { + make_nip98_headers_with_created_at(keys, url, method, body, Timestamp::now().as_secs()) + } + + fn make_nip98_headers_with_created_at( + keys: &Keys, + url: &str, + method: &str, + body: &[u8], + created_at: u64, ) -> axum::http::HeaderMap { use base64::engine::general_purpose::STANDARD as BASE64; use sha2::{Digest, Sha256}; @@ -5971,6 +5981,7 @@ pub(crate) mod postgres_tests { ]; let event = EventBuilder::new(Kind::HttpAuth, "") .tags(tags) + .custom_created_at(Timestamp::from(created_at)) .sign_with_keys(keys) .expect("sign NIP-98 event"); let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); @@ -8730,6 +8741,170 @@ pub(crate) mod postgres_tests { ); } + /// A moderator-privileged agent remains subject to its verified NIP-OA + /// owner's ban on moderation reads. The first request has no stored owner + /// link and uses a time-bounded auth tag; the second uses the durable owner + /// link. A clear fresh-owner control reaches the queue. + /// Mutation: discard `signed_created_at` in `authorize_moderation_read` → + /// the fresh-owner ban is missed and the moderator agent receives 200 → RED. + #[test] + #[ignore = "requires Postgres and disposable Redis via REDIS_URL"] + fn moderation_read_honors_fresh_and_stored_banned_agent_owners() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(mut state) = rt.block_on(nip_fi_off_test_state()) else { + panic!("local Postgres and Redis must be available"); + }; + let mut config = state.config.as_ref().clone(); + config.require_relay_membership = true; + Arc::get_mut(&mut state) + .expect("test state has one owner") + .config = Arc::new(config); + + rt.block_on(async { + let issuer = Keys::generate(); + for scenario in ["fresh-banned-owner", "stored-banned-owner", "fresh-clear-owner"] { + let host = format!( + "moderation-owner-{scenario}-{}.local", + uuid::Uuid::new_v4().simple() + ); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let owner = Keys::generate(); + let agent = Keys::generate(); + for keys in [&owner, &agent] { + state + .db + .ensure_user(community, keys.public_key().as_bytes()) + .await + .expect("ensure owner and moderator-agent users"); + } + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "member", + None, + ) + .await + .expect("seed owner as a community member"); + state + .db + .add_relay_member( + community, + &agent.public_key().to_hex(), + "admin", + None, + ) + .await + .expect("seed agent's own moderator role"); + + let stored_owner = scenario == "stored-banned-owner"; + if stored_owner { + assert!(state + .db + .set_agent_owner_for_authorization( + community, + agent.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .expect("store agent owner link")); + } + let linked_owner = state + .db + .get_agent_channel_policy(community, agent.public_key().as_bytes()) + .await + .expect("read agent owner link") + .and_then(|(_, owner)| owner); + assert_eq!( + linked_owner, + stored_owner.then(|| owner.public_key().to_bytes().to_vec()), + "fresh and stored owner variants must be distinct" + ); + + let banned_owner = scenario != "fresh-clear-owner"; + if banned_owner { + state + .db + .ban_community_member( + community, + owner.public_key().as_bytes(), + issuer.public_key().as_bytes(), + None, + None, + ) + .await + .expect("seed owner ban"); + } + + // The signed event is older than the NIP-OA upper bound while + // remaining fresh for NIP-98. Passing `Utc::now()` or dropping + // the signed time would therefore fail to resolve this owner. + let auth_created_at = Timestamp::now().as_secs().saturating_sub(5); + let owner_tag = buzz_sdk::nip_oa::compute_auth_tag( + &owner, + &agent.public_key(), + &format!("created_at<{}", auth_created_at + 1), + ) + .expect("sign time-bounded owner proof"); + let url = format!("https://{host}/moderation/reports"); + let mut headers = make_nip98_headers_with_created_at( + &agent, + &url, + "GET", + b"", + auth_created_at, + ); + if !stored_owner { + headers.insert( + "x-auth-tag", + owner_tag.parse().expect("valid NIP-OA auth header"), + ); + } + + let (status, _, body) = oneshot_request_full( + Arc::clone(&state), + "GET", + "/moderation/reports", + &host, + headers, + b"", + ) + .await; + if banned_owner { + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "{scenario}: an agent with its own admin role must still inherit its owner's ban; body: {body:?}" + ); + let body: serde_json::Value = + serde_json::from_slice(&body).expect("ban response JSON"); + assert_eq!( + body["error"], + "blocked: you are banned from this community", + "{scenario}: denial must come from the ban gate, not the moderator role gate" + ); + } else { + assert_eq!( + status, + axum::http::StatusCode::OK, + "a moderator agent with an unbanned owner must reach the queue" + ); + assert_eq!(body.as_ref(), b"[]", "fresh clear-owner control"); + } + } + }); + } + // ── T2-seam: admitted malformed query through real handler → 400 ───────── // // Thufir's required seam test: one admitted malformed-query request through diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 4f2c7d1813e..c9e525a341b 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -15945,6 +15945,134 @@ mod tests { ); } + /// A relay and channel administrator is still subject to a community + /// ban at huddle admission. The member control proves the fixture has + /// working channel admission before the banned administrator is tried. + /// Mutation: bypass the huddle ban gate for admins → RED. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn audio_join_rejects_banned_admin() { + let state = audio_test_state_real_db().await.expect( + "PostgreSQL must be available — set BUZZ_TEST_DATABASE_URL or start local postgres", + ); + let (tenant, channel_id, admin) = seed_audio_fixture(state.db.pool()).await; + sqlx::query( + "UPDATE channel_members SET role = 'admin' \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(tenant.community().as_uuid()) + .bind(channel_id) + .bind(admin.public_key().to_bytes().to_vec()) + .execute(state.db.pool()) + .await + .expect("promote channel administrator"); + state + .db + .add_relay_member( + tenant.community(), + &admin.public_key().to_hex(), + "admin", + None, + ) + .await + .expect("seed relay administrator"); + + let control = nostr::Keys::generate(); + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role) \ + VALUES ($1, $2, $3, 'member')", + ) + .bind(tenant.community().as_uuid()) + .bind(channel_id) + .bind(control.public_key().to_bytes().to_vec()) + .execute(state.db.pool()) + .await + .expect("seed unbanned huddle control"); + let (mut control_socket, control_server) = + open_admitted_audio_socket(&state, tenant.clone(), channel_id, &control, None) + .await; + control_socket + .close(None) + .await + .expect("close allowed huddle control"); + control_server.abort(); + + state + .db + .ban_community_member( + tenant.community(), + &admin.public_key().to_bytes(), + &nostr::Keys::generate().public_key().to_bytes(), + None, + None, + ) + .await + .expect("seed administrator ban"); + + let (frames, _) = + run_audio_auth_in(state, tenant, channel_id, None, &admin, true).await; + + assert_eq!( + frames, + vec![serde_json::json!({ + "type": "error", + "message": "blocked: you are banned from this community" + }) + .to_string()] + ); + } + + /// A delegated agent is refused at huddle admission when its proven + /// NIP-OA owner is community-banned, even though the agent has no + /// direct ban row. The owner is also a channel member so the denial + /// reaches the ban gate rather than failing channel authorization. + /// Mutation: omit the proven owner from the huddle restriction lookup + /// → the agent is admitted → RED. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn audio_join_rejects_banned_owner_agent() { + let state = audio_test_state_real_db().await.expect( + "PostgreSQL must be available — set BUZZ_TEST_DATABASE_URL or start local postgres", + ); + let (tenant, channel_id, owner) = seed_audio_fixture(state.db.pool()).await; + let agent = nostr::Keys::generate(); + state + .db + .ban_community_member( + tenant.community(), + &owner.public_key().to_bytes(), + &nostr::Keys::generate().public_key().to_bytes(), + None, + None, + ) + .await + .expect("seed owner ban"); + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") + .expect("sign owner proof"); + let auth_tag: Vec = + serde_json::from_str(&auth_tag).expect("decode auth tag JSON"); + + let (frames, _) = run_audio_auth_tagged( + state, + tenant, + channel_id, + None, + &agent, + true, + Some(auth_tag), + ) + .await; + + assert_eq!( + frames, + vec![serde_json::json!({ + "type": "error", + "message": "blocked: you are banned from this community" + }) + .to_string()] + ); + } + /// A ban closes a live audio socket: admission binds the socket to its /// pubkey, and the ban's pod-local disconnect /// (`AppState::disconnect_pubkey_local`) closes it with a policy close. diff --git a/crates/buzz-test-client/tests/community_ban_routes.rs b/crates/buzz-test-client/tests/community_ban_routes.rs new file mode 100644 index 00000000000..3708261a797 --- /dev/null +++ b/crates/buzz-test-client/tests/community_ban_routes.rs @@ -0,0 +1,693 @@ +//! Community-ban coverage for the relay's registered HTTP and root WebSocket routes. +//! +//! The route inventory is shared with `scripts/check-community-ban-route-inventory.py`. +//! This ignored test uses a disposable local relay/PostgreSQL/MinIO stack and +//! fresh tenant hosts and identities. GIF requests stop at malformed local +//! input before any provider request; Git and workflow requests use absent +//! resources and must stop before subprocess or durable work. +//! +//! ```text +//! ./scripts/start-relay-for-tests.sh +//! RELAY_URL=ws://localhost:3000 RELAY_HTTP_URL=http://localhost:3000 \ +//! cargo test -p buzz-test-client --test community_ban_routes -- --ignored --nocapture +//! ``` + +use std::{collections::BTreeSet, time::Duration}; + +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use base64::Engine as _; +use buzz_test_client::{BuzzTestClient, TestClientError}; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use reqwest::{Client, Method, Response, StatusCode}; +use sha2::{Digest, Sha256}; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; + +const INVENTORY: &str = include_str!("fixtures/community-ban-route-inventory.tsv"); +const BANNED_MESSAGE: &str = "blocked: you are banned from this community"; + +const COMMUNITY_BAN_MATRIX_CASES: &[&str] = &[ + "events", + "moderation_command", + "query", + "count", + "gif_search", + "gif_share", + "workflow_runs", + "workflow_approvals", + "invite_mint", + "moderation_reports", + "moderation_owner_ban", + "moderation_audit", + "moderation_restricted", + "media_upload", + "media_get", + "media_head", + "git_info_refs", + "git_upload_pack", + "git_receive_pack", + "git_default_get", + "git_default_post", +]; + +#[derive(Clone, Debug)] +struct RouteRow { + id: String, + method: String, + path: String, + exercise: String, + control: String, +} + +#[derive(Clone)] +struct Principal { + name: &'static str, + keys: Keys, + nip_oa_tag: Option, +} + +fn relay_http_url() -> String { + std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + }) +} + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn relay_authority() -> String { + let url = url::Url::parse(&relay_http_url()).expect("relay HTTP URL"); + url[url::Position::BeforeHost..url::Position::AfterPort].to_string() +} + +fn http_origin_for_host(host: &str) -> String { + let scheme = if relay_http_url().starts_with("https://") { + "https" + } else { + "http" + }; + format!("{scheme}://{host}") +} + +fn test_host() -> String { + format!( + "community-ban-{}.{}", + Uuid::new_v4().simple(), + relay_authority() + ) +} + +fn database_url() -> String { + std::env::var("DATABASE_URL") + .or_else(|_| std::env::var("BUZZ_TEST_DATABASE_URL")) + .expect("set DATABASE_URL or BUZZ_TEST_DATABASE_URL to a disposable relay-test database") +} + +async fn pool() -> Pool { + sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&database_url()) + .await + .expect("connect to disposable relay-test PostgreSQL") +} + +async fn ensure_community(pool: &Pool, host: &str) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO communities (id, host) VALUES ($1, $2) \ + ON CONFLICT (lower(host)) DO NOTHING", + ) + .bind(id) + .bind(host) + .execute(pool) + .await + .unwrap_or_else(|error| panic!("seed disposable community {host}: {error}")); + sqlx::query_scalar("SELECT id FROM communities WHERE lower(host) = lower($1)") + .bind(host) + .fetch_one(pool) + .await + .unwrap_or_else(|error| panic!("lookup disposable community {host}: {error}")) +} + +async fn seed_member(pool: &Pool, community: Uuid, keys: &Keys, role: &str) { + sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(community) + .bind(keys.public_key().to_bytes().to_vec()) + .execute(pool) + .await + .unwrap_or_else(|error| panic!("seed {role} user: {error}")); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, $3, NULL) \ + ON CONFLICT (community_id, pubkey) DO UPDATE \ + SET role = EXCLUDED.role, updated_at = now()", + ) + .bind(community) + .bind(keys.public_key().to_hex()) + .bind(role) + .execute(pool) + .await + .unwrap_or_else(|error| panic!("seed {role} relay member: {error}")); +} + +async fn ban_member(pool: &Pool, community: Uuid, actor: &Keys, target: &Keys) { + sqlx::query( + "INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey) \ + VALUES ($1, $2, TRUE, $3) \ + ON CONFLICT (community_id, pubkey) DO UPDATE \ + SET banned = TRUE, actor_pubkey = EXCLUDED.actor_pubkey", + ) + .bind(community) + .bind(target.public_key().to_bytes().to_vec()) + .bind(actor.public_key().to_bytes().to_vec()) + .execute(pool) + .await + .unwrap_or_else(|error| panic!("seed community ban: {error}")); +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn nip98_header(keys: &Keys, url: &str, method: &str, body: &[u8]) -> String { + let mut tags = vec![ + Tag::parse(["u", url]).expect("NIP-98 URL tag"), + Tag::parse(["method", method]).expect("NIP-98 method tag"), + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).expect("NIP-98 nonce tag"), + ]; + if !body.is_empty() { + tags.push(Tag::parse(["payload", &sha256_hex(body)]).expect("NIP-98 payload tag")); + } + let event = EventBuilder::new(Kind::Custom(27_235), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + format!("Nostr {}", STANDARD.encode(event.as_json().as_bytes())) +} + +fn blossom_header(keys: &Keys, tag: &str, hash: &str, host: &str) -> String { + let expiration = (Timestamp::now().as_secs() + 55).to_string(); + let tags = vec![ + Tag::parse(["t", tag]).expect("Blossom operation tag"), + Tag::parse(["x", hash]).expect("Blossom hash tag"), + Tag::parse(["expiration", &expiration]).expect("Blossom expiration tag"), + Tag::parse(["server", host]).expect("Blossom server tag"), + ]; + let event = EventBuilder::new(Kind::Custom(24_242), "community-ban route test") + .tags(tags) + .sign_with_keys(keys) + .expect("sign Blossom auth event"); + format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + ) +} + +fn parse_inventory() -> Vec { + INVENTORY + .lines() + .skip(1) + .filter(|line| !line.is_empty()) + .map(|line| { + let columns: Vec<_> = line.split('\t').collect(); + assert_eq!( + columns.len(), + 9, + "malformed community-ban inventory row: {line}" + ); + RouteRow { + id: columns[0].to_string(), + method: columns[2].to_string(), + path: columns[3].to_string(), + exercise: columns[7].to_string(), + control: columns[8].to_string(), + } + }) + .collect() +} + +fn route_path(row: &RouteRow, case: &str, actor: &Principal) -> String { + let uuid = Uuid::new_v4(); + let repo_owner = actor.keys.public_key().to_hex(); + let missing_repo = format!("community-ban-{}", uuid.simple()); + let missing_blob = sha256_hex(format!("missing-{}", uuid.simple()).as_bytes()); + let mut path = row + .path + .replace("{workflow_id}", &uuid.to_string()) + .replace("{run_id}", &Uuid::new_v4().to_string()) + .replace("{sha256_ext}", &format!("{missing_blob}.jpg")) + .replace("{owner}", &repo_owner) + .replace("{repo}", &missing_repo); + if case == "git_info_refs" { + path.push_str("?service=git-upload-pack"); + } + path +} + +fn request_body(case: &str, actor: &Principal) -> Vec { + match case { + "events" => EventBuilder::new(Kind::TextNote, "community-ban route matrix") + .sign_with_keys(&actor.keys) + .expect("sign test message") + .as_json() + .into_bytes(), + "moderation_command" => { + let target = Keys::generate(); + EventBuilder::new(Kind::Custom(9_040), "") + .tags([Tag::parse(["p", &target.public_key().to_hex()]).expect("target tag")]) + .sign_with_keys(&actor.keys) + .expect("sign ban command") + .as_json() + .into_bytes() + } + "query" | "count" => serde_json::to_vec(&serde_json::json!([{ + "kinds": [1], + "limit": 1 + }])) + .expect("serialize Nostr filter"), + "gif_search" | "gif_share" | "invite_mint" => b"{".to_vec(), + "media_upload" => b"not an image file".to_vec(), + "git_upload_pack" | "git_receive_pack" => Vec::new(), + "git_default_post" => serde_json::to_vec(&serde_json::json!({ + "branch": "main", + "expected_manifest": "missing" + })) + .expect("serialize default-branch request"), + _ => Vec::new(), + } +} + +fn git_signing_method<'a>(case: &str, request_method: &'a str) -> &'a str { + match case { + // Smart-HTTP clone and push intentionally reuse a GET-scoped NIP-98 + // credential for their POST pack requests. + "git_upload_pack" | "git_receive_pack" => "GET", + _ => request_method, + } +} + +fn nip98_url_for_case(origin: &str, path: &str, case: &str) -> String { + let repo_root = match case { + "git_info_refs" => path.split_once("/info/refs").map(|(prefix, _)| prefix), + "git_upload_pack" => path.strip_suffix("/git-upload-pack"), + "git_receive_pack" => path.strip_suffix("/git-receive-pack"), + _ => None, + }; + match repo_root { + Some(repo_root) => format!("{origin}{repo_root}"), + None => format!("{origin}{path}"), + } +} + +async fn send_route_request( + client: &Client, + row: &RouteRow, + case: &str, + actor: &Principal, + host: &str, +) -> Response { + let path = route_path(row, case, actor); + let url = format!("{}{}", relay_http_url().trim_end_matches('/'), path); + let signed_url = nip98_url_for_case(&http_origin_for_host(host), &path, case); + let body = request_body(case, actor); + let method = Method::from_bytes(row.method.as_bytes()).expect("inventory HTTP method"); + let mut request = client + .request(method.clone(), url) + .header(reqwest::header::HOST, host) + .header( + reqwest::header::CONTENT_TYPE, + if case == "media_upload" { + "image/jpeg" + } else { + "application/json" + }, + ); + + if case == "media_upload" || case == "media_get" || case == "media_head" { + let (operation, hash) = if case == "media_upload" { + ("upload", sha256_hex(&body)) + } else { + ( + "get", + path.split('/') + .next_back() + .unwrap_or_default() + .split('.') + .next() + .unwrap_or_default() + .to_string(), + ) + }; + let auth = blossom_header(&actor.keys, operation, &hash, host); + request = request.header(reqwest::header::AUTHORIZATION, auth); + if case == "media_upload" { + request = request.header("X-SHA-256", hash); + } + } else { + let auth_method = git_signing_method(case, row.method.as_str()); + let auth = nip98_header(&actor.keys, &signed_url, auth_method, &body); + request = request.header(reqwest::header::AUTHORIZATION, auth); + } + + if let Some(tag) = &actor.nip_oa_tag { + request = request.header("x-auth-tag", tag); + } + if !body.is_empty() + || method == Method::POST + || method == Method::PUT + || method == Method::PATCH + { + request = request.body(body); + } + request + .timeout(Duration::from_secs(15)) + .send() + .await + .unwrap_or_else(|error| { + panic!("{} {} for {} failed: {error}", row.method, path, actor.name) + }) +} + +fn matrix_cases(row: &RouteRow) -> Vec<&str> { + row.exercise + .strip_prefix("matrix:") + .expect("route-matrix exercise") + .split('+') + .collect() +} + +fn assert_ban_denial( + response_status: StatusCode, + body: &str, + route: &RouteRow, + case: &str, + actor: &str, +) { + assert_eq!( + response_status, + StatusCode::FORBIDDEN, + "{} {} case {case}, {actor} must be denied by the ban gate; body: {body}", + route.method, + route.path + ); + if route.id.starts_with("git_default_branch") { + assert_eq!( + body, r#"{"error":"blocked: you are banned from this community"}"#, + "{} {} must preserve Git settings' ban denial response; body: {body}", + route.method, route.path + ); + } else if route.id.starts_with("git_") { + assert!( + body.contains("blocked: banned from this community"), + "{} {} must preserve Git's ban denial contract; body: {body}", + route.method, + route.path + ); + } else if route.method == "HEAD" { + assert!( + body.is_empty(), + "{} {} must suppress the denial body for HEAD; body: {body}", + route.method, + route.path + ); + } else if route.id.starts_with("media_") { + // Blossom deliberately maps both non-membership and ban refusals to + // the same policy response. These fixtures are direct relay members, + // and the paired clear member control proves this is the ban gate. + assert_eq!( + body, r#"{"error":"relay membership required"}"#, + "{} {} must preserve Blossom's policy-denial response; body: {body}", + route.method, route.path + ); + } else { + assert!( + body.contains(BANNED_MESSAGE), + "{} {} must preserve the community ban denial contract; body: {body}", + route.method, + route.path + ); + } +} + +fn assert_allowed_control(response_status: StatusCode, body: &str, route: &RouteRow, case: &str) { + assert!( + matches!(response_status.as_u16(), 200..=299 | 400 | 404 | 409 | 415 | 422), + "{} {} case {case}: unbanned control must pass authentication and authorization, then reach the fixture's expected success or input/resource result; got {}: {body}", + route.method, + route.path, + response_status + ); + let denial_message = if route.id.starts_with("git_default_branch") { + BANNED_MESSAGE + } else if route.id.starts_with("git_") { + "blocked: banned from this community" + } else if route.id.starts_with("media_") { + "relay membership required" + } else { + BANNED_MESSAGE + }; + assert!( + !body.contains(denial_message), + "{} {} case {case}: allowed control must not receive a ban denial; body: {body}", + route.method, + route.path + ); +} + +fn principal<'a>(name: &str, principals: &[&'a Principal]) -> &'a Principal { + principals + .iter() + .find(|principal| principal.name == name) + .unwrap_or_else(|| panic!("unknown route-matrix control principal {name}")) +} + +#[tokio::test] +#[ignore] +async fn community_http_ban_matrix() { + let client = Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .expect("build HTTP client"); + let db = pool().await; + let host = test_host(); + let community = ensure_community(&db, &host).await; + let implemented_cases: BTreeSet<_> = COMMUNITY_BAN_MATRIX_CASES.iter().copied().collect(); + let inventory = parse_inventory(); + let inventory_cases: BTreeSet<_> = inventory + .iter() + .filter(|route| route.exercise.starts_with("matrix:")) + .flat_map(matrix_cases) + .collect(); + assert_eq!( + inventory_cases, implemented_cases, + "every route-matrix exercise must have a behavior implementation" + ); + + let banned_member = Principal { + name: "member", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let banned_admin = Principal { + name: "admin", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let banned_agent_keys = Keys::generate(); + let banned_agent = Principal { + name: "agent", + nip_oa_tag: Some( + buzz_sdk::nip_oa::compute_auth_tag( + &banned_admin.keys, + &banned_agent_keys.public_key(), + "", + ) + .expect("sign delegated-agent owner proof"), + ), + keys: banned_agent_keys, + }; + let banned_moderator_agent_keys = Keys::generate(); + let banned_moderator_agent = Principal { + name: "moderator-agent", + nip_oa_tag: Some( + buzz_sdk::nip_oa::compute_auth_tag( + &banned_admin.keys, + &banned_moderator_agent_keys.public_key(), + "", + ) + .expect("sign moderator-agent owner proof"), + ), + keys: banned_moderator_agent_keys, + }; + let clear_member = Principal { + name: "member", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let clear_admin = Principal { + name: "admin", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let clear_owner = Principal { + name: "owner", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let clear_moderator_agent_keys = Keys::generate(); + let clear_moderator_agent = Principal { + name: "clear-moderator-agent", + nip_oa_tag: Some( + buzz_sdk::nip_oa::compute_auth_tag( + &clear_owner.keys, + &clear_moderator_agent_keys.public_key(), + "", + ) + .expect("sign clear moderator-agent owner proof"), + ), + keys: clear_moderator_agent_keys, + }; + seed_member(&db, community, &banned_member.keys, "member").await; + seed_member(&db, community, &banned_admin.keys, "admin").await; + seed_member(&db, community, &clear_member.keys, "member").await; + seed_member(&db, community, &clear_admin.keys, "admin").await; + seed_member(&db, community, &clear_owner.keys, "owner").await; + seed_member(&db, community, &banned_moderator_agent.keys, "admin").await; + seed_member(&db, community, &clear_moderator_agent.keys, "admin").await; + ban_member(&db, community, &clear_owner.keys, &banned_member.keys).await; + ban_member(&db, community, &clear_owner.keys, &banned_admin.keys).await; + + let banned = [&banned_member, &banned_admin, &banned_agent]; + let controls = [ + &clear_member, + &clear_admin, + &clear_owner, + &clear_moderator_agent, + ]; + for route in parse_inventory() + .into_iter() + .filter(|route| route.exercise.starts_with("matrix:")) + { + for case in matrix_cases(&route) { + let ban_actors: Vec<&Principal> = if case == "moderation_owner_ban" { + vec![&banned_moderator_agent] + } else { + banned.to_vec() + }; + for actor in ban_actors { + let response = send_route_request(&client, &route, case, actor, &host).await; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + assert_ban_denial(status, &body, &route, case, actor.name); + } + + let control_name = match case { + "moderation_command" => "owner", + "moderation_owner_ban" => "clear-moderator-agent", + _ => route.control.as_str(), + }; + let control = principal(control_name, &controls); + let response = send_route_request(&client, &route, case, control, &host).await; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + assert_allowed_control(status, &body, &route, case); + } + } +} + +#[tokio::test] +#[ignore] +async fn ban_is_scoped_to_host_resolved_community() { + let client = Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .expect("build HTTP client"); + let db = pool().await; + let host_a = test_host(); + let host_b = test_host(); + let community_a = ensure_community(&db, &host_a).await; + let community_b = ensure_community(&db, &host_b).await; + let actor = Principal { + name: "tenant-control", + keys: Keys::generate(), + nip_oa_tag: None, + }; + let issuer = Keys::generate(); + seed_member(&db, community_a, &actor.keys, "member").await; + seed_member(&db, community_b, &actor.keys, "member").await; + ban_member(&db, community_a, &issuer, &actor.keys).await; + + let route = parse_inventory() + .into_iter() + .find(|route| route.id == "http_query") + .expect("query route is inventoried"); + let in_b = send_route_request(&client, &route, "query", &actor, &host_b).await; + let in_b_status = in_b.status(); + let in_b_body = in_b.text().await.unwrap_or_default(); + assert_eq!( + in_b_status, + StatusCode::OK, + "ban in A must not block B: {in_b_body}" + ); + + let in_a = send_route_request(&client, &route, "query", &actor, &host_a).await; + let in_a_status = in_a.status(); + let in_a_body = in_a.text().await.unwrap_or_default(); + assert_ban_denial(in_a_status, &in_a_body, &route, "query", actor.name); +} + +#[tokio::test] +#[ignore] +async fn banned_root_websocket_is_refused() { + let db = pool().await; + let host = relay_authority(); + let community = ensure_community(&db, &host).await; + let issuer = Keys::generate(); + let banned = Keys::generate(); + let clear = Keys::generate(); + let banned_owner = Keys::generate(); + let owner_agent = Keys::generate(); + ban_member(&db, community, &issuer, &banned).await; + ban_member(&db, community, &issuer, &banned_owner).await; + + match BuzzTestClient::connect(&relay_url(), &banned).await { + Err(error) => assert!( + error.to_string().to_ascii_lowercase().contains("banned"), + "root WebSocket rejection must identify the ban, got {error}" + ), + Ok(client) => { + let _ = client.disconnect().await; + panic!("a community-banned principal authenticated on the root WebSocket") + } + } + + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&banned_owner, &owner_agent.public_key(), "") + .expect("sign owner proof for WebSocket agent"); + let auth_tag: Vec = serde_json::from_str(&auth_tag).expect("decode owner proof"); + let auth_tag = Tag::parse(auth_tag).expect("parse NIP-OA auth tag"); + let mut agent_socket = BuzzTestClient::connect_unauthenticated(&relay_url()) + .await + .expect("open root WebSocket for owner-banned agent"); + let error = agent_socket + .authenticate_with_nip_oa(&owner_agent, &auth_tag) + .await + .expect_err("a NIP-OA agent of a banned owner must be refused"); + assert!( + error.to_string().to_ascii_lowercase().contains("banned"), + "root WebSocket owner ban must identify the restriction, got {error}" + ); + drop(agent_socket); + + let control = BuzzTestClient::connect(&relay_url(), &clear) + .await + .unwrap_or_else(|error: TestClientError| { + panic!("unbanned root WebSocket control must authenticate: {error}") + }); + control + .disconnect() + .await + .expect("disconnect unbanned root WebSocket control"); +} diff --git a/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv b/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv new file mode 100644 index 00000000000..e2a7abee16f --- /dev/null +++ b/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv @@ -0,0 +1,20 @@ +id file test lane property +http_membership crates/buzz-relay/src/handlers/ingest.rs relay_membership_step_refuses_banned_member_on_http postgres-ci ban_and_unbanned_http_gate +root_admission crates/buzz-relay/src/handlers/auth.rs fi_ban_denial_emits_terminal_authorization_denied postgres-ci root_nip42_ban_denial +root_admission_race crates/buzz-relay/src/handlers/auth.rs ban_committed_during_auth_refuses_admission postgres-ci admission_ban_race_and_other_community_control +root_ban_eviction crates/buzz-relay/src/handlers/relay_admin.rs ban_closes_socket_even_when_audit_insert_fails postgres-ci root_ban_closes_local_socket_before_failed_audit +root_owner_eviction crates/buzz-relay/src/handlers/auth.rs root_auth_records_owner_for_lookup_free_revoke postgres-ci root_socket_owner_cascade_eviction +root_fail_closed crates/buzz-relay/src/handlers/auth.rs fi_ban_check_error_emits_terminal_authorization_unavailable unit root_restriction_lookup_unavailable +root_db_error crates/buzz-relay/src/handlers/auth.rs handler_accounts_ban_check_database_error unit root_restriction_lookup_unavailable_off_mode +audio_admission crates/buzz-relay/src/audio/handler.rs audio_join_rejects_community_banned_member postgres-ci huddle_banned_member_admission +audio_admin_admission crates/buzz-relay/src/audio/handler.rs audio_join_rejects_banned_admin postgres-ci huddle_banned_admin_admission_and_unbanned_control +audio_owner_admission crates/buzz-relay/src/audio/handler.rs audio_join_rejects_banned_owner_agent postgres-ci huddle_agent_banned_through_owner +audio_local_eviction crates/buzz-relay/src/audio/handler.rs ban_disconnect_closes_live_audio_socket postgres-ci pod_local_huddle_socket_eviction +audio_admission_race crates/buzz-relay/src/audio/handler.rs nip_fi_audio_ban_during_final_admission_sends_canonical_denial postgres-ci huddle_admission_ban_race +tenant_local_eviction crates/buzz-relay/src/state.rs disconnect_pubkey_is_fenced_to_the_banning_community unit pod_local_tenant_fence +media_fail_closed crates/buzz-relay/src/api/media.rs failed_restriction_lookup_is_503_not_403 postgres-ci media_upload_get_head_fail_closed +git_fail_closed crates/buzz-relay/src/api/git/transport.rs ban_gate_fails_closed_with_503_when_the_store_is_unreachable postgres-ci git_restriction_lookup_fail_closed +ingest_owner_ban crates/buzz-relay/src/handlers/ingest.rs owner_ban_blocks_every_agent_write_until_unban postgres-ci agent_owner_ban_write_gate +moderation_owner_ban crates/buzz-relay/src/api/bridge.rs moderation_read_honors_fresh_and_stored_banned_agent_owners postgres-ci moderation_owner_ban_auth_time_and_stored_link +timeout_control crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs banned_admin_is_refused_but_timed_out_admin_still_administers relay-e2e timeout_is_not_ban +http_tenant_isolation crates/buzz-test-client/tests/community_ban_routes.rs ban_is_scoped_to_host_resolved_community relay-e2e http_ban_tenant_isolation diff --git a/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv new file mode 100644 index 00000000000..0738a942968 --- /dev/null +++ b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv @@ -0,0 +1,69 @@ +id listener method path auth_plane membership restriction exercise control +relay_root_ws relay GET / dual_nip11_nip42 conditional conditional_enforce e2e:banned_root_websocket_is_refused unbanned +relay_info relay GET /info public_metadata not_applicable not_applicable:public_metadata classified - +nip05 relay GET /.well-known/nostr.json public_metadata not_applicable not_applicable:public_metadata classified - +relay_health relay GET /health health_probe not_applicable not_applicable:health_probe classified - +relay_liveness relay GET /_liveness health_probe not_applicable not_applicable:health_probe classified - +relay_readiness relay GET /_readiness health_probe not_applicable not_applicable:health_probe classified - +http_events relay POST /events community_nip98 community enforce matrix:events+moderation_command member +http_query relay POST /query community_nip98 community enforce matrix:query member +http_count relay POST /count community_nip98 community enforce matrix:count member +gif_search relay POST /gifs/search community_nip98 community enforce matrix:gif_search member +gif_share relay POST /gifs/share community_nip98 community enforce matrix:gif_share member +workflow_runs relay GET /workflows/{workflow_id}/runs community_nip98 community enforce matrix:workflow_runs member +workflow_approvals relay GET /workflows/{workflow_id}/runs/{run_id}/approvals community_nip98 community enforce matrix:workflow_approvals member +operator_list_communities relay GET /operator/communities operator_control not_applicable not_applicable:relay_operator_control classified - +operator_provision_community relay POST /operator/communities operator_control not_applicable not_applicable:relay_operator_control classified - +operator_register_listener_keys relay POST /operator/listener/pubkeys operator_control not_applicable not_applicable:relay_operator_control classified - +operator_remove_listener_keys relay DELETE /operator/listener/pubkeys operator_control not_applicable not_applicable:relay_operator_control classified - +operator_archive_community relay POST /operator/communities/archive operator_control not_applicable not_applicable:relay_operator_control classified - +operator_unarchive_community relay POST /operator/communities/unarchive operator_control not_applicable not_applicable:relay_operator_control classified - +operator_delete_community relay POST /operator/communities/delete operator_control not_applicable not_applicable:relay_operator_control classified - +operator_community_availability relay GET /operator/communities/availability operator_control not_applicable not_applicable:relay_operator_control classified - +operator_transfer_community relay POST /operator/communities/transfer operator_control not_applicable not_applicable:relay_operator_control classified - +invite_mint relay POST /api/invites community_nip98 community enforce matrix:invite_mint admin +join_policy relay GET /api/join-policy public_metadata not_applicable not_applicable:public_metadata classified - +join_policy_terms relay GET /api/join-policy/terms public_metadata not_applicable not_applicable:public_metadata classified - +join_policy_privacy relay GET /api/join-policy/privacy public_metadata not_applicable not_applicable:public_metadata classified - +invite_accept_policy relay POST /api/invites/accept-policy pre_membership_anonymous exempt not_applicable:no_authenticated_principal classified - +invite_claim relay POST /api/invites/claim pre_membership_nip98 exempt enforce pending:BUZZ-268 BUZZ-268 +nip_fi_disconnect relay POST /api/nip-fi/disconnect internal_nip_fi not_applicable not_applicable:internal_auth_plane classified - +moderation_reports relay GET /moderation/reports community_nip98 community enforce matrix:moderation_reports+moderation_owner_ban admin +moderation_audit relay GET /moderation/audit community_nip98 community enforce matrix:moderation_audit+moderation_owner_ban admin +moderation_restricted relay GET /moderation/restricted community_nip98 community enforce matrix:moderation_restricted+moderation_owner_ban admin +workflow_webhook relay POST /hooks/{id} workflow_webhook_secret not_applicable not_applicable:webhook_secret_auth classified - +mesh_demo_echo relay POST /_mesh/demo/echo testbed_internal not_applicable not_applicable:testbed_only classified - +huddle_audio relay GET /huddle/{channel_id}/audio community_nip42 community enforce pg:audio_join_rejects_community_banned_member+audio_join_rejects_banned_admin+audio_join_rejects_banned_owner_agent - +media_upload relay PUT /upload community_blossom community enforce matrix:media_upload member +media_upload_alias relay PUT /media/upload community_blossom community enforce matrix:media_upload member +media_read_get relay GET /media/{sha256_ext} community_blossom community enforce matrix:media_get member +media_read_head relay HEAD /media/{sha256_ext} community_blossom community enforce matrix:media_head member +git_info_refs relay GET /git/{owner}/{repo}/info/refs community_git_nip98 community enforce matrix:git_info_refs admin +git_upload_pack relay POST /git/{owner}/{repo}/git-upload-pack community_git_nip98 community enforce matrix:git_upload_pack admin +git_receive_pack relay POST /git/{owner}/{repo}/git-receive-pack community_git_nip98 community enforce matrix:git_receive_pack admin +git_default_branch_get relay GET /git/{owner}/{repo}/default-branch community_git_nip98 community enforce matrix:git_default_get admin +git_default_branch_post relay POST /git/{owner}/{repo}/default-branch community_git_nip98 community enforce matrix:git_default_post admin +git_policy_hook relay POST /internal/git/policy internal_git_hook internal not_applicable:internal_auth_plane classified - +admin_probe relay GET /api/admin/v1/probe operator_control not_applicable not_applicable:relay_admin_control classified - +admin_reports_list relay GET /api/admin/v1/reports operator_control not_applicable not_applicable:relay_admin_control classified - +admin_report_detail relay GET /api/admin/v1/reports/{id} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_report_resolve relay POST /api/admin/v1/reports/{id}/resolve operator_control not_applicable not_applicable:relay_admin_control classified - +admin_report_reopen relay POST /api/admin/v1/reports/{id}/reopen operator_control not_applicable not_applicable:relay_admin_control classified - +admin_report_cancel relay POST /api/admin/v1/reports/{id}/cancel operator_control not_applicable not_applicable:relay_admin_control classified - +admin_feedback_list relay GET /api/admin/v1/feedback operator_control not_applicable not_applicable:relay_admin_control classified - +admin_feedback_detail_get relay GET /api/admin/v1/feedback/{id} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_feedback_detail_patch relay PATCH /api/admin/v1/feedback/{id} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_feedback_attachment relay GET /api/admin/v1/feedback/{id}/attachments/{sha256} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_operators_list relay GET /api/admin/v1/operators operator_control not_applicable not_applicable:relay_admin_control classified - +admin_operator_upsert relay PUT /api/admin/v1/operators/{pubkey} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_operator_delete relay DELETE /api/admin/v1/operators/{pubkey} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_restrictions_list relay GET /api/admin/v1/members/restrictions operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_unban relay DELETE /api/admin/v1/members/{pubkey}/ban operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_untimeout relay DELETE /api/admin/v1/members/{pubkey}/timeout operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_ban relay POST /api/admin/v1/members/{pubkey}/ban operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_timeout relay POST /api/admin/v1/members/{pubkey}/timeout operator_control not_applicable not_applicable:relay_admin_control classified - +admin_delete_event relay POST /api/admin/v1/events/{id}/delete operator_control not_applicable not_applicable:relay_admin_control classified - +health_liveness health GET /_liveness health_probe not_applicable not_applicable:health_probe classified - +health_readiness health GET /_readiness health_probe not_applicable not_applicable:health_probe classified - +health_status health GET /_status health_probe not_applicable not_applicable:health_probe classified - +health_mesh health GET /_mesh health_probe not_applicable not_applicable:health_probe classified - diff --git a/scripts/check-community-ban-route-inventory.py b/scripts/check-community-ban-route-inventory.py new file mode 100644 index 00000000000..0caf26d4c09 --- /dev/null +++ b/scripts/check-community-ban-route-inventory.py @@ -0,0 +1,556 @@ +#!/usr/bin/env python3 +"""Keep the community-ban route inventory bound to production Axum routes.""" + +from __future__ import annotations + +import ast +import csv +import re +import sys +from collections import Counter +from functools import lru_cache +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +RELAY_SRC = ROOT / "crates/buzz-relay/src" +ROUTE_INVENTORY = ( + ROOT / "crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv" +) +REGRESSION_INVENTORY = ( + ROOT / "crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv" +) +ROUTE_TEST = ROOT / "crates/buzz-test-client/tests/community_ban_routes.rs" +HTTP_METHODS = { + "get": "GET", + "head": "HEAD", + "post": "POST", + "put": "PUT", + "delete": "DELETE", + "patch": "PATCH", + "options": "OPTIONS", + "trace": "TRACE", + "connect": "CONNECT", + "any": "ANY", +} +AUTH_PLANES = { + "dual_nip11_nip42", + "public_metadata", + "health_probe", + "community_nip98", + "community_blossom", + "community_git_nip98", + "community_nip42", + "operator_control", + "pre_membership_anonymous", + "pre_membership_nip98", + "internal_nip_fi", + "workflow_webhook_secret", + "testbed_internal", + "internal_git_hook", +} +MEMBERSHIP_CLASSES = { + "community", + "conditional", + "exempt", + "internal", + "not_applicable", +} +RESTRICTION_CLASSES = {"enforce", "conditional_enforce"} +REQUIRED_REGRESSIONS = { + "http_membership", + "root_admission", + "root_admission_race", + "root_ban_eviction", + "root_owner_eviction", + "root_fail_closed", + "root_db_error", + "audio_admission", + "audio_admin_admission", + "audio_owner_admission", + "audio_local_eviction", + "audio_admission_race", + "tenant_local_eviction", + "media_fail_closed", + "git_fail_closed", + "ingest_owner_ban", + "moderation_owner_ban", + "timeout_control", + "http_tenant_isolation", +} +RAW_STRING_PREFIX = re.compile(r"(?:b)?r(#+)?\"") +CHAR_LITERAL = re.compile(r"'(?:\\(?:u\{[0-9a-fA-F_]+\}|.)|[^'\\])'") + + +def mask_comments_and_strings(source: str) -> str: + """Replace comments and string contents with spaces, preserving offsets.""" + out = list(source) + i = 0 + while i < len(source): + if source.startswith("//", i): + end = source.find("\n", i) + end = len(source) if end < 0 else end + for j in range(i, end): + out[j] = " " + i = end + continue + if source.startswith("/*", i): + depth = 1 + j = i + 2 + while j < len(source) and depth: + if source.startswith("/*", j): + depth += 1 + j += 2 + elif source.startswith("*/", j): + depth -= 1 + j += 2 + else: + j += 1 + for k in range(i, j): + if out[k] != "\n": + out[k] = " " + i = j + continue + + raw_prefix = RAW_STRING_PREFIX.match(source, i) + if raw_prefix: + hashes = raw_prefix.group(1) or "" + end_token = '"' + hashes + start = i + body_start = raw_prefix.end() + end = source.find(end_token, body_start) + end = len(source) if end < 0 else end + len(end_token) + for j in range(start, end): + if out[j] != "\n": + out[j] = " " + i = end + continue + + if source[i] == '"' or ( + source[i] == "b" and i + 1 < len(source) and source[i + 1] == '"' + ): + start = i + quote = i if source[i] == '"' else i + 1 + j = quote + 1 + while j < len(source): + if source[j] == "\\": + j += 2 + continue + if source[j] == '"': + j += 1 + break + j += 1 + for k in range(start, j): + if out[k] != "\n": + out[k] = " " + i = j + continue + + char_literal = CHAR_LITERAL.match(source, i) + if char_literal: + end = char_literal.end() + for j in range(i, end): + if out[j] != "\n": + out[j] = " " + i = end + continue + + i += 1 + return "".join(out) + + +def matching_delimiter(mask: str, start: int) -> int: + opening = mask[start] + pairs = {"(": ")", "[": "]", "{": "}"} + closing = pairs[opening] + depth = 0 + for index in range(start, len(mask)): + if mask[index] == opening: + depth += 1 + elif mask[index] == closing: + depth -= 1 + if depth == 0: + return index + raise ValueError(f"unclosed {opening!r} at byte {start}") + + +def test_module_ranges(mask: str) -> list[tuple[int, int]]: + ranges = [] + pattern = re.compile( + r"#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]\s*" + r"(?:#\s*\[[^\]]*\]\s*)*(?:pub(?:\([^)]*\))?\s+)?" + r"mod\s+[A-Za-z_]\w*\s*\{" + ) + for match in pattern.finditer(mask): + brace = mask.find("{", match.start(), match.end()) + ranges.append((match.start(), matching_delimiter(mask, brace) + 1)) + return ranges + + +def in_ranges(position: int, ranges: list[tuple[int, int]]) -> bool: + return any(start <= position < end for start, end in ranges) + + +def router_functions(path: Path, source: str, mask: str): + pattern = re.compile(r"\bfn\s+([A-Za-z_]\w*)\s*\(") + excluded = test_module_ranges(mask) + relative = path.relative_to(RELAY_SRC).with_suffix("") + module_parts = list(relative.parts) + if module_parts[-1] == "mod": + module_parts.pop() + module = "::".join(module_parts) + + for match in pattern.finditer(mask): + name = match.group(1) + if name != "router" and not name.endswith("_router"): + continue + if in_ranges(match.start(), excluded): + continue + paren = mask.find("(", match.start(), match.end()) + close_paren = matching_delimiter(mask, paren) + brace = mask.find("{", close_paren + 1) + if brace < 0: + continue + end = matching_delimiter(mask, brace) + 1 + yield f"{module}::{name}", name, match.start(), brace, end + + +def split_first_top_level_comma(mask: str, start: int, end: int) -> int | None: + stack: list[str] = [] + pairs = {"(": ")", "[": "]", "{": "}"} + for index in range(start, end): + char = mask[index] + if char in pairs: + stack.append(pairs[char]) + elif stack and char == stack[-1]: + stack.pop() + elif char == "," and not stack: + return index + return None + + +def string_value(expression: str) -> str | None: + expression = expression.strip() + if expression.startswith('"'): + try: + value = ast.literal_eval(expression) + except (SyntaxError, ValueError): + return None + return value if isinstance(value, str) else None + raw = re.fullmatch(r'r(#+)?"(.*)"\1', expression, re.DOTALL) + return raw.group(2) if raw else None + + +def route_constants() -> dict[str, str]: + constants = {} + pattern = re.compile( + r"(?:pub(?:\([^)]*\))?\s+)?const\s+([A-Za-z_]\w*)\s*:\s*&str\s*=\s*" + r'("(?:\\.|[^"\\])*"|r(?:#+)?".*?"(?:#+)?)\s*;', + re.DOTALL, + ) + for path in RELAY_SRC.rglob("*.rs"): + if "tests" in path.parts or path.name.endswith("_tests.rs"): + continue + for match in pattern.finditer(path.read_text()): + value = string_value(match.group(2)) + if value is not None: + constants[match.group(1)] = value + return constants + + +def route_path(expression: str, constants: dict[str, str]) -> str: + literal = string_value(expression) + if literal is not None: + return literal + constant = re.search(r"([A-Za-z_]\w*)\s*$", expression.strip()) + if constant and constant.group(1) in constants: + return constants[constant.group(1)] + raise ValueError(f"route path is not a literal or known string constant: {expression.strip()}") + + +def registered_calls(mask: str, source: str, function_start: int, function_end: int, call: str): + pattern = re.compile(rf"\.\s*{re.escape(call)}\s*\(") + for match in pattern.finditer(mask, function_start, function_end): + opening = mask.find("(", match.start(), match.end()) + closing = matching_delimiter(mask, opening) + comma = split_first_top_level_comma(mask, opening + 1, closing) + if comma is None: + continue + yield ( + source[opening + 1 : comma].strip(), + mask[comma + 1 : closing], + source[comma + 1 : closing], + ) + + +def builder_prefixes(builders) -> dict[str, str]: + main = next( + (builder for builder in builders if builder[0] == "router::build_router"), + None, + ) + if main is None: + raise ValueError("could not find relay router::build_router") + _, _, path, source, mask, start, _, end = main + prefixes = {} + for first, second_mask, second_source in registered_calls(mask, source, start, end, "nest"): + prefix = string_value(first) + target_match = re.search(r"((?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)\s*\(", second_mask) + if prefix is None or target_match is None: + continue + target = target_match.group(1) + candidates = [b[0] for b in builders if b[0] == target or b[0].endswith(f"::{target}")] + if len(candidates) == 1: + prefixes[candidates[0]] = prefix + return prefixes + + +def collect_registered_routes() -> Counter: + constants = route_constants() + builders = [] + for path in RELAY_SRC.rglob("*.rs"): + if "tests" in path.parts or path.name.endswith("_tests.rs"): + continue + source = path.read_text() + mask = mask_comments_and_strings(source) + for qualified_name, name, start, brace, end in router_functions(path, source, mask): + builders.append((qualified_name, name, path, source, mask, brace, start, end)) + + prefixes = builder_prefixes(builders) + registrations: Counter = Counter() + errors = [] + for path in RELAY_SRC.rglob("*.rs"): + if "tests" in path.parts or path.name.endswith("_tests.rs"): + continue + source = path.read_text() + mask = mask_comments_and_strings(source) + test_ranges = test_module_ranges(mask) + router_ranges = [ + (builder[6], builder[7]) for builder in builders if builder[2] == path + ] + for match in re.finditer(r"\.\s*route\s*\(", mask): + if in_ranges(match.start(), test_ranges): + continue + if not any(start <= match.start() < end for start, end in router_ranges): + line = source.count("\n", 0, match.start()) + 1 + errors.append( + f"{path.relative_to(ROOT)}:{line}: production .route registration " + "is outside a recognized router builder" + ) + + for qualified_name, name, path, source, mask, _, start, end in builders: + listener = "health" if qualified_name == "router::build_health_router" else "relay" + prefix = prefixes.get(qualified_name, "") + for path_expression, method_mask, _ in registered_calls(mask, source, start, end, "route"): + try: + route = route_path(path_expression, constants) + except ValueError as error: + errors.append(f"{path.relative_to(ROOT)}::{name}: {error}") + continue + methods = { + method.upper() + for method in re.findall( + r"\b(get|head|post|put|delete|patch|options|trace|connect|any)\s*\(", + method_mask, + ) + } + if not methods: + errors.append( + f"{path.relative_to(ROOT)}::{name}: no Axum HTTP method in route expression" + ) + continue + full_path = f"{prefix.rstrip('/')}{route}" if prefix else route + for method in methods: + registrations[(listener, method, full_path)] += 1 + + if errors: + raise ValueError("route scanner could not classify registrations:\n " + "\n ".join(errors)) + duplicates = [route for route, count in registrations.items() if count > 1] + if duplicates: + rendered = ", ".join(f"{scope} {method} {path}" for scope, method, path in duplicates) + raise ValueError(f"duplicate production route registrations: {rendered}") + return registrations + + +def read_tsv(path: Path, expected_header: list[str]) -> list[dict[str, str]]: + with path.open(newline="") as file: + reader = csv.DictReader(file, delimiter="\t") + if reader.fieldnames != expected_header: + raise ValueError(f"{path.relative_to(ROOT)} header must be {expected_header}") + rows = list(reader) + if any(None in row or any(value is None for value in row.values()) for row in rows): + raise ValueError(f"{path.relative_to(ROOT)} contains a row with the wrong number of columns") + return rows + + +@lru_cache(maxsize=None) +def source_mask(path: Path) -> str | None: + if not path.is_file(): + return None + return mask_comments_and_strings(path.read_text()) + + +def test_function_exists(path: Path, name: str) -> bool: + mask = source_mask(path) + if mask is None: + return False + return re.search(rf"\bfn\s+{re.escape(name)}\s*\(", mask) is not None + + +def check_route_inventory() -> tuple[int, int, int]: + routes = read_tsv( + ROUTE_INVENTORY, + [ + "id", + "listener", + "method", + "path", + "auth_plane", + "membership", + "restriction", + "exercise", + "control", + ], + ) + by_key: dict[tuple[str, str, str], dict[str, str]] = {} + ids = set() + matrix_cases = set() + pending = [] + for row in routes: + route_id = row["id"] + if not route_id or route_id in ids: + raise ValueError(f"duplicate or empty route id: {route_id!r}") + ids.add(route_id) + key = (row["listener"], row["method"], row["path"]) + if key in by_key: + raise ValueError(f"duplicate inventory route: {key}") + by_key[key] = row + if row["listener"] not in {"relay", "health"}: + raise ValueError(f"{route_id}: unknown listener {row['listener']!r}") + if row["auth_plane"] not in AUTH_PLANES: + raise ValueError(f"{route_id}: unknown auth plane {row['auth_plane']!r}") + if row["membership"] not in MEMBERSHIP_CLASSES: + raise ValueError(f"{route_id}: unknown membership class {row['membership']!r}") + if row["method"] not in set(HTTP_METHODS.values()): + raise ValueError(f"{route_id}: unknown HTTP method {row['method']!r}") + + restriction = row["restriction"] + exercise = row["exercise"] + if restriction in RESTRICTION_CLASSES: + if not exercise or exercise == "classified": + raise ValueError(f"{route_id}: enforced restriction needs a behavior exercise") + elif restriction.startswith("not_applicable:"): + if not restriction.partition(":")[2] or exercise != "classified": + raise ValueError(f"{route_id}: restriction exemption needs a reason and classification") + else: + raise ValueError(f"{route_id}: unrecognized restriction class {restriction!r}") + + if row["membership"] == "exempt" and restriction.startswith("not_applicable:"): + if route_id != "invite_accept_policy": + raise ValueError( + f"{route_id}: membership exemption cannot silently exempt restriction" + ) + if exercise.startswith("matrix:"): + if row["control"] not in {"member", "admin", "owner"}: + raise ValueError(f"{route_id}: route-matrix row needs an allowed control principal") + matrix_cases.update(exercise.partition(":")[2].split("+")) + elif exercise.startswith("e2e:"): + test_name = exercise.partition(":")[2] + if not test_function_exists(ROUTE_TEST, test_name): + raise ValueError(f"{route_id}: missing E2E test {test_name!r}") + elif exercise.startswith("pg:"): + tests = exercise.partition(":")[2].split("+") + for test_name in tests: + found = any( + test_function_exists(path, test_name) + for path in RELAY_SRC.rglob("*.rs") + if "tests" not in path.parts and not path.name.endswith("_tests.rs") + ) + if not found: + raise ValueError(f"{route_id}: missing PostgreSQL regression {test_name!r}") + elif exercise.startswith("pending:"): + pending.append((route_id, exercise)) + elif exercise != "classified": + raise ValueError(f"{route_id}: unrecognized exercise classification {exercise!r}") + + registrations = collect_registered_routes() + registered = set(registrations) + inventoried = set(by_key) + missing = sorted(registered - inventoried) + stale = sorted(inventoried - registered) + if missing or stale: + details = [] + if missing: + details.append("registered routes without inventory rows: " + ", ".join(map(str, missing))) + if stale: + details.append("inventory rows without registered routes: " + ", ".join(map(str, stale))) + raise ValueError("; ".join(details)) + + if len(pending) != 1 or pending[0] != ("invite_claim", "pending:BUZZ-268"): + raise ValueError( + "the only pending route coverage must be invite_claim pending:BUZZ-268" + ) + claim = next((row for row in routes if row["id"] == "invite_claim"), None) + if claim is None or claim["membership"] != "exempt" or claim["restriction"] != "enforce": + raise ValueError("invite claim must remain membership-exempt and restriction-enforced") + acceptance = next((row for row in routes if row["id"] == "invite_accept_policy"), None) + if acceptance is None or acceptance["membership"] != "exempt": + raise ValueError("anonymous policy acceptance needs its separate pre-membership classification") + + route_test_mask = mask_comments_and_strings(ROUTE_TEST.read_text()) + supported = re.search( + r"const\s+COMMUNITY_BAN_MATRIX_CASES\s*:\s*&\[&str\]\s*=\s*&\[(.*?)\];", + route_test_mask, + re.DOTALL, + ) + if supported is None: + raise ValueError("route E2E test must declare COMMUNITY_BAN_MATRIX_CASES") + supported_cases = set(re.findall(r'"([a-z_]+)"', ROUTE_TEST.read_text()[supported.start() : supported.end()])) + if matrix_cases != supported_cases: + raise ValueError( + "route-matrix exercise cases differ from COMMUNITY_BAN_MATRIX_CASES: " + f"inventory-only={sorted(matrix_cases - supported_cases)}, " + f"test-only={sorted(supported_cases - matrix_cases)}" + ) + if "community_http_ban_matrix" not in route_test_mask: + raise ValueError("route inventory is not exercised by community_http_ban_matrix") + return len(routes), len(matrix_cases), len(pending) + + +def check_regression_inventory() -> int: + rows = read_tsv( + REGRESSION_INVENTORY, + ["id", "file", "test", "lane", "property"], + ) + seen = set() + for row in rows: + if not row["id"] or row["id"] in seen: + raise ValueError(f"duplicate or empty supporting regression id: {row['id']!r}") + seen.add(row["id"]) + if row["lane"] not in {"unit", "postgres-ci", "relay-e2e"}: + raise ValueError(f"{row['id']}: unknown validation lane {row['lane']!r}") + path = ROOT / row["file"] + if not test_function_exists(path, row["test"]): + raise ValueError(f"{row['id']}: missing referenced test {row['file']}::{row['test']}") + missing = REQUIRED_REGRESSIONS - seen + if missing: + raise ValueError(f"supporting regression inventory is missing {sorted(missing)}") + return len(rows) + + +def main() -> int: + try: + route_count, case_count, pending_count = check_route_inventory() + regression_count = check_regression_inventory() + except (OSError, ValueError, csv.Error) as error: + print(f"community-ban route inventory failed: {error}", file=sys.stderr) + return 1 + print( + "community-ban route inventory passed: " + f"{route_count} registered routes classified, {case_count} HTTP behavior cases, " + f"{regression_count} supporting regressions indexed, {pending_count} BUZZ-268 invite case pending" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 7858ca9381c..e890fd1d7b1 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -78,6 +78,9 @@ ensure_infra() { run_unit_tests() { section "Unit Tests (no infra required)" + run_test_step "community-ban route inventory" \ + python3 scripts/check-community-ban-route-inventory.py + run_test_step "buzz-core tests" \ cargo test -p buzz-core --lib -- --nocapture From d18981d85b9f640cc829437fa489676aaac06fac Mon Sep 17 00:00:00 2001 From: seiler Date: Mon, 5 Oct 2026 21:27:19 +0000 Subject: [PATCH 02/18] docs(testing): clarify invite ban regression contract Signed-off-by: seiler Co-authored-by: Codex (cherry picked from commit 5bbf2b64a82115c40fdadeb981dbfdf7122241f2) Signed-off-by: Brad Seiler --- TESTING.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/TESTING.md b/TESTING.md index 4bf29e2c827..a6e7ad85175 100644 --- a/TESTING.md +++ b/TESTING.md @@ -66,9 +66,12 @@ principal to restrict. Invite claim is also pre-membership, but it carries a verified NIP-98 principal and remains restriction-enforced: it is the sole row marked pending in the inventory until BUZZ-268 qualifies the updated v1/v2 lifecycle: -retain valid unexpired v1 codes, mint only v2, reject banned claimants and -owners transactionally, permanently invalidate v2 on issuer ban, and honor the -optional absolute invalid-after boundary for v1. Legacy v1 codes have no issuer +retain valid unexpired v1 codes and mint only v2, with a 72-hour default and +30-day maximum lifetime. Both formats reject banned claimants and owners +transactionally, and issuer bans permanently invalidate v2. For v1, an unset +operator `invalid_after` uses natural expiry; when set, the code rejects at or +after that absolute boundary, while an earlier code expiry still takes effect. +That operator boundary does not affect v2. Legacy v1 codes have no issuer identity, so issuer-specific revocation remains limited until a verified fleet drain or security approval. Relay-operator APIs, deployment-admin APIs, NIP-FI commands, the localhost Git hook, and the gated mesh test endpoint use From b1f1dc4de14ba30fceed7c5ca88a14383364d4e5 Mon Sep 17 00:00:00 2001 From: seiler Date: Mon, 5 Oct 2026 21:29:44 +0000 Subject: [PATCH 03/18] docs(testing): clarify webhook ban classification Signed-off-by: seiler Co-authored-by: Codex (cherry picked from commit 2374e1c05352db37eafa42d502a148b57dc21080) Signed-off-by: Brad Seiler --- TESTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TESTING.md b/TESTING.md index a6e7ad85175..ec8ac7a238e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -80,9 +80,9 @@ Workflow webhooks also use a secret-authenticated caller with no user principal. Before creating a run, the handler separately checks the saved workflow owner's channel membership and role, but it does not check that owner's community-ban state. The inventory's `not_applicable:webhook_secret_auth` entry is a -provisional caller-auth classification, not proof that an owner ban is -irrelevant. Whether the owner's ban should gate webhook trigger and run -creation remains an explicit policy question. +provisional restriction classification based on secret-only caller auth, not +proof that an owner ban is irrelevant. Whether the owner's ban should gate +webhook trigger and run creation remains an explicit policy question. Root and huddle admission, final-admission races, fail-closed reads, owner-to- agent restriction, timeout behavior, and tenant-scoped eviction point to their From 9855d86430435cb17f77082ef057b6d107a649fc Mon Sep 17 00:00:00 2001 From: seiler Date: Tue, 6 Oct 2026 15:46:21 +0000 Subject: [PATCH 04/18] test(relay): align timeout admin regression Signed-off-by: seiler Co-authored-by: Codex (cherry picked from commit 31968772ebe1b30c2862966e46b75a9ad4b967b6) Signed-off-by: Brad Seiler --- crates/buzz-relay/src/handlers/ingest.rs | 13 +++-- crates/buzz-relay/src/handlers/relay_admin.rs | 47 +++++++++---------- .../fixtures/community-ban-regressions.tsv | 2 +- .../tests/regression_relay_admin_ban_gate.rs | 44 +++++++++-------- .../check-community-ban-route-inventory.py | 2 +- 5 files changed, 54 insertions(+), 54 deletions(-) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index f9b6f4b58db..0b0d26d6418 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2719,10 +2719,10 @@ async fn ingest_event_inner( // Community moderation commands (9040–9044) are direct, community-global // mutations. They are never stored or fanned out as ordinary events; the // handler writes the durable audit/enforcement rows after its own capability - // authorization. These commands are intentionally routed before the - // timeout/write-block gate below so a timed-out admin can lift a timeout. - // The handler independently checks the durable ban state before executing - // any command, which also covers NIP-98 and missed live disconnects. + // authorization. The write-restriction gate above permits only unban and + // untimeout commands during an active timeout. The handler independently + // checks durable ban state before executing any command, which also covers + // NIP-98 and missed live disconnects. if buzz_core::kind::is_moderation_command_kind(kind_u32) { super::moderation_commands::handle_moderation_command(tenant, state, &event).await?; return Ok(IngestResult { @@ -2924,9 +2924,8 @@ async fn ingest_event_inner( } // Handled directly — these mutate relay_members and do NOT get stored. - // The handler enforces the durable community ban itself: the write-path - // gate above exempts relay-admin kinds so timed-out admins keep their - // administrative capability, which leaves bans to the handler. + // The write-restriction gate above rejects banned and timed-out actors + // before this handler; the handler also checks durable community-ban state. if is_relay_admin_kind(event.kind.as_u16() as u32) { crate::handlers::relay_admin::handle_relay_admin_event(tenant, state, &event) .await diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index bd5f7c56a41..9bf715172f0 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -149,12 +149,12 @@ pub(super) enum RelayAdminError { Internal(String), } -/// Decide whether a durable restriction state admits a relay-admin command. +/// Apply the relay-admin handler's local ban check. /// -/// Ban only, deliberately: a timeout is a write-block on *content*, and -/// `ingest_event` exempts relay-admin kinds from its durable write-path gate -/// precisely so restricted-but-not-banned admins retain their administrative -/// capability. Mirrors `moderation_commands::ensure_actor_not_banned`. +/// `ingest_event` runs the shared write-restriction gate before relay-admin +/// dispatch, where active timeouts are rejected. This check independently +/// preserves the handler's durable-ban invariant; passing it does not mean a +/// timed-out command passed the earlier gate. /// /// Split out as a pure function so the admission rule itself is unit-testable /// without a live relay: the end-to-end HTTP test proves the transport, this @@ -182,17 +182,14 @@ pub(super) async fn handle_relay_admin_event( state: &Arc, event: &Event, ) -> Result<(), RelayAdminError> { - // A ban is an admission boundary, not only a WebSocket-auth check. HTTP - // NIP-98 requests and already-authenticated sockets reach this handler - // without passing through a fresh NIP-42 challenge, and `ingest_event` - // exempts relay-admin kinds from its durable write-path gate so a *timed - // out* admin can still administer the roster. That exemption is ban-blind, - // so the ban must be enforced here or not at all: a banned admin otherwise - // keeps mutating `relay_members` — the very table `moderation_authz` - // derives moderator capability from — until someone manually deletes the - // row. Mirrors `moderation_commands.rs`, which defends the same boundary - // for 9040–9044, and holds that file's stated invariant that a direct - // command handler rejects a banned actor on every transport. + // HTTP NIP-98 requests and already-authenticated sockets reach this handler + // without a fresh NIP-42 challenge. The shared write-restriction gate in + // `ingest_event` rejects bans and active timeouts before kind dispatch; this + // handler repeats the durable-ban check as defense in depth before any + // roster mutation. A banned admin must not change `relay_members` — the + // table `moderation_authz` derives moderator capability from. This mirrors + // `moderation_commands.rs`, which applies the same handler-level ban check + // for kinds 9040–9044. // // This gate wraps execution rather than opening it so no future early // return inside the command body can precede it. @@ -531,11 +528,10 @@ mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; - /// The vulnerability this file's ban gate closes: `ingest_event` exempts - /// relay-admin kinds 9030–9033 from its durable write-path restriction - /// gate, so a **banned** admin could add/remove relay members and change - /// the workspace icon over signed NIP-98 `POST /events`. Deleting the - /// admission check must fail here, in the default (non-ignored) suite. + /// The handler-level ban check protects relay-admin commands that reach it + /// after authenticated admission. The shared `ingest_event` restriction + /// gate rejects active timeouts before dispatch; this default (non-ignored) + /// unit test pins the handler's independent durable-ban check. #[test] fn banned_actor_is_not_admitted_to_a_relay_admin_command() { let banned = buzz_db::moderation::RestrictionState { @@ -552,11 +548,10 @@ mod postgres_tests { ); } - /// The counter-invariant, and the reason the ingest exemption exists at - /// all: a timeout restricts *content* writes, not administrative - /// capability. Widening this gate to timeouts would silently change policy. + /// A timeout is not a ban at this handler-local check. The shared ingest + /// gate rejects timed-out relay-admin requests before this check runs. #[test] - fn timed_out_actor_is_still_admitted() { + fn timed_out_actor_passes_local_ban_check() { let timed_out = buzz_db::moderation::RestrictionState { banned: false, own_banned: false, @@ -564,7 +559,7 @@ mod postgres_tests { }; assert!( admits_relay_admin_command(&timed_out).is_ok(), - "a timed-out admin must still administer the roster" + "the local ban check must not classify a timeout as a ban" ); } diff --git a/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv b/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv index e2a7abee16f..8422f392bf4 100644 --- a/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv +++ b/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv @@ -16,5 +16,5 @@ media_fail_closed crates/buzz-relay/src/api/media.rs failed_restriction_lookup_i git_fail_closed crates/buzz-relay/src/api/git/transport.rs ban_gate_fails_closed_with_503_when_the_store_is_unreachable postgres-ci git_restriction_lookup_fail_closed ingest_owner_ban crates/buzz-relay/src/handlers/ingest.rs owner_ban_blocks_every_agent_write_until_unban postgres-ci agent_owner_ban_write_gate moderation_owner_ban crates/buzz-relay/src/api/bridge.rs moderation_read_honors_fresh_and_stored_banned_agent_owners postgres-ci moderation_owner_ban_auth_time_and_stored_link -timeout_control crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs banned_admin_is_refused_but_timed_out_admin_still_administers relay-e2e timeout_is_not_ban +timeout_admin_denial crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs banned_and_timed_out_admins_are_refused_but_unrestricted_admin_can_administer relay-e2e timed_out_admin_relay_admin_denial http_tenant_isolation crates/buzz-test-client/tests/community_ban_routes.rs ban_is_scoped_to_host_resolved_community relay-e2e http_ban_tenant_isolation diff --git a/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs b/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs index c37be8ca404..5d588461f33 100644 --- a/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs +++ b/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs @@ -1,17 +1,14 @@ //! Regression test for the NIP-43 relay-admin durable-ban bypass //! (BUZZ-SEC-007 class, reported 2026-07-27). //! -//! `ingest_event` exempts relay-admin kinds 9030-9033 from its durable -//! write-path restriction gate so a *timed out* admin keeps its administrative -//! capability. That exemption was ban-blind, so a **banned** admin could still -//! add/remove relay members and change the workspace icon via signed NIP-98 -//! `POST /events`. The ban is now enforced inside -//! `relay_admin::handle_relay_admin_event`; this test pins both halves of that -//! contract — bans refused, timeouts still admitted. +//! `ingest_event` runs the durable write-restriction gate before relay-admin +//! kinds 9030-9033. Bans and active timeouts must reject those commands, while +//! an unrestricted admin can still mutate the roster and workspace icon via +//! signed NIP-98 `POST /events`. //! -//! Requires a running relay and its Postgres. Ignored by default: +//! Requires a running relay and its Postgres. Export `DATABASE_URL` for a +//! disposable local database, then run (ignored by default): //! REPRO_RELAY_HTTP=http://localhost:3999 REPRO_HOST=localhost:3999 \ -//! DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz_relay_admin_regression \ //! cargo test -p buzz-test-client --test regression_relay_admin_ban_gate \ //! -- --ignored --nocapture @@ -123,14 +120,14 @@ async fn seed(p: &sqlx::Pool, cid: Uuid, keys: &Keys, role: &str /// Asserts the full contract rather than just "the exploit stopped": /// - banned admin: 403 + exact `blocked:` prefix on 9030/9031/9033, and a /// banned *owner* likewise on 9032 (owner-only kind), covering all four -/// exempt kinds, +/// relay-admin kinds, /// - no roster, role, or icon mutation from any of those attempts, -/// - a *timed-out* admin still reaches relay-admin authorization (the ingest -/// exemption's whole purpose — the fix must not silently widen to timeouts), +/// - a *timed-out* admin gets 403 + the `restricted:` timeout prefix on 9031, +/// and the target member remains in the roster, /// - an unrestricted admin's behaviour is unchanged, mutation included. #[tokio::test] #[ignore] -async fn banned_admin_is_refused_but_timed_out_admin_still_administers() { +async fn banned_and_timed_out_admins_are_refused_but_unrestricted_admin_can_administer() { let p = pool().await; let cid = community_id(&p).await; @@ -290,7 +287,7 @@ async fn banned_admin_is_refused_but_timed_out_admin_still_administers() { "9033: banned admin must not change the workspace icon, got {icon:?}" ); - // ── Timed-out admin: still administers (ingest exemption preserved). ── + // ── Timed-out admin: relay-admin and content writes are refused. ── let (ts, tb) = post_event( &timed_out_admin, &signed( @@ -302,13 +299,22 @@ async fn banned_admin_is_refused_but_timed_out_admin_still_administers() { .await; println!("[timed-out] 9031 remove -> {ts} {tb}"); assert_eq!( - ts, 200, - "timed-out admin must still administer the roster: {tb}" + ts, 403, + "timed-out admin must be refused relay-admin commands: {tb}" + ); + let timeout_message: serde_json::Value = serde_json::from_str(&tb).unwrap_or_default(); + let timeout_text = timeout_message + .get("error") + .and_then(|value| value.as_str()) + .unwrap_or(&tb); + assert!( + timeout_text.starts_with("restricted: you are timed out"), + "timed-out admin denial must use the timeout restriction prefix: {tb}" ); assert_eq!( - role_of(&victim2).await, - None, - "timed-out admin's removal must take effect" + role_of(&victim2).await.as_deref(), + Some("member"), + "timed-out admin must not remove a member" ); // Control: the same timed-out admin is still write-blocked for content. diff --git a/scripts/check-community-ban-route-inventory.py b/scripts/check-community-ban-route-inventory.py index 0caf26d4c09..7345e8117da 100644 --- a/scripts/check-community-ban-route-inventory.py +++ b/scripts/check-community-ban-route-inventory.py @@ -75,7 +75,7 @@ "git_fail_closed", "ingest_owner_ban", "moderation_owner_ban", - "timeout_control", + "timeout_admin_denial", "http_tenant_isolation", } RAW_STRING_PREFIX = re.compile(r"(?:b)?r(#+)?\"") From b52d8f6033ddc9a1f496f6421ae4a9661976704e Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 9 Oct 2026 19:15:14 -0400 Subject: [PATCH 05/18] test(relay): cover current community entry points Keep invitation and webhook-owner gaps explicit until their implementations are integrated. Co-authored-by: Codex Signed-off-by: Brad Seiler --- crates/buzz-test-client/tests/community_ban_routes.rs | 10 +++++++++- .../tests/fixtures/community-ban-route-inventory.tsv | 8 +++++++- scripts/check-community-ban-route-inventory.py | 8 ++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/buzz-test-client/tests/community_ban_routes.rs b/crates/buzz-test-client/tests/community_ban_routes.rs index 3708261a797..de9b3d3eea7 100644 --- a/crates/buzz-test-client/tests/community_ban_routes.rs +++ b/crates/buzz-test-client/tests/community_ban_routes.rs @@ -28,6 +28,8 @@ const BANNED_MESSAGE: &str = "blocked: you are banned from this community"; const COMMUNITY_BAN_MATRIX_CASES: &[&str] = &[ "events", + "buzz_sidebar", + "buzz_read_state", "moderation_command", "query", "count", @@ -268,7 +270,7 @@ fn request_body(case: &str, actor: &Principal) -> Vec { "limit": 1 }])) .expect("serialize Nostr filter"), - "gif_search" | "gif_share" | "invite_mint" => b"{".to_vec(), + "gif_search" | "gif_share" | "invite_mint" | "buzz_read_state" => b"{".to_vec(), "media_upload" => b"not an image file".to_vec(), "git_upload_pack" | "git_receive_pack" => Vec::new(), "git_default_post" => serde_json::to_vec(&serde_json::json!({ @@ -406,6 +408,12 @@ fn assert_ban_denial( route.method, route.path ); + } else if route.id.starts_with("buzz_") { + assert_eq!( + serde_json::from_str::(body).expect("accessory denial JSON") + ["error"]["code"], + "forbidden" + ); } else if route.method == "HEAD" { assert!( body.is_empty(), diff --git a/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv index 0738a942968..b1431793fcf 100644 --- a/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv +++ b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv @@ -31,7 +31,7 @@ nip_fi_disconnect relay POST /api/nip-fi/disconnect internal_nip_fi not_applicab moderation_reports relay GET /moderation/reports community_nip98 community enforce matrix:moderation_reports+moderation_owner_ban admin moderation_audit relay GET /moderation/audit community_nip98 community enforce matrix:moderation_audit+moderation_owner_ban admin moderation_restricted relay GET /moderation/restricted community_nip98 community enforce matrix:moderation_restricted+moderation_owner_ban admin -workflow_webhook relay POST /hooks/{id} workflow_webhook_secret not_applicable not_applicable:webhook_secret_auth classified - +workflow_webhook relay POST /hooks/{id} workflow_webhook_secret not_applicable enforce pending:BUZZ-272 BUZZ-272 mesh_demo_echo relay POST /_mesh/demo/echo testbed_internal not_applicable not_applicable:testbed_only classified - huddle_audio relay GET /huddle/{channel_id}/audio community_nip42 community enforce pg:audio_join_rejects_community_banned_member+audio_join_rejects_banned_admin+audio_join_rejects_banned_owner_agent - media_upload relay PUT /upload community_blossom community enforce matrix:media_upload member @@ -67,3 +67,9 @@ health_liveness health GET /_liveness health_probe not_applicable not_applicable health_readiness health GET /_readiness health_probe not_applicable not_applicable:health_probe classified - health_status health GET /_status health_probe not_applicable not_applicable:health_probe classified - health_mesh health GET /_mesh health_probe not_applicable not_applicable:health_probe classified - +admin_communities relay GET /api/admin/v1/communities operator_control not_applicable not_applicable:relay_admin_control classified - +admin_event relay GET /api/admin/v1/events/{id} operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member_search relay GET /api/admin/v1/members/search operator_control not_applicable not_applicable:relay_admin_control classified - +admin_member relay GET /api/admin/v1/members/{pubkey} operator_control not_applicable not_applicable:relay_admin_control classified - +buzz_sidebar relay GET /buzz/v1/me/sidebar community_nip98 community enforce matrix:buzz_sidebar member +buzz_read_state relay POST /buzz/v1/me/read-state community_nip98 community enforce matrix:buzz_read_state member diff --git a/scripts/check-community-ban-route-inventory.py b/scripts/check-community-ban-route-inventory.py index 7345e8117da..6fbc511f1f4 100644 --- a/scripts/check-community-ban-route-inventory.py +++ b/scripts/check-community-ban-route-inventory.py @@ -293,7 +293,7 @@ def builder_prefixes(builders) -> dict[str, str]: _, _, path, source, mask, start, _, end = main prefixes = {} for first, second_mask, second_source in registered_calls(mask, source, start, end, "nest"): - prefix = string_value(first) + prefix = route_path(first, route_constants()) target_match = re.search(r"((?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)\s*\(", second_mask) if prefix is None or target_match is None: continue @@ -485,9 +485,9 @@ def check_route_inventory() -> tuple[int, int, int]: details.append("inventory rows without registered routes: " + ", ".join(map(str, stale))) raise ValueError("; ".join(details)) - if len(pending) != 1 or pending[0] != ("invite_claim", "pending:BUZZ-268"): + if set(pending) != {("invite_claim", "pending:BUZZ-268"), ("workflow_webhook", "pending:BUZZ-272")}: raise ValueError( - "the only pending route coverage must be invite_claim pending:BUZZ-268" + "pending coverage must explicitly track BUZZ-268 invitations and BUZZ-272 webhook owner bans" ) claim = next((row for row in routes if row["id"] == "invite_claim"), None) if claim is None or claim["membership"] != "exempt" or claim["restriction"] != "enforce": @@ -547,7 +547,7 @@ def main() -> int: print( "community-ban route inventory passed: " f"{route_count} registered routes classified, {case_count} HTTP behavior cases, " - f"{regression_count} supporting regressions indexed, {pending_count} BUZZ-268 invite case pending" + f"{regression_count} supporting regressions indexed, {pending_count} sibling implementation cases pending" ) return 0 From 49b497d345fc06f2afd6f59b645ea9843bd2c1fe Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 9 Oct 2026 19:27:51 -0400 Subject: [PATCH 06/18] test(relay): enable accessory routes in the ban matrix Co-authored-by: Codex Signed-off-by: Brad Seiler --- .github/workflows/_ci-relay.yml | 6 ++++++ TESTING.md | 22 +++++++++++----------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index b3b1ac5588f..d41f9883a3f 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -370,6 +370,8 @@ jobs: ON CONFLICT (lower(host)) DO NOTHING ;" - name: Start relay + env: + BUZZ_V1_ENABLED: "true" run: | chmod +x ./target/ci/buzz-relay nohup env \ @@ -583,6 +585,8 @@ jobs: --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-relay) and test(/workflow_sink/)' - name: Start relay + env: + BUZZ_V1_ENABLED: "true" run: | chmod +x ./target/ci/buzz-relay nohup env \ @@ -654,6 +658,8 @@ jobs: merge-multiple: true path: target/ci - name: Start relay + env: + BUZZ_V1_ENABLED: "true" run: | chmod +x ./target/ci/buzz-relay ./target/ci/git-credential-nostr ./scripts/start-relay-for-tests.sh --no-build diff --git a/TESTING.md b/TESTING.md index ec8ac7a238e..42b3692aa97 100644 --- a/TESTING.md +++ b/TESTING.md @@ -55,7 +55,9 @@ cargo test -p buzz-test-client --test community_ban_routes -- --ignored --nocapt Use a fresh disposable local relay, PostgreSQL, Redis, and MinIO stack for that command, and set `DATABASE_URL` or `BUZZ_TEST_DATABASE_URL` to the disposable -PostgreSQL database. The matrix creates unique tenant hosts and identities; GIF +PostgreSQL database. Start the relay with `BUZZ_V1_ENABLED=true` so the +accessory-route cases exercise their production handlers. The matrix creates +unique tenant hosts and identities; GIF requests stop at malformed local input, and Git/workflow requests use absent resources. Do not point these tests at a shared, development, or hosted database or relay. @@ -63,8 +65,8 @@ The inventory keeps membership separate from restriction policy. Public NIP-11, NIP-05, policy documents, and health probes have no authenticated community principal. Anonymous policy-receipt acceptance is pre-membership and has no principal to restrict. Invite claim is also pre-membership, but it carries a -verified NIP-98 principal and remains restriction-enforced: it is the sole -row marked pending in the inventory until BUZZ-268 qualifies the updated +verified NIP-98 principal and remains restriction-enforced: its coverage remains +pending in the inventory until BUZZ-268 qualifies the updated v1/v2 lifecycle: retain valid unexpired v1 codes and mint only v2, with a 72-hour default and 30-day maximum lifetime. Both formats reject banned claimants and owners @@ -76,13 +78,11 @@ identity, so issuer-specific revocation remains limited until a verified fleet drain or security approval. Relay-operator APIs, deployment-admin APIs, NIP-FI commands, the localhost Git hook, and the gated mesh test endpoint use separate auth planes; their exemption reason is recorded on each route row. -Workflow webhooks also use a secret-authenticated caller with no user principal. -Before creating a run, the handler separately checks the saved workflow owner's -channel membership and role, but it does not check that owner's community-ban -state. The inventory's `not_applicable:webhook_secret_auth` entry is a -provisional restriction classification based on secret-only caller auth, not -proof that an owner ban is irrelevant. Whether the owner's ban should gate -webhook trigger and run creation remains an explicit policy question. +Workflow webhooks use a secret-authenticated caller with no user principal, but +run admission must also check the saved workflow owner's community ban. This +coverage remains explicitly pending BUZZ-272. It is not an exemption for banned +owners. BUZZ-269 currently has two pending sibling cases: invite claim and +workflow-owner webhook admission; final integration requires zero pending rows. Root and huddle admission, final-admission races, fail-closed reads, owner-to- agent restriction, timeout behavior, and tenant-scoped eviction point to their @@ -98,7 +98,7 @@ This scanner is a source-level guard, not a Rust router interpreter. It reads direct `.route(...)` calls inside production functions named `router` or ending in `_router`; a `.route(...)` elsewhere is a scanner error. It resolves a `.nest(...)` prefix only when the relay's `build_router` passes a literal -prefix and a direct call to another recognized router function. It does not +or a resolved string constant and a direct call to another recognized router function. It does not expand macros, discover `.route_service(...)` or `.nest_service(...)`, resolve dynamic path/prefix expressions, or interpret inline nested routers and conditional/opaque router composition. It also does not derive auth or From 71898a869d243b9682dc0dcf1b57db5dddab10f2 Mon Sep 17 00:00:00 2001 From: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> Date: Sun, 11 Oct 2026 22:33:50 -0400 Subject: [PATCH 07/18] docs(testing): distinguish pending sibling ban contracts Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> Signed-off-by: Brad Seiler --- TESTING.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/TESTING.md b/TESTING.md index 42b3692aa97..592cab124da 100644 --- a/TESTING.md +++ b/TESTING.md @@ -66,22 +66,24 @@ NIP-05, policy documents, and health probes have no authenticated community principal. Anonymous policy-receipt acceptance is pre-membership and has no principal to restrict. Invite claim is also pre-membership, but it carries a verified NIP-98 principal and remains restriction-enforced: its coverage remains -pending in the inventory until BUZZ-268 qualifies the updated -v1/v2 lifecycle: -retain valid unexpired v1 codes and mint only v2, with a 72-hour default and -30-day maximum lifetime. Both formats reject banned claimants and owners -transactionally, and issuer bans permanently invalidate v2. For v1, an unset -operator `invalid_after` uses natural expiry; when set, the code rejects at or +pending in the inventory until BUZZ-268 lands and qualifies the updated +v1/v2 lifecycle. After BUZZ-268 lands, the intended contract is to retain valid +unexpired v1 codes and mint only v2, with a 72-hour default and 30-day maximum +lifetime. Both formats will reject banned claimants and owners transactionally, +and issuer bans will permanently invalidate v2. For v1, an unset operator +`invalid_after` will use natural expiry; when set, the code will reject at or after that absolute boundary, while an earlier code expiry still takes effect. -That operator boundary does not affect v2. Legacy v1 codes have no issuer +That operator boundary will not affect v2. Legacy v1 codes have no issuer identity, so issuer-specific revocation remains limited until a verified fleet -drain or security approval. Relay-operator APIs, deployment-admin APIs, +drain or security approval. This sibling contract, including the operator +cutoff, is not implemented by BUZZ-269. Relay-operator APIs, deployment-admin APIs, NIP-FI commands, the localhost Git hook, and the gated mesh test endpoint use separate auth planes; their exemption reason is recorded on each route row. -Workflow webhooks use a secret-authenticated caller with no user principal, but -run admission must also check the saved workflow owner's community ban. This -coverage remains explicitly pending BUZZ-272. It is not an exemption for banned -owners. BUZZ-269 currently has two pending sibling cases: invite claim and +Workflow webhooks use a secret-authenticated caller with no user principal. +After BUZZ-272 lands, run admission will also check the saved workflow owner's +community ban. That behavior is not implemented by BUZZ-269, and its coverage +remains explicitly pending BUZZ-272; the pending row is not an exemption for +banned owners. BUZZ-269 currently has two pending sibling cases: invite claim and workflow-owner webhook admission; final integration requires zero pending rows. Root and huddle admission, final-admission races, fail-closed reads, owner-to- From 67c2369a90fc960cb132f3250c8e05706528ee6a Mon Sep 17 00:00:00 2001 From: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> Date: Sun, 11 Oct 2026 22:59:59 -0400 Subject: [PATCH 08/18] docs(testing): distinguish invite policy from implementation Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> Signed-off-by: Brad Seiler --- TESTING.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/TESTING.md b/TESTING.md index 592cab124da..e19bd55acc3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -65,9 +65,10 @@ The inventory keeps membership separate from restriction policy. Public NIP-11, NIP-05, policy documents, and health probes have no authenticated community principal. Anonymous policy-receipt acceptance is pre-membership and has no principal to restrict. Invite claim is also pre-membership, but it carries a -verified NIP-98 principal and remains restriction-enforced: its coverage remains -pending in the inventory until BUZZ-268 lands and qualifies the updated -v1/v2 lifecycle. After BUZZ-268 lands, the intended contract is to retain valid +verified NIP-98 principal and is classified as restriction-enforced in the +inventory. Enforcement will land with BUZZ-268; coverage remains pending until +that sibling lands and qualifies the updated v1/v2 lifecycle. After BUZZ-268 +lands, the intended contract is to retain valid unexpired v1 codes and mint only v2, with a 72-hour default and 30-day maximum lifetime. Both formats will reject banned claimants and owners transactionally, and issuer bans will permanently invalidate v2. For v1, an unset operator From 6565a0f10726235a3af093c2c4050ae92dd166e9 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Mon, 26 Oct 2026 14:12:16 -0400 Subject: [PATCH 09/18] fix(test): fail the route inventory on every shape it cannot classify The scanner keyed `&str` route constants by bare name, so main's `api::profiles::SEARCH_PATH` collided with `api::gifs::SEARCH_PATH` and reported a false duplicate. Key constants by module and resolve qualified references against that module. Unclassified registrations are now hard errors instead of silent passes: `.route_service`, `.nest_service`, and `.fallback_service` (except the allow-listed SPA fallback in `build_router`), `.nest` outside `router::build_router`, and nest targets that do not resolve to exactly one router builder. A fixture self-test pins each shape and runs in `just test-unit` and `scripts/run-tests.sh unit`. Classify the three routes main added: `POST /profiles/search` (member gate, now in the HTTP ban matrix) and the operator-plane `GET /operator/communities/joined` and `GET /operator/communities/nip-fi-binding`. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Luke Tornquist Signed-off-by: Brad Seiler --- Justfile | 3 +- TESTING.md | 13 +- .../tests/community_ban_routes.rs | 5 +- .../community-ban-route-inventory.tsv | 3 + .../check-community-ban-route-inventory.py | 131 +++++++++++++----- ...heck-community-ban-route-inventory.test.py | 125 +++++++++++++++++ scripts/run-tests.sh | 3 + 7 files changed, 242 insertions(+), 41 deletions(-) create mode 100644 scripts/check-community-ban-route-inventory.test.py diff --git a/Justfile b/Justfile index 40d2af619e4..99cd78deec6 100644 --- a/Justfile +++ b/Justfile @@ -397,9 +397,10 @@ test-unit: #!/usr/bin/env bash set -euo pipefail ./scripts/test-ensure-local-relay-key.sh + python3 scripts/check-community-ban-route-inventory.py + python3 scripts/check-community-ban-route-inventory.test.py # Shared Huddle transport, codec, and loopback authentication regressions. cargo test -p buzz-audio-client - python3 scripts/check-community-ban-route-inventory.py if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib # Mesh JWKS proxy-bypass regression: infra-free loopback I/O with diff --git a/TESTING.md b/TESTING.md index e19bd55acc3..d636b9d5b14 100644 --- a/TESTING.md +++ b/TESTING.md @@ -37,13 +37,18 @@ combination space (PR #6807). Scope Playwright locators — unscoped classifies the relay's production Axum routes by listener, method, path, authentication plane, membership behavior, and community-restriction behavior. The inventory guard finds direct `.route(...)` calls in production functions -named `router` or `*_router`, resolves literal or declared `&str` paths, and -applies direct literal `.nest(...)` prefixes from the relay router. It fails -when one of those registrations is missing from the table or a stale row -remains. Run it directly with: +named `router` or `*_router`, resolves literal or module-qualified `&str` +constant paths, and applies `.nest(...)` prefixes from `router::build_router`. +It fails when one of those registrations is missing from the table or a stale +row remains, and on any shape it cannot classify: `.route_service`, +`.nest_service`, or `.fallback_service` (except the allow-listed SPA fallback), +`.nest` outside `build_router`, or a nest target that does not resolve to +exactly one router builder. `scripts/check-community-ban-route-inventory.test.py` +pins each of those shapes with a fixture. Run both directly with: ```bash python3 scripts/check-community-ban-route-inventory.py +python3 scripts/check-community-ban-route-inventory.test.py ``` `just test-unit` and `just test` run the guard; CI also runs the ignored HTTP diff --git a/crates/buzz-test-client/tests/community_ban_routes.rs b/crates/buzz-test-client/tests/community_ban_routes.rs index de9b3d3eea7..762a6df0ace 100644 --- a/crates/buzz-test-client/tests/community_ban_routes.rs +++ b/crates/buzz-test-client/tests/community_ban_routes.rs @@ -35,6 +35,7 @@ const COMMUNITY_BAN_MATRIX_CASES: &[&str] = &[ "count", "gif_search", "gif_share", + "profile_search", "workflow_runs", "workflow_approvals", "invite_mint", @@ -270,7 +271,9 @@ fn request_body(case: &str, actor: &Principal) -> Vec { "limit": 1 }])) .expect("serialize Nostr filter"), - "gif_search" | "gif_share" | "invite_mint" | "buzz_read_state" => b"{".to_vec(), + "gif_search" | "gif_share" | "profile_search" | "invite_mint" | "buzz_read_state" => { + b"{".to_vec() + } "media_upload" => b"not an image file".to_vec(), "git_upload_pack" | "git_receive_pack" => Vec::new(), "git_default_post" => serde_json::to_vec(&serde_json::json!({ diff --git a/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv index b1431793fcf..75f7ae6bf4b 100644 --- a/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv +++ b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv @@ -10,16 +10,19 @@ http_query relay POST /query community_nip98 community enforce matrix:query memb http_count relay POST /count community_nip98 community enforce matrix:count member gif_search relay POST /gifs/search community_nip98 community enforce matrix:gif_search member gif_share relay POST /gifs/share community_nip98 community enforce matrix:gif_share member +profile_search relay POST /profiles/search community_nip98 community enforce matrix:profile_search member workflow_runs relay GET /workflows/{workflow_id}/runs community_nip98 community enforce matrix:workflow_runs member workflow_approvals relay GET /workflows/{workflow_id}/runs/{run_id}/approvals community_nip98 community enforce matrix:workflow_approvals member operator_list_communities relay GET /operator/communities operator_control not_applicable not_applicable:relay_operator_control classified - operator_provision_community relay POST /operator/communities operator_control not_applicable not_applicable:relay_operator_control classified - +operator_list_joined_communities relay GET /operator/communities/joined operator_control not_applicable not_applicable:relay_operator_control classified - operator_register_listener_keys relay POST /operator/listener/pubkeys operator_control not_applicable not_applicable:relay_operator_control classified - operator_remove_listener_keys relay DELETE /operator/listener/pubkeys operator_control not_applicable not_applicable:relay_operator_control classified - operator_archive_community relay POST /operator/communities/archive operator_control not_applicable not_applicable:relay_operator_control classified - operator_unarchive_community relay POST /operator/communities/unarchive operator_control not_applicable not_applicable:relay_operator_control classified - operator_delete_community relay POST /operator/communities/delete operator_control not_applicable not_applicable:relay_operator_control classified - operator_community_availability relay GET /operator/communities/availability operator_control not_applicable not_applicable:relay_operator_control classified - +operator_community_nip_fi_binding relay GET /operator/communities/nip-fi-binding operator_control not_applicable not_applicable:relay_operator_control classified - operator_transfer_community relay POST /operator/communities/transfer operator_control not_applicable not_applicable:relay_operator_control classified - invite_mint relay POST /api/invites community_nip98 community enforce matrix:invite_mint admin join_policy relay GET /api/join-policy public_metadata not_applicable not_applicable:public_metadata classified - diff --git a/scripts/check-community-ban-route-inventory.py b/scripts/check-community-ban-route-inventory.py index 6fbc511f1f4..af26e8b49a3 100644 --- a/scripts/check-community-ban-route-inventory.py +++ b/scripts/check-community-ban-route-inventory.py @@ -191,14 +191,24 @@ def in_ranges(position: int, ranges: list[tuple[int, int]]) -> bool: return any(start <= position < end for start, end in ranges) +def module_path(path: Path) -> str: + module_parts = list(path.relative_to(RELAY_SRC).with_suffix("").parts) + if module_parts[-1] in {"mod", "lib", "main"}: + module_parts.pop() + return "::".join(module_parts) + + +def production_sources(): + for path in sorted(RELAY_SRC.rglob("*.rs")): + if "tests" in path.parts or path.name.endswith("_tests.rs"): + continue + yield path + + def router_functions(path: Path, source: str, mask: str): pattern = re.compile(r"\bfn\s+([A-Za-z_]\w*)\s*\(") excluded = test_module_ranges(mask) - relative = path.relative_to(RELAY_SRC).with_suffix("") - module_parts = list(relative.parts) - if module_parts[-1] == "mod": - module_parts.pop() - module = "::".join(module_parts) + module = module_path(path) for match in pattern.finditer(mask): name = match.group(1) @@ -241,31 +251,52 @@ def string_value(expression: str) -> str | None: return raw.group(2) if raw else None -def route_constants() -> dict[str, str]: +def route_constants() -> dict[tuple[str, str], str]: + """Map `(module, NAME)` to each production `&str` constant's value. + + Keyed by module so same-named constants in different modules (for example + `api::gifs::SEARCH_PATH` and `api::profiles::SEARCH_PATH`) stay distinct. + """ constants = {} pattern = re.compile( r"(?:pub(?:\([^)]*\))?\s+)?const\s+([A-Za-z_]\w*)\s*:\s*&str\s*=\s*" r'("(?:\\.|[^"\\])*"|r(?:#+)?".*?"(?:#+)?)\s*;', re.DOTALL, ) - for path in RELAY_SRC.rglob("*.rs"): - if "tests" in path.parts or path.name.endswith("_tests.rs"): - continue + for path in production_sources(): + module = module_path(path) for match in pattern.finditer(path.read_text()): value = string_value(match.group(2)) if value is not None: - constants[match.group(1)] = value + constants[(module, match.group(1))] = value return constants -def route_path(expression: str, constants: dict[str, str]) -> str: +def route_path(expression: str, constants: dict[tuple[str, str], str], module: str) -> str: literal = string_value(expression) if literal is not None: return literal - constant = re.search(r"([A-Za-z_]\w*)\s*$", expression.strip()) - if constant and constant.group(1) in constants: - return constants[constant.group(1)] - raise ValueError(f"route path is not a literal or known string constant: {expression.strip()}") + reference = re.fullmatch( + r"(?:(?:crate|self)::)?((?:[A-Za-z_]\w*::)*)([A-Za-z_]\w*)", expression.strip() + ) + if reference is None: + raise ValueError( + f"route path is not a literal or known string constant: {expression.strip()}" + ) + qualifier = reference.group(1).rstrip(":") + name = reference.group(2) + candidates = [key for key in constants if key[1] == name] + if qualifier: + candidates = [ + key for key in candidates if key[0] == qualifier or key[0].endswith(f"::{qualifier}") + ] + elif len(candidates) > 1: + candidates = [key for key in candidates if key[0] == module] + if len(candidates) != 1: + raise ValueError( + f"route path is not a literal or known string constant: {expression.strip()}" + ) + return constants[candidates[0]] def registered_calls(mask: str, source: str, function_start: int, function_end: int, call: str): @@ -291,25 +322,36 @@ def builder_prefixes(builders) -> dict[str, str]: if main is None: raise ValueError("could not find relay router::build_router") _, _, path, source, mask, start, _, end = main + constants = route_constants() prefixes = {} for first, second_mask, second_source in registered_calls(mask, source, start, end, "nest"): - prefix = route_path(first, route_constants()) + prefix = route_path(first, constants, module_path(path)) target_match = re.search(r"((?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)\s*\(", second_mask) - if prefix is None or target_match is None: - continue - target = target_match.group(1) + if target_match is None: + raise ValueError( + f"router::build_router: nest target is not a router builder call: " + f"{second_source.strip()}" + ) + target = re.sub(r"^(?:crate|self)::", "", target_match.group(1)) candidates = [b[0] for b in builders if b[0] == target or b[0].endswith(f"::{target}")] - if len(candidates) == 1: - prefixes[candidates[0]] = prefix + if len(candidates) != 1: + raise ValueError( + f"router::build_router: nest target {target} matches " + f"{len(candidates)} router builders, expected exactly 1" + ) + prefixes[candidates[0]] = prefix return prefixes +# Service-style registrations bypass the per-route method classification, so +# each one is a hard error unless it is allow-listed here with its builder. +ALLOWED_SERVICE_REGISTRATIONS = {("router::build_router", "fallback_service")} + + def collect_registered_routes() -> Counter: constants = route_constants() builders = [] - for path in RELAY_SRC.rglob("*.rs"): - if "tests" in path.parts or path.name.endswith("_tests.rs"): - continue + for path in production_sources(): source = path.read_text() mask = mask_comments_and_strings(source) for qualified_name, name, start, brace, end in router_functions(path, source, mask): @@ -318,31 +360,50 @@ def collect_registered_routes() -> Counter: prefixes = builder_prefixes(builders) registrations: Counter = Counter() errors = [] - for path in RELAY_SRC.rglob("*.rs"): - if "tests" in path.parts or path.name.endswith("_tests.rs"): - continue + for path in production_sources(): source = path.read_text() mask = mask_comments_and_strings(source) test_ranges = test_module_ranges(mask) - router_ranges = [ - (builder[6], builder[7]) for builder in builders if builder[2] == path - ] - for match in re.finditer(r"\.\s*route\s*\(", mask): + path_builders = [builder for builder in builders if builder[2] == path] + + def enclosing_builder(position: int) -> str | None: + for builder in path_builders: + if builder[6] <= position < builder[7]: + return builder[0] + return None + + for match in re.finditer( + r"\.\s*(route|nest|route_service|nest_service|fallback_service)\s*\(", mask + ): if in_ranges(match.start(), test_ranges): continue - if not any(start <= match.start() < end for start, end in router_ranges): - line = source.count("\n", 0, match.start()) + 1 + call = match.group(1) + builder = enclosing_builder(match.start()) + line = source.count("\n", 0, match.start()) + 1 + location = f"{path.relative_to(ROOT)}:{line}" + if call.endswith("_service"): + if (builder, call) not in ALLOWED_SERVICE_REGISTRATIONS: + errors.append( + f"{location}: production .{call} registration is not classified; " + "use .route with an HTTP method or allow-list it" + ) + elif builder is None: errors.append( - f"{path.relative_to(ROOT)}:{line}: production .route registration " + f"{location}: production .{call} registration " "is outside a recognized router builder" ) + elif call == "nest" and builder != "router::build_router": + errors.append( + f"{location}: production .nest registration outside " + "router::build_router is not classified" + ) for qualified_name, name, path, source, mask, _, start, end in builders: listener = "health" if qualified_name == "router::build_health_router" else "relay" prefix = prefixes.get(qualified_name, "") for path_expression, method_mask, _ in registered_calls(mask, source, start, end, "route"): try: - route = route_path(path_expression, constants) + route = route_path(path_expression, constants, module_path(path)) except ValueError as error: errors.append(f"{path.relative_to(ROOT)}::{name}: {error}") continue diff --git a/scripts/check-community-ban-route-inventory.test.py b/scripts/check-community-ban-route-inventory.test.py new file mode 100644 index 00000000000..62876a1550e --- /dev/null +++ b/scripts/check-community-ban-route-inventory.test.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Fixture self-tests: every route shape the scanner can't classify must fail.""" + +from __future__ import annotations + +import importlib.util +import tempfile +import textwrap +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent / "check-community-ban-route-inventory.py" +SPEC = importlib.util.spec_from_file_location("route_inventory", SCRIPT) +scanner = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(scanner) + +API_MOD = """ +pub mod admin; +pub mod gifs; +pub mod profiles; +""" +GIFS = """ +pub(crate) const SEARCH_PATH: &str = "/gifs/search"; +""" +PROFILES = """ +pub(crate) const SEARCH_PATH: &str = "/profiles/search"; +""" +ADMIN = """ +pub fn router(state: State) -> Router { + Router::new().route("/probe", get(probe)) +} +""" + + +def build_router(extra: str = "") -> str: + return textwrap.dedent( + f""" + pub fn build_router(state: State) -> Router {{ + Router::new() + .route(api::gifs::SEARCH_PATH, post(api::gifs::search)) + .route(api::profiles::SEARCH_PATH, post(api::profiles::search)) + .nest("/api/admin/v1", api::admin::router(state.clone())) + {extra} + }} + """ + ) + + +class RouteScannerFixtures(unittest.TestCase): + def scan(self, router: str, admin: str = ADMIN): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "crates/buzz-relay/src" + (src / "api").mkdir(parents=True) + (src / "router.rs").write_text(router) + (src / "api/mod.rs").write_text(API_MOD) + (src / "api/gifs.rs").write_text(GIFS) + (src / "api/profiles.rs").write_text(PROFILES) + (src / "api/admin.rs").write_text(admin) + saved = scanner.ROOT, scanner.RELAY_SRC + scanner.ROOT, scanner.RELAY_SRC = root, src + try: + return scanner.collect_registered_routes() + finally: + scanner.ROOT, scanner.RELAY_SRC = saved + + def assert_rejected(self, message: str, router: str, admin: str = ADMIN): + with self.assertRaisesRegex(ValueError, message): + self.scan(router, admin) + + def test_same_named_constants_resolve_per_module(self): + routes = self.scan(build_router()) + self.assertEqual( + set(routes), + { + ("relay", "POST", "/gifs/search"), + ("relay", "POST", "/profiles/search"), + ("relay", "GET", "/api/admin/v1/probe"), + }, + ) + + def test_spa_fallback_service_is_allowed(self): + self.scan(build_router(".fallback_service(spa_fallback)")) + + def test_route_service_is_rejected(self): + self.assert_rejected( + r"\.route_service registration is not classified", + build_router('.route_service("/probe", service)'), + ) + + def test_nest_service_is_rejected(self): + self.assert_rejected( + r"\.nest_service registration is not classified", + build_router('.nest_service("/probe", service)'), + ) + + def test_fallback_service_outside_build_router_is_rejected(self): + self.assert_rejected( + r"\.fallback_service registration is not classified", + build_router(), + ADMIN.replace('get(probe))', "get(probe)).fallback_service(service)"), + ) + + def test_nest_outside_build_router_is_rejected(self): + self.assert_rejected( + r"\.nest registration outside router::build_router", + build_router(), + ADMIN.replace('get(probe))', 'get(probe)).nest("/zz", other_router(state))'), + ) + + def test_unresolved_nest_target_is_rejected(self): + self.assert_rejected( + r"nest target missing::router matches 0 router builders", + build_router('.nest("/zz", missing::router(state.clone()))'), + ) + + def test_unknown_route_constant_is_rejected(self): + self.assert_rejected( + r"not a literal or known string constant: api::gifs::MISSING", + build_router(".route(api::gifs::MISSING, get(missing))"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index e890fd1d7b1..ab5bd07dc7d 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -81,6 +81,9 @@ run_unit_tests() { run_test_step "community-ban route inventory" \ python3 scripts/check-community-ban-route-inventory.py + run_test_step "community-ban route scanner fixtures" \ + python3 scripts/check-community-ban-route-inventory.test.py + run_test_step "buzz-core tests" \ cargo test -p buzz-core --lib -- --nocapture From fd380fcfd8e9c342ab02278e269472dafeb1f05c Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Mon, 26 Oct 2026 14:23:57 -0400 Subject: [PATCH 10/18] fix(test): pin unrouted registrations and reject repeated nests The route inventory allow-listed every `.fallback_service` inside `router::build_router`, and did not scan `.fallback` at all, so a new sub-router fallback could add an unclassified handler without changing the inventory. Allow-list entries now name the builder, call, and exact whitespace-normalized argument, and each may appear once. The existing buzz_v1 404 fallback is allow-listed explicitly. `builder_prefixes` kept one prefix per builder, so nesting a builder at a second prefix silently overwrote the first. A repeated nest is now a hard error. Five new fixtures cover a non-SPA fallback service, a repeated SPA fallback, a handler fallback inside and outside build_router, and a builder nested twice. All five fail against the previous scanner. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Luke Tornquist Signed-off-by: Brad Seiler --- TESTING.md | 7 ++-- .../check-community-ban-route-inventory.py | 39 ++++++++++++++++--- ...heck-community-ban-route-inventory.test.py | 34 ++++++++++++++++ 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/TESTING.md b/TESTING.md index d636b9d5b14..6502cac9285 100644 --- a/TESTING.md +++ b/TESTING.md @@ -41,9 +41,10 @@ named `router` or `*_router`, resolves literal or module-qualified `&str` constant paths, and applies `.nest(...)` prefixes from `router::build_router`. It fails when one of those registrations is missing from the table or a stale row remains, and on any shape it cannot classify: `.route_service`, -`.nest_service`, or `.fallback_service` (except the allow-listed SPA fallback), -`.nest` outside `build_router`, or a nest target that does not resolve to -exactly one router builder. `scripts/check-community-ban-route-inventory.test.py` +`.nest_service`, `.fallback_service`, or `.fallback` (except allow-listed +registrations, each pinned to its builder and exact argument and allowed once), +`.nest` outside `build_router`, a nest target that does not resolve to exactly +one router builder, or a builder nested more than once. `scripts/check-community-ban-route-inventory.test.py` pins each of those shapes with a fixture. Run both directly with: ```bash diff --git a/scripts/check-community-ban-route-inventory.py b/scripts/check-community-ban-route-inventory.py index af26e8b49a3..b12b93732c2 100644 --- a/scripts/check-community-ban-route-inventory.py +++ b/scripts/check-community-ban-route-inventory.py @@ -339,13 +339,28 @@ def builder_prefixes(builders) -> dict[str, str]: f"router::build_router: nest target {target} matches " f"{len(candidates)} router builders, expected exactly 1" ) + if candidates[0] in prefixes: + raise ValueError( + f"router::build_router: {candidates[0]} is nested more than once; " + "the inventory records one prefix per router builder" + ) prefixes[candidates[0]] = prefix return prefixes -# Service-style registrations bypass the per-route method classification, so -# each one is a hard error unless it is allow-listed here with its builder. -ALLOWED_SERVICE_REGISTRATIONS = {("router::build_router", "fallback_service")} +# Service and fallback registrations bypass the per-route method +# classification, so each one is a hard error unless this list names its +# builder, call, and exact (whitespace-normalized) argument, once. +ALLOWED_UNROUTED_REGISTRATIONS = { + # SPA static bundles; it only serves files, never a community API. + ("router::build_router", "fallback_service", "spa_fallback"), + # Unknown accessory paths answer 404 instead of SPA HTML. + ( + "api::buzz_v1::router", + "fallback", + '|| async { auth::Error::new(axum::http::StatusCode::NOT_FOUND, "not_found") }', + ), +} def collect_registered_routes() -> Counter: @@ -360,6 +375,7 @@ def collect_registered_routes() -> Counter: prefixes = builder_prefixes(builders) registrations: Counter = Counter() errors = [] + unrouted_seen = set() for path in production_sources(): source = path.read_text() mask = mask_comments_and_strings(source) @@ -373,7 +389,8 @@ def enclosing_builder(position: int) -> str | None: return None for match in re.finditer( - r"\.\s*(route|nest|route_service|nest_service|fallback_service)\s*\(", mask + r"\.\s*(route|nest|route_service|nest_service|fallback_service|fallback)\s*\(", + mask, ): if in_ranges(match.start(), test_ranges): continue @@ -381,12 +398,22 @@ def enclosing_builder(position: int) -> str | None: builder = enclosing_builder(match.start()) line = source.count("\n", 0, match.start()) + 1 location = f"{path.relative_to(ROOT)}:{line}" - if call.endswith("_service"): - if (builder, call) not in ALLOWED_SERVICE_REGISTRATIONS: + if call.endswith("_service") or call == "fallback": + opening = mask.find("(", match.start(), match.end()) + argument = " ".join( + source[opening + 1 : matching_delimiter(mask, opening)].split() + ) + registration = (builder, call, argument) + if registration not in ALLOWED_UNROUTED_REGISTRATIONS: errors.append( f"{location}: production .{call} registration is not classified; " "use .route with an HTTP method or allow-list it" ) + elif registration in unrouted_seen: + errors.append( + f"{location}: allow-listed .{call} registration appears more than once" + ) + unrouted_seen.add(registration) elif builder is None: errors.append( f"{location}: production .{call} registration " diff --git a/scripts/check-community-ban-route-inventory.test.py b/scripts/check-community-ban-route-inventory.test.py index 62876a1550e..a554f98f227 100644 --- a/scripts/check-community-ban-route-inventory.test.py +++ b/scripts/check-community-ban-route-inventory.test.py @@ -101,6 +101,40 @@ def test_fallback_service_outside_build_router_is_rejected(self): ADMIN.replace('get(probe))', "get(probe)).fallback_service(service)"), ) + def test_non_spa_fallback_service_in_build_router_is_rejected(self): + self.assert_rejected( + r"\.fallback_service registration is not classified", + build_router( + ".fallback_service(spa_fallback)" + '.merge(Router::new().route("/x", get(x)).fallback_service(private_service))' + ), + ) + + def test_repeated_spa_fallback_service_is_rejected(self): + self.assert_rejected( + r"\.fallback_service registration appears more than once", + build_router(".fallback_service(spa_fallback).fallback_service(spa_fallback)"), + ) + + def test_handler_fallback_is_rejected(self): + self.assert_rejected( + r"\.fallback registration is not classified", + build_router(".fallback(private_handler)"), + ) + + def test_handler_fallback_outside_build_router_is_rejected(self): + self.assert_rejected( + r"\.fallback registration is not classified", + build_router(), + ADMIN.replace('get(probe))', "get(probe)).fallback(private_handler)"), + ) + + def test_builder_nested_twice_is_rejected(self): + self.assert_rejected( + r"api::admin::router is nested more than once", + build_router('.nest("/alternate-admin", api::admin::router(state.clone()))'), + ) + def test_nest_outside_build_router_is_rejected(self): self.assert_rejected( r"\.nest registration outside router::build_router", From bb5b0343f9283755757ac4426a39676a82b8b074 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Mon, 26 Oct 2026 14:25:11 -0400 Subject: [PATCH 11/18] fix(test): pass relay_keypair to add_relay_member in ban-matrix tests Main (#8482) added a relay_keypair parameter to Db::add_relay_member. The merge in 60df78770 kept three ban-matrix test call sites at four arguments, so the buzz-relay lib tests did not compile. Pass a generated key, as the other tests do. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Luke Tornquist Signed-off-by: Brad Seiler --- crates/buzz-relay/src/api/bridge.rs | 2 ++ crates/buzz-relay/src/audio/handler.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index fca6517456c..9163c57e273 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -8793,6 +8793,7 @@ pub(crate) mod postgres_tests { &owner.public_key().to_hex(), "member", None, + &nostr::Keys::generate(), ) .await .expect("seed owner as a community member"); @@ -8803,6 +8804,7 @@ pub(crate) mod postgres_tests { &agent.public_key().to_hex(), "admin", None, + &nostr::Keys::generate(), ) .await .expect("seed agent's own moderator role"); diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index c9e525a341b..d99a1d4d7b2 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -15973,6 +15973,7 @@ mod tests { &admin.public_key().to_hex(), "admin", None, + &nostr::Keys::generate(), ) .await .expect("seed relay administrator"); From cf8b6b26d71ea0f63d26b3468dab4f798ca9cded Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Mon, 26 Oct 2026 14:28:43 -0400 Subject: [PATCH 12/18] fix(test): consume MembershipWrite in ban-matrix seeds add_relay_member returns a #[must_use] MembershipWrite on main, so clippy -D warnings rejected the three ban-matrix seed calls. Map to the outcome before expect, as the neighboring tests do. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Luke Tornquist Signed-off-by: Brad Seiler --- crates/buzz-relay/src/api/bridge.rs | 2 ++ crates/buzz-relay/src/audio/handler.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 9163c57e273..ccb65322c58 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -8796,6 +8796,7 @@ pub(crate) mod postgres_tests { &nostr::Keys::generate(), ) .await + .map(|write| write.outcome) .expect("seed owner as a community member"); state .db @@ -8807,6 +8808,7 @@ pub(crate) mod postgres_tests { &nostr::Keys::generate(), ) .await + .map(|write| write.outcome) .expect("seed agent's own moderator role"); let stored_owner = scenario == "stored-banned-owner"; diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index d99a1d4d7b2..1a0cc60c244 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -15976,6 +15976,7 @@ mod tests { &nostr::Keys::generate(), ) .await + .map(|write| write.outcome) .expect("seed relay administrator"); let control = nostr::Keys::generate(); From 5285894bd30594192d5210e60f3c6362e18e7519 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Thu, 29 Oct 2026 20:22:01 -0400 Subject: [PATCH 13/18] test(auth): bind route controls to exact outcomes and merged routers Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler --- TESTING.md | 47 +++---- .../tests/community_ban_routes.rs | 131 ++++++++++++++---- .../fixtures/community-ban-regressions.tsv | 4 + .../community-ban-route-inventory.tsv | 4 +- .../check-community-ban-route-inventory.py | 40 ++++-- ...heck-community-ban-route-inventory.test.py | 35 +++++ 6 files changed, 194 insertions(+), 67 deletions(-) diff --git a/TESTING.md b/TESTING.md index 6502cac9285..936de3aaacb 100644 --- a/TESTING.md +++ b/TESTING.md @@ -44,7 +44,9 @@ row remains, and on any shape it cannot classify: `.route_service`, `.nest_service`, `.fallback_service`, or `.fallback` (except allow-listed registrations, each pinned to its builder and exact argument and allowed once), `.nest` outside `build_router`, a nest target that does not resolve to exactly -one router builder, or a builder nested more than once. `scripts/check-community-ban-route-inventory.test.py` +one router builder, or a builder nested more than once. New or repeated +`.merge(...)` registrations also fail; existing root aliases and the Git +settings merge are pinned to their exact builder and argument. `scripts/check-community-ban-route-inventory.test.py` pins each of those shapes with a fixture. Run both directly with: ```bash @@ -63,8 +65,12 @@ Use a fresh disposable local relay, PostgreSQL, Redis, and MinIO stack for that command, and set `DATABASE_URL` or `BUZZ_TEST_DATABASE_URL` to the disposable PostgreSQL database. Start the relay with `BUZZ_V1_ENABLED=true` so the accessory-route cases exercise their production handlers. The matrix creates -unique tenant hosts and identities; GIF -requests stop at malformed local input, and Git/workflow requests use absent resources. +unique tenant hosts and identities. Unset `BUZZ_KLIPY_API_KEY`: GIF controls +assert the exact provider-disabled response without contacting a provider. +The malformed JPEG control asserts its validation response; Git, workflow and +media-read controls assert exact missing-resource responses. Successful reads +and writes must return their expected JSON shape, not merely avoid a ban. +Ordinary-member routes also run a clear delegated-agent control. Do not point these tests at a shared, development, or hosted database or relay. The inventory keeps membership separate from restriction policy. Public NIP-11, @@ -72,26 +78,15 @@ NIP-05, policy documents, and health probes have no authenticated community principal. Anonymous policy-receipt acceptance is pre-membership and has no principal to restrict. Invite claim is also pre-membership, but it carries a verified NIP-98 principal and is classified as restriction-enforced in the -inventory. Enforcement will land with BUZZ-268; coverage remains pending until -that sibling lands and qualifies the updated v1/v2 lifecycle. After BUZZ-268 -lands, the intended contract is to retain valid -unexpired v1 codes and mint only v2, with a 72-hour default and 30-day maximum -lifetime. Both formats will reject banned claimants and owners transactionally, -and issuer bans will permanently invalidate v2. For v1, an unset operator -`invalid_after` will use natural expiry; when set, the code will reject at or -after that absolute boundary, while an earlier code expiry still takes effect. -That operator boundary will not affect v2. Legacy v1 codes have no issuer -identity, so issuer-specific revocation remains limited until a verified fleet -drain or security approval. This sibling contract, including the operator -cutoff, is not implemented by BUZZ-269. Relay-operator APIs, deployment-admin APIs, +inventory. The integrated invitation regressions cover restricted nonmembers, +verified owners and v1/v2 lifecycle behavior; see +[Invitation compatibility and revocation](#invitation-compatibility-and-revocation) +for the authoritative contract. Relay-operator APIs, deployment-admin APIs, NIP-FI commands, the localhost Git hook, and the gated mesh test endpoint use -separate auth planes; their exemption reason is recorded on each route row. -Workflow webhooks use a secret-authenticated caller with no user principal. -After BUZZ-272 lands, run admission will also check the saved workflow owner's -community ban. That behavior is not implemented by BUZZ-269, and its coverage -remains explicitly pending BUZZ-272; the pending row is not an exemption for -banned owners. BUZZ-269 currently has two pending sibling cases: invite claim and -workflow-owner webhook admission; final integration requires zero pending rows. +separate auth planes; each exemption is recorded in its route row. +Secret-authenticated workflow webhooks recheck the saved workflow owner's +community ban through the workflow engine. Their PostgreSQL regression covers +owner bans, tenant scope and failed lookups. No sibling coverage remains pending. Root and huddle admission, final-admission races, fail-closed reads, owner-to- agent restriction, timeout behavior, and tenant-scoped eviction point to their @@ -108,9 +103,11 @@ direct `.route(...)` calls inside production functions named `router` or ending in `_router`; a `.route(...)` elsewhere is a scanner error. It resolves a `.nest(...)` prefix only when the relay's `build_router` passes a literal or a resolved string constant and a direct call to another recognized router function. It does not -expand macros, discover `.route_service(...)` or `.nest_service(...)`, resolve -dynamic path/prefix expressions, or interpret inline nested routers and -conditional/opaque router composition. It also does not derive auth or +expand macros or interpret dynamic paths, prefixes or arbitrary router +composition. Service-style calls and unsupported nesting or merging are errors, +rather than silently omitted registrations. The existing root merge aliases and +Git settings merge are explicit, single-use exceptions; their builders' direct +routes remain inventoried. It also does not derive auth or restriction policy from handler code, or prove that the named regression function contains the expected assertions or is selected by CI. If production routes use an unrecognized registration shape, extend the scanner and bind diff --git a/crates/buzz-test-client/tests/community_ban_routes.rs b/crates/buzz-test-client/tests/community_ban_routes.rs index 762a6df0ace..3e9424ab075 100644 --- a/crates/buzz-test-client/tests/community_ban_routes.rs +++ b/crates/buzz-test-client/tests/community_ban_routes.rs @@ -2,8 +2,8 @@ //! //! The route inventory is shared with `scripts/check-community-ban-route-inventory.py`. //! This ignored test uses a disposable local relay/PostgreSQL/MinIO stack and -//! fresh tenant hosts and identities. GIF requests stop at malformed local -//! input before any provider request; Git and workflow requests use absent +//! fresh tenant hosts and identities. Run without BUZZ_KLIPY_API_KEY so GIF +//! controls assert the disabled-provider response; Git and workflow requests use absent //! resources and must stop before subprocess or durable work. //! //! ```text @@ -252,7 +252,7 @@ fn route_path(row: &RouteRow, case: &str, actor: &Principal) -> String { fn request_body(case: &str, actor: &Principal) -> Vec { match case { - "events" => EventBuilder::new(Kind::TextNote, "community-ban route matrix") + "events" => EventBuilder::new(Kind::Metadata, "{}") .sign_with_keys(&actor.keys) .expect("sign test message") .as_json() @@ -274,7 +274,9 @@ fn request_body(case: &str, actor: &Principal) -> Vec { "gif_search" | "gif_share" | "profile_search" | "invite_mint" | "buzz_read_state" => { b"{".to_vec() } - "media_upload" => b"not an image file".to_vec(), + "media_upload" => vec![ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0, + ], "git_upload_pack" | "git_receive_pack" => Vec::new(), "git_default_post" => serde_json::to_vec(&serde_json::json!({ "branch": "main", @@ -444,28 +446,78 @@ fn assert_ban_denial( } fn assert_allowed_control(response_status: StatusCode, body: &str, route: &RouteRow, case: &str) { - assert!( - matches!(response_status.as_u16(), 200..=299 | 400 | 404 | 409 | 415 | 422), - "{} {} case {case}: unbanned control must pass authentication and authorization, then reach the fixture's expected success or input/resource result; got {}: {body}", - route.method, - route.path, - response_status - ); - let denial_message = if route.id.starts_with("git_default_branch") { - BANNED_MESSAGE - } else if route.id.starts_with("git_") { - "blocked: banned from this community" - } else if route.id.starts_with("media_") { - "relay membership required" - } else { - BANNED_MESSAGE + let expected_status = match case { + "events" + | "moderation_command" + | "query" + | "count" + | "buzz_sidebar" + | "moderation_reports" + | "moderation_owner_ban" + | "moderation_audit" + | "moderation_restricted" => StatusCode::OK, + "profile_search" | "invite_mint" | "buzz_read_state" => StatusCode::BAD_REQUEST, + // This isolated fixture runs without a GIF provider key. + "gif_search" | "gif_share" | "workflow_runs" | "workflow_approvals" | "media_get" + | "media_head" | "git_info_refs" | "git_upload_pack" | "git_receive_pack" + | "git_default_get" | "git_default_post" => StatusCode::NOT_FOUND, + "media_upload" => StatusCode::UNPROCESSABLE_ENTITY, + _ => panic!("no positive-control contract for {case}"), }; - assert!( - !body.contains(denial_message), - "{} {} case {case}: allowed control must not receive a ban denial; body: {body}", - route.method, - route.path + assert_eq!( + response_status, expected_status, + "{} {} case {case}: positive control reached an unexpected result: {body}", + route.method, route.path ); + + if case == "media_head" { + assert!(body.is_empty(), "HEAD must suppress the missing-blob body"); + return; + } + if matches!( + case, + "git_info_refs" | "git_upload_pack" | "git_receive_pack" + ) { + assert_eq!(body, "repository not found"); + return; + } + let value: serde_json::Value = serde_json::from_str(body) + .unwrap_or_else(|error| panic!("case {case}: expected JSON, got {body}: {error}")); + match case { + "events" | "moderation_command" => { + assert_eq!(value["accepted"], true, "case {case}: {body}"); + assert!(value["event_id"].as_str().is_some_and(|id| id.len() == 64)); + } + "query" + | "moderation_reports" + | "moderation_owner_ban" + | "moderation_audit" + | "moderation_restricted" => assert!(value.is_array(), "case {case}: {body}"), + "count" => assert!(value["count"].as_u64().is_some(), "count response: {body}"), + "buzz_sidebar" => { + assert!(value["channels"].is_array(), "sidebar response: {body}"); + assert!(value.get("next_cursor").is_some()); + } + "buzz_read_state" => { + assert_eq!(value["error"]["code"], "invalid_request"); + assert!(value["error"]["request_id"].as_str().is_some()); + } + _ => { + let expected_error = match case { + "profile_search" => "invalid profile search JSON", + "invite_mint" => { + "invalid invite JSON: EOF while parsing an object at line 1 column 1" + } + "gif_search" | "gif_share" => "GIF search is not configured", + "workflow_runs" | "workflow_approvals" => "workflow not found", + "media_get" => "not found", + "media_upload" => "invalid image data", + "git_default_get" | "git_default_post" => "repository not found", + _ => unreachable!(), + }; + assert_eq!(value["error"], expected_error, "case {case}: {body}"); + } + } } fn principal<'a>(name: &str, principals: &[&'a Principal]) -> &'a Principal { @@ -538,6 +590,19 @@ async fn community_http_ban_matrix() { keys: Keys::generate(), nip_oa_tag: None, }; + let clear_member_agent_keys = Keys::generate(); + let clear_member_agent = Principal { + name: "clear-member-agent", + nip_oa_tag: Some( + buzz_sdk::nip_oa::compute_auth_tag( + &clear_member.keys, + &clear_member_agent_keys.public_key(), + "", + ) + .expect("sign clear ordinary-owner agent proof"), + ), + keys: clear_member_agent_keys, + }; let clear_admin = Principal { name: "admin", keys: Keys::generate(), @@ -574,6 +639,7 @@ async fn community_http_ban_matrix() { let banned = [&banned_member, &banned_admin, &banned_agent]; let controls = [ &clear_member, + &clear_member_agent, &clear_admin, &clear_owner, &clear_moderator_agent, @@ -600,11 +666,18 @@ async fn community_http_ban_matrix() { "moderation_owner_ban" => "clear-moderator-agent", _ => route.control.as_str(), }; - let control = principal(control_name, &controls); - let response = send_route_request(&client, &route, case, control, &host).await; - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - assert_allowed_control(status, &body, &route, case); + let control_names = if control_name == "member" { + vec!["member", "clear-member-agent"] + } else { + vec![control_name] + }; + for name in control_names { + let control = principal(name, &controls); + let response = send_route_request(&client, &route, case, control, &host).await; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + assert_allowed_control(status, &body, &route, case); + } } } } diff --git a/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv b/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv index 8422f392bf4..5385e8ade75 100644 --- a/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv +++ b/crates/buzz-test-client/tests/fixtures/community-ban-regressions.tsv @@ -18,3 +18,7 @@ ingest_owner_ban crates/buzz-relay/src/handlers/ingest.rs owner_ban_blocks_every moderation_owner_ban crates/buzz-relay/src/api/bridge.rs moderation_read_honors_fresh_and_stored_banned_agent_owners postgres-ci moderation_owner_ban_auth_time_and_stored_link timeout_admin_denial crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs banned_and_timed_out_admins_are_refused_but_unrestricted_admin_can_administer relay-e2e timed_out_admin_relay_admin_denial http_tenant_isolation crates/buzz-test-client/tests/community_ban_routes.rs ban_is_scoped_to_host_resolved_community relay-e2e http_ban_tenant_isolation +invite_restricted_claim crates/buzz-relay/src/api/invites/postgres_tests/restriction_tests.rs signed_nonmember_claims_reject_restrictions_without_consuming_invite postgres-ci restricted_nonmember_claim_never_consumes_invite +workflow_owner_ban crates/buzz-relay/src/api/bridge/workflow_webhook_postgres_tests.rs webhook_admission_enforces_owner_ban_tenant_scope_and_lookup_failure postgres-ci webhook_owner_ban_tenant_scope_and_fail_closed_lookup +durable_live_revalidation crates/buzz-relay/src/state.rs missed_member_owner_ban_and_first_owner_link_revoke_root_audio_sessions postgres-ci missed_fanout_member_and_owner_revocation +token_owner_revalidation crates/buzz-relay/src/state.rs token_owner_admission_survives_scan_with_nip_oa_disabled_until_owner_removal postgres-ci nip_fi_owner_admission_matches_live_scan diff --git a/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv index 75f7ae6bf4b..93764a228e8 100644 --- a/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv +++ b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv @@ -29,12 +29,12 @@ join_policy relay GET /api/join-policy public_metadata not_applicable not_applic join_policy_terms relay GET /api/join-policy/terms public_metadata not_applicable not_applicable:public_metadata classified - join_policy_privacy relay GET /api/join-policy/privacy public_metadata not_applicable not_applicable:public_metadata classified - invite_accept_policy relay POST /api/invites/accept-policy pre_membership_anonymous exempt not_applicable:no_authenticated_principal classified - -invite_claim relay POST /api/invites/claim pre_membership_nip98 exempt enforce pending:BUZZ-268 BUZZ-268 +invite_claim relay POST /api/invites/claim pre_membership_nip98 exempt enforce pg:signed_nonmember_claims_reject_restrictions_without_consuming_invite - nip_fi_disconnect relay POST /api/nip-fi/disconnect internal_nip_fi not_applicable not_applicable:internal_auth_plane classified - moderation_reports relay GET /moderation/reports community_nip98 community enforce matrix:moderation_reports+moderation_owner_ban admin moderation_audit relay GET /moderation/audit community_nip98 community enforce matrix:moderation_audit+moderation_owner_ban admin moderation_restricted relay GET /moderation/restricted community_nip98 community enforce matrix:moderation_restricted+moderation_owner_ban admin -workflow_webhook relay POST /hooks/{id} workflow_webhook_secret not_applicable enforce pending:BUZZ-272 BUZZ-272 +workflow_webhook relay POST /hooks/{id} workflow_webhook_secret not_applicable enforce pg:webhook_admission_enforces_owner_ban_tenant_scope_and_lookup_failure - mesh_demo_echo relay POST /_mesh/demo/echo testbed_internal not_applicable not_applicable:testbed_only classified - huddle_audio relay GET /huddle/{channel_id}/audio community_nip42 community enforce pg:audio_join_rejects_community_banned_member+audio_join_rejects_banned_admin+audio_join_rejects_banned_owner_agent - media_upload relay PUT /upload community_blossom community enforce matrix:media_upload member diff --git a/scripts/check-community-ban-route-inventory.py b/scripts/check-community-ban-route-inventory.py index b12b93732c2..ff1f280841f 100644 --- a/scripts/check-community-ban-route-inventory.py +++ b/scripts/check-community-ban-route-inventory.py @@ -348,10 +348,17 @@ def builder_prefixes(builders) -> dict[str, str]: return prefixes -# Service and fallback registrations bypass the per-route method -# classification, so each one is a hard error unless this list names its -# builder, call, and exact (whitespace-normalized) argument, once. +# Service, fallback, and merge registrations bypass per-route classification. +# Pin the existing composition seams by builder and exact normalized argument; +# reject new or repeated compositions until their routes can be classified. ALLOWED_UNROUTED_REGISTRATIONS = { + # These root aliases are constructed in build_router at the root prefix. + ("router::build_router", "merge", "media_router"), + ("router::build_router", "merge", "git_router"), + ("router::build_router", "merge", "git_policy_router"), + ("router::build_router", "merge", "optional"), + # Git settings already carry their complete /git paths; no nest prefix. + ("api::git::transport::git_router", "merge", "super::settings::router()"), # SPA static bundles; it only serves files, never a community API. ("router::build_router", "fallback_service", "spa_fallback"), # Unknown accessory paths answer 404 instead of SPA HTML. @@ -381,6 +388,11 @@ def collect_registered_routes() -> Counter: mask = mask_comments_and_strings(source) test_ranges = test_module_ranges(mask) path_builders = [builder for builder in builders if builder[2] == path] + # Root aliases have root paths only when merged outside a nest target. + nest_arguments = [] + for nest in re.finditer(r"\.\s*nest\s*\(", mask): + opening = mask.find("(", nest.start(), nest.end()) + nest_arguments.append((opening, matching_delimiter(mask, opening))) def enclosing_builder(position: int) -> str | None: for builder in path_builders: @@ -389,7 +401,7 @@ def enclosing_builder(position: int) -> str | None: return None for match in re.finditer( - r"\.\s*(route|nest|route_service|nest_service|fallback_service|fallback)\s*\(", + r"\.\s*(route|nest|route_service|nest_service|fallback_service|fallback|merge)\s*\(", mask, ): if in_ranges(match.start(), test_ranges): @@ -398,13 +410,22 @@ def enclosing_builder(position: int) -> str | None: builder = enclosing_builder(match.start()) line = source.count("\n", 0, match.start()) + 1 location = f"{path.relative_to(ROOT)}:{line}" - if call.endswith("_service") or call == "fallback": + if call.endswith("_service") or call in {"fallback", "merge"}: opening = mask.find("(", match.start(), match.end()) argument = " ".join( source[opening + 1 : matching_delimiter(mask, opening)].split() ) registration = (builder, call, argument) - if registration not in ALLOWED_UNROUTED_REGISTRATIONS: + if ( + call == "merge" + and builder == "router::build_router" + and in_ranges(match.start(), nest_arguments) + ): + errors.append( + f"{location}: root .merge registration inside a .nest target " + "is not classified" + ) + elif registration not in ALLOWED_UNROUTED_REGISTRATIONS: errors.append( f"{location}: production .{call} registration is not classified; " "use .route with an HTTP method or allow-list it" @@ -551,7 +572,6 @@ def check_route_inventory() -> tuple[int, int, int]: found = any( test_function_exists(path, test_name) for path in RELAY_SRC.rglob("*.rs") - if "tests" not in path.parts and not path.name.endswith("_tests.rs") ) if not found: raise ValueError(f"{route_id}: missing PostgreSQL regression {test_name!r}") @@ -573,10 +593,8 @@ def check_route_inventory() -> tuple[int, int, int]: details.append("inventory rows without registered routes: " + ", ".join(map(str, stale))) raise ValueError("; ".join(details)) - if set(pending) != {("invite_claim", "pending:BUZZ-268"), ("workflow_webhook", "pending:BUZZ-272")}: - raise ValueError( - "pending coverage must explicitly track BUZZ-268 invitations and BUZZ-272 webhook owner bans" - ) + if pending: + raise ValueError(f"integrated ban coverage cannot retain pending sibling cases: {pending}") claim = next((row for row in routes if row["id"] == "invite_claim"), None) if claim is None or claim["membership"] != "exempt" or claim["restriction"] != "enforce": raise ValueError("invite claim must remain membership-exempt and restriction-enforced") diff --git a/scripts/check-community-ban-route-inventory.test.py b/scripts/check-community-ban-route-inventory.test.py index a554f98f227..d0e4bcc1d41 100644 --- a/scripts/check-community-ban-route-inventory.test.py +++ b/scripts/check-community-ban-route-inventory.test.py @@ -68,6 +68,41 @@ def assert_rejected(self, message: str, router: str, admin: str = ADMIN): with self.assertRaisesRegex(ValueError, message): self.scan(router, admin) + def test_nested_builder_merge_is_rejected(self): + self.assert_rejected( + r"\.merge registration is not classified", + build_router(), + ADMIN.replace('get(probe))', 'get(probe)).merge(crate::api::profiles::router(state))'), + ) + + def test_unclassified_root_merge_is_rejected(self): + self.assert_rejected( + r"\.merge registration is not classified", + build_router(".merge(api::admin::router(state.clone()))"), + ) + + def test_existing_root_merge_alias_is_allowed_once(self): + self.scan(build_router(".merge(media_router)")) + self.assert_rejected( + r"allow-listed \.merge registration appears more than once", + build_router(".merge(media_router).merge(media_router)"), + ) + + def test_root_merge_alias_moved_inside_nest_is_rejected(self): + self.assert_rejected( + r"root \.merge registration inside a \.nest target is not classified", + build_router().replace( + 'api::admin::router(state.clone()))', + 'api::admin::router(state.clone()).merge(git_policy_router))', + ), + ) + + def test_merge_outside_router_builder_is_rejected(self): + self.assert_rejected( + r"\.merge registration is not classified", + build_router() + "\nfn hidden() { opaque.merge(private_router); }", + ) + def test_same_named_constants_resolve_per_module(self): routes = self.scan(build_router()) self.assertEqual( From a8dd685bdebc197db9b5f69e1379752f7296eaef Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Thu, 29 Oct 2026 20:50:15 -0400 Subject: [PATCH 14/18] test(auth): match Git controls to handler response contracts Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler --- TESTING.md | 5 +-- .../tests/community_ban_routes.rs | 34 ++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/TESTING.md b/TESTING.md index 936de3aaacb..7e558076730 100644 --- a/TESTING.md +++ b/TESTING.md @@ -67,8 +67,9 @@ PostgreSQL database. Start the relay with `BUZZ_V1_ENABLED=true` so the accessory-route cases exercise their production handlers. The matrix creates unique tenant hosts and identities. Unset `BUZZ_KLIPY_API_KEY`: GIF controls assert the exact provider-disabled response without contacting a provider. -The malformed JPEG control asserts its validation response; Git, workflow and -media-read controls assert exact missing-resource responses. Successful reads +The malformed JPEG and invalid Git-push repository-name controls assert their +validation responses; Git reads, workflow and media-read controls assert exact +missing-resource responses. Successful reads and writes must return their expected JSON shape, not merely avoid a ban. Ordinary-member routes also run a clear delegated-agent control. Do not point these tests at a shared, development, or hosted database or relay. diff --git a/crates/buzz-test-client/tests/community_ban_routes.rs b/crates/buzz-test-client/tests/community_ban_routes.rs index 3e9424ab075..38325ab05ea 100644 --- a/crates/buzz-test-client/tests/community_ban_routes.rs +++ b/crates/buzz-test-client/tests/community_ban_routes.rs @@ -3,8 +3,9 @@ //! The route inventory is shared with `scripts/check-community-ban-route-inventory.py`. //! This ignored test uses a disposable local relay/PostgreSQL/MinIO stack and //! fresh tenant hosts and identities. Run without BUZZ_KLIPY_API_KEY so GIF -//! controls assert the disabled-provider response; Git and workflow requests use absent -//! resources and must stop before subprocess or durable work. +//! controls assert the disabled-provider response. Git reads and workflows use +//! absent resources; Git push uses an invalid name. All stop before subprocess +//! or durable work. //! //! ```text //! ./scripts/start-relay-for-tests.sh @@ -244,6 +245,11 @@ fn route_path(row: &RouteRow, case: &str, actor: &Principal) -> String { .replace("{sha256_ext}", &format!("{missing_blob}.jpg")) .replace("{owner}", &repo_owner) .replace("{repo}", &missing_repo); + // Push initializes absent repositories, so reject a deliberately invalid + // name after the shared admission gate and before storage/subprocess work. + if case == "git_receive_pack" { + path = path.replace(&missing_repo, "invalid..repo"); + } if case == "git_info_refs" { path.push_str("?service=git-upload-pack"); } @@ -456,11 +462,13 @@ fn assert_allowed_control(response_status: StatusCode, body: &str, route: &Route | "moderation_owner_ban" | "moderation_audit" | "moderation_restricted" => StatusCode::OK, - "profile_search" | "invite_mint" | "buzz_read_state" => StatusCode::BAD_REQUEST, + "profile_search" | "invite_mint" | "buzz_read_state" | "git_receive_pack" => { + StatusCode::BAD_REQUEST + } // This isolated fixture runs without a GIF provider key. "gif_search" | "gif_share" | "workflow_runs" | "workflow_approvals" | "media_get" - | "media_head" | "git_info_refs" | "git_upload_pack" | "git_receive_pack" - | "git_default_get" | "git_default_post" => StatusCode::NOT_FOUND, + | "media_head" | "git_info_refs" | "git_upload_pack" | "git_default_get" + | "git_default_post" => StatusCode::NOT_FOUND, "media_upload" => StatusCode::UNPROCESSABLE_ENTITY, _ => panic!("no positive-control contract for {case}"), }; @@ -476,9 +484,20 @@ fn assert_allowed_control(response_status: StatusCode, body: &str, route: &Route } if matches!( case, - "git_info_refs" | "git_upload_pack" | "git_receive_pack" + "git_info_refs" + | "git_upload_pack" + | "git_receive_pack" + | "git_default_get" + | "git_default_post" ) { - assert_eq!(body, "repository not found"); + assert_eq!( + body, + if case == "git_receive_pack" { + "invalid repo name" + } else { + "repository not found" + } + ); return; } let value: serde_json::Value = serde_json::from_str(body) @@ -512,7 +531,6 @@ fn assert_allowed_control(response_status: StatusCode, body: &str, route: &Route "workflow_runs" | "workflow_approvals" => "workflow not found", "media_get" => "not found", "media_upload" => "invalid image data", - "git_default_get" | "git_default_post" => "repository not found", _ => unreachable!(), }; assert_eq!(value["error"], expected_error, "case {case}: {body}"); From 1ac1ad5c9a7e6b585f1ec5306a6eb9ca2441eaaf Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 12:33:13 -0500 Subject: [PATCH 15/18] fix: fence discovery snapshots against newer publications Capture the relay-authored coordinate heads before channel state, require the same heads under the replacement lock, and recapture after a competing publication. Preserve the writer-locked member roster path. Add controlled concurrency and database precondition regressions. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/buzz-db/src/store/replaceable.rs | 154 +++++++++++++++++- .../discovery_ordering_postgres_tests.rs | 97 +++++++++++ crates/buzz-relay/src/handlers/mod.rs | 2 + .../buzz-relay/src/handlers/side_effects.rs | 119 ++++++++++---- 4 files changed, 330 insertions(+), 42 deletions(-) create mode 100644 crates/buzz-relay/src/handlers/discovery_ordering_postgres_tests.rs diff --git a/crates/buzz-db/src/store/replaceable.rs b/crates/buzz-db/src/store/replaceable.rs index ffc56214bd0..f9ecaa8e6cf 100644 --- a/crates/buzz-db/src/store/replaceable.rs +++ b/crates/buzz-db/src/store/replaceable.rs @@ -404,6 +404,38 @@ impl Db { event: &nostr::Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { + self.replace_addressable_event_checked(community_id, event, channel_id, None) + .await? + .ok_or_else(|| DbError::InvalidData("unconditional replacement lost its head".into())) + } + + /// Replace only if the live coordinate still has `expected_revision`. + /// `None` requires an empty coordinate. Returns `None` on a changed head, + /// without retiring or inserting anything; callers must recapture their + /// source state before retrying. The check shares the replacement lock. + pub async fn replace_addressable_event_if_current( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, + expected_revision: Option<&[u8]>, + ) -> Result> { + self.replace_addressable_event_checked( + community_id, + event, + channel_id, + Some(expected_revision), + ) + .await + } + + async fn replace_addressable_event_checked( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, + expected_revision: Option>, + ) -> Result> { let kind_i32 = buzz_core::kind::event_kind_i32(event); let pubkey_bytes = event.pubkey.to_bytes(); let created_at_secs = event.created_at.as_secs() as i64; @@ -458,6 +490,14 @@ impl Db { .fetch_optional(tx.conn()) .await?; + if let Some(expected) = expected_revision { + let current = existing.as_ref().map(|(_, id)| id.as_slice()); + if current != expected { + tx.rollback().await?; + return Ok(None); + } + } + // Stale-write protection: reject if incoming is not newer. // NIP-16: created_at is second-resolution. On same-second tie, lowest // event id (lexicographic) wins — deterministic across relays. @@ -469,7 +509,7 @@ impl Db { if dominated { tx.rollback().await?; let received_at = chrono::Utc::now(); - return Ok(( + return Ok(Some(( StoredEvent::with_received_at( event.clone(), received_at, @@ -477,7 +517,7 @@ impl Db { false, ), false, - )); + ))); } } @@ -530,7 +570,7 @@ impl Db { // ON CONFLICT fired — the event ID already exists. Rollback the // soft-delete so we don't lose the previous replaceable event. tx.rollback().await?; - return Ok(( + return Ok(Some(( StoredEvent::with_received_at( event.clone(), received_at, @@ -538,7 +578,7 @@ impl Db { false, ), false, - )); + ))); } crate::store::event_follow_up::after_admitted_insert( @@ -557,10 +597,10 @@ impl Db { tx.commit().await?; - Ok(( + Ok(Some(( StoredEvent::with_received_at(event.clone(), received_at, channel_id, true), true, - )) + ))) }) .await } @@ -701,6 +741,108 @@ mod postgres_tests { .expect("create channel"); } + /// An older snapshot may not acquire a later timestamp after another + /// producer has published. Failed preconditions leave the current event + /// and its mention index intact, including the initially-empty case. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn addressable_replacement_requires_the_captured_head() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community_uuid = Uuid::new_v4(); + let channel = Uuid::new_v4(); + let keys = Keys::generate(); + seed_community_channel(&db.pool, community_uuid, channel, &keys).await; + let community = CommunityId::from_uuid(community_uuid); + let base = Timestamp::now().as_secs(); + let sign = |offset, value| { + EventBuilder::new(Kind::Custom(39000), value) + .allow_self_tagging() + .tags([ + Tag::parse(["d", &channel.to_string()]).unwrap(), + Tag::parse(["p", &keys.public_key().to_hex()]).unwrap(), + ]) + .custom_created_at(Timestamp::from(base + offset)) + .sign_with_keys(&keys) + .unwrap() + }; + let original = sign(0, "original"); + assert!( + db.replace_addressable_event_if_current(community, &original, Some(channel), None) + .await + .unwrap() + .unwrap() + .1 + ); + let newer = sign(1, "newer"); + assert!( + db.replace_addressable_event_if_current(community, &newer, Some(channel), None) + .await + .unwrap() + .is_none(), + "empty-coordinate precondition must reject an existing head" + ); + assert!( + db.replace_addressable_event_if_current( + community, + &newer, + Some(channel), + Some(original.id.as_bytes().as_slice()) + ) + .await + .unwrap() + .unwrap() + .1 + ); + let stale = sign(2, "stale snapshot with a later timestamp"); + assert!(db + .replace_addressable_event_if_current( + community, + &stale, + Some(channel), + Some(original.id.as_bytes().as_slice()) + ) + .await + .unwrap() + .is_none()); + // Matching the head does not bypass normal NIP-16 stale-write rules. + assert!( + !db.replace_addressable_event_if_current( + community, + &original, + Some(channel), + Some(newer.id.as_bytes().as_slice()) + ) + .await + .unwrap() + .unwrap() + .1 + ); + let events = db + .query_events(&event::EventQuery { + kinds: Some(vec![39000]), + channel_id: Some(channel), + ..event::EventQuery::for_community(community) + }) + .await + .unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event.id, newer.id); + let mentions: i64 = sqlx::query_scalar( + "SELECT count(*) FROM event_mentions WHERE community_id = $1 AND event_id = $2", + ) + .bind(community.as_uuid()) + .bind(newer.id.as_bytes().as_slice()) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!( + mentions, 1, + "failed replacement must retain the current mention index" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn addressable_replacement_rolls_back_when_mention_indexing_fails() { diff --git a/crates/buzz-relay/src/handlers/discovery_ordering_postgres_tests.rs b/crates/buzz-relay/src/handlers/discovery_ordering_postgres_tests.rs new file mode 100644 index 00000000000..716e6e71f9a --- /dev/null +++ b/crates/buzz-relay/src/handlers/discovery_ordering_postgres_tests.rs @@ -0,0 +1,97 @@ +//! Force an old discovery producer to finish after a newer metadata edit. + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use nostr::{EventBuilder, Keys, Kind, Tag}; +use tokio::sync::oneshot; +use uuid::Uuid; + +use super::group_state_postgres_tests::{ + community, create_channel, edit_channel, group_state_tags, http_auth, state, +}; +use super::ingest::ingest_event; +use super::side_effects::emit_group_discovery_events; + +type Pause = (oneshot::Sender<()>, oneshot::Receiver<()>); + +fn pauses() -> &'static Mutex> { + static PAUSES: OnceLock>> = OnceLock::new(); + PAUSES.get_or_init(Default::default) +} + +/// Consume a channel-scoped, one-shot pause; other producers can proceed. +pub(super) async fn after_capture(channel: Uuid) { + let pause = pauses().lock().unwrap().remove(&channel); + if let Some((captured, resume)) = pause { + let _ = captured.send(()); + let _ = resume.await; + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn delayed_discovery_cannot_restore_stale_metadata() { + let state = state().await; + let tenant = community(&state, "discovery-ordering").await; + let owner = Keys::generate(); + let channel = create_channel(&state, &tenant, &owner, &[]).await; + state + .db + .add_relay_member( + tenant.community(), + &owner.public_key().to_hex(), + "owner", + None, + &Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed relay owner"); + edit_channel(&state, &tenant, &owner, channel, &[&["auto_join", "true"]]) + .await + .expect("enable auto_join"); + let has_auto_join = |tags: &[Vec]| tags.iter().any(|tag| tag[0] == "auto_join"); + assert!(has_auto_join( + &group_state_tags(&state, &tenant, channel, 39000).await + )); + + let (captured, did_capture) = oneshot::channel(); + let (resume, resumed) = oneshot::channel(); + pauses() + .lock() + .unwrap() + .insert(channel, (captured, resumed)); + let old_producer = tokio::spawn({ + let state = state.clone(); + let tenant = tenant.clone(); + async move { emit_group_discovery_events(&tenant, &state, channel).await } + }); + tokio::time::timeout(std::time::Duration::from_secs(10), did_capture) + .await + .expect("old producer reached capture") + .expect("capture signalled"); + + // The real edit path commits and publishes false while the older producer + // still holds its true snapshot. No clock sleeps or scheduler luck needed. + let edit = EventBuilder::new(Kind::Custom(9002), "") + .tags([ + Tag::parse(["h", &channel.to_string()]).unwrap(), + Tag::parse(["auto_join", "false"]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + let result = ingest_event(&state, &tenant, edit, http_auth(&owner)) + .await + .unwrap(); + assert!(result.accepted, "{}", result.message); + assert!(!has_auto_join( + &group_state_tags(&state, &tenant, channel, 39000).await + )); + resume.send(()).expect("old producer waiting"); + old_producer.await.unwrap().unwrap(); + assert!( + !has_auto_join(&group_state_tags(&state, &tenant, channel, 39000).await), + "delayed discovery restored auto_join after the accepted false edit" + ); +} diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index d15623f5bf9..48707faec33 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -23,6 +23,8 @@ mod create_with_members_postgres_tests; #[cfg(test)] mod deletion_postgres_tests; #[cfg(test)] +mod discovery_ordering_postgres_tests; +#[cfg(test)] mod edit_metadata_postgres_tests; /// EVENT handler — WS dispatcher → ingest pipeline → fan-out. pub mod event; diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 38b6dbc38ab..674f478cdf9 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1520,8 +1520,9 @@ async fn emit_addressable_discovery_event( channel_id: Uuid, kind: u32, tags: Vec, + previous: Option<&buzz_core::StoredEvent>, relay_pubkey_hex: &str, -) -> anyhow::Result<()> { +) -> anyhow::Result { // Ensure the new event's created_at is strictly greater than any existing event // of the same (kind, pubkey, channel_id). Without this, rapid successive updates // (e.g. set topic then set purpose in the same second) can produce events with @@ -1531,22 +1532,9 @@ async fn emit_addressable_discovery_event( .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - let min_ts = { - let existing = state - .db - .query_events_for_event_write(&buzz_db::event::EventQuery { - kinds: Some(vec![kind as i32]), - channel_id: Some(channel_id), - limit: Some(1), - ..buzz_db::event::EventQuery::for_community(tenant.community()) - }) - .await - .unwrap_or_default(); - existing - .first() - .map(|e| e.event.created_at.as_secs() + 1) - .unwrap_or(now) - }; + let min_ts = previous + .map(|stored| stored.event.created_at.as_secs() + 1) + .unwrap_or(now); let ts = now.max(min_ts); let event = EventBuilder::new(Kind::Custom(kind as u16), "") @@ -1555,16 +1543,24 @@ async fn emit_addressable_discovery_event( .sign_with_keys(&state.relay_keypair) .map_err(|e| anyhow::anyhow!("failed to sign kind:{kind}: {e}"))?; - let (stored, was_inserted) = state + let replacement = state .db - .replace_addressable_event(tenant.community(), &event, Some(channel_id)) + .replace_addressable_event_if_current( + tenant.community(), + &event, + Some(channel_id), + previous.map(|stored| stored.event.id.as_bytes().as_slice()), + ) .await .map_err(|e| anyhow::anyhow!("channel state write failed: {}", e.kind()))?; + let Some((stored, was_inserted)) = replacement else { + return Ok(false); + }; if was_inserted { let kind_u32 = event_kind_u32(&stored.event); dispatch_persistent_event(tenant, state, &stored, kind_u32, relay_pubkey_hex, None).await; } - Ok(()) + Ok(true) } /// Tags that identify the channel on every relay-signed group-state event: @@ -1654,19 +1650,28 @@ async fn dispatch_group_members_event( } } -/// Emit NIP-29 group discovery events (39000, 39001, 39002) signed by the relay keypair. -/// Called after group creation, metadata changes, or membership changes. -/// Events are stored channel-scoped (`channel_id = Some(...)`) so that existing -/// access control applies — private channel member lists are only visible to members. -/// -/// NOTE: Channel-scoped storage means live global subscriptions (e.g. `{kinds:[39000]}`) -/// won't receive these events via fan-out. Clients discover groups via historical REQ -/// queries. Live push for open-channel discovery is a future enhancement. -pub async fn emit_group_discovery_events( +async fn try_emit_group_metadata_and_admins( tenant: &TenantContext, state: &Arc, channel_id: Uuid, -) -> anyhow::Result<()> { +) -> anyhow::Result { + // Read both coordinate revisions BEFORE capturing source state. If another + // producer publishes after either read, its revision fences this snapshot + // out atomically, even when this producer resumes much later on another pod. + let mut previous = Vec::with_capacity(2); + for kind in [KIND_NIP29_GROUP_METADATA, KIND_NIP29_GROUP_ADMINS] { + let events = state + .db + .query_events_for_event_write(&buzz_db::event::EventQuery { + kinds: Some(vec![kind as i32]), + authors: Some(vec![state.relay_keypair.public_key().to_bytes().to_vec()]), + channel_id: Some(channel_id), + limit: Some(1), + ..buzz_db::event::EventQuery::for_community(tenant.community()) + }) + .await?; + previous.push(events.into_iter().next()); + } let channel = state .db .get_channel_for_event_write(tenant.community(), channel_id) @@ -1679,20 +1684,27 @@ pub async fn emit_group_discovery_events( let relay_pubkey_hex = hex::encode(state.relay_keypair.public_key().to_bytes()); let group_id = channel_id.to_string(); + #[cfg(test)] + super::discovery_ordering_postgres_tests::after_capture(channel_id).await; + { let tags = buzz_db::channel::group_metadata_tags(&channel, &members) .into_iter() .map(Tag::parse) .collect::, _>>()?; - emit_addressable_discovery_event( + if !emit_addressable_discovery_event( tenant, state, channel_id, KIND_NIP29_GROUP_METADATA, tags, + previous[0].as_ref(), &relay_pubkey_hex, ) - .await?; + .await? + { + return Ok(false); + } } { @@ -1705,20 +1717,55 @@ pub async fn emit_group_discovery_events( let pubkey_hex = hex::encode(&m.pubkey); tags.push(Tag::parse(["p", &pubkey_hex, &m.role])?); } - emit_addressable_discovery_event( + if !emit_addressable_discovery_event( tenant, state, channel_id, KIND_NIP29_GROUP_ADMINS, tags, + previous[1].as_ref(), &relay_pubkey_hex, ) - .await?; + .await? + { + return Ok(false); + } } + Ok(true) +} + +/// Emit NIP-29 group discovery events (39000, 39001, 39002) signed by the relay keypair. +/// Called after group creation, metadata changes, or membership changes. +/// Events are stored channel-scoped (`channel_id = Some(...)`) so that existing +/// access control applies — private channel member lists are only visible to members. +/// +/// NOTE: Channel-scoped storage means live global subscriptions (e.g. `{kinds:[39000]}`) +/// won't receive these events via fan-out. Clients discover groups via historical REQ +/// queries. Live push for open-channel discovery is a future enhancement. +pub async fn emit_group_discovery_events( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, +) -> anyhow::Result<()> { + // Conflicts mean a different producer committed after our revision read. + // Recapture rather than giving the old tags a later timestamp. Bound work + // under sustained contention and report exhaustion through the caller. + let mut published = false; + for _ in 0..8 { + if try_emit_group_metadata_and_admins(tenant, state, channel_id).await? { + published = true; + break; + } + } + if !published { + anyhow::bail!("group discovery publication repeatedly changed for {channel_id}"); + } + let relay_pubkey_hex = hex::encode(state.relay_keypair.public_key().to_bytes()); + // Re-capture membership behind the writer lock immediately before the - // authoritative 39002 replacement. Metadata/admin snapshots retain their - // existing behavior; only membership publication needs this freshness fence. + // authoritative 39002 replacement. Its membership lock also fences changes + // that have committed without a discovery publication yet. let relay_pubkey = state.relay_keypair.public_key().to_bytes(); let mut member_snapshot = state .db From 47d8fed4ce98db569d0ce4975951225782e0bfa4 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 14:38:31 -0500 Subject: [PATCH 16/18] fix(test): reject nested Git settings merges Reject the allow-listed settings merge when its parent router has a nest prefix so stale root paths cannot satisfy the route inventory. Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../check-community-ban-route-inventory.py | 5 ++++ ...heck-community-ban-route-inventory.test.py | 30 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/check-community-ban-route-inventory.py b/scripts/check-community-ban-route-inventory.py index ff1f280841f..7c942ee83c4 100644 --- a/scripts/check-community-ban-route-inventory.py +++ b/scripts/check-community-ban-route-inventory.py @@ -430,6 +430,11 @@ def enclosing_builder(position: int) -> str | None: f"{location}: production .{call} registration is not classified; " "use .route with an HTTP method or allow-list it" ) + elif call == "merge" and prefixes.get(builder): + errors.append( + f"{location}: allow-listed .merge in a nested builder " + "is not classified; merged routes need the parent's prefix" + ) elif registration in unrouted_seen: errors.append( f"{location}: allow-listed .{call} registration appears more than once" diff --git a/scripts/check-community-ban-route-inventory.test.py b/scripts/check-community-ban-route-inventory.test.py index d0e4bcc1d41..50ca1430ecd 100644 --- a/scripts/check-community-ban-route-inventory.test.py +++ b/scripts/check-community-ban-route-inventory.test.py @@ -47,7 +47,7 @@ def build_router(extra: str = "") -> str: class RouteScannerFixtures(unittest.TestCase): - def scan(self, router: str, admin: str = ADMIN): + def scan(self, router: str, admin: str = ADMIN, extra_sources=None): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) src = root / "crates/buzz-relay/src" @@ -57,6 +57,10 @@ def scan(self, router: str, admin: str = ADMIN): (src / "api/gifs.rs").write_text(GIFS) (src / "api/profiles.rs").write_text(PROFILES) (src / "api/admin.rs").write_text(admin) + for relative, source in (extra_sources or {}).items(): + path = src / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source) saved = scanner.ROOT, scanner.RELAY_SRC scanner.ROOT, scanner.RELAY_SRC = root, src try: @@ -88,6 +92,30 @@ def test_existing_root_merge_alias_is_allowed_once(self): build_router(".merge(media_router).merge(media_router)"), ) + def test_git_settings_merge_requires_root_parent(self): + sources = { + "api/git/transport.rs": """ +pub fn git_router(state: State) -> Router { + Router::new() + .route("/git/{owner}/{repo}/info/refs", get(info)) + .merge(super::settings::router()) +} +""", + "api/git/settings.rs": """ +pub fn router() -> Router { + Router::new().route("/git/{owner}/{repo}/default-branch", get(read).put(write)) +} +""", + } + routes = self.scan(build_router(".merge(git_router)"), extra_sources=sources) + self.assertIn(("relay", "GET", "/git/{owner}/{repo}/default-branch"), routes) + self.assertIn(("relay", "PUT", "/git/{owner}/{repo}/default-branch"), routes) + with self.assertRaisesRegex(ValueError, r"allow-listed \.merge in a nested builder"): + self.scan( + build_router('.nest("/alternate", api::git::transport::git_router(state))'), + extra_sources=sources, + ) + def test_root_merge_alias_moved_inside_nest_is_rejected(self): self.assert_rejected( r"root \.merge registration inside a \.nest target is not classified", From 638c65ccc6d1a1de4f03e23fc78952156e7ba215 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 15:01:52 -0500 Subject: [PATCH 17/18] test(relay): classify merged FI upgrade routes Record the new GET and POST upgrade routes in the operator control plane alongside the existing binding endpoint. Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../tests/fixtures/community-ban-route-inventory.tsv | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv index 93764a228e8..812e2a9cbdc 100644 --- a/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv +++ b/crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv @@ -23,6 +23,8 @@ operator_unarchive_community relay POST /operator/communities/unarchive operator operator_delete_community relay POST /operator/communities/delete operator_control not_applicable not_applicable:relay_operator_control classified - operator_community_availability relay GET /operator/communities/availability operator_control not_applicable not_applicable:relay_operator_control classified - operator_community_nip_fi_binding relay GET /operator/communities/nip-fi-binding operator_control not_applicable not_applicable:relay_operator_control classified - +operator_community_nip_fi_upgrade_status relay GET /operator/communities/nip-fi-upgrade operator_control not_applicable not_applicable:relay_operator_control classified - +operator_enable_community_nip_fi relay POST /operator/communities/nip-fi-upgrade operator_control not_applicable not_applicable:relay_operator_control classified - operator_transfer_community relay POST /operator/communities/transfer operator_control not_applicable not_applicable:relay_operator_control classified - invite_mint relay POST /api/invites community_nip98 community enforce matrix:invite_mint admin join_policy relay GET /api/join-policy public_metadata not_applicable not_applicable:public_metadata classified - From 070b77b00e12c0552dac45eaa58713f2e572fab9 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 15:23:24 -0500 Subject: [PATCH 18/18] fix(relay): refresh members after discovery contention Publish the independently locked member snapshot before reporting exhausted metadata retries; competing producers may stop before their own roster refresh. Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../discovery_ordering_postgres_tests.rs | 95 +++++++++++++++++++ .../buzz-relay/src/handlers/side_effects.rs | 9 +- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/crates/buzz-relay/src/handlers/discovery_ordering_postgres_tests.rs b/crates/buzz-relay/src/handlers/discovery_ordering_postgres_tests.rs index 716e6e71f9a..8e5170a1c4f 100644 --- a/crates/buzz-relay/src/handlers/discovery_ordering_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/discovery_ordering_postgres_tests.rs @@ -95,3 +95,98 @@ async fn delayed_discovery_cannot_restore_stale_metadata() { "delayed discovery restored auto_join after the accepted false edit" ); } + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn discovery_exhaustion_still_refreshes_members() { + let state = state().await; + let tenant = community(&state, "discovery-exhaustion").await; + let owner = Keys::generate(); + let channel = create_channel(&state, &tenant, &owner, &[]).await; + let member = Keys::generate().public_key(); + state + .db + .add_member( + tenant.community(), + channel, + &member.to_bytes(), + buzz_core::channel::MemberRole::Member, + None, + ) + .await + .unwrap(); + let lists_member = |tags: &[Vec]| { + tags.iter().any(|tag| { + tag.first().map(String::as_str) == Some("p") && tag.get(1) == Some(&member.to_hex()) + }) + }; + assert!(!lists_member( + &group_state_tags(&state, &tenant, channel, 39002).await + )); + + let arm_pause = || { + let (captured, did_capture) = oneshot::channel(); + let (resume, resumed) = oneshot::channel(); + pauses() + .lock() + .unwrap() + .insert(channel, (captured, resumed)); + (did_capture, resume) + }; + let mut pause = Some(arm_pause()); + let producer = tokio::spawn({ + let state = state.clone(); + let tenant = tenant.clone(); + async move { emit_group_discovery_events(&tenant, &state, channel).await } + }); + for attempt in 0..8 { + let (captured, resume) = pause.take().unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(10), captured) + .await + .expect("producer captured on every attempt") + .unwrap(); + let heads = state + .db + .query_events_for_event_write(&buzz_db::event::EventQuery { + kinds: Some(vec![39000]), + authors: Some(vec![state.relay_keypair.public_key().to_bytes().to_vec()]), + channel_id: Some(channel), + limit: Some(1), + ..buzz_db::event::EventQuery::for_community(tenant.community()) + }) + .await + .unwrap(); + let head = &heads[0].event; + // Publish only metadata, modelling a competing producer that disappears + // before its independent member-list refresh. Every expected head loses. + let competitor = EventBuilder::new(Kind::Custom(39000), "") + .tags(head.tags.iter().cloned()) + .custom_created_at(nostr::Timestamp::from(head.created_at.as_secs() + 1)) + .sign_with_keys(&state.relay_keypair) + .unwrap(); + assert!( + state + .db + .replace_addressable_event(tenant.community(), &competitor, Some(channel)) + .await + .unwrap() + .1 + ); + if attempt < 7 { + pause = Some(arm_pause()); + } + resume.send(()).unwrap(); + } + let result = tokio::time::timeout(std::time::Duration::from_secs(10), producer) + .await + .unwrap() + .unwrap(); + assert!(result + .unwrap_err() + .to_string() + .contains("repeatedly changed")); + assert!( + lists_member(&group_state_tags(&state, &tenant, channel, 39002).await), + "exhausted metadata retries skipped the independent member-list refresh" + ); +} diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 674f478cdf9..fdad55f35b4 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1758,9 +1758,6 @@ pub async fn emit_group_discovery_events( break; } } - if !published { - anyhow::bail!("group discovery publication repeatedly changed for {channel_id}"); - } let relay_pubkey_hex = hex::encode(state.relay_keypair.public_key().to_bytes()); // Re-capture membership behind the writer lock immediately before the @@ -1775,6 +1772,12 @@ pub async fn emit_group_discovery_events( member_snapshot.release().await?; dispatch_group_members_event(tenant, state, stored_members, &relay_pubkey_hex).await; + // Metadata/admin contention must not skip the independent member snapshot. + // Winning producers can disappear before they publish their own 39002. + if !published { + anyhow::bail!("group discovery publication repeatedly changed for {channel_id}"); + } + Ok(()) }