mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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},
|
||||
|
||||
123
crates/buzz-relay/src/api/operator/invites.rs
Normal file
123
crates/buzz-relay/src/api/operator/invites.rs
Normal 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)))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user