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:
Kalvin C
2026-07-27 15:19:39 -07:00
committed by GitHub
parent 98a7b13348
commit d500c2d5cf
24 changed files with 1732 additions and 35 deletions

View File

@@ -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() {

View File

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

View File

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