diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acdf5237bd..dd18179ee4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -340,6 +340,7 @@ jobs: cargo build --profile ci -p buzz-relay -p git-credential-nostr cargo nextest archive \ --cargo-profile ci \ + -p buzz-db \ -p buzz-relay \ -p buzz-test-client \ --lib \ @@ -671,11 +672,11 @@ jobs: done cat /tmp/buzz-relay.log exit 1 - - name: Invite claim security tests + - name: Invite security tests run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(claim_)' \ + -E '(package(buzz-db) and test(/relay_invite::tests/)) or (package(buzz-relay) and test(/api::invites::tests/))' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz diff --git a/Cargo.lock b/Cargo.lock index 1a63b4f425..3b60dc4579 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -925,6 +925,7 @@ dependencies = [ name = "buzz-core" version = "0.1.0" dependencies = [ + "base64", "chrono", "hex", "hmac 0.13.0", @@ -949,6 +950,7 @@ dependencies = [ "chrono", "hex", "nostr", + "rand 0.10.1", "serde", "serde_json", "sha2 0.11.0", diff --git a/Cargo.toml b/Cargo.toml index 3499285f91..3ac7ee4cce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,7 @@ reqwest = { version = "0.13", features = ["json", "rustls"], default-features = sha2 = "0.11" hex = "0.4" hmac = "0.13" +base64 = "0.22" # Randomness rand = "0.10" diff --git a/crates/buzz-core/Cargo.toml b/crates/buzz-core/Cargo.toml index 489df360dd..c55225adf6 100644 --- a/crates/buzz-core/Cargo.toml +++ b/crates/buzz-core/Cargo.toml @@ -11,6 +11,7 @@ description = "Core types, event verification, and filter matching for Buzz" test-utils = [] [dependencies] +base64 = { workspace = true } nostr = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/buzz-core/src/invite.rs b/crates/buzz-core/src/invite.rs new file mode 100644 index 0000000000..8f557c95aa --- /dev/null +++ b/crates/buzz-core/src/invite.rs @@ -0,0 +1,109 @@ +//! Shared pure contracts for relay invite links. +//! +//! The relay transport and database persistence layers both depend on +//! `buzz-core`. Protocol constants and deterministic v2 code operations live +//! here so neither layer becomes the accidental source of truth. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use sha2::{Digest, Sha256}; + +/// Minimum invite lifetime accepted by the mint API: 60 seconds. +pub const MIN_INVITE_TTL_SECS: u64 = 60; + +/// Default invite lifetime when the mint request omits `ttl_secs`: 72 hours. +pub const DEFAULT_INVITE_TTL_SECS: u64 = 72 * 60 * 60; + +/// Maximum invite lifetime accepted by the mint API: 30 days. +pub const MAX_INVITE_TTL_SECS: u64 = 30 * 24 * 60 * 60; + +/// Maximum supported `max_uses` value. Matches the database constraint. +pub const MAX_INVITE_USES: i32 = 10_000; + +/// Prefix that distinguishes v2 opaque database-backed codes from v1 tokens. +pub const V2_PREFIX: &str = "v2."; + +/// Number of random bytes encoded in a v2 invite code. +pub const V2_SECRET_LEN: usize = 32; + +/// A malformed v2 opaque invite code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidV2InviteCode; + +/// Build a canonical v2 code from its random secret. +pub fn encode_v2_code(secret: &[u8; V2_SECRET_LEN]) -> String { + format!("{V2_PREFIX}{}", URL_SAFE_NO_PAD.encode(secret)) +} + +/// Validate the canonical v2 opaque-code shape without consulting storage. +/// +/// A valid code is exactly `v2.` followed by the unpadded base64url encoding +/// of a 32-byte secret. The decode/re-encode comparison rejects aliases such +/// as padded or otherwise non-canonical encodings. +pub fn validate_v2_code(code: &str) -> Result<(), InvalidV2InviteCode> { + let encoded = code.strip_prefix(V2_PREFIX).ok_or(InvalidV2InviteCode)?; + let secret = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| InvalidV2InviteCode)?; + if secret.len() != V2_SECRET_LEN || URL_SAFE_NO_PAD.encode(&secret) != encoded { + return Err(InvalidV2InviteCode); + } + Ok(()) +} + +/// Hash the complete v2 code to the digest persisted by the database. +pub fn hash_v2_code(code: &str) -> [u8; 32] { + Sha256::digest(code.as_bytes()).into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v2_code_round_trip_is_canonical() { + let secret = [7_u8; V2_SECRET_LEN]; + let code = encode_v2_code(&secret); + + assert_eq!(validate_v2_code(&code), Ok(())); + assert_eq!( + code, + format!("{V2_PREFIX}{}", URL_SAFE_NO_PAD.encode(secret)) + ); + } + + #[test] + fn v2_validation_rejects_malformed_and_noncanonical_codes() { + let valid = encode_v2_code(&[7_u8; V2_SECRET_LEN]); + let short = format!( + "{V2_PREFIX}{}", + URL_SAFE_NO_PAD.encode([7_u8; V2_SECRET_LEN - 1]) + ); + + for malformed in [ + "v2.", + "v2.not-base64!", + short.as_str(), + &format!("{valid}="), + ] { + assert_eq!( + validate_v2_code(malformed), + Err(InvalidV2InviteCode), + "accepted malformed v2 code: {malformed}" + ); + } + } + + #[test] + fn v2_hash_covers_the_complete_code() { + let code = encode_v2_code(&[7_u8; V2_SECRET_LEN]); + + let expected: [u8; 32] = Sha256::digest(code.as_bytes()).into(); + + assert_eq!(hash_v2_code(&code), expected); + assert_ne!( + hash_v2_code(&code), + hash_v2_code(code.trim_start_matches(V2_PREFIX)) + ); + } +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index dd57b46937..66b7708f1d 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -20,6 +20,8 @@ pub mod event; pub mod filter; /// Git permission types — ref patterns, protection rules, policy evaluation. pub mod git_perms; +/// Shared invite-link contract constants. +pub mod invite; /// Buzz kind number registry — custom event type constants. pub mod kind; /// Network utilities — SSRF-safe IP classification. diff --git a/crates/buzz-db/Cargo.toml b/crates/buzz-db/Cargo.toml index 23ea3c9d8f..01f1e172b6 100644 --- a/crates/buzz-db/Cargo.toml +++ b/crates/buzz-db/Cargo.toml @@ -20,6 +20,7 @@ sha2 = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } nostr = { workspace = true } +rand = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index a4ce50a8e8..2a3ba9a63e 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -39,6 +39,8 @@ pub mod product_feedback; pub mod push; /// Reaction persistence. pub mod reaction; +/// Use-limited relay invite persistence (v2 opaque tokens). +pub mod relay_invite; /// Relay-level membership persistence (NIP-43). pub mod relay_members; /// Replica freshness fence for keyset-cursor read routing. @@ -3045,6 +3047,51 @@ impl Db { relay_members::backfill_from_allowlist(&self.pool, community).await } + /// Mints a v2 use-limited relay invite. 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. + pub async fn mint_relay_invite( + &self, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, + ) -> Result { + relay_invite::mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await + } + + /// Delete one bounded batch of invites expired before `cutoff`. + pub async fn reap_expired_relay_invites( + &self, + cutoff: chrono::DateTime, + ) -> Result { + relay_invite::reap_expired_relay_invites(&self.pool, cutoff).await + } + + /// Atomically claims a v2 relay invite. The full redemption (membership + /// insert, policy evidence, use_count increment) runs in one PostgreSQL + /// transaction with `FOR UPDATE` on the invite row. + /// + /// `token_hash` is the SHA-256 of the presented v2 code (32 bytes). + pub async fn claim_relay_invite( + &self, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, + ) -> Result { + relay_invite::claim_relay_invite( + &self.pool, + community, + token_hash, + claimer_pubkey, + policy_version, + ) + .await + } + /// Sidecar an accepted product-feedback event, idempotent by event id. pub async fn insert_product_feedback( &self, diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1674b0ec4d..1d1b7e05d4 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -560,7 +560,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 24); + assert_eq!(migrations.len(), 25); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -879,6 +879,31 @@ mod tests { .to_lowercase() .contains("for update")); assert!(ttl_shared.contains("NEW.kind <> 9007")); + + // Use-limited invite links: durable relay_invites table stores only + // the SHA-256 of an opaque v2 code, scoped by community_id. Never + // listed in _operator_global_tables — it is community-scoped. + assert_eq!(migrations[24].version, 25); + let relay_invites = migrations[24].sql.as_str(); + assert!(relay_invites.contains("CREATE TABLE relay_invites")); + assert!(relay_invites + .contains("token_hash BYTEA NOT NULL CHECK (length(token_hash) = 32)")); + assert!(relay_invites.contains("PRIMARY KEY (community_id, id)")); + assert!(relay_invites.contains("UNIQUE (community_id, token_hash)")); + assert!( + relay_invites.contains("max_uses INTEGER CHECK (max_uses BETWEEN 1 AND 10000)") + ); + assert!(relay_invites.contains("CHECK (max_uses IS NULL OR use_count <= max_uses)")); + assert!(relay_invites.contains("role = 'member'")); + assert!(relay_invites + .contains("CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at)")); + assert!(!relay_invites.contains("_operator_global_tables")); + + let desired_schema = include_str!("../../../schema/schema.sql"); + assert!( + desired_schema.contains("CREATE TABLE join_policy_acceptances"), + "desired-state schema must include join-policy evidence used by invite claims", + ); } #[test] @@ -1121,7 +1146,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(24)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(25)); } #[tokio::test] diff --git a/crates/buzz-db/src/relay_invite.rs b/crates/buzz-db/src/relay_invite.rs new file mode 100644 index 0000000000..82b71b07bb --- /dev/null +++ b/crates/buzz-db/src/relay_invite.rs @@ -0,0 +1,671 @@ +//! Use-limited relay invite persistence (v2 opaque tokens). +//! +//! Unlike the stateless v1 HMAC invite tokens in `buzz-relay::invite_token`, +//! v2 invites are backed by durable rows in `relay_invites`. The table stores +//! only `SHA-256(code)` — never the reusable bearer secret — so a leaked +//! database does not immediately yield valid invite codes. +//! +//! Every lookup binds both `(community_id, token_hash)` to prevent cross-tenant +//! authorization seams: a code minted on tenant A presented to tenant B returns +//! `Invalid`, not a membership. +//! +//! ## Atomic redemption +//! +//! `claim_relay_invite` executes the full redemption in one PostgreSQL +//! transaction: `SELECT FOR UPDATE` on the invite row, membership insert, +//! join-policy evidence insert, and `use_count` increment all commit together. +//! `FOR UPDATE` serializes concurrent claims for one invite across relay +//! processes — exactly one claimant can win the final slot. + +use buzz_core::invite::{ + encode_v2_code, hash_v2_code, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS, + V2_SECRET_LEN, +}; +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Row as _}; + +use crate::error::Result; +use crate::CommunityId; + +/// Outcome of a v2 invite claim. Expected invalid/expired/exhausted states are +/// typed variants so the relay layer can map them to distinct HTTP responses +/// without inspecting database errors. +#[derive(Debug, PartialEq)] +pub enum ClaimOutcome { + /// A new relay member was inserted. `use_count` is the post-increment count; + /// `uses_remaining` is `None` for unlimited invites. + Joined { + /// Post-claim use count. + use_count: i32, + /// Remaining slots, or `None` when the invite is unlimited. + uses_remaining: Option, + }, + /// The claimer was already a member. `use_count` was NOT incremented. + AlreadyMember { + /// Current use count (unchanged by this claim). + use_count: i32, + /// Remaining slots, or `None` when the invite is unlimited. + uses_remaining: Option, + }, + /// The invite's `expires_at` has passed. + Expired, + /// The invite's use budget is fully consumed. + Exhausted, + /// No invite row matches `(community_id, token_hash)`. + Invalid, +} + +/// A freshly minted v2 invite, including the plaintext code and metadata. +#[derive(Debug)] +pub struct MintedInvite { + /// The full v2 code string (`v2.`). Returned to the caller + /// exactly once; the database stores only the SHA-256 hash. + pub code: String, + /// When the invite expires (UTC). + pub expires_at: DateTime, + /// `None` means unlimited; `Some(n)` means at most `n` uses. + pub max_uses: Option, + /// Remaining uses at mint time (equals `max_uses` when bounded, `None` + /// when unlimited). + pub uses_remaining: Option, + /// The invite's database-generated UUID. + pub invite_id: uuid::Uuid, +} + +fn validate_mint_inputs(ttl_secs: u64, max_uses: Option) -> Result<()> { + if !(MIN_INVITE_TTL_SECS..=MAX_INVITE_TTL_SECS).contains(&ttl_secs) { + return Err(crate::error::DbError::InvalidData(format!( + "ttl_secs must be between {MIN_INVITE_TTL_SECS} and {MAX_INVITE_TTL_SECS}" + ))); + } + + if let Some(max_uses) = max_uses { + if !(1..=MAX_INVITE_USES).contains(&max_uses) { + return Err(crate::error::DbError::InvalidData(format!( + "max_uses must be between 1 and {MAX_INVITE_USES}" + ))); + } + } + + Ok(()) +} + +/// Mint a v2 invite: generate a 32-byte random secret, hash it, persist the +/// row, and return the plaintext code plus metadata. +/// +/// `ttl_secs` must be in the shared invite lifetime range. +/// `max_uses` must be `None` (unlimited) or `Some(1..=10000)`. +pub async fn mint_relay_invite( + pool: &PgPool, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, +) -> Result { + validate_mint_inputs(ttl_secs, max_uses)?; + + // Generate 32 random bytes and encode as base64url — this is the secret. + let secret: [u8; V2_SECRET_LEN] = rand::random(); + let code = encode_v2_code(&secret); + let token_hash = hash_v2_code(&code); + let now = Utc::now(); + let expires_at = now + chrono::Duration::seconds(ttl_secs as i64); + + let row = sqlx::query( + "INSERT INTO relay_invites (community_id, token_hash, max_uses, expires_at, created_by) \ + VALUES ($1, $2, $3, $4, $5) \ + RETURNING id", + ) + .bind(community.as_uuid()) + .bind(token_hash.as_slice()) + .bind(max_uses) + .bind(expires_at) + .bind(created_by) + .fetch_one(pool) + .await?; + + let invite_id: uuid::Uuid = row.try_get("id")?; + + Ok(MintedInvite { + code, + expires_at, + max_uses, + uses_remaining: max_uses, + invite_id, + }) +} + +fn log_claim_outcome( + community: CommunityId, + invite_id: Option, + outcome: &'static str, + max_uses: Option, + use_count: Option, +) { + tracing::info!( + community = %community, + invite_id = ?invite_id, + outcome, + max_uses = ?max_uses, + use_count = ?use_count, + "relay invite claim completed" + ); +} + +/// Maximum rows deleted by one retention sweep so cleanup cannot monopolize +/// the invite table on a busy deployment. +const RETENTION_SWEEP_BATCH_SIZE: i64 = 1_000; + +/// Delete one bounded batch of invite rows expired before `cutoff`. +/// +/// The relay calls this from its leader-only periodic tick. Ordering by the +/// expiry index makes old rows drain first without turning cleanup into an +/// unbounded transaction. +pub async fn reap_expired_relay_invites(pool: &PgPool, cutoff: DateTime) -> Result { + let result = sqlx::query( + "DELETE FROM relay_invites \ + WHERE (community_id, id) IN (\ + SELECT community_id, id FROM relay_invites \ + WHERE expires_at < $1 \ + ORDER BY expires_at \ + LIMIT $2\ + )", + ) + .bind(cutoff) + .bind(RETENTION_SWEEP_BATCH_SIZE) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +/// Atomically claim a v2 relay invite. +/// +/// Executes the full redemption in one PostgreSQL transaction: +/// 1. Hash the presented code. +/// 2. `SELECT ... FOR UPDATE` on the invite row scoped by `(community, token_hash)`. +/// 3. If no row → `Invalid`. +/// 4. If `expires_at <= now()` → `Expired`. +/// 5. Check existing membership. +/// 6. If already a member → insert policy evidence (if configured), commit, +/// return `AlreadyMember` (no increment). +/// 7. If `max_uses` is set and `use_count >= max_uses` → `Exhausted`. +/// 8. Insert relay member with role `member`, `added_by = 'invite'`. +/// 9. Insert join-policy acceptance evidence (if configured). +/// 10. Increment `use_count`. +/// 11. Commit. +/// +/// `FOR UPDATE` serializes concurrent claims so exactly one claimant wins the +/// final slot. Membership insertion, policy evidence, and consumption share +/// one commit — a failure in any rolls back all. +pub async fn claim_relay_invite( + pool: &PgPool, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, +) -> Result { + let mut tx = pool.begin().await?; + + // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. + let row = sqlx::query( + "SELECT id, max_uses, use_count, expires_at \ + FROM relay_invites \ + WHERE community_id = $1 AND token_hash = $2 \ + FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(token_hash) + .fetch_optional(&mut *tx) + .await?; + + // 3. No matching invite. + let Some(invite) = row else { + tx.rollback().await?; + log_claim_outcome(community, None, "invalid", None, None); + return Ok(ClaimOutcome::Invalid); + }; + + let invite_id: uuid::Uuid = invite.try_get("id")?; + let max_uses: Option = invite.try_get("max_uses")?; + let use_count: i32 = invite.try_get("use_count")?; + let expires_at: DateTime = invite.try_get("expires_at")?; + + // Expiry is checked before membership deliberately. An expired bearer must + // not authorize fresh policy-acceptance evidence, even for an existing + // member; exhausted-but-live invites remain valid for idempotent retries. + if expires_at <= Utc::now() { + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "expired", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::Expired); + } + + let uses_remaining = || max_uses.map(|mu| mu - use_count); + + // 5. Check existing membership. + let existing = + sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(claimer_pubkey) + .fetch_optional(&mut *tx) + .await?; + + if existing.is_some() { + // 6. Already a member — insert policy evidence but do NOT increment. + if let Some(version) = policy_version { + sqlx::query( + "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(claimer_pubkey) + .bind(version) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + log_claim_outcome( + community, + Some(invite_id), + "already_member", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::AlreadyMember { + use_count, + uses_remaining: uses_remaining(), + }); + } + + // 7. Capacity check. + if let Some(mu) = max_uses { + if use_count >= mu { + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "exhausted", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::Exhausted); + } + } + + // 8. Insert relay member. The conflict branch covers a claimant admitted + // concurrently through a different invite: only the transaction that + // actually inserted membership may consume this invite. + let inserted = sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, 'member', 'invite') \ + ON CONFLICT (community_id, pubkey) DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(claimer_pubkey) + .execute(&mut *tx) + .await? + .rows_affected() + > 0; + + // 9. Insert join-policy acceptance evidence. This is required for both a + // new member and a claimant whose concurrent membership insert won first. + if let Some(version) = policy_version { + sqlx::query( + "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(claimer_pubkey) + .bind(version) + .execute(&mut *tx) + .await?; + } + + if !inserted { + tx.commit().await?; + log_claim_outcome( + community, + Some(invite_id), + "already_member", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::AlreadyMember { + use_count, + uses_remaining: uses_remaining(), + }); + } + + // 10. Increment use_count (for every new member, even unlimited). + let new_use_count = use_count + 1; + sqlx::query("UPDATE relay_invites SET use_count = $1 WHERE community_id = $2 AND id = $3") + .bind(new_use_count) + .bind(community.as_uuid()) + .bind(invite_id) + .execute(&mut *tx) + .await?; + + // 11. Commit. + tx.commit().await?; + + let new_uses_remaining = max_uses.map(|mu| mu - new_use_count); + + log_claim_outcome( + community, + Some(invite_id), + "joined", + max_uses, + Some(new_use_count), + ); + + Ok(ClaimOutcome::Joined { + use_count: new_use_count, + uses_remaining: new_uses_remaining, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::relay_members::is_relay_member; + use sqlx::PgPool; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("relay-invite-test-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + async fn delete_test_community(pool: &PgPool, community: CommunityId) { + let mut tx = pool.begin().await.expect("begin test cleanup"); + sqlx::query("DELETE FROM relay_invites WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test invites"); + sqlx::query("DELETE FROM relay_members WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test members"); + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test community"); + tx.commit().await.expect("commit test cleanup"); + } + + fn test_pubkey() -> String { + format!("{:064x}", Uuid::new_v4().as_u128()) + } + + async fn use_count(pool: &PgPool, community: CommunityId, invite_id: Uuid) -> i32 { + sqlx::query_scalar( + "SELECT use_count FROM relay_invites WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(invite_id) + .fetch_one(pool) + .await + .expect("read invite use_count") + } + + #[test] + fn mint_validation_rejects_invalid_bounds_before_database_access() { + for (ttl, max_uses) in [ + (MIN_INVITE_TTL_SECS - 1, None), + (MAX_INVITE_TTL_SECS + 1, None), + (3600, Some(0)), + (3600, Some(-1)), + (3600, Some(MAX_INVITE_USES + 1)), + ] { + let error = validate_mint_inputs(ttl, max_uses).expect_err("invalid mint contract"); + assert!(matches!(error, crate::DbError::InvalidData(_)), "{error:?}"); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bounded_claim_exhausts_and_existing_member_retry_does_not_consume() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let first = test_pubkey(); + let second = test_pubkey(); + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint bounded invite"); + let hash = hash_v2_code(&invite.code); + + assert_eq!( + claim_relay_invite(&pool, community, &hash, &first, None) + .await + .expect("first claim"), + ClaimOutcome::Joined { + use_count: 1, + uses_remaining: Some(0), + } + ); + assert_eq!( + claim_relay_invite(&pool, community, &hash, &first, None) + .await + .expect("idempotent retry"), + ClaimOutcome::AlreadyMember { + use_count: 1, + uses_remaining: Some(0), + } + ); + assert_eq!( + claim_relay_invite(&pool, community, &hash, &second, None) + .await + .expect("exhausted claim"), + ClaimOutcome::Exhausted + ); + assert_eq!(use_count(&pool, community, invite.invite_id).await, 1); + assert!(is_relay_member(&pool, community, &first) + .await + .expect("first membership")); + assert!(!is_relay_member(&pool, community, &second) + .await + .expect("second membership")); + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_claims_serialize_the_final_slot() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let first = test_pubkey(); + let second = test_pubkey(); + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint bounded invite"); + let hash = hash_v2_code(&invite.code); + + let (first_outcome, second_outcome) = tokio::join!( + claim_relay_invite(&pool, community, &hash, &first, None), + claim_relay_invite(&pool, community, &hash, &second, None), + ); + let outcomes = [ + first_outcome.expect("first concurrent claim"), + second_outcome.expect("second concurrent claim"), + ]; + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, ClaimOutcome::Joined { .. })) + .count(), + 1 + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, ClaimOutcome::Exhausted)) + .count(), + 1 + ); + assert_eq!(use_count(&pool, community, invite.invite_id).await, 1); + let admitted = is_relay_member(&pool, community, &first) + .await + .expect("first membership") as u8 + + is_relay_member(&pool, community, &second) + .await + .expect("second membership") as u8; + assert_eq!(admitted, 1); + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn expiry_and_tenant_scope_return_typed_failures() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + let invite = mint_relay_invite(&pool, community_a, "owner", 3600, Some(2)) + .await + .expect("mint invite"); + let hash = hash_v2_code(&invite.code); + + assert_eq!( + claim_relay_invite(&pool, community_b, &hash, &test_pubkey(), None) + .await + .expect("cross-tenant claim"), + ClaimOutcome::Invalid + ); + + sqlx::query( + "UPDATE relay_invites SET expires_at = now() - interval '1 second' \ + WHERE community_id = $1 AND id = $2", + ) + .bind(community_a.as_uuid()) + .bind(invite.invite_id) + .execute(&pool) + .await + .expect("expire invite"); + assert_eq!( + claim_relay_invite(&pool, community_a, &hash, &test_pubkey(), None) + .await + .expect("expired claim"), + ClaimOutcome::Expired + ); + assert_eq!(use_count(&pool, community_a, invite.invite_id).await, 0); + delete_test_community(&pool, community_a).await; + delete_test_community(&pool, community_b).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn retention_sweep_deletes_only_invites_older_than_cutoff() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let old = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint old invite"); + let recent = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint recent invite"); + let cutoff = Utc::now() - chrono::Duration::days(30); + + sqlx::query("UPDATE relay_invites SET expires_at = $1 WHERE community_id = $2 AND id = $3") + .bind(cutoff - chrono::Duration::seconds(1)) + .bind(community.as_uuid()) + .bind(old.invite_id) + .execute(&pool) + .await + .expect("age old invite"); + + assert_eq!( + reap_expired_relay_invites(&pool, cutoff) + .await + .expect("reap expired invites"), + 1 + ); + let remaining: Vec = + sqlx::query_scalar("SELECT id FROM relay_invites WHERE community_id = $1 ORDER BY id") + .bind(community.as_uuid()) + .fetch_all(&pool) + .await + .expect("read remaining invites"); + assert_eq!(remaining, vec![recent.invite_id]); + + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unlimited_invites_count_each_new_member() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let invite = mint_relay_invite(&pool, community, "owner", 3600, None) + .await + .expect("mint unlimited invite"); + let hash = hash_v2_code(&invite.code); + + for (expected_count, pubkey) in [(1, test_pubkey()), (2, test_pubkey())] { + assert_eq!( + claim_relay_invite(&pool, community, &hash, &pubkey, None) + .await + .expect("unlimited claim"), + ClaimOutcome::Joined { + use_count: expected_count, + uses_remaining: None, + } + ); + } + assert_eq!(use_count(&pool, community, invite.invite_id).await, 2); + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn policy_evidence_failure_rolls_back_membership_and_consumption() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let pubkey = test_pubkey(); + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint bounded invite"); + let hash = hash_v2_code(&invite.code); + + let error = claim_relay_invite(&pool, community, &hash, &pubkey, Some("too-short")) + .await + .expect_err("policy CHECK must reject an invalid version"); + assert!(matches!(error, crate::DbError::Sqlx(_)), "{error:?}"); + assert!(!is_relay_member(&pool, community, &pubkey) + .await + .expect("membership after rollback")); + assert_eq!(use_count(&pool, community, invite.invite_id).await, 0); + + assert!(matches!( + claim_relay_invite(&pool, community, &hash, &pubkey, None) + .await + .expect("claim after rollback"), + ClaimOutcome::Joined { use_count: 1, .. } + )); + delete_test_community(&pool, community).await; + } +} diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6719e85f79..6104171cca 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -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, + /// 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, +} + +fn validate_mint_request( + request: &MintInviteRequest, +) -> Result<(u64, Option), (StatusCode, Json)> { + 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>, 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, 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::(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::(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() { diff --git a/crates/buzz-relay/src/invite_token.rs b/crates/buzz-relay/src/invite_token.rs index 436040c32a..e271f40f0b 100644 --- a/crates/buzz-relay/src/invite_token.rs +++ b/crates/buzz-relay/src/invite_token.rs @@ -49,10 +49,10 @@ use buzz_core::tenant::CommunityId; type HmacSha256 = Hmac; /// 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 { 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; diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 22101219eb..533428a4e8 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -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; } diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index 76c0262ea5..c4e140f723 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -14,8 +14,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { Input } from "@/shared/ui/input"; import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; +import { Switch } from "@/shared/ui/switch"; const TTL_OPTIONS: { label: string; value: number }[] = [ { label: "1 day", value: 24 * 60 * 60 }, @@ -31,8 +33,9 @@ type CopyStatus = "idle" | "copying" | "copied"; /** * Share-with-link footer for the community invite dialog. * - * Each copy action mints a fresh stateless invite code and places its - * shareable landing-page URL on the clipboard. + * Each copy action mints a fresh database-backed invite code and places its + * shareable landing-page URL on the clipboard. Invites may be unlimited or + * capped to a caller-selected number of successful joins. */ export function InviteLinkSection({ onTtlSecsChange, @@ -42,6 +45,14 @@ export function InviteLinkSection({ ttlSecs: number; }) { const [copyStatus, setCopyStatus] = React.useState("idle"); + const [maxUsesEnabled, setMaxUsesEnabled] = React.useState(true); + const [maxUsesInput, setMaxUsesInput] = React.useState("3"); + const parsedMaxUses = Number(maxUsesInput); + const maxUsesValid = + !maxUsesEnabled || + (Number.isInteger(parsedMaxUses) && + parsedMaxUses >= 1 && + parsedMaxUses <= 10000); const ttlLabel = TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days"; const copyLabel = @@ -58,10 +69,13 @@ export function InviteLinkSection({ }, [copyStatus]); async function handleCopy() { - if (copyStatus === "copying") return; + if (copyStatus === "copying" || !maxUsesValid) return; setCopyStatus("copying"); try { - const invite = await mintInvite(ttlSecs); + const invite = await mintInvite({ + ttlSecs, + maxUses: maxUsesEnabled ? parsedMaxUses : null, + }); await writeTextToClipboard(invite.url); setCopyStatus("copied"); toast.success("Invite link copied"); @@ -118,13 +132,45 @@ export function InviteLinkSection({ +
+ + + {maxUsesEnabled ? ( + setMaxUsesInput(event.target.value)} + placeholder="3" + type="number" + value={maxUsesInput} + /> + ) : null} + {maxUsesEnabled && !maxUsesValid ? ( + + Enter a whole number from 1 to 10,000 + + ) : null} +