mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(invites): add use-limited invite links (#3141)
## Summary - add database-backed v2 invite links with optional maximum-use limits and atomic final-slot redemption - preserve v1 invite compatibility while adding exhausted/expired/invalid client handling across desktop, web, and mobile - emit structured claim-outcome logs with community, invite ID, outcome, maximum uses, and post-claim count ## Verification - `cargo fmt --all -- --check` - `cargo test -p buzz-db` (85 passed, 134 Postgres-dependent ignored) - `cargo clippy -p buzz-db --all-targets -- -D warnings` - desktop `npm run typecheck` - push hook: desktop checks/tests, desktop Tauri tests, Rust tests, and branch-skew passed - Postgres integration tests were previously reviewed green at the pre-rebase tree; local rerun on this session was unavailable because Postgres/Docker were not running - mobile push-hook check could not start because Flutter is unavailable locally --------- Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c <c57bf9b4275088b2b33db7f746975407210f159fbd8bd733c0e532375f69aa80@buzz.block.builderlab.xyz> Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -25,7 +25,12 @@ use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list};
|
||||
use crate::invite_token::{self, DEFAULT_INVITE_TTL_SECS};
|
||||
use buzz_core::invite::{
|
||||
hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES,
|
||||
MIN_INVITE_TTL_SECS, V2_PREFIX,
|
||||
};
|
||||
|
||||
use crate::invite_token;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{api_error, bridge, internal_error};
|
||||
@@ -43,10 +48,42 @@ pub(crate) const CLAIM_RATE_CACHE_CAPACITY: u64 = 10_000;
|
||||
/// Body for `POST /api/invites`.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct MintInviteRequest {
|
||||
/// Requested lifetime in seconds. Clamped to
|
||||
/// Requested lifetime in seconds. Must be between
|
||||
/// [`MIN_INVITE_TTL_SECS`] and
|
||||
/// [`invite_token::MAX_INVITE_TTL_SECS`]; defaults to 72 h.
|
||||
#[serde(default)]
|
||||
pub ttl_secs: Option<u64>,
|
||||
/// Maximum number of uses before the invite is exhausted. `None` (omitted
|
||||
/// or `null`) means unlimited — preserves current behavior. When present,
|
||||
/// must be an integer from 1 through [`MAX_INVITE_USES`].
|
||||
#[serde(default)]
|
||||
pub max_uses: Option<i32>,
|
||||
}
|
||||
|
||||
fn validate_mint_request(
|
||||
request: &MintInviteRequest,
|
||||
) -> Result<(u64, Option<i32>), (StatusCode, Json<Value>)> {
|
||||
let ttl = request.ttl_secs.unwrap_or(DEFAULT_INVITE_TTL_SECS);
|
||||
if !(MIN_INVITE_TTL_SECS..=MAX_INVITE_TTL_SECS).contains(&ttl) {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!(
|
||||
"ttl_secs must be between {} and {MAX_INVITE_TTL_SECS}",
|
||||
MIN_INVITE_TTL_SECS
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(max_uses) = request.max_uses {
|
||||
if !(1..=MAX_INVITE_USES).contains(&max_uses) {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("max_uses must be between 1 and {MAX_INVITE_USES}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok((ttl, request.max_uses))
|
||||
}
|
||||
|
||||
/// Body for `POST /api/invites/claim`.
|
||||
@@ -260,9 +297,17 @@ pub async fn mint_invite(
|
||||
})?
|
||||
};
|
||||
|
||||
let key = invite_token::derive_invite_key(&state.relay_keypair);
|
||||
let ttl = request.ttl_secs.unwrap_or(DEFAULT_INVITE_TTL_SECS);
|
||||
let (code, expires_at) = invite_token::mint_invite(&key, tenant.community(), ttl);
|
||||
let (ttl, max_uses) = validate_mint_request(&request)?;
|
||||
|
||||
// Mint a v2 opaque, database-backed invite.
|
||||
let invite = state
|
||||
.db
|
||||
.mint_relay_invite(tenant.community(), &sender_hex, ttl, max_uses)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, &message),
|
||||
error => internal_error(&format!("invite mint: {error}")),
|
||||
})?;
|
||||
|
||||
// Same TLS-posture logic as nip98_expected_url: wss deployments get an
|
||||
// https landing page URL, ws dev/test deployments get http.
|
||||
@@ -275,19 +320,30 @@ pub async fn mint_invite(
|
||||
tracing::info!(
|
||||
community = %tenant.community(),
|
||||
minted_by = %sender_hex,
|
||||
expires_at,
|
||||
invite_id = %invite.invite_id,
|
||||
expires_at = %invite.expires_at,
|
||||
max_uses = ?invite.max_uses,
|
||||
"relay invite minted"
|
||||
);
|
||||
|
||||
// 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": code,
|
||||
"expires_at": expires_at,
|
||||
"url": format!("{scheme}://{}/invite/{}", tenant.host(), code),
|
||||
"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),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Claim an invite code — `POST /api/invites/claim`, NIP-98 signed by the
|
||||
/// *joining* pubkey. Exempt from the relay-membership gate by design.
|
||||
///
|
||||
/// Routing is by exact prefix: `v2.` codes go to the database-backed
|
||||
/// redemption path; every other code goes to the v1 HMAC verifier. A `v2.`
|
||||
/// code is never fallen back to v1 verification.
|
||||
pub async fn claim_invite(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
@@ -305,7 +361,88 @@ pub async fn claim_invite(
|
||||
let request: ClaimInviteRequest = serde_json::from_slice(&body)
|
||||
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid claim JSON: {e}")))?;
|
||||
|
||||
let claimer_hex = pubkey.to_hex();
|
||||
let key = invite_token::derive_invite_key(&state.relay_keypair);
|
||||
|
||||
// --- v2 database-backed path ---
|
||||
//
|
||||
// Route by exact prefix: v2. codes use the durable invite table. No
|
||||
// fallback to v1 HMAC verification for malformed v2 input.
|
||||
if request.code.starts_with(V2_PREFIX) {
|
||||
validate_v2_code(&request.code)
|
||||
.map_err(|_| api_error(StatusCode::FORBIDDEN, "invite_invalid"))?;
|
||||
|
||||
// Join-policy receipt verification, same mechanism as v1: the receipt
|
||||
// is bound to the code string by SHA-256, so it works for v2 codes.
|
||||
if let Some(policy) = &state.config.join_policy {
|
||||
let receipt = request
|
||||
.policy_receipt
|
||||
.as_deref()
|
||||
.ok_or_else(|| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
|
||||
invite_token::verify_policy_acceptance(&key, receipt, &request.code, &policy.version)
|
||||
.map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?;
|
||||
}
|
||||
|
||||
let token_hash = hash_v2_code(&request.code);
|
||||
let outcome = state
|
||||
.db
|
||||
.claim_relay_invite(
|
||||
tenant.community(),
|
||||
&token_hash,
|
||||
&claimer_hex,
|
||||
state
|
||||
.config
|
||||
.join_policy
|
||||
.as_ref()
|
||||
.map(|policy| policy.version.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("v2 invite claim: {e}")))?;
|
||||
|
||||
return match outcome {
|
||||
buzz_db::relay_invite::ClaimOutcome::Joined { .. } => {
|
||||
tracing::info!(
|
||||
community = %tenant.community(),
|
||||
member = %claimer_hex,
|
||||
"relay member added via v2 invite"
|
||||
);
|
||||
// NIP-43 side effects only on Joined, never on other outcomes.
|
||||
if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await {
|
||||
tracing::warn!(
|
||||
"failed to publish NIP-43 member-added delta after v2 claim: {e}"
|
||||
);
|
||||
}
|
||||
if let Err(e) = publish_nip43_membership_list(&tenant, &state).await {
|
||||
tracing::warn!("failed to publish NIP-43 membership list after v2 claim: {e}");
|
||||
}
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "joined",
|
||||
"community_id": tenant.community().to_string(),
|
||||
"host": tenant.host(),
|
||||
"role": "member",
|
||||
})))
|
||||
}
|
||||
buzz_db::relay_invite::ClaimOutcome::AlreadyMember { .. } => {
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "already_member",
|
||||
"community_id": tenant.community().to_string(),
|
||||
"host": tenant.host(),
|
||||
"role": "member",
|
||||
})))
|
||||
}
|
||||
buzz_db::relay_invite::ClaimOutcome::Expired => {
|
||||
Err(api_error(StatusCode::FORBIDDEN, "invite_expired"))
|
||||
}
|
||||
buzz_db::relay_invite::ClaimOutcome::Exhausted => {
|
||||
Err(api_error(StatusCode::FORBIDDEN, "invite_exhausted"))
|
||||
}
|
||||
buzz_db::relay_invite::ClaimOutcome::Invalid => {
|
||||
Err(api_error(StatusCode::FORBIDDEN, "invite_invalid"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- v1 HMAC path (stateless tokens, drain window) ---
|
||||
let payload = invite_token::verify_invite(&key, tenant.community(), &request.code).map_err(
|
||||
|e| match e {
|
||||
// Expired is post-MAC: revealing it helps the UX without helping a forger.
|
||||
@@ -317,7 +454,6 @@ pub async fn claim_invite(
|
||||
},
|
||||
)?;
|
||||
|
||||
let claimer_hex = pubkey.to_hex();
|
||||
if let Some(policy) = &state.config.join_policy {
|
||||
let receipt = request
|
||||
.policy_receipt
|
||||
@@ -395,7 +531,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{claim_key_rate_limited, CLAIM_RATE_LIMIT};
|
||||
use super::{claim_key_rate_limited, CLAIM_RATE_LIMIT, MAX_INVITE_USES, MIN_INVITE_TTL_SECS};
|
||||
use axum::{
|
||||
body::{to_bytes, Body},
|
||||
http::{header, Request, StatusCode},
|
||||
@@ -409,7 +545,7 @@ mod tests {
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::invite_token::{derive_invite_key, InvitePayload};
|
||||
use crate::invite_token::{derive_invite_key, InvitePayload, MAX_INVITE_TTL_SECS};
|
||||
|
||||
use crate::router::build_router;
|
||||
use crate::state::AppState;
|
||||
@@ -592,6 +728,355 @@ mod tests {
|
||||
serde_json::from_slice(&bytes).expect("response JSON")
|
||||
}
|
||||
|
||||
async fn mint_code(state: Arc<AppState>, host: &str, owner: &Keys, request: Value) -> String {
|
||||
let response = post_json(state, host, "/api/invites", owner, request.to_string()).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
read_json(response)
|
||||
.await
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.expect("minted code")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn event_count(state: &AppState, community: buzz_core::CommunityId, kind: i32) -> i64 {
|
||||
state
|
||||
.db
|
||||
.count_events(&buzz_db::EventQuery {
|
||||
kinds: Some(vec![kind]),
|
||||
global_only: true,
|
||||
..buzz_db::EventQuery::for_community(community)
|
||||
})
|
||||
.await
|
||||
.expect("count side-effect events")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_request_deserialization_is_strict() {
|
||||
for valid in [
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({ "max_uses": null }),
|
||||
serde_json::json!({ "max_uses": 1 }),
|
||||
serde_json::json!({ "max_uses": MAX_INVITE_USES }),
|
||||
] {
|
||||
serde_json::from_value::<super::MintInviteRequest>(valid).expect("valid request");
|
||||
}
|
||||
|
||||
for invalid in [
|
||||
serde_json::json!({ "max_uses": 1.5 }),
|
||||
serde_json::json!({ "max_uses": "10" }),
|
||||
serde_json::json!({ "ttl_secs": -1 }),
|
||||
serde_json::json!({ "ttl_secs": 1.5 }),
|
||||
serde_json::json!({ "ttl_secs": "3600" }),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<super::MintInviteRequest>(invalid.clone()).is_err(),
|
||||
"accepted wrong JSON type: {invalid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_request_validation_enforces_bounds_without_a_database() {
|
||||
use super::validate_mint_request;
|
||||
|
||||
for (request, expected) in [
|
||||
(
|
||||
super::MintInviteRequest::default(),
|
||||
(crate::invite_token::DEFAULT_INVITE_TTL_SECS, None),
|
||||
),
|
||||
(
|
||||
super::MintInviteRequest {
|
||||
ttl_secs: Some(MIN_INVITE_TTL_SECS),
|
||||
max_uses: Some(1),
|
||||
},
|
||||
(MIN_INVITE_TTL_SECS, Some(1)),
|
||||
),
|
||||
(
|
||||
super::MintInviteRequest {
|
||||
ttl_secs: Some(MAX_INVITE_TTL_SECS),
|
||||
max_uses: Some(MAX_INVITE_USES),
|
||||
},
|
||||
(MAX_INVITE_TTL_SECS, Some(MAX_INVITE_USES)),
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_mint_request(&request).expect("valid request"),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
for request in [
|
||||
super::MintInviteRequest {
|
||||
ttl_secs: None,
|
||||
max_uses: Some(0),
|
||||
},
|
||||
super::MintInviteRequest {
|
||||
ttl_secs: None,
|
||||
max_uses: Some(-1),
|
||||
},
|
||||
super::MintInviteRequest {
|
||||
ttl_secs: None,
|
||||
max_uses: Some(MAX_INVITE_USES + 1),
|
||||
},
|
||||
super::MintInviteRequest {
|
||||
ttl_secs: Some(MIN_INVITE_TTL_SECS - 1),
|
||||
max_uses: None,
|
||||
},
|
||||
super::MintInviteRequest {
|
||||
ttl_secs: Some(MAX_INVITE_TTL_SECS + 1),
|
||||
max_uses: None,
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_mint_request(&request)
|
||||
.expect_err("invalid request")
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn mint_validates_max_uses_and_ttl_bounds() {
|
||||
let host = format!("invites-validation-{}.example", Uuid::new_v4().simple());
|
||||
let owner = Keys::generate();
|
||||
let state = invite_test_state(&host)
|
||||
.await
|
||||
.expect("requires reachable Postgres and relay test state");
|
||||
let community = state
|
||||
.db
|
||||
.lookup_community_by_host(&host)
|
||||
.await
|
||||
.expect("lookup")
|
||||
.expect("community exists");
|
||||
state
|
||||
.db
|
||||
.add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None)
|
||||
.await
|
||||
.expect("seed owner");
|
||||
|
||||
for body in [
|
||||
serde_json::json!({ "max_uses": 0 }),
|
||||
serde_json::json!({ "max_uses": -1 }),
|
||||
serde_json::json!({ "max_uses": MAX_INVITE_USES + 1 }),
|
||||
serde_json::json!({ "ttl_secs": MIN_INVITE_TTL_SECS - 1 }),
|
||||
serde_json::json!({ "ttl_secs": MAX_INVITE_TTL_SECS + 1 }),
|
||||
] {
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites",
|
||||
&owner,
|
||||
body.to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{body}");
|
||||
}
|
||||
|
||||
for body in [
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({ "max_uses": null }),
|
||||
serde_json::json!({ "max_uses": 1 }),
|
||||
serde_json::json!({ "max_uses": MAX_INVITE_USES }),
|
||||
serde_json::json!({ "ttl_secs": MIN_INVITE_TTL_SECS }),
|
||||
serde_json::json!({ "ttl_secs": MAX_INVITE_TTL_SECS }),
|
||||
] {
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites",
|
||||
&owner,
|
||||
body.to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK, "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn malformed_and_unknown_v2_codes_are_forbidden_without_v1_fallback() {
|
||||
let host = format!("invites-v2-invalid-{}.example", Uuid::new_v4().simple());
|
||||
let joiner = Keys::generate();
|
||||
let state = invite_test_state(&host)
|
||||
.await
|
||||
.expect("requires reachable Postgres and relay test state");
|
||||
let unknown = format!("v2.{}", URL_SAFE_NO_PAD.encode([9_u8; 32]));
|
||||
|
||||
for code in [
|
||||
"v2.".to_string(),
|
||||
"v2.not-base64!".to_string(),
|
||||
format!("v2.{}", URL_SAFE_NO_PAD.encode([9_u8; 31])),
|
||||
unknown,
|
||||
] {
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/claim",
|
||||
&joiner,
|
||||
serde_json::json!({ "code": code }).to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN, "{code}");
|
||||
assert_eq!(
|
||||
read_json(response)
|
||||
.await
|
||||
.get("error")
|
||||
.and_then(Value::as_str),
|
||||
Some("invite_invalid")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn bounded_v2_claims_publish_side_effects_only_for_joined() {
|
||||
let host = format!(
|
||||
"invites-v2-side-effects-{}.example",
|
||||
Uuid::new_v4().simple()
|
||||
);
|
||||
let owner = Keys::generate();
|
||||
let first = Keys::generate();
|
||||
let second = Keys::generate();
|
||||
let state = invite_test_state(&host)
|
||||
.await
|
||||
.expect("requires reachable Postgres and relay test state");
|
||||
let community = state
|
||||
.db
|
||||
.lookup_community_by_host(&host)
|
||||
.await
|
||||
.expect("lookup")
|
||||
.expect("community exists");
|
||||
state
|
||||
.db
|
||||
.add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None)
|
||||
.await
|
||||
.expect("seed owner");
|
||||
let before_delta_count = event_count(
|
||||
&state,
|
||||
community.id,
|
||||
buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32,
|
||||
)
|
||||
.await;
|
||||
let before_list_count = event_count(
|
||||
&state,
|
||||
community.id,
|
||||
buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32,
|
||||
)
|
||||
.await;
|
||||
let code = mint_code(
|
||||
state.clone(),
|
||||
&host,
|
||||
&owner,
|
||||
serde_json::json!({ "max_uses": 1 }),
|
||||
)
|
||||
.await;
|
||||
let claim_body = serde_json::json!({ "code": code }).to_string();
|
||||
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/claim",
|
||||
&first,
|
||||
claim_body.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
read_json(response)
|
||||
.await
|
||||
.get("status")
|
||||
.and_then(Value::as_str),
|
||||
Some("joined")
|
||||
);
|
||||
let delta_count = event_count(
|
||||
&state,
|
||||
community.id,
|
||||
buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32,
|
||||
)
|
||||
.await;
|
||||
let list_count = event_count(
|
||||
&state,
|
||||
community.id,
|
||||
buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(delta_count, before_delta_count + 1);
|
||||
assert_eq!(list_count, before_list_count + 1);
|
||||
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/claim",
|
||||
&first,
|
||||
claim_body.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
read_json(response)
|
||||
.await
|
||||
.get("status")
|
||||
.and_then(Value::as_str),
|
||||
Some("already_member")
|
||||
);
|
||||
assert_eq!(
|
||||
event_count(
|
||||
&state,
|
||||
community.id,
|
||||
buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32,
|
||||
)
|
||||
.await,
|
||||
delta_count
|
||||
);
|
||||
assert_eq!(
|
||||
event_count(
|
||||
&state,
|
||||
community.id,
|
||||
buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32,
|
||||
)
|
||||
.await,
|
||||
list_count
|
||||
);
|
||||
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/claim",
|
||||
&second,
|
||||
claim_body,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
read_json(response)
|
||||
.await
|
||||
.get("error")
|
||||
.and_then(Value::as_str),
|
||||
Some("invite_exhausted")
|
||||
);
|
||||
assert_eq!(
|
||||
event_count(
|
||||
&state,
|
||||
community.id,
|
||||
buzz_core::kind::KIND_NIP43_MEMBER_ADDED as i32,
|
||||
)
|
||||
.await,
|
||||
delta_count
|
||||
);
|
||||
assert_eq!(
|
||||
event_count(
|
||||
&state,
|
||||
community.id,
|
||||
buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32,
|
||||
)
|
||||
.await,
|
||||
list_count
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn owner_mints_and_new_pubkey_claims() {
|
||||
|
||||
@@ -49,10 +49,10 @@ use buzz_core::tenant::CommunityId;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Default invite lifetime: 72 hours.
|
||||
pub const DEFAULT_INVITE_TTL_SECS: u64 = 72 * 60 * 60;
|
||||
pub use buzz_core::invite::DEFAULT_INVITE_TTL_SECS;
|
||||
|
||||
/// Maximum invite lifetime a mint request may ask for: 30 days.
|
||||
pub const MAX_INVITE_TTL_SECS: u64 = 30 * 24 * 60 * 60;
|
||||
pub use buzz_core::invite::MAX_INVITE_TTL_SECS;
|
||||
|
||||
/// Maximum accepted code length (defense against absurd inputs before any
|
||||
/// parsing work happens). A real code is ~200 bytes.
|
||||
@@ -121,10 +121,11 @@ fn sign_payload(key: &[u8; 32], payload_bytes: &[u8]) -> Vec<u8> {
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Mint an invite code for `community`, expiring `ttl_secs` from now.
|
||||
/// Mint a legacy v1 invite code for compatibility tests.
|
||||
///
|
||||
/// The role is fixed to `"member"` — elevated roles are granted post-join via
|
||||
/// the existing kind:9032 change-role command, never via a bearer link.
|
||||
/// Production minting uses database-backed v2 codes. Remove this helper with
|
||||
/// v1 claim verification after the compatibility drain window.
|
||||
#[cfg(test)]
|
||||
pub fn mint_invite(key: &[u8; 32], community: CommunityId, ttl_secs: u64) -> (String, u64) {
|
||||
let ttl = ttl_secs.clamp(60, MAX_INVITE_TTL_SECS);
|
||||
let expires_at = now_unix() + ttl;
|
||||
|
||||
@@ -1426,6 +1426,20 @@ async fn run_usage_metrics_tick(
|
||||
*leader = None;
|
||||
return Err(error);
|
||||
}
|
||||
let invite_retention_cutoff = chrono::Utc::now() - chrono::Duration::days(30);
|
||||
match state
|
||||
.db
|
||||
.reap_expired_relay_invites(invite_retention_cutoff)
|
||||
.await
|
||||
{
|
||||
Ok(deleted) if deleted > 0 => {
|
||||
info!(deleted, "reaped expired relay invites");
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "failed to reap expired relay invites");
|
||||
}
|
||||
}
|
||||
run_storage_sweep_tick(state, emission_scope, &host_map).await;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user