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
co-authored by npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent 98a7b13348
commit d500c2d5cf
24 changed files with 1732 additions and 35 deletions
+3 -2
View File
@@ -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
Generated
+2
View File
@@ -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",
+1
View File
@@ -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"
+1
View File
@@ -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 }
+109
View File
@@ -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))
);
}
}
+2
View File
@@ -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.
+1
View File
@@ -20,6 +20,7 @@ sha2 = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
nostr = { workspace = true }
rand = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
+47
View File
@@ -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<i32>,
) -> Result<relay_invite::MintedInvite> {
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<chrono::Utc>,
) -> Result<u64> {
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::ClaimOutcome> {
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,
+27 -2
View File
@@ -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]
+671
View File
@@ -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<i32>,
},
/// 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<i32>,
},
/// 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.<base64url secret>`). 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<Utc>,
/// `None` means unlimited; `Some(n)` means at most `n` uses.
pub max_uses: Option<i32>,
/// Remaining uses at mint time (equals `max_uses` when bounded, `None`
/// when unlimited).
pub uses_remaining: Option<i32>,
/// The invite's database-generated UUID.
pub invite_id: uuid::Uuid,
}
fn validate_mint_inputs(ttl_secs: u64, max_uses: Option<i32>) -> 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<i32>,
) -> Result<MintedInvite> {
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<uuid::Uuid>,
outcome: &'static str,
max_uses: Option<i32>,
use_count: Option<i32>,
) {
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<Utc>) -> Result<u64> {
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<ClaimOutcome> {
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<i32> = invite.try_get("max_uses")?;
let use_count: i32 = invite.try_get("use_count")?;
let expires_at: DateTime<Utc> = 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<Uuid> =
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;
}
}
+497 -12
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() {
+6 -5
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;
+14
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;
}
@@ -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<CopyStatus>("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({
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-3 flex items-center gap-2 text-xs">
<Switch
checked={maxUsesEnabled}
data-testid="invite-link-max-uses-switch"
id="invite-max-uses"
onCheckedChange={setMaxUsesEnabled}
/>
<label className="text-muted-foreground" htmlFor="invite-max-uses">
Limit uses
</label>
{maxUsesEnabled ? (
<Input
className="h-7 w-20"
data-testid="invite-link-max-uses-input"
inputMode="numeric"
max={10000}
min={1}
onChange={(event) => setMaxUsesInput(event.target.value)}
placeholder="3"
type="number"
value={maxUsesInput}
/>
) : null}
{maxUsesEnabled && !maxUsesValid ? (
<span
className="text-destructive"
data-testid="invite-link-max-uses-error"
>
Enter a whole number from 1 to 10,000
</span>
) : null}
</div>
<Separator className="my-4 bg-input/40" />
<div className="flex justify-end">
<Button
className="shrink-0 border-border shadow-none"
data-copy-status={copyStatus}
data-testid="copy-invite-link"
disabled={copyStatus === "copying"}
disabled={copyStatus === "copying" || !maxUsesValid}
onClick={() => void handleCopy()}
size="sm"
type="button"
@@ -3,6 +3,7 @@ import * as React from "react";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import {
inviteErrorMessage,
isInviteExhaustedError,
isInviteExpiredError,
} from "@/shared/api/inviteHelpers";
import { claimInvite } from "@/shared/api/invites";
@@ -39,7 +40,9 @@ export function useClaimInvite() {
{
error: isInviteExpiredError(error)
? "This invite code has expired — ask for a new one."
: inviteErrorMessage(error),
: isInviteExhaustedError(error)
? "This invite has reached its use limit. Ask for a new invite."
: inviteErrorMessage(error),
},
transaction.id,
),
@@ -3,6 +3,7 @@ import test from "node:test";
import {
inviteErrorMessage,
isInviteExhaustedError,
isInviteExpiredError,
relayHttpFromWs,
} from "./inviteHelpers.ts";
@@ -34,3 +35,9 @@ test("invite expiry sentinel is recognized without hiding other errors", () => {
"network unavailable",
);
});
test("invite exhaustion sentinel is recognized distinctly from expiry", () => {
assert.equal(isInviteExhaustedError(new Error("invite_exhausted")), true);
assert.equal(isInviteExhaustedError(new Error("invite_expired")), false);
assert.equal(isInviteExhaustedError(new Error("invite_invalid")), false);
});
+5
View File
@@ -1,4 +1,5 @@
export const INVITE_EXPIRED_ERROR = "invite_expired";
export const INVITE_EXHAUSTED_ERROR = "invite_exhausted";
/**
* Parsed invite either a full (relay + code) or bare-code form.
@@ -89,3 +90,7 @@ export function inviteErrorMessage(error: unknown): string {
export function isInviteExpiredError(error: unknown): boolean {
return inviteErrorMessage(error) === INVITE_EXPIRED_ERROR;
}
export function isInviteExhaustedError(error: unknown): boolean {
return inviteErrorMessage(error) === INVITE_EXHAUSTED_ERROR;
}
+129 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getJoinPolicy } from "./invites.ts";
import { getJoinPolicy, mintInvite } from "./invites.ts";
function withFetch(response, run) {
const originalFetch = globalThis.fetch;
@@ -81,3 +81,131 @@ test("getJoinPolicy maps the native command response", async () => {
globalThis.window = previousWindow;
}
});
// --- mintInvite serialization ---
// The test-loader transpiles TS imports. tauri.ts imports `invoke` from
// @tauri-apps/api/core, which calls `window.__TAURI_INTERNALS__.invoke`.
// We stub that here so getRelayHttpUrl() and signRelayEvent() work in node.
function setupTauriStubs(
httpBase,
authEvent = {
id: "x",
sig: "y",
pubkey: "z",
kind: 27235,
created_at: 1,
tags: [],
},
) {
const calls = { invokeArgs: [] };
globalThis.window = globalThis.window ?? {};
globalThis.window.__TAURI_INTERNALS__ = {
invoke: async (command, args) => {
calls.invokeArgs.push({ command, args });
if (command === "get_relay_http_url") return httpBase;
if (command === "sign_event") return JSON.stringify(authEvent);
throw new Error(`Unexpected Tauri command: ${command}`);
},
};
return calls;
}
function teardownTauriStubs() {
delete globalThis.window.__TAURI_INTERNALS__;
}
test("mintInvite serializes bounded max_uses in the request body", async () => {
setupTauriStubs("https://relay.example");
try {
const originalFetch = globalThis.fetch;
let capturedBody;
globalThis.fetch = async (_url, init) => {
capturedBody = JSON.parse(init.body);
return new Response(
JSON.stringify({
code: "v2.abc123",
expires_at: 1785100000,
url: "https://relay.example/invite/v2.abc123",
max_uses: 10,
uses_remaining: 10,
}),
);
};
try {
const result = await mintInvite({ ttlSecs: 259200, maxUses: 10 });
assert.equal(capturedBody.ttl_secs, 259200);
assert.equal(capturedBody.max_uses, 10);
assert.equal(result.code, "v2.abc123");
assert.equal(result.maxUses, 10);
assert.equal(result.usesRemaining, 10);
assert.equal(result.expiresAt, 1785100000);
assert.equal(result.url, "https://relay.example/invite/v2.abc123");
} finally {
globalThis.fetch = originalFetch;
}
} finally {
teardownTauriStubs();
}
});
test("mintInvite omits max_uses when null (unlimited)", async () => {
setupTauriStubs("https://relay.example");
try {
const originalFetch = globalThis.fetch;
let capturedBody;
globalThis.fetch = async (_url, init) => {
capturedBody = JSON.parse(init.body);
return new Response(
JSON.stringify({
code: "v2.abc123",
expires_at: 1785100000,
url: "https://relay.example/invite/v2.abc123",
max_uses: null,
uses_remaining: null,
}),
);
};
try {
const result = await mintInvite({ ttlSecs: 259200, maxUses: null });
assert.equal(capturedBody.ttl_secs, 259200);
assert.equal(Object.hasOwn(capturedBody, "max_uses"), false);
assert.equal(result.maxUses, null);
assert.equal(result.usesRemaining, null);
} finally {
globalThis.fetch = originalFetch;
}
} finally {
teardownTauriStubs();
}
});
test("mintInvite omits max_uses when not provided (unlimited default)", async () => {
setupTauriStubs("https://relay.example");
try {
const originalFetch = globalThis.fetch;
let capturedBody;
globalThis.fetch = async (_url, init) => {
capturedBody = JSON.parse(init.body);
return new Response(
JSON.stringify({
code: "v2.abc123",
expires_at: 1785100000,
url: "https://relay.example/invite/v2.abc123",
max_uses: null,
uses_remaining: null,
}),
);
};
try {
await mintInvite({ ttlSecs: 86400 });
assert.equal(capturedBody.ttl_secs, 86400);
assert.equal(Object.hasOwn(capturedBody, "max_uses"), false);
} finally {
globalThis.fetch = originalFetch;
}
} finally {
teardownTauriStubs();
}
});
+19 -3
View File
@@ -25,6 +25,8 @@ export type MintedInvite = {
code: string;
expiresAt: number;
url: string;
maxUses: number | null;
usesRemaining: number | null;
};
export type JoinPolicy = {
@@ -189,15 +191,29 @@ export async function acceptJoinPolicy(
}
/** Mint an invite code on the active community's relay (owner/admin only). */
export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
export async function mintInvite(options?: {
ttlSecs?: number;
maxUses?: number | null;
}): Promise<MintedInvite> {
const base = await getRelayHttpUrl();
const body = JSON.stringify(ttlSecs ? { ttl_secs: ttlSecs } : {});
const payload: Record<string, unknown> = {};
if (options?.ttlSecs != null) payload.ttl_secs = options.ttlSecs;
if (options?.maxUses != null) payload.max_uses = options.maxUses;
const body = JSON.stringify(payload);
const raw = await invitePost<{
code: string;
expires_at: number;
url: string;
max_uses: number | null;
uses_remaining: number | null;
}>(base, "/api/invites", body);
return { code: raw.code, expiresAt: raw.expires_at, url: raw.url };
return {
code: raw.code,
expiresAt: raw.expires_at,
url: raw.url,
maxUses: raw.max_uses,
usesRemaining: raw.uses_remaining,
};
}
/**
+33
View File
@@ -0,0 +1,33 @@
-- Use-limited invite links: durable invite records for atomic redemption.
--
-- Stateless HMAC bearer tokens (v1) cannot enforce use limits: their signed
-- payload is immutable and no invite row exists to record consumption. This
-- migration introduces a durable `relay_invites` table that stores only the
-- SHA-256 hash of an opaque v2 code, never the reusable bearer secret itself.
--
-- Every lookup binds both (community_id, token_hash) so a code presented on
-- the wrong tenant host returns Invalid — there is no cross-tenant lookup by
-- hash alone. `FOR UPDATE` during claim serializes concurrent claims for one
-- invite across relay processes; membership insertion, join-policy evidence,
-- and use_count increment share a single commit so exactly one claimant can
-- win the final slot.
--
-- max_uses is optional: NULL means unlimited (preserving current behavior).
-- use_count is always incremented for new members, even when unlimited, for
-- observability. role is pinned to 'member' — invite links never grant admin.
CREATE TABLE relay_invites (
community_id UUID NOT NULL REFERENCES communities(id),
id UUID NOT NULL DEFAULT gen_random_uuid(),
token_hash BYTEA NOT NULL CHECK (length(token_hash) = 32),
role TEXT NOT NULL DEFAULT 'member' CHECK (role = 'member'),
max_uses INTEGER CHECK (max_uses BETWEEN 1 AND 10000),
use_count INTEGER NOT NULL DEFAULT 0 CHECK (use_count >= 0),
expires_at TIMESTAMPTZ NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, id),
UNIQUE (community_id, token_hash),
CHECK (max_uses IS NULL OR use_count <= max_uses)
);
CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at);
@@ -261,12 +261,17 @@ String _communityNameFromClaim(Map<String, dynamic> claim, String relayUrl) {
}
bool _requiresFreshInvite(Object error) {
return error.toString().contains('join_policy_required');
final message = error.toString();
return message.contains('join_policy_required') ||
message.contains('invite_exhausted');
}
String _friendlyInviteError(Object error) {
final message = error.toString();
if (message.contains('invite_expired')) return 'This invite has expired.';
if (message.contains('invite_exhausted')) {
return 'This invite has reached its use limit. Ask for a new invite.';
}
if (message.contains('invite_invalid')) return 'This invite is not valid.';
if (message.contains('join_policy_required')) {
return 'This invite approval has expired. Re-open the invite link to try again.';
@@ -218,6 +218,49 @@ void main() {
expect(attempts, 1);
});
test('invite_exhausted requires a fresh invite and cannot retry', () async {
final keys = nostr.Keys.generate();
var attempts = 0;
final storage = CommunityStorage(secure: FakeSecureStorage());
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
inviteJoinHttpClientProvider.overrideWithValue(
http_testing.MockClient((request) async {
attempts++;
return http.Response(
jsonEncode({'error': 'invite_exhausted'}),
403,
);
}),
),
],
);
addTearDown(container.dispose);
await container
.read(inviteJoinProvider.notifier)
.prepare(
const InviteDeepLink(
relayUrl: 'wss://relay.example.com',
code: 'v2.exhausted-secret',
),
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
final state = container.read(inviteJoinProvider);
expect(state.status, InviteJoinStatus.error);
expect(state.requiresFreshInvite, isTrue);
expect(
state.errorMessage,
'This invite has reached its use limit. Ask for a new invite.',
);
await container.read(inviteJoinProvider.notifier).confirmJoin();
expect(attempts, 1);
});
test('failed claim can be retried and preserves policy receipt', () async {
final keys = nostr.Keys.generate();
var attempts = 0;
+37
View File
@@ -572,6 +572,43 @@ CREATE TABLE relay_members (
CREATE INDEX idx_relay_members_role ON relay_members (community_id, role);
-- ── Join policy acceptances ──────────────────────────────────────────────────
-- Durable evidence of the policy version accepted when an invite claim grants
-- relay membership. The composite foreign key keeps evidence bound to a live
-- member in the same community and removes it with that membership.
CREATE TABLE join_policy_acceptances (
community_id UUID NOT NULL,
pubkey TEXT NOT NULL,
policy_version TEXT NOT NULL CHECK (length(policy_version) = 64),
accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, pubkey, policy_version),
FOREIGN KEY (community_id, pubkey)
REFERENCES relay_members (community_id, pubkey) ON DELETE CASCADE
);
-- ── Relay invites (use-limited invite links) ──────────────────────────────────
-- Conformance: durable invite records for atomic redemption, community-scoped.
-- Stores only SHA-256(code) as 32-byte BYTEA; never the reusable bearer code.
-- PK and UNIQUE both lead with community_id. max_uses NULL = unlimited.
CREATE TABLE relay_invites (
community_id UUID NOT NULL REFERENCES communities(id),
id UUID NOT NULL DEFAULT gen_random_uuid(),
token_hash BYTEA NOT NULL CHECK (length(token_hash) = 32),
role TEXT NOT NULL DEFAULT 'member' CHECK (role = 'member'),
max_uses INTEGER CHECK (max_uses BETWEEN 1 AND 10000),
use_count INTEGER NOT NULL DEFAULT 0 CHECK (use_count >= 0),
expires_at TIMESTAMPTZ NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (community_id, id),
UNIQUE (community_id, token_hash),
CHECK (max_uses IS NULL OR use_count <= max_uses)
);
CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at);
-- ── Archived identities (NIP-IA) ──────────────────────────────────────────────
-- Conformance: archive cannot hide a key in another community. PK scoped.
+17 -3
View File
@@ -24,6 +24,20 @@ type JoinPolicy = {
type PolicyDocument = { title: string; markdown: string };
/** Convert relay invite sentinels into user-facing recovery guidance. */
function inviteClaimErrorMessage(message: string): string {
if (message.includes("invite_exhausted")) {
return "This invite has reached its use limit. Ask for a new invite.";
}
if (message.includes("invite_expired")) {
return "This invite has expired. Ask for a new invite.";
}
if (message.includes("invite_invalid")) {
return "This invite is invalid. Check the link or ask for a new invite.";
}
return message;
}
/** Landing page for a community invite link (`/invite/<code>`). */
export function InvitePage({ code }: { code: string }) {
const relay = relayWsUrl();
@@ -110,9 +124,9 @@ export function InvitePage({ code }: { code: string }) {
await claimInviteInBrowser(code, receipt);
window.location.assign("/");
} catch (error) {
setBrowserJoinError(
error instanceof Error ? error.message : "Could not claim this invite.",
);
const message =
error instanceof Error ? error.message : "Could not claim this invite.";
setBrowserJoinError(inviteClaimErrorMessage(message));
} finally {
setJoiningBrowser(false);
}