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