feat(invites): add use-limited invite links (#3141)

## Summary

- add database-backed v2 invite links with optional maximum-use limits
and atomic final-slot redemption
- preserve v1 invite compatibility while adding
exhausted/expired/invalid client handling across desktop, web, and
mobile
- emit structured claim-outcome logs with community, invite ID, outcome,
maximum uses, and post-claim count

## Verification

- `cargo fmt --all -- --check`
- `cargo test -p buzz-db` (85 passed, 134 Postgres-dependent ignored)
- `cargo clippy -p buzz-db --all-targets -- -D warnings`
- desktop `npm run typecheck`
- push hook: desktop checks/tests, desktop Tauri tests, Rust tests, and
branch-skew passed
- Postgres integration tests were previously reviewed green at the
pre-rebase tree; local rerun on this session was unavailable because
Postgres/Docker were not running
- mobile push-hook check could not start because Flutter is unavailable
locally

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c <c57bf9b4275088b2b33db7f746975407210f159fbd8bd733c0e532375f69aa80@buzz.block.builderlab.xyz>
Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
This commit is contained in:
Kalvin C
2026-07-27 15:19:39 -07:00
committed by GitHub
parent 98a7b13348
commit d500c2d5cf
24 changed files with 1732 additions and 35 deletions

View File

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

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

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.