From 2b16d0ea1a5550146c3dbe303ad918debcf0094e Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 9 Nov 2026 13:23:56 -0800 Subject: [PATCH] feat(relay): let an operator mint invites for a community owner or admin (#9265) ## Summary Adds `POST /operator/communities/invites`, so a deployment's trusted operator (its account service) can mint an invite link on behalf of a community owner or admin. The website where people manage their communities never holds the owner's own key, so it can't call `POST /api/invites` (owner-signed NIP-98). - **Trust model:** the same as `/operator/communities/{archive,transfer}`. A configured operator signs the request (NIP-98, payload-bound, replay-checked) and vouches only for **which identity** its signed-in caller is bound to (`actor_pubkey`). The relay decides that identity's authority from **its own roster at mint time**: owner or admin only, the same check `/api/invites` makes. Nothing the operator says about roles is trusted. - **Same invite:** it mints through `db.mint_relay_invite`, with the same lifecycle gate and restriction check, the same `ttl_secs`/`max_uses` bounds and defaults (`validate_mint_request`), and the same response (`minted_invite_json`, extracted from `/api/invites`). The link is an ordinary `https:///invite/` that every client's join flow already claims. - Archived, deleted and deleting communities return 404 (`lookup_community_host` only resolves active ones). Request body (unknown fields rejected): ```json { "community_id": "", "actor_pubkey": "<64-hex>", "ttl_secs": 259200, "max_uses": 5 } ``` Errors keep the `/api/invites` text in `error` and add a stable `code`, so the operator can tell its own misconfiguration from the actor's refusal without matching on text: | Status | `code` | When | |---|---|---| | 403 | `not_operator` | the signer is not a configured operator | | 400 | `invalid_request` | bad JSON, an unknown field, a bad pubkey, or out-of-range limits | | 404 | `community_not_found` | unknown, archived, deleted or deleting community | | 403 | `not_owner` | the actor isn't an owner or admin in the relay's roster | | 403 | `restricted` | the actor is banned or restricted (`invite_restricted`) | | 503 | `unavailable` | community writes are paused | This is an HTTP operator route rather than an event kind because it belongs on the existing operator plane (operator-signed, outside the event data plane), like the other operator community actions. ### Stack (invite links on the Builderlab Communities page) 1. **This PR**: the relay route. It ships first. 2. The account service endpoint that calls it (private repo). Depends on this PR. 3. The website UI (private repo). Depends on 2. ### Related issue None found. ### Testing - New Postgres journey `operator_mints_invites_only_for_relay_owners_and_admins` (run against a freshly migrated local database): - An owner mints with the defaults. The URL, null `max_uses` and the 72h expiry match `/api/invites`. - A joiner **claims the minted code through `POST /api/invites/claim`** and becomes a member. - An admin mints with limits. - A banned admin is refused. - A member and an outsider get 403 with the same refusal text as `/api/invites`. The test pins each error's `code`. - Out-of-range `ttl_secs` and `max_uses`, an unknown field, a bad pubkey and a non-operator signer are all refused. - An unknown community and an archived community get 404. - `cargo test -p buzz-relay --lib -- --include-ignored api::invites api::operator`: 78 passed and 2 timed out under heavy machine load. Both timeouts (`claim_follow_up_survives_a_client_disconnect`, `temporary_listing_failure_is_recovered_for_each_admission_path`) also pass on this branch when run on their own, as on `main`. - `cargo fmt` and pre-push hooks (clippy) passed. - **Not yet done:** a run against a local relay with `curl`, and a human check. --------- Signed-off-by: Bradley Axen Signed-off-by: Kalvin Chau Co-authored-by: drone03 <3efda7d0089450ac8094dd25cfa734546dc0d6bad0ab87b64dacfff867bdc4d3@buzz.block.builderlab.xyz> Co-authored-by: Kalvin Chau Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 --- crates/buzz-db/src/error.rs | 4 + crates/buzz-db/src/store/relay_invite.rs | 49 ++- crates/buzz-relay/src/api/invites.rs | 52 ++- crates/buzz-relay/src/api/operator.rs | 3 + crates/buzz-relay/src/api/operator/invites.rs | 123 ++++++ .../postgres_tests/invite_postgres_tests.rs | 381 ++++++++++++++++++ crates/buzz-relay/src/router.rs | 4 + 7 files changed, 591 insertions(+), 25 deletions(-) create mode 100644 crates/buzz-relay/src/api/operator/invites.rs create mode 100644 crates/buzz-relay/src/api/operator/postgres_tests/invite_postgres_tests.rs diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index 19f3d1057a..1627521d7e 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -54,6 +54,10 @@ pub enum DbError { #[error("invitation principal is restricted")] InviteRestricted, + /// The invitation issuer is not an owner or admin at mint time. + #[error("invitation issuer is not an owner or admin")] + InviteIssuerNotAdmin, + /// A verified invitation owner conflicts with the recorded owner. #[error("invitation owner does not match the recorded owner")] InviteOwnerMismatch, diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index c6db801d27..327beb32ab 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -142,6 +142,33 @@ pub async fn mint_relay_invite( created_by: &str, ttl_secs: u64, max_uses: Option, +) -> Result { + mint(pool, community, created_by, ttl_secs, max_uses, false).await +} + +/// Like [`mint_relay_invite`], but refuses with +/// [`DbError::InviteIssuerNotAdmin`](crate::DbError::InviteIssuerNotAdmin) +/// unless `created_by` is an owner or admin inside the mint transaction. The +/// role row is held `FOR SHARE` through commit, so a concurrent demotion, +/// removal or ownership transfer either lands first and refuses this mint or +/// waits for it. +pub async fn mint_relay_invite_as_admin( + pool: &PgPool, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, +) -> Result { + mint(pool, community, created_by, ttl_secs, max_uses, true).await +} + +async fn mint( + pool: &PgPool, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, + require_admin: bool, ) -> Result { validate_mint_inputs(ttl_secs, max_uses)?; if !is_canonical_invite_pubkey(created_by) { @@ -168,6 +195,19 @@ pub async fn mint_relay_invite( ) .await?; lock_admission(tx.conn(), community).await?; + if require_admin { + let role: Option = sqlx::query_scalar( + "SELECT role FROM relay_members \ + WHERE community_id = $1 AND pubkey = $2 FOR SHARE", + ) + .bind(community.as_uuid()) + .bind(created_by) + .fetch_optional(tx.conn()) + .await?; + if !matches!(role.as_deref(), Some("owner" | "admin")) { + return Err(crate::DbError::InviteIssuerNotAdmin); + } + } if principal_restricted(tx.conn(), community, created_by).await? { return Err(crate::DbError::InviteRestricted); } @@ -693,20 +733,21 @@ pub async fn claim_relay_invite_with_owner( } impl Db { - /// Mints a v2 use-limited relay invite. The plaintext code is returned - /// exactly once; only its SHA-256 hash is persisted. + /// Mints a v2 use-limited relay invite for an owner or admin; see + /// [`mint_relay_invite_as_admin`]. The plaintext code is returned exactly + /// once; only its SHA-256 hash is persisted. /// /// `max_uses` is `None` for unlimited or `Some(1..=10000)`. /// `ttl_secs` must be in the shared invite lifetime range. #[datastore_span(name = "mint_relay_invite", system = "postgresql")] - pub async fn mint_relay_invite( + pub async fn mint_relay_invite_as_admin( &self, community: CommunityId, created_by: &str, ttl_secs: u64, max_uses: Option, ) -> Result { - mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await + mint_relay_invite_as_admin(&self.pool, community, created_by, ttl_secs, max_uses).await } /// Claim an invite and persist caller-verified ownership atomically. diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index cf783c09cf..2f15061a96 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -66,7 +66,7 @@ pub struct MintInviteRequest { pub max_uses: Option, } -fn validate_mint_request( +pub(crate) fn validate_mint_request( request: &MintInviteRequest, ) -> Result<(u64, Option), (StatusCode, Json)> { let ttl = request.ttl_secs.unwrap_or(DEFAULT_INVITE_TTL_SECS); @@ -231,9 +231,13 @@ pub async fn accept_policy( Ok(Json(serde_json::json!({ "receipt": receipt }))) } -fn map_mint_error(error: buzz_db::DbError) -> (StatusCode, Json) { +pub(crate) fn map_mint_error(error: buzz_db::DbError) -> (StatusCode, Json) { match error { buzz_db::DbError::InviteRestricted => api_error(StatusCode::FORBIDDEN, "invite_restricted"), + buzz_db::DbError::InviteIssuerNotAdmin => api_error( + StatusCode::FORBIDDEN, + "only relay owners and admins can create invites", + ), buzz_db::DbError::InvalidData(message) | buzz_db::DbError::DeletionSafety(message) => { api_error(StatusCode::BAD_REQUEST, &message) } @@ -410,18 +414,10 @@ async fn mint_invite_inner( // Mint a v2 opaque, database-backed invite. let invite = state .db - .mint_relay_invite(tenant.community(), &sender_hex, ttl, max_uses) + .mint_relay_invite_as_admin(tenant.community(), &sender_hex, ttl, max_uses) .await .map_err(map_mint_error)?; - // Same TLS-posture logic as nip98_expected_url: wss deployments get an - // https landing page URL, ws dev/test deployments get http. - let scheme = if state.config.relay_url.trim_start().starts_with("wss://") { - "https" - } else { - "http" - }; - let minted = crate::access::Detail { invite_id: Some(invite.invite_id), expires_at: Some(invite.expires_at.timestamp()), @@ -429,21 +425,35 @@ async fn mint_invite_inner( ..Default::default() }; - // expires_at as unix seconds for the response contract. - let expires_at_unix = invite.expires_at.timestamp() as u64; - Ok(( - Json(serde_json::json!({ - "code": invite.code, - "expires_at": expires_at_unix, - "max_uses": invite.max_uses, - "uses_remaining": invite.uses_remaining, - "url": format!("{scheme}://{}/invite/{}", tenant.host(), invite.code), - })), + Json(minted_invite_json(state, tenant.host(), &invite)), minted, )) } +/// The mint response contract shared by every mint route: the code, its +/// expiry in Unix seconds, its use limits, and the landing-page URL on `host`. +pub(crate) fn minted_invite_json( + state: &AppState, + host: &str, + invite: &buzz_db::relay_invite::MintedInvite, +) -> Value { + // Same TLS-posture logic as nip98_expected_url: wss deployments get an + // https landing page URL, ws dev/test deployments get http. + let scheme = if state.config.relay_url.trim_start().starts_with("wss://") { + "https" + } else { + "http" + }; + serde_json::json!({ + "code": invite.code, + "expires_at": invite.expires_at.timestamp() as u64, + "max_uses": invite.max_uses, + "uses_remaining": invite.uses_remaining, + "url": format!("{scheme}://{host}/invite/{}", invite.code), + }) +} + /// Claim an invite code — `POST /api/invites/claim`, NIP-98 signed by the /// *joining* pubkey. Exempt from the relay-membership gate by design. /// diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index b9fa7a59f5..43bdb652c8 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -5,7 +5,9 @@ //! ingest, relay membership, channel scoping, storage, or fan-out. mod community_policy; +mod invites; pub use community_policy::{community_nip_fi_policy_status, set_community_nip_fi_policy}; +pub use invites::mint_community_invite; use crate::access::{AccessSlot, AuthMethod, AuthResult}; use std::sync::Arc; @@ -1247,6 +1249,7 @@ pub async fn no_store_policy_response( #[cfg(test)] mod postgres_tests { mod initial_policy_postgres_tests; + mod invite_postgres_tests; use std::{ collections::HashSet, sync::{Arc, Mutex}, diff --git a/crates/buzz-relay/src/api/operator/invites.rs b/crates/buzz-relay/src/api/operator/invites.rs new file mode 100644 index 0000000000..6cf0ca21db --- /dev/null +++ b/crates/buzz-relay/src/api/operator/invites.rs @@ -0,0 +1,123 @@ +//! Operator-mediated invite minting for a community owner or admin. +//! +//! A trusted operator vouches only for which identity its signed-in caller is +//! bound to. The relay decides that identity's authority from its own +//! membership data at mint time, exactly as `POST /api/invites` does. + +use super::*; +use crate::access::AuthReason; +use crate::api::invites::{ + map_mint_error, minted_invite_json, validate_mint_request, MintInviteRequest, +}; + +const PATH: &str = "/operator/communities/invites"; + +type Error = (StatusCode, Json); + +/// Adds a stable `code` beside the `/api/invites` error text, so the operator +/// can tell its own misconfiguration from the actor's refusal without +/// matching on prose. +fn with_code((status, Json(mut body)): Error, code: &'static str) -> Error { + body["code"] = code.into(); + (status, Json(body)) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + community_id: Uuid, + actor_pubkey: String, + #[serde(default)] + ttl_secs: Option, + #[serde(default)] + max_uses: Option, +} + +/// Mint an invite as the operator-attested `actor_pubkey`, who must be an +/// owner or admin of the active community. The limits, defaults, storage and +/// response match `POST /api/invites`; errors add a stable `code`: +/// `not_operator`, `invalid_request`, `community_not_found`, `not_owner`, +/// `restricted` or `unavailable`. +pub async fn mint_community_invite( + State(state): State>, + slot: AccessSlot, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, Error> { + authorize_operator_request(&state, &slot, &headers, "POST", PATH, None, Some(&body)) + .await + .map_err(|error| match error.0 { + StatusCode::FORBIDDEN => with_code(error, "not_operator"), + _ => error, + })?; + let request: Request = serde_json::from_slice(&body).map_err(|e| { + coded_api_error( + StatusCode::BAD_REQUEST, + "invalid_request", + &format!("invalid community-invite JSON: {e}"), + ) + })?; + let actor = validate_pubkey_hex(&request.actor_pubkey).ok_or_else(|| { + coded_api_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "invalid actor_pubkey: expected 64-char hex pubkey", + ) + })?; + let (ttl, max_uses) = validate_mint_request(&MintInviteRequest { + ttl_secs: request.ttl_secs, + max_uses: request.max_uses, + }) + .map_err(|error| with_code(error, "invalid_request"))?; + let community = CommunityId::from_uuid(request.community_id); + // Archived, deleted and deleting communities have no host here. + let host = state + .db + .lookup_community_host(community) + .await + .map_err(|e| internal_error(&format!("community invite host lookup: {e}")))? + .ok_or_else(|| { + coded_api_error( + StatusCode::NOT_FOUND, + "community_not_found", + "community not found", + ) + })?; + slot.in_community(community); + // The mint checks the actor's role and restriction, and the community's + // deletion state, in one transaction. It does not re-check archiving: an + // invite minted while an archive lands cannot be claimed while archived. + let invite = state + .db + .mint_relay_invite_as_admin(community, &actor, ttl, max_uses) + .await + .map_err(|error| match error { + buzz_db::DbError::InviteIssuerNotAdmin => { + slot.finish(AuthResult::Denied(AuthReason::NotRelayAdmin)); + coded_api_error( + StatusCode::FORBIDDEN, + "not_owner", + "only relay owners and admins can create invites", + ) + } + buzz_db::DbError::InviteRestricted => { + slot.finish(AuthResult::Denied(AuthReason::Restricted)); + with_code(map_mint_error(error), "restricted") + } + error => { + let error = map_mint_error(error); + match error.0 { + StatusCode::SERVICE_UNAVAILABLE => with_code(error, "unavailable"), + StatusCode::BAD_REQUEST => with_code(error, "invalid_request"), + _ => error, + } + } + })?; + slot.detail(crate::access::Detail { + invite_id: Some(invite.invite_id), + expires_at: Some(invite.expires_at.timestamp()), + max_uses: invite.max_uses, + ..Default::default() + }); + Ok(Json(minted_invite_json(&state, &host, &invite))) +} diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/invite_postgres_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/invite_postgres_tests.rs new file mode 100644 index 0000000000..03e2d92dd7 --- /dev/null +++ b/crates/buzz-relay/src/api/operator/postgres_tests/invite_postgres_tests.rs @@ -0,0 +1,381 @@ +mod postgres_tests { + use super::super::*; + + const PATH: &str = "/operator/communities/invites"; + + async fn mint(state: &Arc, operator: &Keys, body: Value) -> axum::response::Response { + signed_operator_request( + state.clone(), + operator, + "POST", + PATH, + Some(body.to_string()), + ) + .await + } + + /// The refusal's text, after checking its status and stable code. + async fn error(response: axum::response::Response, status: StatusCode, code: &str) -> String { + assert_eq!(response.status(), status); + let json = read_json(response).await; + assert_eq!(json["code"], code, "{json}"); + json["error"].as_str().expect("error text").to_owned() + } + + /// Claim `code` on the community host the way a joining app does. + async fn claim(state: &Arc, host: &str, joiner: &Keys, code: &str) -> StatusCode { + let body = serde_json::json!({ "code": code }).to_string(); + let auth = nip98_auth_header( + joiner, + &format!("https://{host}/api/invites/claim"), + "POST", + Some(body.as_bytes()), + ); + build_router(state.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/invites/claim") + .header(header::HOST, host) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response") + .status() + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn operator_mints_invites_only_for_relay_owners_and_admins() { + let operator = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let [owner, admin, member, outsider, joiner] = std::array::from_fn(|_| Keys::generate()); + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(state.clone(), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup community") + .expect("community exists") + .id; + for (keys, role) in [(&admin, "admin"), (&member, "member")] { + state + .db + .add_relay_member( + community, + &keys.public_key().to_hex(), + role, + None, + &state.relay_keypair, + ) + .await + .map(|write| write.outcome) + .expect("seed member"); + } + let request = |actor: &Keys, limits: Value| { + let mut body = serde_json::json!({ + "community_id": community.to_string(), + "actor_pubkey": actor.public_key().to_hex(), + }); + body.as_object_mut() + .expect("object") + .extend(limits.as_object().expect("limits").clone()); + body + }; + + // An owner gets the /api/invites contract with its defaults. + let response = mint(&state, &operator, request(&owner, serde_json::json!({}))).await; + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + let code = json["code"].as_str().expect("code").to_owned(); + assert_eq!(json["url"], format!("https://{host}/invite/{code}")); + assert_eq!(json["max_uses"], Value::Null); + let expires_at = json["expires_at"].as_i64().expect("expires_at"); + let default_expiry = chrono::Utc::now().timestamp() + 72 * 60 * 60; + assert!((default_expiry - 60..=default_expiry).contains(&expires_at)); + // The code is an ordinary relay invite: the join flow claims it. + assert_eq!(claim(&state, &host, &joiner, &code).await, StatusCode::OK); + assert_eq!( + state + .db + .get_relay_member(community, &joiner.public_key().to_hex()) + .await + .expect("joiner lookup") + .expect("joiner joined") + .role, + "member" + ); + + // An admin may mint too, with bounded limits. + let response = mint( + &state, + &operator, + request( + &admin, + serde_json::json!({ "ttl_secs": 3600, "max_uses": 5 }), + ), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!(json["max_uses"], 5); + assert_eq!(json["uses_remaining"], 5); + + // A restricted admin keeps the role but can no longer mint. + state + .db + .ban_community_member( + community, + admin.public_key().as_bytes(), + owner.public_key().as_bytes(), + None, + None, + ) + .await + .expect("ban admin"); + assert_eq!( + error( + mint(&state, &operator, request(&admin, serde_json::json!({}))).await, + StatusCode::FORBIDDEN, + "restricted" + ) + .await, + "invite_restricted" + ); + + // The relay's roster decides authority, whatever the operator asserts. + for actor in [&member, &outsider] { + assert_eq!( + error( + mint(&state, &operator, request(actor, serde_json::json!({}))).await, + StatusCode::FORBIDDEN, + "not_owner" + ) + .await, + "only relay owners and admins can create invites" + ); + } + // Same bounds as /api/invites. + for (limits, message) in [ + ( + serde_json::json!({ "ttl_secs": 30 * 24 * 60 * 60 + 1 }), + "ttl_secs must be between", + ), + ( + serde_json::json!({ "max_uses": 0 }), + "max_uses must be between", + ), + ( + serde_json::json!({ "max_uses": 10_001 }), + "max_uses must be between", + ), + ] { + let text = error( + mint(&state, &operator, request(&owner, limits)).await, + StatusCode::BAD_REQUEST, + "invalid_request", + ) + .await; + assert!(text.starts_with(message), "{text}"); + } + let mut unknown = request(&owner, serde_json::json!({})); + unknown["role"] = "owner".into(); + error( + mint(&state, &operator, unknown).await, + StatusCode::BAD_REQUEST, + "invalid_request", + ) + .await; + let mut bad_actor = request(&owner, serde_json::json!({})); + bad_actor["actor_pubkey"] = "not-a-pubkey".into(); + error( + mint(&state, &operator, bad_actor).await, + StatusCode::BAD_REQUEST, + "invalid_request", + ) + .await; + // Only configured operators may vouch for a caller; that refusal is the + // operator's own misconfiguration, so it carries its own code. + error( + mint( + &state, + &Keys::generate(), + request(&owner, serde_json::json!({})), + ) + .await, + StatusCode::FORBIDDEN, + "not_operator", + ) + .await; + // Unknown and archived communities have nothing to invite to. + let mut unknown_community = request(&owner, serde_json::json!({})); + unknown_community["community_id"] = Uuid::new_v4().to_string().into(); + assert_eq!( + error( + mint(&state, &operator, unknown_community).await, + StatusCode::NOT_FOUND, + "community_not_found" + ) + .await, + "community not found" + ); + archive_for_owner_deletion(&state, &host, &owner).await; + assert_eq!( + error( + mint(&state, &operator, request(&owner, serde_json::json!({}))).await, + StatusCode::NOT_FOUND, + "community_not_found" + ) + .await, + "community not found" + ); + } + + /// Mint on a task while `holder` keeps an uncommitted change, wait until + /// the mint is blocked on it, then commit it and return the mint response. + async fn mint_across_commit( + state: &Arc, + operator: &Keys, + body: Value, + mut holder: sqlx::Transaction<'static, sqlx::Postgres>, + ) -> axum::response::Response { + let holder_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *holder) + .await + .expect("holder pid"); + let task = tokio::spawn({ + let (state, operator) = (state.clone(), operator.clone()); + async move { mint(&state, &operator, body).await } + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let blocked: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity \ + WHERE $1 = ANY(pg_blocking_pids(pid)))", + ) + .bind(holder_pid) + .fetch_one(state.db.pool()) + .await + .expect("read lock waiters"); + if blocked { + break; + } + assert!(!task.is_finished(), "the mint finished without waiting"); + assert!( + std::time::Instant::now() < deadline, + "the mint never waited for the uncommitted change" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + holder.commit().await.expect("commit holder"); + task.await.expect("mint task") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn operator_mint_rechecks_role_and_lifecycle_inside_the_mint() { + let operator = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + let [owner, admin] = std::array::from_fn(|_| Keys::generate()); + let host = format!("community-{}.example", Uuid::new_v4().simple()); + assert_eq!( + provision_community(state.clone(), &operator, &host, &owner) + .await + .status(), + StatusCode::OK + ); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup community") + .expect("community exists") + .id; + let admin_hex = admin.public_key().to_hex(); + state + .db + .add_relay_member(community, &admin_hex, "admin", None, &state.relay_keypair) + .await + .map(|write| write.outcome) + .expect("seed admin"); + let request = |actor: &Keys| { + serde_json::json!({ + "community_id": community.to_string(), + "actor_pubkey": actor.public_key().to_hex(), + }) + }; + let invites = || async { + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM relay_invites WHERE community_id = $1", + ) + .bind(community.as_uuid()) + .fetch_one(state.db.pool()) + .await + .expect("count invites") + }; + + // A demotion that lands while the mint is in flight refuses it. + let mut holder = state.db.pool().begin().await.expect("begin demotion"); + sqlx::query( + "UPDATE relay_members SET role = 'member' WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(&admin_hex) + .execute(&mut *holder) + .await + .expect("demote admin"); + error( + mint_across_commit(&state, &operator, request(&admin), holder).await, + StatusCode::FORBIDDEN, + "not_owner", + ) + .await; + assert_eq!(invites().await, 0, "a refused mint must not persist"); + + // A deletion fence that lands after the host lookup refuses it too. + let mut holder = state.db.pool().begin().await.expect("begin fence"); + sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") + .bind(community.as_uuid()) + .execute(&mut *holder) + .await + .expect("take deletion lock"); + // Fence as the deletion executor does, so the tombstone guard admits it. + sqlx::query( + "SELECT set_config('buzz.deletion_executor_community', $1::text, true), \ + set_config('buzz.deletion_fence_generation', (deletion_fence_generation + 1)::text, true) \ + FROM communities WHERE id = $1", + ) + .bind(community.as_uuid()) + .execute(&mut *holder) + .await + .expect("act as deletion executor"); + sqlx::query( + "UPDATE communities SET deletion_state = 'quiescing', \ + deletion_fence_generation = deletion_fence_generation + 1 WHERE id = $1", + ) + .bind(community.as_uuid()) + .execute(&mut *holder) + .await + .expect("fence community"); + error( + mint_across_commit(&state, &operator, request(&owner), holder).await, + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + ) + .await; + assert_eq!(invites().await, 0, "a fenced mint must not persist"); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 9d42fd5ac6..45a7446ce1 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -522,6 +522,10 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities/transfer", post(api::operator::transfer_community), ) + .route( + "/operator/communities/invites", + post(api::operator::mint_community_invite), + ) // Relay invites: mint (owner/admin) + claim (membership-gate exempt) .route("/api/invites", post(api::invites::mint_invite)) .route("/api/join-policy", get(api::invites::join_policy))