mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00: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:
@@ -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
crates/buzz-core/src/invite.rs
Normal file
109
crates/buzz-core/src/invite.rs
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user