Files
buzz/crates/buzz-core/src/invite.rs
Kalvin C d500c2d5cf 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>
2026-07-27 15:19:39 -07:00

110 lines
3.5 KiB
Rust

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