From 8284fd89ffd13c6f44937bd9b7764a62f51dc59f Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Thu, 3 Sep 2026 16:46:19 -0400 Subject: [PATCH 01/11] feat(relay): add admin HTTP routes for member restriction management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three routes to the /api/admin/v1 plane that expose the existing ban/timeout DB layer (moderation.rs) via HTTP — completing the admin surface for un-ban, un-timeout, and restriction listing: GET /members/restrictions?communityId={uuid} DELETE /members/{pubkey}/ban?communityId={uuid} DELETE /members/{pubkey}/timeout?communityId={uuid} The DB methods (list_community_restrictions, unban_community_member, untimeout_community_member) and the community_bans schema already exist from Phase 1; these routes are the only missing piece. The WebSocket paths (kind 9041 / 9043) continue to work unchanged. Route contract: - All three require admin auth (GET: read-only, accepted in both nip98 and disabled modes; DELETE: require_mutation_principal). - communityId resolves directly to CommunityId::from_uuid — no host-lookup needed; the admin plane is already operator-scoped. - Mutations write an audit row (action: unban / untimeout) and return 204 on success, 409 when no active restriction exists for the target. - Pubkey path params are validated as 64-char hex and reject with 404 on malformed input (consistent with the operators route convention). Response shape: MemberRestrictionRecord maps BanRecord fields to a camelCase JSON envelope with Vec pubkeys hex-encoded as strings. BanRecord remains a pure DB row type with no Serialize derive. Tests: - restriction_record_converts_ban_record_pubkeys_to_hex: pure unit test pinning the hex-encoding and field mapping of BanRecord → JSON. - list_restrictions_rejects_missing_credential: GET without credential returns 401 (no DB access). - unban_member_rejects_missing_credential: DELETE /ban without credential returns 401 (no DB access). - untimeout_member_rejects_missing_credential: DELETE /timeout without credential returns 401 (no DB access). - unban_member_returns_409_when_no_active_ban: signed DELETE against a community with no ban row returns 409 [requires Postgres]. - untimeout_member_returns_409_when_no_active_timeout: signed DELETE against a community with no timeout row returns 409 [requires Postgres]. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/admin/mod.rs | 368 ++++++++++++++++++++++++- 1 file changed, 367 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 19f2153b95b..aa3e2be552f 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -60,6 +60,9 @@ pub fn router(state: Arc) -> Router { .route("/operators", get(list_operators)) .route("/operators/{pubkey}", put(upsert_operator)) .route("/operators/{pubkey}", delete(delete_operator)) + .route("/members/restrictions", get(list_member_restrictions)) + .route("/members/{pubkey}/ban", delete(unban_member)) + .route("/members/{pubkey}/timeout", delete(untimeout_member)) .layer(middleware::from_fn(security_headers)) // Mutation routes carry a JSON body (max ~4 KB); read-only routes have no body. .layer(RequestBodyLimitLayer::new(4096)) @@ -1108,7 +1111,198 @@ async fn delete_operator( Ok(Json(serde_json::json!({"deleted": canonical_hex}))) } -// ── Staffing helpers ────────────────────────────────────────────────────────── +// ── Member restriction routes ───────────────────────────────────────────────── + +/// JSON response shape for one restriction record. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MemberRestrictionRecord { + /// Target member pubkey as lowercase hex. + pubkey: String, + /// Whether the member is currently banned. + banned: bool, + /// Ban expiry timestamp; `null` while `banned` ⇒ permanent. + ban_expires_at: Option>, + /// Moderator-supplied ban reason (private to the admin plane). + ban_reason: Option, + /// Write-block until this timestamp; `null` or past ⇒ not timed out. + muted_until: Option>, + /// Moderator-supplied timeout reason (private to the admin plane). + mute_reason: Option, + /// Last-acting moderator pubkey as lowercase hex. + actor_pubkey: String, + /// Last modification time. + updated_at: DateTime, +} + +impl From for MemberRestrictionRecord { + fn from(r: buzz_db::moderation::BanRecord) -> Self { + Self { + pubkey: hex::encode(&r.pubkey), + banned: r.banned, + ban_expires_at: r.ban_expires_at, + ban_reason: r.ban_reason, + muted_until: r.muted_until, + mute_reason: r.mute_reason, + actor_pubkey: hex::encode(&r.actor_pubkey), + updated_at: r.updated_at, + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CommunityQuery { + community_id: Uuid, +} + +/// GET /members/restrictions?communityId={uuid} +/// +/// List all currently active bans and timeouts for the given community. +/// Returns 400 if `communityId` is absent or not a valid UUID. +/// Requires admin auth (nip98 or disabled mode). +async fn list_member_restrictions( + State(state): State>, + uri: Uri, + headers: HeaderMap, + Query(query): Query, +) -> Result>, ApiError> { + authorize( + &state, + &headers, + uri.path_and_query() + .map_or_else(|| uri.path(), |pq| pq.as_str()), + "GET", + None, + ) + .await?; + + let community = buzz_core::CommunityId::from_uuid(query.community_id); + let records = state.db.list_community_restrictions(community).await?; + Ok(Json(records.into_iter().map(Into::into).collect())) +} + +/// DELETE /members/{pubkey}/ban?communityId={uuid} +/// +/// Lift an active ban for the given member in the given community. +/// Returns 204 on success, 409 if no active ban exists. +/// Requires nip98 auth. +async fn unban_member( + State(state): State>, + uri: Uri, + headers: HeaderMap, + Path(pubkey_hex): Path, + Query(query): Query, +) -> Result { + let principal_opt = authorize( + &state, + &headers, + uri.path_and_query() + .map_or_else(|| uri.path(), |pq| pq.as_str()), + "DELETE", + None, + ) + .await?; + + let principal = require_mutation_principal(principal_opt)?; + + let target_bytes = decode_hex_pubkey(&pubkey_hex)?; + let community = buzz_core::CommunityId::from_uuid(query.community_id); + + let lifted = state + .db + .unban_community_member(community, &target_bytes, &principal.pubkey) + .await?; + if !lifted { + return Err(ApiError::conflict("no active ban for this member")); + } + + let actor_authority = match principal.role { + AdminRole::Operator => "relay_operator", + AdminRole::Moderator => "relay_moderator", + }; + state + .db + .insert_moderation_action( + community, + buzz_db::moderation::NewAction { + actor_pubkey: &principal.pubkey, + action: "unban", + target_pubkey: Some(&target_bytes), + target_event_id: None, + channel_id: None, + reason_code: None, + public_reason: None, + private_reason: None, + matched_principal: None, + actor_authority: Some(actor_authority), + }, + ) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// DELETE /members/{pubkey}/timeout?communityId={uuid} +/// +/// Clear an active timeout/write-block for the given member in the given +/// community. Returns 204 on success, 409 if no active timeout exists. +/// Requires nip98 auth. +async fn untimeout_member( + State(state): State>, + uri: Uri, + headers: HeaderMap, + Path(pubkey_hex): Path, + Query(query): Query, +) -> Result { + let principal_opt = authorize( + &state, + &headers, + uri.path_and_query() + .map_or_else(|| uri.path(), |pq| pq.as_str()), + "DELETE", + None, + ) + .await?; + + let principal = require_mutation_principal(principal_opt)?; + + let target_bytes = decode_hex_pubkey(&pubkey_hex)?; + let community = buzz_core::CommunityId::from_uuid(query.community_id); + + let lifted = state + .db + .untimeout_community_member(community, &target_bytes, &principal.pubkey) + .await?; + if !lifted { + return Err(ApiError::conflict("no active timeout for this member")); + } + + let actor_authority = match principal.role { + AdminRole::Operator => "relay_operator", + AdminRole::Moderator => "relay_moderator", + }; + state + .db + .insert_moderation_action( + community, + buzz_db::moderation::NewAction { + actor_pubkey: &principal.pubkey, + action: "untimeout", + target_pubkey: Some(&target_bytes), + target_event_id: None, + channel_id: None, + reason_code: None, + public_reason: None, + private_reason: None, + matched_principal: None, + actor_authority: Some(actor_authority), + }, + ) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} /// Returns true if any config-backed operator is effective — a non-empty /// `RELAY_OPERATOR_PUBKEYS` (every entry is an operator) or, when that list is @@ -1724,6 +1918,178 @@ mod postgres_tests { ); } + // ── Member restriction tests ────────────────────────────────────────── + + #[test] + fn restriction_record_converts_ban_record_pubkeys_to_hex() { + // Pure unit test: BanRecord → MemberRestrictionRecord hex encodes the + // Vec pubkeys. No database or state needed. + let record = buzz_db::moderation::BanRecord { + pubkey: vec![0xAB; 32], + banned: true, + ban_expires_at: None, + ban_reason: Some("spam".to_string()), + muted_until: None, + mute_reason: None, + actor_pubkey: vec![0xCD; 32], + updated_at: chrono::Utc::now(), + }; + let response: MemberRestrictionRecord = record.into(); + assert_eq!(response.pubkey, "ab".repeat(32)); + assert_eq!(response.actor_pubkey, "cd".repeat(32)); + assert!(response.banned); + assert_eq!(response.ban_reason.as_deref(), Some("spam")); + } + + #[tokio::test] + async fn list_restrictions_rejects_missing_credential() { + let state = test_state().await; + let community_id = Uuid::nil(); + let response = status_for( + state, + Request::builder() + .uri(format!("/members/restrictions?communityId={community_id}")) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "GET /members/restrictions without credential must return 401" + ); + } + + #[tokio::test] + async fn unban_member_rejects_missing_credential() { + let state = test_state().await; + let pubkey_hex = "ab".repeat(32); + let community_id = Uuid::nil(); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(format!( + "/members/{pubkey_hex}/ban?communityId={community_id}" + )) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "DELETE /members/{{pubkey}}/ban without credential must return 401" + ); + } + + #[tokio::test] + async fn untimeout_member_rejects_missing_credential() { + let state = test_state().await; + let pubkey_hex = "ab".repeat(32); + let community_id = Uuid::nil(); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(format!( + "/members/{pubkey_hex}/timeout?communityId={community_id}" + )) + .header(header::HOST, "admin.example") + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "DELETE /members/{{pubkey}}/timeout without credential must return 401" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unban_member_returns_409_when_no_active_ban() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.expect("migrate test database"); + + // Create an isolated community so the test doesn't clash with other rows. + let community_uuid = Uuid::new_v4(); + let host = format!("unban-test-{}.example", community_uuid.simple()); + db.ensure_configured_community(&host) + .await + .expect("create test community"); + + let state = test_state().await; + let operator_keys = test_operator_keys(); + let pubkey_hex = "ab".repeat(32); + let community_id = community_uuid; + + let path = format!("/members/{pubkey_hex}/ban?communityId={community_id}"); + let auth = make_nostr_auth_delete(&operator_keys, &path); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + response.status(), + StatusCode::CONFLICT, + "unban with no active ban must return 409" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn untimeout_member_returns_409_when_no_active_timeout() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.expect("migrate test database"); + + let community_uuid = Uuid::new_v4(); + let host = format!("untimeout-test-{}.example", community_uuid.simple()); + db.ensure_configured_community(&host) + .await + .expect("create test community"); + + let state = test_state().await; + let operator_keys = test_operator_keys(); + let pubkey_hex = "ab".repeat(32); + let community_id = community_uuid; + + let path = format!("/members/{pubkey_hex}/timeout?communityId={community_id}"); + let auth = make_nostr_auth_delete(&operator_keys, &path); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + response.status(), + StatusCode::CONFLICT, + "untimeout with no active timeout must return 409" + ); + } + // ── NIP-98 mode helpers and tests ───────────────────────────────────── /// Replay guard that always returns `true` — every event is "fresh". From 0bea14103ee5c0ea218130e6933172df9bf668e8 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Thu, 3 Sep 2026 17:54:06 -0400 Subject: [PATCH 02/11] fix(relay): make unban/untimeout lift+audit atomic and add success tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues corrected in the admin restriction management routes: IMPORTANT 1 — audit must be atomic with the lift. Both DELETEs previously committed the ban/timeout lift (unban_community_member / untimeout_community_member) and then inserted the audit row in a separate autocommit. An audit-insert failure after a successful lift produced a dishonest state: enforcement changed, NIP-98 replay consumed, and a fresh retry would get 409 — an audit-contract violation (VISION_MODERATION.md). Fix: two new transactional helpers in buzz-db — unban_member_with_audit and untimeout_member_with_audit — run the conditional-lift UPDATE and the moderation_actions INSERT in one SQL transaction. If the INSERT fails, the UPDATE rolls back and the restriction stays active. The HTTP handlers now call the single transactional method instead of two separate autocommits. The Db trait exposes two new wrapper methods: unban_community_member_with_audit untimeout_community_member_with_audit MINOR — expired-ban predicate. unban_member (both the free function and its Db wrapper) used WHERE banned = true rather than the active-ban predicate used by all read paths (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())). An expired ban would return 204 + audit instead of the agreed 409. Fix: corrected WHERE clause in unban_member and both transactional helpers. IMPORTANT 2 — tests now bind the success contracts. New Postgres-gated tests (all #[ignore = "requires Postgres"]): In buzz-relay api::admin: - list_restrictions_returns_active_bans_and_timeouts: seeds ban+timeout, GET returns both as JSON with correct fields. - unban_member_returns_204_clears_ban_and_inserts_audit: seeds active ban, DELETE returns 204, ban cleared, audit row has actor/target/authority, other community's ban untouched (tenant isolation). - untimeout_member_returns_204_clears_timeout_and_inserts_audit: same pattern for timeout. - unban_member_returns_409_for_expired_ban: expired ban → 409, no audit row inserted (rollback evidence via HTTP path). In buzz-db store/moderation: - unban_with_audit_rolls_back_lift_when_audit_insert_fails: calls unban_member_with_audit with an invalid actor_authority to trigger the DB CHECK constraint on moderation_actions.actor_authority, verifies Err returned and ban remains active (direct rollback proof). Non-blocking: GET /members/restrictions is unbounded (no pagination). Acceptable for the rostered-admin surface now; noted as known follow-up in PR body. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-db/src/store/moderation.rs | 193 ++++++++++- crates/buzz-relay/src/api/admin/mod.rs | 453 ++++++++++++++++++++++--- 2 files changed, 601 insertions(+), 45 deletions(-) diff --git a/crates/buzz-db/src/store/moderation.rs b/crates/buzz-db/src/store/moderation.rs index b5cc4d30930..542b134b873 100644 --- a/crates/buzz-db/src/store/moderation.rs +++ b/crates/buzz-db/src/store/moderation.rs @@ -376,7 +376,8 @@ pub async fn unban_member( UPDATE community_bans SET banned = false, ban_expires_at = NULL, ban_reason = NULL, actor_pubkey = $3, updated_at = now() - WHERE community_id = $1 AND pubkey = $2 AND banned = true + WHERE community_id = $1 AND pubkey = $2 + AND (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) "#, ) .bind(community.as_uuid()) @@ -388,6 +389,112 @@ pub async fn unban_member( Ok(result.rows_affected() > 0) } +/// Lift an active ban and insert the audit row in a single transaction. +/// +/// Returns `true` when the ban was active and both the lift and the audit-insert +/// committed. Returns `false` (without inserting an audit row) when no active +/// unexpired ban exists for the member, leaving the caller free to return 409. +/// +/// The predicate matches the definition used by all read paths: +/// `banned AND (ban_expires_at IS NULL OR ban_expires_at > now())`. +pub async fn unban_member_with_audit( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + actor_authority: &str, +) -> Result { + let mut tx = pool.begin().await?; + + let result = sqlx::query( + r#" + UPDATE community_bans + SET banned = false, ban_expires_at = NULL, ban_reason = NULL, + actor_pubkey = $3, updated_at = now() + WHERE community_id = $1 AND pubkey = $2 + AND (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(actor) + .execute(&mut *tx) + .await?; + + if result.rows_affected() == 0 { + // No active ban — roll back (no-op) and signal 409 to the caller. + tx.rollback().await?; + return Ok(false); + } + + sqlx::query( + r#" + INSERT INTO moderation_actions ( + community_id, actor_pubkey, action, target_pubkey, actor_authority + ) VALUES ($1, $2, 'unban', $3, $4) + "#, + ) + .bind(community.as_uuid()) + .bind(actor) + .bind(pubkey) + .bind(actor_authority) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) +} + +/// Lift an active timeout and insert the audit row in a single transaction. +/// +/// Returns `true` when the timeout was active and both the lift and the +/// audit-insert committed. Returns `false` when no active timeout exists. +pub async fn untimeout_member_with_audit( + pool: &PgPool, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + actor_authority: &str, +) -> Result { + let mut tx = pool.begin().await?; + + let result = sqlx::query( + r#" + UPDATE community_bans + SET muted_until = NULL, mute_reason = NULL, + actor_pubkey = $3, updated_at = now() + WHERE community_id = $1 AND pubkey = $2 AND muted_until > now() + "#, + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(actor) + .execute(&mut *tx) + .await?; + + if result.rows_affected() == 0 { + tx.rollback().await?; + return Ok(false); + } + + sqlx::query( + r#" + INSERT INTO moderation_actions ( + community_id, actor_pubkey, action, target_pubkey, actor_authority + ) VALUES ($1, $2, 'untimeout', $3, $4) + "#, + ) + .bind(community.as_uuid()) + .bind(actor) + .bind(pubkey) + .bind(actor_authority) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) +} + /// Upsert a timeout: sets `muted_until` + reason. pub async fn timeout_member( pool: &PgPool, @@ -744,6 +851,37 @@ impl Db { unban_member(&self.pool, community, pubkey, actor).await } + /// Lift an active ban and insert the audit row atomically. + /// + /// Returns `false` when no active unexpired ban exists (409 signal); the + /// audit row is only inserted when the lift commits. Expired bans + /// (`banned AND ban_expires_at <= now()`) return `false` — read paths + /// already treat them as inactive. + #[datastore_span(name = "unban_community_member_with_audit", system = "postgresql")] + pub async fn unban_community_member_with_audit( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + actor_authority: &str, + ) -> Result { + unban_member_with_audit(&self.pool, community, pubkey, actor, actor_authority).await + } + + /// Lift an active timeout and insert the audit row atomically. + /// + /// Returns `false` when no active timeout exists (409 signal). + #[datastore_span(name = "untimeout_community_member_with_audit", system = "postgresql")] + pub async fn untimeout_community_member_with_audit( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + actor_authority: &str, + ) -> Result { + untimeout_member_with_audit(&self.pool, community, pubkey, actor, actor_authority).await + } + /// Upsert a community timeout/write-block for a member pubkey. #[datastore_span(name = "timeout_community_member", system = "postgresql")] pub async fn timeout_community_member( @@ -1141,6 +1279,59 @@ mod postgres_tests { ); } + /// Atomicity guard: `unban_member_with_audit` must roll back the ban lift + /// when the audit INSERT fails, leaving the restriction active. + /// + /// This exercises the transactional boundary: the UPDATE and the INSERT share + /// one SQL transaction; a CHECK violation on the INSERT must roll back both. + /// Passing `actor_authority = "invalid"` triggers the DB CHECK constraint + /// on `moderation_actions.actor_authority`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unban_with_audit_rolls_back_lift_when_audit_insert_fails() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let pubkey = random_32(); + let actor = random_32(); + + ban_member(&pool, community, &pubkey, &actor, None, None) + .await + .expect("insert ban fixture"); + + // Verify the ban is active before we attempt the lift. + let state_before = restriction_state(&pool, community, &pubkey) + .await + .expect("restriction_state before"); + assert!(state_before.banned, "pre-condition: pubkey must be banned"); + + // Use an invalid actor_authority that violates the DB CHECK constraint — + // this causes the audit INSERT to fail, which must roll back the UPDATE. + let result = + unban_member_with_audit(&pool, community, &pubkey, &actor, "invalid_authority").await; + assert!( + result.is_err(), + "unban_with_audit must return Err when audit INSERT violates a constraint" + ); + + // The ban must still be active — the rolled-back UPDATE must not have committed. + let state_after = restriction_state(&pool, community, &pubkey) + .await + .expect("restriction_state after"); + assert!( + state_after.banned, + "ban must remain active after a failed unban_with_audit (rollback)" + ); + + // No audit row must have been inserted. + let actions = list_actions(&pool, community, 10) + .await + .expect("list actions"); + assert!( + actions.is_empty(), + "no audit row must be committed when the transaction rolls back" + ); + } + /// Every non-`illegal` category still lands `open` for community triage; the /// auto-escalation branch must not widen to the ordinary report flow. #[tokio::test] diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index aa3e2be552f..db5bf92d9f7 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -1209,36 +1209,23 @@ async fn unban_member( let target_bytes = decode_hex_pubkey(&pubkey_hex)?; let community = buzz_core::CommunityId::from_uuid(query.community_id); - let lifted = state - .db - .unban_community_member(community, &target_bytes, &principal.pubkey) - .await?; - if !lifted { - return Err(ApiError::conflict("no active ban for this member")); - } - let actor_authority = match principal.role { AdminRole::Operator => "relay_operator", AdminRole::Moderator => "relay_moderator", }; - state + + let lifted = state .db - .insert_moderation_action( + .unban_community_member_with_audit( community, - buzz_db::moderation::NewAction { - actor_pubkey: &principal.pubkey, - action: "unban", - target_pubkey: Some(&target_bytes), - target_event_id: None, - channel_id: None, - reason_code: None, - public_reason: None, - private_reason: None, - matched_principal: None, - actor_authority: Some(actor_authority), - }, + &target_bytes, + &principal.pubkey, + actor_authority, ) .await?; + if !lifted { + return Err(ApiError::conflict("no active ban for this member")); + } Ok(axum::http::StatusCode::NO_CONTENT) } @@ -1270,36 +1257,23 @@ async fn untimeout_member( let target_bytes = decode_hex_pubkey(&pubkey_hex)?; let community = buzz_core::CommunityId::from_uuid(query.community_id); - let lifted = state - .db - .untimeout_community_member(community, &target_bytes, &principal.pubkey) - .await?; - if !lifted { - return Err(ApiError::conflict("no active timeout for this member")); - } - let actor_authority = match principal.role { AdminRole::Operator => "relay_operator", AdminRole::Moderator => "relay_moderator", }; - state + + let lifted = state .db - .insert_moderation_action( + .untimeout_community_member_with_audit( community, - buzz_db::moderation::NewAction { - actor_pubkey: &principal.pubkey, - action: "untimeout", - target_pubkey: Some(&target_bytes), - target_event_id: None, - channel_id: None, - reason_code: None, - public_reason: None, - private_reason: None, - matched_principal: None, - actor_authority: Some(actor_authority), - }, + &target_bytes, + &principal.pubkey, + actor_authority, ) .await?; + if !lifted { + return Err(ApiError::conflict("no active timeout for this member")); + } Ok(axum::http::StatusCode::NO_CONTENT) } @@ -2090,6 +2064,397 @@ mod postgres_tests { ); } + // ── Restriction management success tests (require Postgres) ────────── + + /// Build an AppState that uses a real Postgres connection pool so HTTP + /// routes that hit the DB can commit and read back results. + async fn nip98_state_with_real_pool(pool: sqlx::PgPool) -> Arc { + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.relay_operator_pubkeys = vec![test_operator_keys().public_key().to_hex()]; + config.relay_operator_api_origin = Some("https://admin.example".to_string()); + config.admin = Some(crate::config::AdminConfig { + host: "admin.example".to_string(), + auth: crate::config::AdminAuth::Nip98, + web_dir: None, + }); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Arc::new(state) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn list_restrictions_returns_active_bans_and_timeouts() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.expect("migrate test database"); + + let community_uuid = Uuid::new_v4(); + let host = format!("list-restrictions-{}.example", community_uuid.simple()); + db.ensure_configured_community(&host) + .await + .expect("create test community"); + let community = buzz_core::CommunityId::from_uuid(community_uuid); + + let banned_pubkey = vec![0xAAu8; 32]; + let timed_out_pubkey = vec![0xBBu8; 32]; + let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); + + // Insert a permanent ban and a timeout in this community. + db.ban_community_member(community, &banned_pubkey, &actor_pubkey, None, None) + .await + .expect("insert ban fixture"); + db.timeout_community_member( + community, + &timed_out_pubkey, + &actor_pubkey, + chrono::Utc::now() + chrono::Duration::hours(1), + None, + ) + .await + .expect("insert timeout fixture"); + + let state = nip98_state_with_real_pool(pool).await; + let path = format!("/members/restrictions?communityId={community_uuid}"); + let auth = make_nostr_auth(&test_operator_keys(), &path); + let response = status_for( + state, + Request::builder() + .method("GET") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + + assert_eq!( + response.status(), + StatusCode::OK, + "GET restrictions must return 200" + ); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read body"); + let records: Vec = + serde_json::from_slice(&body).expect("parse JSON array"); + + assert_eq!(records.len(), 2, "must return both the ban and the timeout"); + + let banned_hex = hex::encode(&banned_pubkey); + let timed_out_hex = hex::encode(&timed_out_pubkey); + let pubkeys: std::collections::HashSet = records + .iter() + .filter_map(|r| r["pubkey"].as_str().map(String::from)) + .collect(); + assert!( + pubkeys.contains(&banned_hex), + "banned pubkey must appear in the response" + ); + assert!( + pubkeys.contains(&timed_out_hex), + "timed-out pubkey must appear in the response" + ); + + // Verify the banned record has banned=true in JSON. + let banned_rec = records + .iter() + .find(|r| r["pubkey"].as_str() == Some(&banned_hex)) + .expect("banned record"); + assert_eq!( + banned_rec["banned"], + serde_json::Value::Bool(true), + "banned record must have banned=true" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unban_member_returns_204_clears_ban_and_inserts_audit() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.expect("migrate test database"); + + let community_uuid = Uuid::new_v4(); + let host = format!("unban-success-{}.example", community_uuid.simple()); + db.ensure_configured_community(&host) + .await + .expect("create test community"); + let community = buzz_core::CommunityId::from_uuid(community_uuid); + + // Insert a permanent ban as the target member. + let target_pubkey = vec![0xCCu8; 32]; + let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); + db.ban_community_member(community, &target_pubkey, &actor_pubkey, None, None) + .await + .expect("insert ban fixture"); + + // Tenant isolation: ban a different pubkey in a *different* community so + // we can verify the DELETE only clears the intended restriction. + let other_community_uuid = Uuid::new_v4(); + let other_host = format!("unban-other-{}.example", other_community_uuid.simple()); + db.ensure_configured_community(&other_host) + .await + .expect("create other community"); + let other_community = buzz_core::CommunityId::from_uuid(other_community_uuid); + db.ban_community_member(other_community, &target_pubkey, &actor_pubkey, None, None) + .await + .expect("insert ban fixture for other community"); + + let state = nip98_state_with_real_pool(pool.clone()).await; + let target_hex = hex::encode(&target_pubkey); + let path = format!("/members/{target_hex}/ban?communityId={community_uuid}"); + let auth = make_nostr_auth_delete(&test_operator_keys(), &path); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "unban of an active ban must return 204" + ); + + // Ban must be cleared in the target community. + let ban = db + .get_community_ban(community, &target_pubkey) + .await + .expect("read ban after unban"); + assert!( + ban.map_or(true, |r| !r.banned), + "ban must be cleared after successful unban" + ); + + // Audit row must exist in the target community. + let actions = db + .list_moderation_actions(community, 10) + .await + .expect("list moderation actions"); + let unban_action = actions.iter().find(|a| a.action == "unban"); + assert!(unban_action.is_some(), "audit row for unban must exist"); + let action = unban_action.unwrap(); + assert_eq!( + action.actor_pubkey, actor_pubkey, + "audit actor must be the operator" + ); + assert_eq!( + action.target_pubkey.as_deref(), + Some(target_pubkey.as_slice()), + "audit target_pubkey must match" + ); + assert_eq!( + action.actor_authority.as_str(), + "relay_operator", + "audit actor_authority must be relay_operator" + ); + + // Other community's ban must be untouched (tenant isolation). + let other_ban = db + .get_community_ban(other_community, &target_pubkey) + .await + .expect("read other community ban"); + assert!( + other_ban.map_or(false, |r| r.banned), + "unban must not affect the same pubkey in another community" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn untimeout_member_returns_204_clears_timeout_and_inserts_audit() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.expect("migrate test database"); + + let community_uuid = Uuid::new_v4(); + let host = format!("untimeout-success-{}.example", community_uuid.simple()); + db.ensure_configured_community(&host) + .await + .expect("create test community"); + let community = buzz_core::CommunityId::from_uuid(community_uuid); + + let target_pubkey = vec![0xDDu8; 32]; + let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); + db.timeout_community_member( + community, + &target_pubkey, + &actor_pubkey, + chrono::Utc::now() + chrono::Duration::hours(1), + Some("test reason"), + ) + .await + .expect("insert timeout fixture"); + + let state = nip98_state_with_real_pool(pool.clone()).await; + let target_hex = hex::encode(&target_pubkey); + let path = format!("/members/{target_hex}/timeout?communityId={community_uuid}"); + let auth = make_nostr_auth_delete(&test_operator_keys(), &path); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "untimeout of an active timeout must return 204" + ); + + // Timeout must be cleared. + let ban = db + .get_community_ban(community, &target_pubkey) + .await + .expect("read ban after untimeout"); + assert!( + ban.map_or(true, |r| r + .muted_until + .map_or(true, |t| t <= chrono::Utc::now())), + "timeout must be cleared after successful untimeout" + ); + + // Audit row must exist. + let actions = db + .list_moderation_actions(community, 10) + .await + .expect("list moderation actions"); + let untimeout_action = actions.iter().find(|a| a.action == "untimeout"); + assert!( + untimeout_action.is_some(), + "audit row for untimeout must exist" + ); + let action = untimeout_action.unwrap(); + assert_eq!(action.actor_pubkey, actor_pubkey, "audit actor must match"); + assert_eq!( + action.target_pubkey.as_deref(), + Some(target_pubkey.as_slice()), + "audit target_pubkey must match" + ); + assert_eq!( + action.actor_authority.as_str(), + "relay_operator", + "audit actor_authority must be relay_operator" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unban_member_returns_409_for_expired_ban() { + // An expired ban (banned=true, ban_expires_at <= now()) is treated as + // inactive by all read paths; the DELETE must also return 409 rather + // than 204 for an expired ban. + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.expect("migrate test database"); + + let community_uuid = Uuid::new_v4(); + let host = format!("unban-expired-{}.example", community_uuid.simple()); + db.ensure_configured_community(&host) + .await + .expect("create test community"); + let community = buzz_core::CommunityId::from_uuid(community_uuid); + + // Insert a ban that already expired. + let target_pubkey = vec![0xEEu8; 32]; + let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); + db.ban_community_member( + community, + &target_pubkey, + &actor_pubkey, + None, + // Expired 1 hour ago. + Some(chrono::Utc::now() - chrono::Duration::hours(1)), + ) + .await + .expect("insert expired ban fixture"); + + let state = nip98_state_with_real_pool(pool.clone()).await; + let target_hex = hex::encode(&target_pubkey); + let path = format!("/members/{target_hex}/ban?communityId={community_uuid}"); + let auth = make_nostr_auth_delete(&test_operator_keys(), &path); + let response = status_for( + state, + Request::builder() + .method("DELETE") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + + assert_eq!( + response.status(), + StatusCode::CONFLICT, + "unban of an expired ban must return 409" + ); + + // No audit row should have been inserted (transaction rolled back). + let actions = db + .list_moderation_actions(community, 10) + .await + .expect("list moderation actions"); + assert!( + actions.is_empty(), + "no audit row must be inserted when unban returns 409" + ); + } + // ── NIP-98 mode helpers and tests ───────────────────────────────────── /// Replay guard that always returns `true` — every event is "fresh". From ec81e97015592036024ce41664074b0a7dc32976 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Thu, 3 Sep 2026 18:27:15 -0400 Subject: [PATCH 03/11] test(relay): extend unban/untimeout success tests for restriction independence and community isolation Restriction independence: seed the target with both a ban and an active timeout; assert that unban clears only the ban (timeout survives), and that untimeout clears only the timeout (ban survives). A regression widening either UPDATE to clear both restrictions would fail the new co-existing-restriction assertion. Community isolation for untimeout: seed the same pubkey with an active timeout in a second community; assert it stays active after the untimeout. A regression dropping the community_id = $1 predicate would clear the second-community timeout and fail the new cross-community assertion. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/admin/mod.rs | 77 ++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index db5bf92d9f7..233537c3d98 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -2225,6 +2225,18 @@ mod postgres_tests { .await .expect("insert ban fixture"); + // Also give the same target an active timeout in the same community. + // Restriction independence: unban must NOT clear the timeout. + db.timeout_community_member( + community, + &target_pubkey, + &actor_pubkey, + chrono::Utc::now() + chrono::Duration::hours(2), + Some("test-timeout"), + ) + .await + .expect("insert timeout fixture alongside ban"); + // Tenant isolation: ban a different pubkey in a *different* community so // we can verify the DELETE only clears the intended restriction. let other_community_uuid = Uuid::new_v4(); @@ -2265,10 +2277,20 @@ mod postgres_tests { .await .expect("read ban after unban"); assert!( - ban.map_or(true, |r| !r.banned), + ban.as_ref().map_or(true, |r| !r.banned), "ban must be cleared after successful unban" ); + // Timeout must survive: unban must not clear the co-existing timeout. + // A regression that widened the unban UPDATE to also clear muted_until + // would fail this assertion. + assert!( + ban.as_ref() + .and_then(|r| r.muted_until) + .map_or(false, |t| t > chrono::Utc::now()), + "unban must not clear the co-existing active timeout" + ); + // Audit row must exist in the target community. let actions = db .list_moderation_actions(community, 10) @@ -2331,6 +2353,30 @@ mod postgres_tests { .await .expect("insert timeout fixture"); + // Also give the same target an active ban in the same community. + // Restriction independence: untimeout must NOT clear the ban. + db.ban_community_member(community, &target_pubkey, &actor_pubkey, None, None) + .await + .expect("insert ban fixture alongside timeout"); + + // Cross-community isolation: give the same target a timeout in a second + // community. The untimeout must NOT clear it (binds community_id = $1). + let other_community_uuid = Uuid::new_v4(); + let other_host = format!("untimeout-other-{}.example", other_community_uuid.simple()); + db.ensure_configured_community(&other_host) + .await + .expect("create other community"); + let other_community = buzz_core::CommunityId::from_uuid(other_community_uuid); + db.timeout_community_member( + other_community, + &target_pubkey, + &actor_pubkey, + chrono::Utc::now() + chrono::Duration::hours(1), + Some("test reason other community"), + ) + .await + .expect("insert timeout fixture for other community"); + let state = nip98_state_with_real_pool(pool.clone()).await; let target_hex = hex::encode(&target_pubkey); let path = format!("/members/{target_hex}/timeout?communityId={community_uuid}"); @@ -2353,18 +2399,41 @@ mod postgres_tests { "untimeout of an active timeout must return 204" ); - // Timeout must be cleared. - let ban = db + // Timeout must be cleared in the target community. + let restriction = db .get_community_ban(community, &target_pubkey) .await .expect("read ban after untimeout"); assert!( - ban.map_or(true, |r| r + restriction.as_ref().map_or(true, |r| r .muted_until .map_or(true, |t| t <= chrono::Utc::now())), "timeout must be cleared after successful untimeout" ); + // Ban must survive: untimeout must not clear the co-existing ban. + // A regression that widened the untimeout UPDATE to also clear banned + // would fail this assertion. + assert!( + restriction.as_ref().map_or(false, |r| r.banned), + "untimeout must not clear the co-existing active ban" + ); + + // Other community's timeout must be untouched (community_id predicate). + // A regression that dropped the community_id = $1 WHERE clause would + // clear this timeout and fail this assertion. + let other_restriction = db + .get_community_ban(other_community, &target_pubkey) + .await + .expect("read other community restriction"); + assert!( + other_restriction + .as_ref() + .and_then(|r| r.muted_until) + .map_or(false, |t| t > chrono::Utc::now()), + "untimeout must not affect the same pubkey's timeout in another community" + ); + // Audit row must exist. let actions = db .list_moderation_actions(community, 10) From 5171603a3eebed642c188c52d6e4c562a6761460 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Thu, 3 Sep 2026 19:48:48 -0400 Subject: [PATCH 04/11] =?UTF-8?q?fix(relay):=20apply=20clippy=20map=5For?= =?UTF-8?q?=E2=86=92is=5Fsome=5Fand/is=5Fnone=5For=20and=20drop=20redundan?= =?UTF-8?q?t=20migrate=20in=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven clippy::unnecessary_map_or errors in tests added by this PR: - map_or(true, |r| !r.banned) → is_none_or(|r| !r.banned) - map_or(false, |t| t > ...) (×3) → is_some_and(|t| t > ...) - map_or(false, |r| r.banned) (×2) → is_some_and(|r| r.banned) - map_or(true, |r| r.muted_until.map_or(true,…)) → is_none_or(…is_none_or…) Six db.migrate() calls removed from the new Postgres-backed tests. The postgres-test framework (postgres-test-setup.sh) builds the per-test database from schema/schema.sql via pgschema, not by running sqlx migrations. Calling db.migrate() on a desired-state clone fails with 42710 "type channel_type already exists" because migration 0001 attempts to re-create types already present in the schema. The per-test clone is fully migrated before the test binary runs; no explicit migration call is required. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/admin/mod.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 233537c3d98..775d05f3a6d 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -1990,7 +1990,6 @@ mod postgres_tests { .await .expect("connect test database"); let db = buzz_db::Db::from_pool(pool.clone()); - db.migrate().await.expect("migrate test database"); // Create an isolated community so the test doesn't clash with other rows. let community_uuid = Uuid::new_v4(); @@ -2031,7 +2030,6 @@ mod postgres_tests { .await .expect("connect test database"); let db = buzz_db::Db::from_pool(pool.clone()); - db.migrate().await.expect("migrate test database"); let community_uuid = Uuid::new_v4(); let host = format!("untimeout-test-{}.example", community_uuid.simple()); @@ -2119,7 +2117,6 @@ mod postgres_tests { .await .expect("connect test database"); let db = buzz_db::Db::from_pool(pool.clone()); - db.migrate().await.expect("migrate test database"); let community_uuid = Uuid::new_v4(); let host = format!("list-restrictions-{}.example", community_uuid.simple()); @@ -2209,7 +2206,6 @@ mod postgres_tests { .await .expect("connect test database"); let db = buzz_db::Db::from_pool(pool.clone()); - db.migrate().await.expect("migrate test database"); let community_uuid = Uuid::new_v4(); let host = format!("unban-success-{}.example", community_uuid.simple()); @@ -2277,7 +2273,7 @@ mod postgres_tests { .await .expect("read ban after unban"); assert!( - ban.as_ref().map_or(true, |r| !r.banned), + ban.as_ref().is_none_or(|r| !r.banned), "ban must be cleared after successful unban" ); @@ -2287,7 +2283,7 @@ mod postgres_tests { assert!( ban.as_ref() .and_then(|r| r.muted_until) - .map_or(false, |t| t > chrono::Utc::now()), + .is_some_and(|t| t > chrono::Utc::now()), "unban must not clear the co-existing active timeout" ); @@ -2320,7 +2316,7 @@ mod postgres_tests { .await .expect("read other community ban"); assert!( - other_ban.map_or(false, |r| r.banned), + other_ban.is_some_and(|r| r.banned), "unban must not affect the same pubkey in another community" ); } @@ -2332,7 +2328,6 @@ mod postgres_tests { .await .expect("connect test database"); let db = buzz_db::Db::from_pool(pool.clone()); - db.migrate().await.expect("migrate test database"); let community_uuid = Uuid::new_v4(); let host = format!("untimeout-success-{}.example", community_uuid.simple()); @@ -2405,9 +2400,9 @@ mod postgres_tests { .await .expect("read ban after untimeout"); assert!( - restriction.as_ref().map_or(true, |r| r - .muted_until - .map_or(true, |t| t <= chrono::Utc::now())), + restriction + .as_ref() + .is_none_or(|r| r.muted_until.is_none_or(|t| t <= chrono::Utc::now())), "timeout must be cleared after successful untimeout" ); @@ -2415,7 +2410,7 @@ mod postgres_tests { // A regression that widened the untimeout UPDATE to also clear banned // would fail this assertion. assert!( - restriction.as_ref().map_or(false, |r| r.banned), + restriction.as_ref().is_some_and(|r| r.banned), "untimeout must not clear the co-existing active ban" ); @@ -2430,7 +2425,7 @@ mod postgres_tests { other_restriction .as_ref() .and_then(|r| r.muted_until) - .map_or(false, |t| t > chrono::Utc::now()), + .is_some_and(|t| t > chrono::Utc::now()), "untimeout must not affect the same pubkey's timeout in another community" ); @@ -2468,7 +2463,6 @@ mod postgres_tests { .await .expect("connect test database"); let db = buzz_db::Db::from_pool(pool.clone()); - db.migrate().await.expect("migrate test database"); let community_uuid = Uuid::new_v4(); let host = format!("unban-expired-{}.example", community_uuid.simple()); From c7106d808716eeddaa75b52e4df9f449d7093863 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 4 Sep 2026 10:11:04 -0400 Subject: [PATCH 05/11] fix(relay): use UUID from ensure_configured_community in ban/timeout test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4 tests that insert ban/timeout fixtures via db.ban_community_member or db.timeout_community_member were constructing a community_uuid = Uuid::new_v4() and calling db.ensure_configured_community(&host) — which inserts a community row with a DB-generated UUID that is NOT community_uuid. Inserting into community_bans then triggers enforce_community_write_fence, which queries communities WHERE id = community_uuid, finds nothing, and raises 'community ... is missing'. Fix: capture the EnsuredCommunityRecord returned by ensure_configured_community and use its .id (a CommunityId backed by the DB-assigned UUID) as the community throughout each test. Shadow community_uuid with *community.as_uuid() where the UUID is needed in URL format strings. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/admin/mod.rs | 46 ++++++++++++++++---------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 775d05f3a6d..d6066689c4e 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -2120,10 +2120,12 @@ mod postgres_tests { let community_uuid = Uuid::new_v4(); let host = format!("list-restrictions-{}.example", community_uuid.simple()); - db.ensure_configured_community(&host) + let community = db + .ensure_configured_community(&host) .await - .expect("create test community"); - let community = buzz_core::CommunityId::from_uuid(community_uuid); + .expect("create test community") + .id; + let community_uuid = *community.as_uuid(); let banned_pubkey = vec![0xAAu8; 32]; let timed_out_pubkey = vec![0xBBu8; 32]; @@ -2209,10 +2211,12 @@ mod postgres_tests { let community_uuid = Uuid::new_v4(); let host = format!("unban-success-{}.example", community_uuid.simple()); - db.ensure_configured_community(&host) + let community = db + .ensure_configured_community(&host) .await - .expect("create test community"); - let community = buzz_core::CommunityId::from_uuid(community_uuid); + .expect("create other community") + .id; + let community_uuid = *community.as_uuid(); // Insert a permanent ban as the target member. let target_pubkey = vec![0xCCu8; 32]; @@ -2237,10 +2241,11 @@ mod postgres_tests { // we can verify the DELETE only clears the intended restriction. let other_community_uuid = Uuid::new_v4(); let other_host = format!("unban-other-{}.example", other_community_uuid.simple()); - db.ensure_configured_community(&other_host) + let other_community = db + .ensure_configured_community(&other_host) .await - .expect("create other community"); - let other_community = buzz_core::CommunityId::from_uuid(other_community_uuid); + .expect("create other community") + .id; db.ban_community_member(other_community, &target_pubkey, &actor_pubkey, None, None) .await .expect("insert ban fixture for other community"); @@ -2331,10 +2336,12 @@ mod postgres_tests { let community_uuid = Uuid::new_v4(); let host = format!("untimeout-success-{}.example", community_uuid.simple()); - db.ensure_configured_community(&host) + let community = db + .ensure_configured_community(&host) .await - .expect("create test community"); - let community = buzz_core::CommunityId::from_uuid(community_uuid); + .expect("create test community") + .id; + let community_uuid = *community.as_uuid(); let target_pubkey = vec![0xDDu8; 32]; let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); @@ -2358,10 +2365,11 @@ mod postgres_tests { // community. The untimeout must NOT clear it (binds community_id = $1). let other_community_uuid = Uuid::new_v4(); let other_host = format!("untimeout-other-{}.example", other_community_uuid.simple()); - db.ensure_configured_community(&other_host) + let other_community = db + .ensure_configured_community(&other_host) .await - .expect("create other community"); - let other_community = buzz_core::CommunityId::from_uuid(other_community_uuid); + .expect("create other community") + .id; db.timeout_community_member( other_community, &target_pubkey, @@ -2466,10 +2474,12 @@ mod postgres_tests { let community_uuid = Uuid::new_v4(); let host = format!("unban-expired-{}.example", community_uuid.simple()); - db.ensure_configured_community(&host) + let community = db + .ensure_configured_community(&host) .await - .expect("create test community"); - let community = buzz_core::CommunityId::from_uuid(community_uuid); + .expect("create test community") + .id; + let community_uuid = *community.as_uuid(); // Insert a ban that already expired. let target_pubkey = vec![0xEEu8; 32]; From 0ef4e9419b91e268ad2b0045550958c1197222ba Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 4 Sep 2026 15:32:44 -0400 Subject: [PATCH 06/11] feat(admin): add keyset pagination to GET /members/restrictions Add a capped `limit` query param (default and max 200), stable keyset pagination (`ORDER BY updated_at DESC, pubkey DESC` with a compound keyset predicate), and a `{ items, nextCursor }` response envelope to `GET /api/admin/v1/members/restrictions`. The tie-breaker on `pubkey` (BYTEA) makes the cursor deterministic when multiple rows share the same `updated_at`. The opaque cursor token is base64url of `{updated_at_micros}_{pubkey_hex}`. The existing `list_community_restrictions` (unbounded) is kept intact for the embedded dashboard bridge consumer (`GET /moderation/restricted`), which returns a bare JSON array whose response contract must not change. New PostgreSQL regression test seeds 5 rows, forces two to share an identical `updated_at` to exercise the tie-breaker, walks pages of size 2 to exhaustion, and asserts exactly-once coverage. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-db/src/store/moderation.rs | 57 ++++++ crates/buzz-relay/src/api/admin/mod.rs | 244 ++++++++++++++++++++++++- 2 files changed, 291 insertions(+), 10 deletions(-) diff --git a/crates/buzz-db/src/store/moderation.rs b/crates/buzz-db/src/store/moderation.rs index 542b134b873..d580b5c46a2 100644 --- a/crates/buzz-db/src/store/moderation.rs +++ b/crates/buzz-db/src/store/moderation.rs @@ -647,6 +647,49 @@ pub async fn list_restricted(pool: &PgPool, community: CommunityId) -> Result, Vec)>, +) -> Result> { + let (cursor_ts, cursor_pk) = cursor.unzip(); + let rows = sqlx::query( + r#" + SELECT pubkey, + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned, + ban_expires_at, ban_reason, muted_until, + mute_reason, actor_pubkey, updated_at + FROM community_bans + WHERE community_id = $1 + AND ( + (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) + OR muted_until > now() + ) + AND ( + $2::timestamptz IS NULL + OR (updated_at, pubkey) < ($2, $3::bytea) + ) + ORDER BY updated_at DESC, pubkey DESC + LIMIT $4 + "#, + ) + .bind(community.as_uuid()) + .bind(cursor_ts) + .bind(cursor_pk) + .bind(limit) + .fetch_all(pool) + .await?; + + rows.into_iter().map(row_to_ban).collect() +} + /// Insert a moderation audit row, returning its id. pub async fn insert_action( pool: &PgPool, @@ -935,6 +978,20 @@ impl Db { list_restricted(&self.pool, community).await } + /// List currently restricted members with stable keyset pagination. + /// + /// Returns at most `limit` rows. Supply the `(updated_at, pubkey)` of the + /// last row as `cursor` to advance to the next page. + #[datastore_span(name = "list_community_restrictions_page", system = "postgresql")] + pub async fn list_community_restrictions_page( + &self, + community: CommunityId, + limit: i64, + cursor: Option<(DateTime, Vec)>, + ) -> Result> { + list_restricted_page(&self.pool, community, limit, cursor).await + } + /// Insert a moderation audit action row. #[datastore_span(name = "insert_moderation_action", system = "postgresql")] pub async fn insert_moderation_action( diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index d6066689c4e..fb5b4f7d7a1 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -1150,23 +1150,93 @@ impl From for MemberRestrictionRecord { } } +/// Paginated response for `GET /members/restrictions`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RestrictionsPage { + /// Restriction records for this page, newest first. + items: Vec, + /// Opaque cursor for the next page, or `null` when exhausted. + /// + /// Encoding: `base64url(updated_at_micros_decimal + "_" + pubkey_hex)`. + /// Treat as opaque — the format may change across releases. + next_cursor: Option, +} + +/// Encode a keyset cursor as a base64url-safe opaque token. +/// +/// Format: `{updated_at_micros}_{pubkey_hex}` encoded with URL_SAFE_NO_PAD. +fn encode_cursor(updated_at: DateTime, pubkey: &[u8]) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine as _; + let payload = format!("{}_{}", updated_at.timestamp_micros(), hex::encode(pubkey)); + URL_SAFE_NO_PAD.encode(payload.as_bytes()) +} + +/// Decode an opaque cursor token back to `(updated_at, pubkey_bytes)`. +fn decode_cursor(token: &str) -> Result<(DateTime, Vec), ApiError> { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine as _; + let bytes = URL_SAFE_NO_PAD + .decode(token) + .map_err(|_| ApiError::bad_request("invalid_cursor", "cursor is not valid base64url"))?; + let s = std::str::from_utf8(&bytes) + .map_err(|_| ApiError::bad_request("invalid_cursor", "cursor is not valid UTF-8"))?; + let (ts_str, pk_hex) = s + .split_once('_') + .ok_or_else(|| ApiError::bad_request("invalid_cursor", "cursor format is invalid"))?; + let micros: i64 = ts_str + .parse() + .map_err(|_| ApiError::bad_request("invalid_cursor", "cursor timestamp is invalid"))?; + let dt = DateTime::from_timestamp_micros(micros) + .ok_or_else(|| ApiError::bad_request("invalid_cursor", "cursor timestamp out of range"))?; + let pubkey = hex::decode(pk_hex) + .map_err(|_| ApiError::bad_request("invalid_cursor", "cursor pubkey is invalid hex"))?; + if pubkey.len() != 32 { + return Err(ApiError::bad_request( + "invalid_cursor", + "cursor pubkey must be 32 bytes", + )); + } + Ok((dt, pubkey)) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CommunityQuery { community_id: Uuid, } -/// GET /members/restrictions?communityId={uuid} +/// Query params for `GET /members/restrictions`. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RestrictionsQuery { + community_id: Uuid, + /// Maximum number of records to return (1–200, default 200). + limit: Option, + /// Opaque continuation cursor from a prior response's `nextCursor` field. + cursor: Option, +} + +/// GET /members/restrictions?communityId={uuid}[&limit={1-200}][&cursor={token}] /// -/// List all currently active bans and timeouts for the given community. -/// Returns 400 if `communityId` is absent or not a valid UUID. -/// Requires admin auth (nip98 or disabled mode). +/// List currently active bans and timeouts for the given community, newest +/// first, with stable keyset pagination. +/// +/// **Response shape:** `{ "items": [...], "nextCursor": ""|null }` +/// +/// - `limit` — page size, 1–200, default 200. Enforced as a SQL `LIMIT`. +/// - `cursor` — opaque token from a prior page's `nextCursor`. Omit for the +/// first page. Format: base64url of `{updated_at_micros}_{pubkey_hex}`. +/// +/// Returns 400 if `communityId` is absent / invalid, `limit` is out of range, +/// or `cursor` is malformed. Returns 401 without a valid admin credential. async fn list_member_restrictions( State(state): State>, uri: Uri, headers: HeaderMap, - Query(query): Query, -) -> Result>, ApiError> { + Query(query): Query, +) -> Result, ApiError> { authorize( &state, &headers, @@ -1177,9 +1247,27 @@ async fn list_member_restrictions( ) .await?; + let page_limit = limit(query.limit)?; + let cursor = query.cursor.as_deref().map(decode_cursor).transpose()?; + let community = buzz_core::CommunityId::from_uuid(query.community_id); - let records = state.db.list_community_restrictions(community).await?; - Ok(Json(records.into_iter().map(Into::into).collect())) + let records = state + .db + .list_community_restrictions_page(community, page_limit, cursor) + .await?; + + let next_cursor = if records.len() as i64 == page_limit { + records + .last() + .map(|r| encode_cursor(r.updated_at, &r.pubkey)) + } else { + None + }; + + Ok(Json(RestrictionsPage { + items: records.into_iter().map(Into::into).collect(), + next_cursor, + })) } /// DELETE /members/{pubkey}/ban?communityId={uuid} @@ -2169,9 +2257,9 @@ mod postgres_tests { let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("read body"); - let records: Vec = - serde_json::from_slice(&body).expect("parse JSON array"); + let page: serde_json::Value = serde_json::from_slice(&body).expect("parse JSON"); + let records = page["items"].as_array().expect("items must be an array"); assert_eq!(records.len(), 2, "must return both the ban and the timeout"); let banned_hex = hex::encode(&banned_pubkey); @@ -2199,6 +2287,142 @@ mod postgres_tests { serde_json::Value::Bool(true), "banned record must have banned=true" ); + + // Two records returned with default limit=200 → no next page. + assert_eq!( + page["nextCursor"], + serde_json::Value::Null, + "nextCursor must be null when all records fit in one page" + ); + } + + /// Pagination regression: seed more than the cap, prove no page exceeds the + /// cap, walk pages to exhaustion, assert exactly-once coverage, and exercise + /// the tie-breaker with rows that share identical `updated_at` timestamps. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn list_restrictions_pagination_exhaustive() { + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect test database"); + let db = buzz_db::Db::from_pool(pool.clone()); + + let community_uuid = Uuid::new_v4(); + let host = format!("list-restrict-pg-{}.example", community_uuid.simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("create test community") + .id; + let community_uuid = *community.as_uuid(); + + let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); + // Seed 5 bans — more than the page size of 2 we'll use below. + // Pubkeys 0x01..0x05 are distinct; each UPDATE sets updated_at = now() + // so they naturally differ unless inserted in the same microsecond. + let mut all_pubkeys: Vec> = Vec::new(); + for i in 1u8..=5 { + let pk = vec![i; 32]; + db.ban_community_member(community, &pk, &actor_pubkey, None, None) + .await + .expect("insert ban"); + all_pubkeys.push(pk); + } + + // Force two rows to share the EXACT same `updated_at` so the + // tie-breaker path is exercised: update both in the same transaction + // with an explicit identical timestamp. + let shared_ts = chrono::Utc::now() - chrono::Duration::seconds(5); + sqlx::query( + "UPDATE community_bans SET updated_at = $1 + WHERE community_id = $2 AND pubkey = ANY($3::bytea[])", + ) + .bind(shared_ts) + .bind(community.as_uuid()) + .bind( + all_pubkeys[..2] + .iter() + .map(|p| p.as_slice()) + .collect::>(), + ) + .execute(&pool) + .await + .expect("force identical updated_at on first two rows"); + + let state = nip98_state_with_real_pool(pool).await; + let operator_keys = test_operator_keys(); + + // Walk pages with limit=2 until nextCursor is null. + let mut seen_pubkeys: std::collections::HashSet = std::collections::HashSet::new(); + let mut page_count = 0usize; + let mut cursor_token: Option = None; + + loop { + let path = match &cursor_token { + None => format!("/members/restrictions?communityId={community_uuid}&limit=2"), + Some(tok) => format!( + "/members/restrictions?communityId={community_uuid}&limit=2&cursor={tok}" + ), + }; + let auth = make_nostr_auth(&operator_keys, &path); + let response = status_for( + Arc::clone(&state), + Request::builder() + .method("GET") + .uri(&path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + + assert_eq!( + response.status(), + StatusCode::OK, + "page {page_count}: GET restrictions must return 200" + ); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read body"); + let page: serde_json::Value = serde_json::from_slice(&body).expect("parse JSON"); + + let items = page["items"].as_array().expect("items array"); + assert!( + items.len() <= 2, + "page {page_count}: must not exceed the limit of 2; got {}", + items.len() + ); + + for item in items { + let pk = item["pubkey"].as_str().expect("pubkey str").to_owned(); + assert!( + seen_pubkeys.insert(pk.clone()), + "page {page_count}: pubkey {pk} appeared more than once across pages" + ); + } + + page_count += 1; + + cursor_token = page["nextCursor"].as_str().map(str::to_owned); + if cursor_token.is_none() { + break; + } + + assert!( + page_count <= 5, + "pagination must terminate within 5 pages for 5 rows" + ); + } + + // Exactly-once coverage: every seeded pubkey must appear exactly once. + let expected: std::collections::HashSet = + all_pubkeys.iter().map(hex::encode).collect(); + assert_eq!( + seen_pubkeys, expected, + "every seeded pubkey must appear exactly once across all pages" + ); } #[tokio::test] From 50c514faf53e4871f5ca1d7c3baea809e3ade219 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 4 Sep 2026 15:41:29 -0400 Subject: [PATCH 07/11] fix(admin): default limit for GET /members/restrictions to 200 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route-level default must be 200 (the max) — the shared `limit()` helper defaults to 50, which is wrong for this route. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/admin/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index fb5b4f7d7a1..7f7f3363dd2 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -1247,7 +1247,7 @@ async fn list_member_restrictions( ) .await?; - let page_limit = limit(query.limit)?; + let page_limit = limit(Some(query.limit.unwrap_or(200)))?; let cursor = query.cursor.as_deref().map(decode_cursor).transpose()?; let community = buzz_core::CommunityId::from_uuid(query.community_id); From 1dacf48c454397878a70d097c9e4dc46ee89ad16 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 4 Sep 2026 16:03:43 -0400 Subject: [PATCH 08/11] test(admin): bind default=200 cap in pagination regression test Upgrade list_restrictions_pagination_exhaustive to be falsifiable on the default/max cap: - Seed 201 rows: 199 with distinct timestamps (newest first) + a tied pair at an identical older timestamp pinned to sort positions 200-201, straddling the default 200-row page boundary. - First route request omits limit: asserts exactly 200 items + a non-null nextCursor. Reverting the one-liner to limit(query.limit)? produces 50 instead of 200, failing this assertion. - Asserts limit=201 -> 400. - Walks remaining pages; asserts exactly-once coverage over all 201 pubkeys. The tied pair at positions 200-201 deterministically exercises the tie-breaker at the page boundary. Also fix RestrictionsPage.next_cursor field doc: null only when the returned page is smaller than limit; an exactly full final page emits a non-null cursor and the next request returns the empty terminal page. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/admin/mod.rs | 217 ++++++++++++++++++------- 1 file changed, 159 insertions(+), 58 deletions(-) diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 7f7f3363dd2..3a2b1ec5aa4 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -1156,7 +1156,10 @@ impl From for MemberRestrictionRecord { struct RestrictionsPage { /// Restriction records for this page, newest first. items: Vec, - /// Opaque cursor for the next page, or `null` when exhausted. + /// Opaque cursor for the next page, or `null` when the returned page is + /// smaller than `limit` (i.e., all rows have been delivered). An exactly + /// full final page emits a non-null cursor; the subsequent request returns + /// an empty page with `null`. /// /// Encoding: `base64url(updated_at_micros_decimal + "_" + pubkey_hex)`. /// Treat as opaque — the format may change across releases. @@ -2296,9 +2299,16 @@ mod postgres_tests { ); } - /// Pagination regression: seed more than the cap, prove no page exceeds the - /// cap, walk pages to exhaustion, assert exactly-once coverage, and exercise - /// the tie-breaker with rows that share identical `updated_at` timestamps. + /// Pagination regression: bind the default=200 cap, SQL LIMIT enforcement, + /// keyset continuation, exactly-once coverage, and tie-breaker correctness + /// in a single falsifiable route test. + /// + /// Seeding strategy: + /// - Rows 1–199: distinct `updated_at` values, newest first (positions 1–199). + /// - Rows 200–201: two distinct pubkeys forced to share an identical older + /// `updated_at` so they land at positions 200–201 in `ORDER BY updated_at + /// DESC, pubkey DESC`, straddling the default 200-row page boundary. + /// A broken tie-breaker will either duplicate or omit one of these rows. #[tokio::test] #[ignore = "requires Postgres"] async fn list_restrictions_pagination_exhaustive() { @@ -2317,53 +2327,154 @@ mod postgres_tests { let community_uuid = *community.as_uuid(); let actor_pubkey = test_operator_keys().public_key().to_bytes().to_vec(); - // Seed 5 bans — more than the page size of 2 we'll use below. - // Pubkeys 0x01..0x05 are distinct; each UPDATE sets updated_at = now() - // so they naturally differ unless inserted in the same microsecond. + + // Build 201 distinct pubkeys. Pubkey layout: + // indices 0..=198 (199 rows) → [i+1, i+1, ..., i+1] where i ∈ 0..199 + // indices 199..=200 (2 rows) → [0xE0 | j, 0xE0 | j, ..., 0xE0 | j] + // The tied pair uses a 0xE0-prefix to sort below all 0x01..0xC7 entries + // under `pubkey DESC`, ensuring they land at the bottom of every page + // regardless of the tie-breaking direction. let mut all_pubkeys: Vec> = Vec::new(); - for i in 1u8..=5 { - let pk = vec![i; 32]; - db.ban_community_member(community, &pk, &actor_pubkey, None, None) - .await - .expect("insert ban"); - all_pubkeys.push(pk); + for i in 1u8..=199 { + all_pubkeys.push(vec![i; 32]); + } + all_pubkeys.push(vec![0xE1u8; 32]); // tied pair member A (index 199) + all_pubkeys.push(vec![0xE2u8; 32]); // tied pair member B (index 200) + + // Batch-insert all 201 rows with explicit `updated_at` stamps: + // rows 0..198 → now() - (i+1) seconds (distinct, newer) + // rows 199-200 → now() - 1000 seconds (identical, oldest) + // This guarantees the tied pair occupies sort positions 200–201. + let actor_bytes = actor_pubkey; + + // Insert in two batches to keep parameter counts manageable. + // Batch 1: rows 0..=198 (distinct timestamps). + for (i, pk) in all_pubkeys[..199].iter().enumerate() { + let secs_ago = (i as i64) + 1; + sqlx::query( + r#" + INSERT INTO community_bans + (community_id, pubkey, banned, actor_pubkey, updated_at) + VALUES ($1, $2, true, $3, + now() - make_interval(secs => $4)) + ON CONFLICT (community_id, pubkey) DO UPDATE SET + banned = true, + actor_pubkey = EXCLUDED.actor_pubkey, + updated_at = EXCLUDED.updated_at + "#, + ) + .bind(community.as_uuid()) + .bind(pk.as_slice()) + .bind(actor_bytes.as_slice()) + .bind(secs_ago) + .execute(&pool) + .await + .expect("insert row"); } - // Force two rows to share the EXACT same `updated_at` so the - // tie-breaker path is exercised: update both in the same transaction - // with an explicit identical timestamp. - let shared_ts = chrono::Utc::now() - chrono::Duration::seconds(5); - sqlx::query( - "UPDATE community_bans SET updated_at = $1 - WHERE community_id = $2 AND pubkey = ANY($3::bytea[])", - ) - .bind(shared_ts) - .bind(community.as_uuid()) - .bind( - all_pubkeys[..2] - .iter() - .map(|p| p.as_slice()) - .collect::>(), - ) - .execute(&pool) - .await - .expect("force identical updated_at on first two rows"); + // Batch 2: tied pair at an identical old timestamp. + let tied_ts = chrono::Utc::now() - chrono::Duration::seconds(1000); + for pk in &all_pubkeys[199..] { + sqlx::query( + r#" + INSERT INTO community_bans + (community_id, pubkey, banned, actor_pubkey, updated_at) + VALUES ($1, $2, true, $3, $4) + ON CONFLICT (community_id, pubkey) DO UPDATE SET + banned = true, + actor_pubkey = EXCLUDED.actor_pubkey, + updated_at = EXCLUDED.updated_at + "#, + ) + .bind(community.as_uuid()) + .bind(pk.as_slice()) + .bind(actor_bytes.as_slice()) + .bind(tied_ts) + .execute(&pool) + .await + .expect("insert tied row"); + } let state = nip98_state_with_real_pool(pool).await; let operator_keys = test_operator_keys(); - // Walk pages with limit=2 until nextCursor is null. - let mut seen_pubkeys: std::collections::HashSet = std::collections::HashSet::new(); - let mut page_count = 0usize; - let mut cursor_token: Option = None; + // ── assertion 1: limit=201 → 400 ───────────────────────────────── + let bad_path = format!("/members/restrictions?communityId={community_uuid}&limit=201"); + let bad_auth = make_nostr_auth(&operator_keys, &bad_path); + let bad_response = status_for( + Arc::clone(&state), + Request::builder() + .method("GET") + .uri(&bad_path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, bad_auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + bad_response.status(), + StatusCode::BAD_REQUEST, + "limit=201 must return 400" + ); + + // ── assertion 2: default limit → exactly 200 items + non-null cursor ─ + // (This is the falsifiable binding of default=200 and max=200.) + let first_path = format!("/members/restrictions?communityId={community_uuid}"); + let first_auth = make_nostr_auth(&operator_keys, &first_path); + let first_response = status_for( + Arc::clone(&state), + Request::builder() + .method("GET") + .uri(&first_path) + .header(header::HOST, "admin.example") + .header(header::AUTHORIZATION, first_auth) + .body(Body::empty()) + .expect("request"), + ) + .await; + assert_eq!( + first_response.status(), + StatusCode::OK, + "default-limit request must return 200" + ); + let first_body = axum::body::to_bytes(first_response.into_body(), usize::MAX) + .await + .expect("read body"); + let first_page: serde_json::Value = + serde_json::from_slice(&first_body).expect("parse JSON"); + let first_items = first_page["items"].as_array().expect("items array"); + assert_eq!( + first_items.len(), + 200, + "default limit must return exactly 200 items (binds default=200 and SQL cap)" + ); + assert!( + first_page["nextCursor"].is_string(), + "200 items from 201 seeded → nextCursor must be non-null" + ); + + // Collect pubkeys from the first page. + let mut seen_pubkeys: std::collections::HashSet = first_items + .iter() + .filter_map(|r| r["pubkey"].as_str().map(String::from)) + .collect(); + assert_eq!( + seen_pubkeys.len(), + 200, + "first page must have 200 distinct pubkeys" + ); + + // ── assertion 3: walk remaining pages to exhaustion ─────────────── + let mut cursor_token = first_page["nextCursor"].as_str().map(str::to_owned); + let mut page_count = 1usize; // already consumed first page above loop { - let path = match &cursor_token { - None => format!("/members/restrictions?communityId={community_uuid}&limit=2"), - Some(tok) => format!( - "/members/restrictions?communityId={community_uuid}&limit=2&cursor={tok}" - ), + let tok = match &cursor_token { + Some(t) => t.clone(), + None => break, }; + let path = format!("/members/restrictions?communityId={community_uuid}&cursor={tok}"); let auth = make_nostr_auth(&operator_keys, &path); let response = status_for( Arc::clone(&state), @@ -2376,47 +2487,37 @@ mod postgres_tests { .expect("request"), ) .await; - assert_eq!( response.status(), StatusCode::OK, - "page {page_count}: GET restrictions must return 200" + "continuation page {page_count}: must return 200" ); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("read body"); let page: serde_json::Value = serde_json::from_slice(&body).expect("parse JSON"); - let items = page["items"].as_array().expect("items array"); assert!( - items.len() <= 2, - "page {page_count}: must not exceed the limit of 2; got {}", + items.len() <= 200, + "continuation page {page_count}: must not exceed cap; got {}", items.len() ); - for item in items { let pk = item["pubkey"].as_str().expect("pubkey str").to_owned(); assert!( seen_pubkeys.insert(pk.clone()), - "page {page_count}: pubkey {pk} appeared more than once across pages" + "continuation page {page_count}: pubkey {pk} appeared more than once" ); } - page_count += 1; - cursor_token = page["nextCursor"].as_str().map(str::to_owned); - if cursor_token.is_none() { - break; - } - assert!( - page_count <= 5, - "pagination must terminate within 5 pages for 5 rows" + page_count <= 10, + "pagination must terminate within 10 pages for 201 rows" ); } - // Exactly-once coverage: every seeded pubkey must appear exactly once. + // ── assertion 4: exactly-once coverage ─────────────────────────── let expected: std::collections::HashSet = all_pubkeys.iter().map(hex::encode).collect(); assert_eq!( From 86a5ebb506f57b4bc400488204ff34f6f2e2ebb0 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 4 Sep 2026 16:20:18 -0400 Subject: [PATCH 09/11] docs(admin): correct seeding comment and update PR body for pagination test The tied-pair comment incorrectly claimed 0xE1/0xE2 sort "below" 0x01..0xC7 under pubkey DESC (they sort above). The older timestamp is what pins them to positions 200/201. Correct and clarify the comment; note the pair's internal order under pubkey DESC. PR body Tests section updated to describe the shipped 201-row / default-200 boundary test. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/admin/mod.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 3a2b1ec5aa4..003a4197ac2 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -2330,16 +2330,17 @@ mod postgres_tests { // Build 201 distinct pubkeys. Pubkey layout: // indices 0..=198 (199 rows) → [i+1, i+1, ..., i+1] where i ∈ 0..199 - // indices 199..=200 (2 rows) → [0xE0 | j, 0xE0 | j, ..., 0xE0 | j] - // The tied pair uses a 0xE0-prefix to sort below all 0x01..0xC7 entries - // under `pubkey DESC`, ensuring they land at the bottom of every page - // regardless of the tie-breaking direction. + // indices 199..=200 (2 rows) → [0xE1..] and [0xE2..] (the tied pair) + // The tied pair is pinned to positions 200/201 by their OLDER timestamp + // (now()-1000s vs. now()-1..199s), not by pubkey ordering. Under + // `pubkey DESC` 0xE2 sorts above 0xE1, so 0xE2 lands at position 200 + // and 0xE1 at position 201; the default-200 page boundary splits the tie. let mut all_pubkeys: Vec> = Vec::new(); for i in 1u8..=199 { all_pubkeys.push(vec![i; 32]); } - all_pubkeys.push(vec![0xE1u8; 32]); // tied pair member A (index 199) - all_pubkeys.push(vec![0xE2u8; 32]); // tied pair member B (index 200) + all_pubkeys.push(vec![0xE1u8; 32]); // tied pair member A — position 201 + all_pubkeys.push(vec![0xE2u8; 32]); // tied pair member B — position 200 // Batch-insert all 201 rows with explicit `updated_at` stamps: // rows 0..198 → now() - (i+1) seconds (distinct, newer) From 03e9d3609c88dc0ba5a1686cb35d10623bbf0bdd Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Mon, 21 Sep 2026 11:55:59 -0400 Subject: [PATCH 10/11] fix(admin): rewrite while_let_loop as let-else to satisfy clippy The pagination loop in the restrictions cursor test used a `loop { match ... None => break }` pattern that clippy -D warnings flags as `clippy::while_let_loop`. Rewrote it as a `let ... else { break }` form, which is idiomatic and clippy-clean. This was the root cause of the four red CI lanes (Rust Lint + Windows Rust on both the PR and a secondary run) at the prior head. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/admin/mod.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 003a4197ac2..d1c923d3772 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -2471,9 +2471,8 @@ mod postgres_tests { let mut page_count = 1usize; // already consumed first page above loop { - let tok = match &cursor_token { - Some(t) => t.clone(), - None => break, + let Some(tok) = cursor_token.clone() else { + break; }; let path = format!("/members/restrictions?communityId={community_uuid}&cursor={tok}"); let auth = make_nostr_auth(&operator_keys, &path); From 319c97874b4f1c9c51bd75e70da93bb91e7fa652 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Mon, 21 Sep 2026 12:08:21 -0400 Subject: [PATCH 11/11] fix(admin): use while-let for cursor loop, satisfying clippy::while_let_loop Loop body and termination semantics are identical to the prior loop/let-else form; this is the idiomatic Rust pattern clippy recommends. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/admin/mod.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index d1c923d3772..b807b0f86ed 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -2470,10 +2470,7 @@ mod postgres_tests { let mut cursor_token = first_page["nextCursor"].as_str().map(str::to_owned); let mut page_count = 1usize; // already consumed first page above - loop { - let Some(tok) = cursor_token.clone() else { - break; - }; + while let Some(tok) = cursor_token.clone() { let path = format!("/members/restrictions?communityId={community_uuid}&cursor={tok}"); let auth = make_nostr_auth(&operator_keys, &path); let response = status_for(