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://<host>/invite/<code>` 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": "<uuid>", "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 <baxen@squareup.com>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: drone03 <3efda7d0089450ac8094dd25cfa734546dc0d6bad0ab87b64dacfff867bdc4d3@buzz.block.builderlab.xyz>
Co-authored-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bradley Axen
2026-11-09 13:23:56 -08:00
committed by GitHub
parent 66095e5598
commit 2b16d0ea1a
7 changed files with 591 additions and 25 deletions

View File

@@ -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,

View File

@@ -142,6 +142,33 @@ pub async fn mint_relay_invite(
created_by: &str,
ttl_secs: u64,
max_uses: Option<i32>,
) -> Result<MintedInvite> {
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<i32>,
) -> Result<MintedInvite> {
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<i32>,
require_admin: bool,
) -> Result<MintedInvite> {
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<String> = 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<i32>,
) -> Result<MintedInvite> {
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.

View File

@@ -66,7 +66,7 @@ pub struct MintInviteRequest {
pub max_uses: Option<i32>,
}
fn validate_mint_request(
pub(crate) fn validate_mint_request(
request: &MintInviteRequest,
) -> Result<(u64, Option<i32>), (StatusCode, Json<Value>)> {
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<Value>) {
pub(crate) fn map_mint_error(error: buzz_db::DbError) -> (StatusCode, Json<Value>) {
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.
///

View File

@@ -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},

View File

@@ -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<Value>);
/// 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<u64>,
#[serde(default)]
max_uses: Option<i32>,
}
/// 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<Arc<AppState>>,
slot: AccessSlot,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<Value>, 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)))
}

View File

@@ -0,0 +1,381 @@
mod postgres_tests {
use super::super::*;
const PATH: &str = "/operator/communities/invites";
async fn mint(state: &Arc<AppState>, 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<AppState>, 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<AppState>,
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");
}
}

View File

@@ -522,6 +522,10 @@ pub fn build_router(state: Arc<AppState>) -> 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))