mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat: relay invite links (mint + claim + landing page + deep link) (#1668)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27 <b7b5ae1dc079cf3b0cac216bc07ee5b6dd42d348a163df1adec5a1e48ee8c2f7@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27 <b7b5ae1dc079cf3b0cac216bc07ee5b6dd42d348a163df1adec5a1e48ee8c2f7@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
613
crates/buzz-relay/src/api/invites.rs
Normal file
613
crates/buzz-relay/src/api/invites.rs
Normal file
@@ -0,0 +1,613 @@
|
||||
//! Relay invite HTTP API — mint and claim stateless invite codes.
|
||||
//!
|
||||
//! Routes (both NIP-98 signed, outside the Nostr event data plane):
|
||||
//!
|
||||
//! - `POST /api/invites` — mint an invite code. Caller must hold the `owner`
|
||||
//! or `admin` role in the tenant community (mirrors the kind:9030 authz).
|
||||
//! - `POST /api/invites/claim` — claim an invite code. Deliberately **exempt
|
||||
//! from the relay-membership gate**: the whole point is that the caller is
|
||||
//! not a member yet. NIP-98 proves control of the joining pubkey; the HMAC
|
||||
//! on the code proves an admin authorized the join.
|
||||
//!
|
||||
//! Token format, key derivation, and security trade-offs live in
|
||||
//! [`crate::invite_token`].
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Json,
|
||||
};
|
||||
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 crate::state::AppState;
|
||||
|
||||
use super::{api_error, bridge, internal_error};
|
||||
|
||||
/// Fixed-window size for the per-pubkey claim rate limiter.
|
||||
pub(crate) const CLAIM_RATE_WINDOW: Duration = Duration::from_secs(60);
|
||||
/// Max claim attempts per pubkey per window. Claims are idempotent and a real
|
||||
/// user performs exactly one, so this only bounds brute-force probing.
|
||||
const CLAIM_RATE_LIMIT: u32 = 10;
|
||||
/// Maximum distinct pubkeys retained by the process-local claim limiter.
|
||||
/// NIP-98 proves key ownership, not that a key is costly to create, so this
|
||||
/// bound is required in addition to expiry.
|
||||
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
|
||||
/// [`invite_token::MAX_INVITE_TTL_SECS`]; defaults to 72 h.
|
||||
#[serde(default)]
|
||||
pub ttl_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// Body for `POST /api/invites/claim`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ClaimInviteRequest {
|
||||
/// The invite code to redeem.
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// Shared prelude: bind the tenant from the Host header and verify the NIP-98
|
||||
/// signature + replay for `path`.
|
||||
async fn authenticate(
|
||||
state: &Arc<AppState>,
|
||||
headers: &HeaderMap,
|
||||
path: &str,
|
||||
body: &[u8],
|
||||
) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json<Value>)> {
|
||||
let raw_host = headers
|
||||
.get(axum::http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let tenant = crate::tenant::bind_community(&state.db, raw_host)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
api_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"relay: no community is configured for this host",
|
||||
)
|
||||
})?;
|
||||
|
||||
let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path);
|
||||
let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options(
|
||||
headers,
|
||||
"POST",
|
||||
&url,
|
||||
Some(body),
|
||||
true, // invites always require NIP-98; no X-Pubkey dev fallback
|
||||
true, // POST bodies must be covered by a payload tag
|
||||
)?;
|
||||
bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?;
|
||||
|
||||
Ok((tenant, pubkey))
|
||||
}
|
||||
|
||||
/// Mint an invite code — `POST /api/invites`, NIP-98 signed by an owner/admin.
|
||||
///
|
||||
/// Returns the code, its expiry, and a shareable landing-page URL on the
|
||||
/// tenant host.
|
||||
pub async fn mint_invite(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites", &body).await?;
|
||||
|
||||
// Authz mirrors kind:9030 (add member): owner or admin only.
|
||||
let sender_hex = pubkey.to_hex();
|
||||
let member = state
|
||||
.db
|
||||
.get_relay_member(tenant.community(), &sender_hex)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("invite mint role lookup: {e}")))?;
|
||||
let role = member.map(|m| m.role).unwrap_or_default();
|
||||
if role != "owner" && role != "admin" {
|
||||
return Err(api_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"only relay owners and admins can create invites",
|
||||
));
|
||||
}
|
||||
|
||||
let request: MintInviteRequest = if body.is_empty() {
|
||||
MintInviteRequest::default()
|
||||
} else {
|
||||
serde_json::from_slice(&body).map_err(|e| {
|
||||
api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("invalid invite JSON: {e}"),
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Same TLS-posture logic as nip98_expected_url: wss deployments get an
|
||||
// https landing page URL, ws dev/test deployments get http.
|
||||
let scheme = if state.config.relay_url.trim_start().starts_with("wss://") {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
community = %tenant.community(),
|
||||
minted_by = %sender_hex,
|
||||
expires_at,
|
||||
"relay invite minted"
|
||||
);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"code": code,
|
||||
"expires_at": expires_at,
|
||||
"url": format!("{scheme}://{}/invite/{}", tenant.host(), code),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Claim an invite code — `POST /api/invites/claim`, NIP-98 signed by the
|
||||
/// *joining* pubkey. Exempt from the relay-membership gate by design.
|
||||
pub async fn claim_invite(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites/claim", &body).await?;
|
||||
|
||||
if claim_rate_limited(&state, tenant.community(), &pubkey) {
|
||||
return Err(api_error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"too many invite claim attempts, slow down",
|
||||
));
|
||||
}
|
||||
|
||||
let request: ClaimInviteRequest = serde_json::from_slice(&body)
|
||||
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid claim JSON: {e}")))?;
|
||||
|
||||
let key = invite_token::derive_invite_key(&state.relay_keypair);
|
||||
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.
|
||||
invite_token::InviteError::Expired => {
|
||||
api_error(StatusCode::FORBIDDEN, "invite_expired")
|
||||
}
|
||||
// Everything else stays coarse so the endpoint is a poor oracle.
|
||||
_ => api_error(StatusCode::FORBIDDEN, "invite_invalid"),
|
||||
},
|
||||
)?;
|
||||
|
||||
let claimer_hex = pubkey.to_hex();
|
||||
let was_inserted = state
|
||||
.db
|
||||
.add_relay_member(tenant.community(), &claimer_hex, &payload.r, Some("invite"))
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("invite claim insert: {e}")))?;
|
||||
|
||||
if was_inserted {
|
||||
tracing::info!(
|
||||
community = %tenant.community(),
|
||||
member = %claimer_hex,
|
||||
"relay member added via invite"
|
||||
);
|
||||
if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await {
|
||||
tracing::warn!("failed to publish NIP-43 member-added delta after claim: {e}");
|
||||
}
|
||||
if let Err(e) = publish_nip43_membership_list(&tenant, &state).await {
|
||||
tracing::warn!("failed to publish NIP-43 membership list after claim: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": if was_inserted { "joined" } else { "already_member" },
|
||||
"community_id": tenant.community().to_string(),
|
||||
"host": tenant.host(),
|
||||
"role": payload.r,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Fixed-window rate limit on claim attempts, keyed by community and claimer
|
||||
/// pubkey so traffic for one tenant cannot consume another tenant's allowance.
|
||||
///
|
||||
/// Entries expire after one window and the cache has a hard capacity. Both are
|
||||
/// important because a pre-membership caller can cheaply create fresh Nostr
|
||||
/// keypairs; retaining one immortal entry per key would make the limiter itself
|
||||
/// an unbounded-memory denial-of-service vector.
|
||||
fn claim_rate_limited(
|
||||
state: &AppState,
|
||||
community: buzz_core::tenant::CommunityId,
|
||||
pubkey: &nostr::PublicKey,
|
||||
) -> bool {
|
||||
claim_key_rate_limited(
|
||||
&state.invite_claim_rate_limiter,
|
||||
(community, pubkey.to_bytes()),
|
||||
)
|
||||
}
|
||||
|
||||
fn claim_key_rate_limited(
|
||||
cache: &moka::sync::Cache<crate::state::ScopedPubkeyKey, Arc<std::sync::atomic::AtomicU32>>,
|
||||
key: crate::state::ScopedPubkeyKey,
|
||||
) -> bool {
|
||||
let counter = cache.get_with(key, || Arc::new(std::sync::atomic::AtomicU32::new(0)));
|
||||
counter.fetch_add(1, Ordering::Relaxed) >= CLAIM_RATE_LIMIT
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{claim_key_rate_limited, CLAIM_RATE_LIMIT};
|
||||
use axum::{
|
||||
body::{to_bytes, Body},
|
||||
http::{header, Request, StatusCode},
|
||||
};
|
||||
use base64::Engine;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::router::build_router;
|
||||
use crate::state::AppState;
|
||||
|
||||
struct AlwaysFreshReplayGuard;
|
||||
|
||||
impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard {
|
||||
fn try_mark_in_scope<'a>(
|
||||
&'a self,
|
||||
_scope: &'a str,
|
||||
_event_id: &'a nostr::EventId,
|
||||
_ttl_secs: u64,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<bool, buzz_auth::AuthError>> + Send + 'a>,
|
||||
> {
|
||||
Box::pin(async { Ok(true) })
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
|
||||
|
||||
fn claim_cache(
|
||||
capacity: u64,
|
||||
ttl: Duration,
|
||||
) -> moka::sync::Cache<crate::state::ScopedPubkeyKey, Arc<std::sync::atomic::AtomicU32>> {
|
||||
moka::sync::Cache::builder()
|
||||
.max_capacity(capacity)
|
||||
.time_to_live(ttl)
|
||||
.build()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_limiter_rejects_after_limit() {
|
||||
let cache = claim_cache(100, Duration::from_secs(60));
|
||||
let key = (buzz_core::CommunityId::from_uuid(Uuid::nil()), [7; 32]);
|
||||
|
||||
for _ in 0..CLAIM_RATE_LIMIT {
|
||||
assert!(!claim_key_rate_limited(&cache, key));
|
||||
}
|
||||
assert!(claim_key_rate_limited(&cache, key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_limiter_expires_entries() {
|
||||
let cache = claim_cache(100, Duration::from_millis(10));
|
||||
let key = (buzz_core::CommunityId::from_uuid(Uuid::nil()), [8; 32]);
|
||||
assert!(!claim_key_rate_limited(&cache, key));
|
||||
assert!(cache.get(&key).is_some());
|
||||
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
cache.run_pending_tasks();
|
||||
|
||||
assert!(cache.get(&key).is_none());
|
||||
assert!(!claim_key_rate_limited(&cache, key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_limiter_isolates_communities_for_same_pubkey() {
|
||||
let cache = claim_cache(100, Duration::from_secs(60));
|
||||
let pubkey = [9; 32];
|
||||
let community_a = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xAAAA));
|
||||
let community_b = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xBBBB));
|
||||
|
||||
for _ in 0..CLAIM_RATE_LIMIT {
|
||||
assert!(!claim_key_rate_limited(&cache, (community_a, pubkey)));
|
||||
}
|
||||
assert!(claim_key_rate_limited(&cache, (community_a, pubkey)));
|
||||
assert!(!claim_key_rate_limited(&cache, (community_b, pubkey)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_limiter_bounds_distinct_pubkeys() {
|
||||
let capacity = 10;
|
||||
let cache = claim_cache(capacity, Duration::from_secs(60));
|
||||
for id in 0..100_u64 {
|
||||
let mut pubkey = [0; 32];
|
||||
pubkey[..8].copy_from_slice(&id.to_le_bytes());
|
||||
let key = (buzz_core::CommunityId::from_uuid(Uuid::nil()), pubkey);
|
||||
assert!(!claim_key_rate_limited(&cache, key));
|
||||
}
|
||||
cache.run_pending_tasks();
|
||||
|
||||
assert!(cache.entry_count() <= capacity);
|
||||
}
|
||||
|
||||
fn nip98_auth_header(keys: &Keys, url: &str, body: &[u8]) -> String {
|
||||
let hash: [u8; 32] = Sha256::digest(body).into();
|
||||
let tags = vec![
|
||||
Tag::parse(["u", url]).expect("u tag"),
|
||||
Tag::parse(["method", "POST"]).expect("method tag"),
|
||||
Tag::parse(["payload", hex::encode(hash).as_str()]).expect("payload tag"),
|
||||
];
|
||||
let event = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.expect("sign NIP-98 event");
|
||||
let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event");
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(event_json.as_bytes());
|
||||
format!("Nostr {encoded}")
|
||||
}
|
||||
|
||||
/// Build a closed-relay (`require_relay_membership = true`) test state with
|
||||
/// a fresh community on `host`; returns `None` when Postgres is unavailable.
|
||||
async fn invite_test_state(host: &str) -> Option<Arc<AppState>> {
|
||||
let mut config = crate::config::Config::from_env().ok()?;
|
||||
config.database_url = TEST_DB_URL.to_string();
|
||||
config.redis_url = "redis://127.0.0.1:1".to_string();
|
||||
config.relay_url = format!("wss://{host}");
|
||||
// The claim route must work on relays where membership is enforced —
|
||||
// that is the entire point of an invite.
|
||||
config.require_relay_membership = true;
|
||||
|
||||
let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?;
|
||||
let db = buzz_db::Db::from_pool(pool.clone());
|
||||
db.ensure_configured_community(host).await.ok()?;
|
||||
|
||||
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
|
||||
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
|
||||
.ok()?;
|
||||
let pubsub = Arc::new(
|
||||
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
|
||||
.await
|
||||
.ok()?,
|
||||
);
|
||||
let audit = buzz_audit::AuditService::new(pool.clone());
|
||||
let auth = buzz_auth::AuthService::new(config.auth.clone());
|
||||
let search = buzz_search::SearchService::new(pool.clone());
|
||||
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
|
||||
db.clone(),
|
||||
buzz_workflow::WorkflowConfig::default(),
|
||||
));
|
||||
let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?;
|
||||
let (mut state, _audit_shutdown) = AppState::new(
|
||||
config,
|
||||
db,
|
||||
redis_pool,
|
||||
audit,
|
||||
pubsub,
|
||||
auth,
|
||||
search,
|
||||
workflow_engine,
|
||||
Keys::generate(),
|
||||
media_storage,
|
||||
);
|
||||
state.nip98_replay = Arc::new(AlwaysFreshReplayGuard);
|
||||
Some(Arc::new(state))
|
||||
}
|
||||
|
||||
async fn post_json(
|
||||
state: Arc<AppState>,
|
||||
host: &str,
|
||||
path: &str,
|
||||
keys: &Keys,
|
||||
body: String,
|
||||
) -> axum::response::Response {
|
||||
let url = format!("https://{host}{path}");
|
||||
let auth = nip98_auth_header(keys, &url, body.as_bytes());
|
||||
build_router(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header(header::HOST, host)
|
||||
.header(header::AUTHORIZATION, auth)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response")
|
||||
}
|
||||
|
||||
async fn read_json(response: axum::response::Response) -> Value {
|
||||
let bytes = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.expect("read response body");
|
||||
serde_json::from_slice(&bytes).expect("response JSON")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn owner_mints_and_new_pubkey_claims() {
|
||||
let host = format!("invites-{}.example", Uuid::new_v4().simple());
|
||||
let owner = Keys::generate();
|
||||
let joiner = Keys::generate();
|
||||
let Some(state) = invite_test_state(&host).await else {
|
||||
return;
|
||||
};
|
||||
let community = state
|
||||
.db
|
||||
.lookup_community_by_host(&host)
|
||||
.await
|
||||
.expect("lookup")
|
||||
.expect("community exists");
|
||||
let community_id = community.id;
|
||||
state
|
||||
.db
|
||||
.add_relay_member(community_id, &owner.public_key().to_hex(), "owner", None)
|
||||
.await
|
||||
.expect("seed owner");
|
||||
|
||||
// Mint.
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites",
|
||||
&owner,
|
||||
"{}".to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let json = read_json(response).await;
|
||||
let code = json.get("code").and_then(Value::as_str).expect("code");
|
||||
let url = json.get("url").and_then(Value::as_str).expect("url");
|
||||
assert!(url.contains("/invite/"), "unexpected url: {url}");
|
||||
|
||||
// Claim on a closed relay by a pubkey that is not yet a member.
|
||||
let claim_body = serde_json::json!({ "code": code }).to_string();
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host,
|
||||
"/api/invites/claim",
|
||||
&joiner,
|
||||
claim_body.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let json = read_json(response).await;
|
||||
assert_eq!(json.get("status").and_then(Value::as_str), Some("joined"));
|
||||
assert_eq!(json.get("role").and_then(Value::as_str), Some("member"));
|
||||
|
||||
let member = state
|
||||
.db
|
||||
.get_relay_member(community_id, &joiner.public_key().to_hex())
|
||||
.await
|
||||
.expect("member lookup")
|
||||
.expect("joiner is now a member");
|
||||
assert_eq!(member.role, "member");
|
||||
|
||||
// Second claim is idempotent.
|
||||
let response = post_json(state, &host, "/api/invites/claim", &joiner, claim_body).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let json = read_json(response).await;
|
||||
assert_eq!(
|
||||
json.get("status").and_then(Value::as_str),
|
||||
Some("already_member")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn non_admin_cannot_mint() {
|
||||
let host = format!("invites-{}.example", Uuid::new_v4().simple());
|
||||
let member = Keys::generate();
|
||||
let outsider = Keys::generate();
|
||||
let Some(state) = invite_test_state(&host).await else {
|
||||
return;
|
||||
};
|
||||
let community = state
|
||||
.db
|
||||
.lookup_community_by_host(&host)
|
||||
.await
|
||||
.expect("lookup")
|
||||
.expect("community exists");
|
||||
let community_id = community.id;
|
||||
state
|
||||
.db
|
||||
.add_relay_member(community_id, &member.public_key().to_hex(), "member", None)
|
||||
.await
|
||||
.expect("seed member");
|
||||
|
||||
for keys in [&member, &outsider] {
|
||||
let response =
|
||||
post_json(state.clone(), &host, "/api/invites", keys, "{}".to_string()).await;
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn claim_rejects_invalid_code() {
|
||||
let host = format!("invites-{}.example", Uuid::new_v4().simple());
|
||||
let joiner = Keys::generate();
|
||||
let Some(state) = invite_test_state(&host).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let body = serde_json::json!({ "code": "garbage.code" }).to_string();
|
||||
let response = post_json(state.clone(), &host, "/api/invites/claim", &joiner, body).await;
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
let json = read_json(response).await;
|
||||
assert_eq!(
|
||||
json.get("error").and_then(Value::as_str),
|
||||
Some("invite_invalid")
|
||||
);
|
||||
|
||||
let community = state
|
||||
.db
|
||||
.lookup_community_by_host(&host)
|
||||
.await
|
||||
.expect("lookup")
|
||||
.expect("community exists");
|
||||
let is_member = state
|
||||
.db
|
||||
.is_relay_member(community.id, &joiner.public_key().to_hex())
|
||||
.await
|
||||
.expect("member check");
|
||||
assert!(!is_member, "invalid code must not admit anyone");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn code_minted_for_one_community_fails_on_another() {
|
||||
let host_a = format!("invites-a-{}.example", Uuid::new_v4().simple());
|
||||
let host_b = format!("invites-b-{}.example", Uuid::new_v4().simple());
|
||||
let owner = Keys::generate();
|
||||
let joiner = Keys::generate();
|
||||
let Some(state) = invite_test_state(&host_a).await else {
|
||||
return;
|
||||
};
|
||||
state
|
||||
.db
|
||||
.ensure_configured_community(&host_b)
|
||||
.await
|
||||
.expect("second community");
|
||||
let community_a = state
|
||||
.db
|
||||
.lookup_community_by_host(&host_a)
|
||||
.await
|
||||
.expect("lookup")
|
||||
.expect("community a");
|
||||
state
|
||||
.db
|
||||
.add_relay_member(community_a.id, &owner.public_key().to_hex(), "owner", None)
|
||||
.await
|
||||
.expect("seed owner");
|
||||
|
||||
let response = post_json(
|
||||
state.clone(),
|
||||
&host_a,
|
||||
"/api/invites",
|
||||
&owner,
|
||||
"{}".to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let json = read_json(response).await;
|
||||
let code = json.get("code").and_then(Value::as_str).expect("code");
|
||||
|
||||
// Present community A's code on community B's host.
|
||||
let body = serde_json::json!({ "code": code }).to_string();
|
||||
let response = post_json(state, &host_b, "/api/invites/claim", &joiner, body).await;
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod bridge;
|
||||
pub mod events;
|
||||
pub mod git;
|
||||
pub mod invites;
|
||||
pub mod media;
|
||||
pub mod nip05;
|
||||
pub mod operator;
|
||||
|
||||
329
crates/buzz-relay/src/invite_token.rs
Normal file
329
crates/buzz-relay/src/invite_token.rs
Normal file
@@ -0,0 +1,329 @@
|
||||
//! Stateless relay invite tokens.
|
||||
//!
|
||||
//! An invite code is a compact, URL-safe, HMAC-signed blob minted by a relay
|
||||
//! admin/owner and later presented by a joining user. The relay verifies the
|
||||
//! signature and expiry, then inserts the presenter into `relay_members` —
|
||||
//! no server-side invite storage is required.
|
||||
//!
|
||||
//! ## Format
|
||||
//!
|
||||
//! ```text
|
||||
//! code = base64url(payload_json) + "." + base64url(hmac_sha256(key, payload_json))
|
||||
//! ```
|
||||
//!
|
||||
//! `payload_json` is a canonical JSON object:
|
||||
//!
|
||||
//! ```json
|
||||
//! {"c":"<community uuid>","r":"member","e":1767000000,"n":"<random nonce>"}
|
||||
//! ```
|
||||
//!
|
||||
//! ## Key derivation
|
||||
//!
|
||||
//! The HMAC key is derived from the relay's signing secret key:
|
||||
//! `key = sha256(relay_secret_key_bytes || "buzz-invite-v1")`. Rotating the
|
||||
//! relay keypair therefore invalidates all outstanding invites, which is the
|
||||
//! intended blast-radius control for a leaked link.
|
||||
//!
|
||||
//! ## Security properties (and non-properties)
|
||||
//!
|
||||
//! - Codes are **multi-use until expiry** — there is no server-side "used"
|
||||
//! bit. Default expiry is deliberately short ([`DEFAULT_INVITE_TTL_SECS`]).
|
||||
//! - Codes are **community-scoped**: a code minted for community A fails
|
||||
//! verification when presented to community B, even on the same deployment.
|
||||
//! - Codes are **role-capped at `member`** at mint time (enforced by the mint
|
||||
//! route, and re-checked here on verify so a hand-crafted payload with an
|
||||
//! elevated role is rejected even if it carries a valid MAC from a future
|
||||
//! buggy caller).
|
||||
//! - Revocation is coarse: rotate the relay keypair, or remove the member
|
||||
//! after the fact. Per-code revocation requires the future `relay_invites`
|
||||
//! table increment.
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use buzz_core::tenant::CommunityId;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Default invite lifetime: 72 hours.
|
||||
pub const DEFAULT_INVITE_TTL_SECS: u64 = 72 * 60 * 60;
|
||||
|
||||
/// Maximum invite lifetime a mint request may ask for: 30 days.
|
||||
pub const MAX_INVITE_TTL_SECS: u64 = 30 * 24 * 60 * 60;
|
||||
|
||||
/// Maximum accepted code length (defense against absurd inputs before any
|
||||
/// parsing work happens). A real code is ~200 bytes.
|
||||
const MAX_CODE_LEN: usize = 1024;
|
||||
|
||||
/// Domain-separation label mixed into the HMAC key derivation.
|
||||
const KEY_DERIVATION_LABEL: &[u8] = b"buzz-invite-v1";
|
||||
|
||||
/// The signed payload carried inside an invite code.
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct InvitePayload {
|
||||
/// Community the invite admits into (UUID string form).
|
||||
pub c: String,
|
||||
/// Role granted on claim. Only `"member"` is valid in v1.
|
||||
pub r: String,
|
||||
/// Expiry as unix seconds.
|
||||
pub e: u64,
|
||||
/// Random nonce so identically-parameterised invites differ.
|
||||
pub n: String,
|
||||
}
|
||||
|
||||
/// Why a code failed verification. Variants are deliberately coarse — the
|
||||
/// HTTP layer maps all of them to a generic rejection so the endpoint does
|
||||
/// not become an oracle for forging codes.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum InviteError {
|
||||
/// Structurally invalid (bad base64, bad JSON, wrong shape, too long).
|
||||
Malformed,
|
||||
/// MAC did not verify.
|
||||
BadSignature,
|
||||
/// Signature fine, but the expiry has passed.
|
||||
Expired,
|
||||
/// Signature fine, but minted for a different community.
|
||||
WrongCommunity,
|
||||
/// Signature fine, but the role is not one this relay grants via invite.
|
||||
InvalidRole,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InviteError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let msg = match self {
|
||||
InviteError::Malformed => "malformed invite code",
|
||||
InviteError::BadSignature => "invalid invite signature",
|
||||
InviteError::Expired => "invite code expired",
|
||||
InviteError::WrongCommunity => "invite not valid for this relay",
|
||||
InviteError::InvalidRole => "invite grants an unsupported role",
|
||||
};
|
||||
f.write_str(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the invite HMAC key from the relay's signing secret.
|
||||
///
|
||||
/// `sha256(secret_key_bytes || label)` — the label domain-separates this use
|
||||
/// from any other HMAC built on the same keypair.
|
||||
pub fn derive_invite_key(relay_keys: &nostr::Keys) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(relay_keys.secret_key().as_secret_bytes());
|
||||
hasher.update(KEY_DERIVATION_LABEL);
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
fn sign_payload(key: &[u8; 32], payload_bytes: &[u8]) -> Vec<u8> {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size");
|
||||
mac.update(payload_bytes);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Mint an invite code for `community`, expiring `ttl_secs` from now.
|
||||
///
|
||||
/// 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.
|
||||
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;
|
||||
|
||||
let nonce: [u8; 16] = rand::random();
|
||||
let payload = InvitePayload {
|
||||
c: community.as_uuid().to_string(),
|
||||
r: "member".to_string(),
|
||||
e: expires_at,
|
||||
n: URL_SAFE_NO_PAD.encode(nonce),
|
||||
};
|
||||
let payload_bytes = serde_json::to_vec(&payload).expect("payload serializes");
|
||||
let mac = sign_payload(key, &payload_bytes);
|
||||
|
||||
let code = format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(&payload_bytes),
|
||||
URL_SAFE_NO_PAD.encode(mac)
|
||||
);
|
||||
(code, expires_at)
|
||||
}
|
||||
|
||||
/// Verify an invite code presented to `community`.
|
||||
///
|
||||
/// Order matters: signature is checked before any claims inside the payload
|
||||
/// are trusted (expiry / community / role), and errors after the MAC check
|
||||
/// still return coarse variants so the endpoint stays a poor oracle.
|
||||
pub fn verify_invite(
|
||||
key: &[u8; 32],
|
||||
community: CommunityId,
|
||||
code: &str,
|
||||
) -> Result<InvitePayload, InviteError> {
|
||||
if code.len() > MAX_CODE_LEN {
|
||||
return Err(InviteError::Malformed);
|
||||
}
|
||||
let (payload_b64, mac_b64) = code.split_once('.').ok_or(InviteError::Malformed)?;
|
||||
let payload_bytes = URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.map_err(|_| InviteError::Malformed)?;
|
||||
let mac_bytes = URL_SAFE_NO_PAD
|
||||
.decode(mac_b64)
|
||||
.map_err(|_| InviteError::Malformed)?;
|
||||
|
||||
// Constant-time MAC verification before trusting anything in the payload.
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size");
|
||||
mac.update(&payload_bytes);
|
||||
mac.verify_slice(&mac_bytes)
|
||||
.map_err(|_| InviteError::BadSignature)?;
|
||||
|
||||
let payload: InvitePayload =
|
||||
serde_json::from_slice(&payload_bytes).map_err(|_| InviteError::Malformed)?;
|
||||
|
||||
if payload.e < now_unix() {
|
||||
return Err(InviteError::Expired);
|
||||
}
|
||||
if payload.c != community.as_uuid().to_string() {
|
||||
return Err(InviteError::WrongCommunity);
|
||||
}
|
||||
if payload.r != "member" {
|
||||
return Err(InviteError::InvalidRole);
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn test_key() -> [u8; 32] {
|
||||
derive_invite_key(&nostr::Keys::generate())
|
||||
}
|
||||
|
||||
fn community() -> CommunityId {
|
||||
CommunityId::from_uuid(Uuid::new_v4())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_then_verify_roundtrip() {
|
||||
let key = test_key();
|
||||
let c = community();
|
||||
let (code, expires_at) = mint_invite(&key, c, 3600);
|
||||
let payload = verify_invite(&key, c, &code).expect("valid code verifies");
|
||||
assert_eq!(payload.c, c.as_uuid().to_string());
|
||||
assert_eq!(payload.r, "member");
|
||||
assert_eq!(payload.e, expires_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_community() {
|
||||
let key = test_key();
|
||||
let (code, _) = mint_invite(&key, community(), 3600);
|
||||
assert_eq!(
|
||||
verify_invite(&key, community(), &code),
|
||||
Err(InviteError::WrongCommunity)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_payload() {
|
||||
let key = test_key();
|
||||
let c = community();
|
||||
let (code, _) = mint_invite(&key, c, 3600);
|
||||
let (payload_b64, mac_b64) = code.split_once('.').unwrap();
|
||||
|
||||
// Re-encode a payload with an elevated role but keep the original MAC.
|
||||
let mut payload: InvitePayload =
|
||||
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload_b64).unwrap()).unwrap();
|
||||
payload.r = "owner".to_string();
|
||||
let forged = format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()),
|
||||
mac_b64
|
||||
);
|
||||
assert_eq!(
|
||||
verify_invite(&key, c, &forged),
|
||||
Err(InviteError::BadSignature)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_key() {
|
||||
let c = community();
|
||||
let (code, _) = mint_invite(&test_key(), c, 3600);
|
||||
assert_eq!(
|
||||
verify_invite(&test_key(), c, &code),
|
||||
Err(InviteError::BadSignature)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_expired() {
|
||||
let key = test_key();
|
||||
let c = community();
|
||||
// ttl clamps to 60s minimum, so hand-mint an already-expired payload.
|
||||
let payload = InvitePayload {
|
||||
c: c.as_uuid().to_string(),
|
||||
r: "member".to_string(),
|
||||
e: now_unix() - 10,
|
||||
n: "n".to_string(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&payload).unwrap();
|
||||
let mac = sign_payload(&key, &bytes);
|
||||
let code = format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(&bytes),
|
||||
URL_SAFE_NO_PAD.encode(mac)
|
||||
);
|
||||
assert_eq!(verify_invite(&key, c, &code), Err(InviteError::Expired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage() {
|
||||
let key = test_key();
|
||||
let c = community();
|
||||
assert_eq!(verify_invite(&key, c, ""), Err(InviteError::Malformed));
|
||||
assert_eq!(
|
||||
verify_invite(&key, c, "not-a-code"),
|
||||
Err(InviteError::Malformed)
|
||||
);
|
||||
// "a" / "b" are not valid base64 payloads.
|
||||
assert_eq!(verify_invite(&key, c, "a.b"), Err(InviteError::Malformed));
|
||||
let huge = "x".repeat(MAX_CODE_LEN + 1);
|
||||
assert_eq!(verify_invite(&key, c, &huge), Err(InviteError::Malformed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ttl_is_capped() {
|
||||
let key = test_key();
|
||||
let (_, expires_at) = mint_invite(&key, community(), u64::MAX);
|
||||
assert!(expires_at <= now_unix() + MAX_INVITE_TTL_SECS + 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_role_other_than_member_rejected() {
|
||||
// Even a *correctly signed* payload with an elevated role must fail
|
||||
// verification (defense against a future buggy mint caller).
|
||||
let key = test_key();
|
||||
let c = community();
|
||||
let payload = InvitePayload {
|
||||
c: c.as_uuid().to_string(),
|
||||
r: "admin".to_string(),
|
||||
e: now_unix() + 3600,
|
||||
n: "n".to_string(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&payload).unwrap();
|
||||
let mac = sign_payload(&key, &bytes);
|
||||
let code = format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(&bytes),
|
||||
URL_SAFE_NO_PAD.encode(mac)
|
||||
);
|
||||
assert_eq!(verify_invite(&key, c, &code), Err(InviteError::InvalidRole));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ pub mod connection;
|
||||
pub mod error;
|
||||
/// WebSocket message handlers for NIP-01 client commands.
|
||||
pub mod handlers;
|
||||
/// Stateless HMAC-signed relay invite tokens (mint/verify).
|
||||
pub mod invite_token;
|
||||
/// Relay-signed mesh-LLM status publisher.
|
||||
pub mod mesh_status_publisher;
|
||||
/// Prometheus metrics: recorder, upkeep, HTTP middleware.
|
||||
|
||||
@@ -68,6 +68,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
"/operator/communities/availability",
|
||||
get(api::operator::community_availability),
|
||||
)
|
||||
// Relay invites: mint (owner/admin) + claim (membership-gate exempt)
|
||||
.route("/api/invites", post(api::invites::mint_invite))
|
||||
.route("/api/invites/claim", post(api::invites::claim_invite))
|
||||
// Moderation queue reads (NIP-98 auth + mod-authz gate, L6)
|
||||
.route("/moderation/reports", get(api::bridge::moderation_reports))
|
||||
.route("/moderation/audit", get(api::bridge::moderation_audit))
|
||||
@@ -115,7 +118,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
|| path == "/_status"
|
||||
|| path == "/info";
|
||||
// Files with extensions (e.g. /assets/missing.js) should 404.
|
||||
let has_ext = path.rsplit('/').next().is_some_and(|seg| seg.contains('.'));
|
||||
// Exception: /invite/<code> — invite codes contain a "."
|
||||
// (payload.mac separator) but are SPA routes, not files.
|
||||
let has_ext = !path.starts_with("/invite/")
|
||||
&& path.rsplit('/').next().is_some_and(|seg| seg.contains('.'));
|
||||
if reserved || has_ext {
|
||||
Ok(StatusCode::NOT_FOUND.into_response())
|
||||
} else {
|
||||
|
||||
@@ -368,6 +368,12 @@ pub struct AppState {
|
||||
/// Per-uploader sliding-window rate limiter for media upload starts.
|
||||
/// Key: (community_id, uploader pubkey bytes). Value: (count, window_start).
|
||||
pub media_upload_rate_limiter: Arc<ScopedRateLimiter>,
|
||||
/// Per-claimer fixed-window rate limiter for invite claim attempts
|
||||
/// (`POST /api/invites/claim`). Entries expire after the claim window and
|
||||
/// the cache has a hard capacity because pre-membership callers can cheaply
|
||||
/// generate fresh Nostr keys.
|
||||
pub invite_claim_rate_limiter:
|
||||
Arc<moka::sync::Cache<ScopedPubkeyKey, Arc<std::sync::atomic::AtomicU32>>>,
|
||||
/// Current in-flight media uploads per (community, uploader pubkey).
|
||||
pub media_uploads_in_flight: Arc<DashMap<ScopedPubkeyKey, u32>>,
|
||||
/// Cache for observer agent-owner authorization (kind 24200).
|
||||
@@ -509,6 +515,12 @@ impl AppState {
|
||||
observer_rate_limiter: Arc::new(DashMap::new()),
|
||||
mesh_connect_rate_limiter: Arc::new(DashMap::new()),
|
||||
media_upload_rate_limiter: Arc::new(DashMap::new()),
|
||||
invite_claim_rate_limiter: Arc::new(
|
||||
moka::sync::Cache::builder()
|
||||
.max_capacity(crate::api::invites::CLAIM_RATE_CACHE_CAPACITY)
|
||||
.time_to_live(crate::api::invites::CLAIM_RATE_WINDOW)
|
||||
.build(),
|
||||
),
|
||||
media_uploads_in_flight: Arc::new(DashMap::new()),
|
||||
observer_owner_cache: Arc::new(
|
||||
moka::sync::Cache::builder()
|
||||
|
||||
@@ -36,6 +36,35 @@ fn parse_message_deep_link(url: &Url) -> Option<serde_json::Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Parse the query string of a `buzz://join?…` URL into the JSON payload
|
||||
/// emitted on `deep-link-join`. Requires a ws(s) `relay` URL and a non-empty
|
||||
/// `code`; returns `None` otherwise so the frontend never sees a half-formed
|
||||
/// payload.
|
||||
fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
|
||||
let mut relay: Option<String> = None;
|
||||
let mut code: Option<String> = None;
|
||||
for (k, v) in url.query_pairs() {
|
||||
let v = v.into_owned();
|
||||
if v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match k.as_ref() {
|
||||
"relay" => relay = Some(v),
|
||||
"code" => code = Some(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let (relay_url, code) = (relay?, code?);
|
||||
match Url::parse(&relay_url) {
|
||||
Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {}
|
||||
_ => return None,
|
||||
}
|
||||
Some(serde_json::json!({
|
||||
"relayUrl": relay_url,
|
||||
"code": code,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct NostrBindDeepLinkPayload {
|
||||
@@ -142,6 +171,16 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
|
||||
}
|
||||
let _ = app.emit("deep-link-connect", relay_url);
|
||||
}
|
||||
Some("join") => {
|
||||
// `buzz://join?relay=<ws(s)://...>&code=<invite code>` — fired by
|
||||
// the relay's /invite/<code> landing page. The frontend claims the
|
||||
// invite against the relay's HTTP API, then adds the workspace.
|
||||
let Some(payload) = parse_join_deep_link(&url) else {
|
||||
eprintln!("buzz-desktop: join deep link missing/invalid relay or code: {url_str}");
|
||||
return;
|
||||
};
|
||||
let _ = app.emit("deep-link-join", payload);
|
||||
}
|
||||
Some("message") => {
|
||||
// `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`
|
||||
//
|
||||
@@ -178,7 +217,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
|
||||
mod tests {
|
||||
use url::Url;
|
||||
|
||||
use super::{parse_message_deep_link, parse_nostr_bind_deep_link};
|
||||
use super::{parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link};
|
||||
|
||||
fn valid_nostr_bind_url() -> Url {
|
||||
Url::parse(
|
||||
@@ -237,6 +276,38 @@ mod tests {
|
||||
assert!(payload["threadRootId"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_join_deep_link_extracts_relay_and_code() {
|
||||
let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap();
|
||||
let payload = parse_join_deep_link(&url).expect("required params present");
|
||||
assert_eq!(payload["relayUrl"], "wss://relay.example");
|
||||
assert_eq!(payload["code"], "abc.def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_join_deep_link_rejects_missing_code() {
|
||||
let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap();
|
||||
assert!(parse_join_deep_link(&url).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_join_deep_link_rejects_empty_code() {
|
||||
let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap();
|
||||
assert!(parse_join_deep_link(&url).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_join_deep_link_rejects_missing_relay() {
|
||||
let url = Url::parse("buzz://join?code=abc.def").unwrap();
|
||||
assert!(parse_join_deep_link(&url).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_join_deep_link_rejects_non_websocket_relay() {
|
||||
let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap();
|
||||
assert!(parse_join_deep_link(&url).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nostr_bind_deep_link_accepts_valid_url() {
|
||||
let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap();
|
||||
|
||||
165
desktop/src/features/relay-members/ui/InviteLinkSection.tsx
Normal file
165
desktop/src/features/relay-members/ui/InviteLinkSection.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Check, Copy, Link2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { mintInvite } from "@/shared/api/invites";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
|
||||
const TTL_OPTIONS: { label: string; value: number }[] = [
|
||||
{ label: "1 day", value: 24 * 60 * 60 },
|
||||
{ label: "3 days", value: 3 * 24 * 60 * 60 },
|
||||
{ label: "7 days", value: 7 * 24 * 60 * 60 },
|
||||
{ label: "30 days", value: 30 * 24 * 60 * 60 },
|
||||
];
|
||||
|
||||
function formatExpiry(expiresAtUnix: number): string {
|
||||
return new Date(expiresAtUnix * 1000).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "Create invite link" section of the relay-access settings card.
|
||||
*
|
||||
* Mints a stateless invite code via `POST /api/invites` (owner/admin only —
|
||||
* the parent card already gates on that) and surfaces the shareable
|
||||
* `/invite/<code>` landing-page URL. Codes are multi-use until expiry and
|
||||
* are not individually revocable; the relay key is the revocation lever.
|
||||
*/
|
||||
export function InviteLinkSection() {
|
||||
const [ttlSecs, setTtlSecs] = React.useState(TTL_OPTIONS[1].value);
|
||||
const [minting, setMinting] = React.useState(false);
|
||||
const [invite, setInvite] = React.useState<{
|
||||
url: string;
|
||||
expiresAt: number;
|
||||
} | null>(null);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const ttlLabel =
|
||||
TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days";
|
||||
|
||||
async function handleCreate() {
|
||||
setInvite(null);
|
||||
setCopied(false);
|
||||
setMinting(true);
|
||||
try {
|
||||
const minted = await mintInvite(ttlSecs);
|
||||
setInvite({ url: minted.url, expiresAt: minted.expiresAt });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? `Couldn't create invite link: ${error.message}`
|
||||
: "Couldn't create invite link",
|
||||
);
|
||||
} finally {
|
||||
setMinting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!invite) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(invite.url);
|
||||
setCopied(true);
|
||||
toast.success("Invite link copied");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("Couldn't copy to clipboard");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-medium">Invite link</span>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="inline-flex shrink-0 self-stretch sm:self-auto">
|
||||
<Button
|
||||
className="rounded-r-none flex-1 sm:flex-none"
|
||||
data-testid="create-invite-link"
|
||||
disabled={minting}
|
||||
onClick={() => void handleCreate()}
|
||||
type="button"
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
Create invite link
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Choose invite expiry"
|
||||
className="rounded-l-none border-l border-primary-foreground/20 px-2 text-xs"
|
||||
data-testid="invite-link-ttl-trigger"
|
||||
disabled={minting}
|
||||
type="button"
|
||||
>
|
||||
{ttlLabel}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuLabel>Expires after</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) => setTtlSecs(Number(value))}
|
||||
value={String(ttlSecs)}
|
||||
>
|
||||
{TTL_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
data-testid={`invite-link-ttl-${option.value}`}
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{invite ? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 rounded-md border border-border/70 bg-background/70 px-2">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground"
|
||||
data-testid="invite-link-url"
|
||||
title={invite.url}
|
||||
>
|
||||
{invite.url}
|
||||
</span>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
data-testid="copy-invite-link"
|
||||
onClick={() => void handleCopy()}
|
||||
size="icon"
|
||||
title="Copy invite link"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Copy invite link</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{invite
|
||||
? `Anyone with this link can join as a member until ${formatExpiry(invite.expiresAt)}.`
|
||||
: "Create a shareable link that lets anyone join this relay as a member until it expires."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { Input } from "@/shared/ui/input";
|
||||
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
|
||||
import { WorkspaceIconSettingsCard } from "@/features/workspaces/ui/WorkspaceIconSettingsCard";
|
||||
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
|
||||
import { InviteLinkSection } from "./InviteLinkSection";
|
||||
|
||||
type AssignableRelayRole = Exclude<RelayMemberRole, "owner">;
|
||||
|
||||
@@ -443,6 +444,8 @@ export function RelayMembersSettingsCard({
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<InviteLinkSection />
|
||||
|
||||
{membersQuery.error instanceof Error ? (
|
||||
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{membersQuery.error.message}
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
expandTilde,
|
||||
normalizeRelayUrl,
|
||||
} from "@/features/workspaces/workspaceStorage";
|
||||
import {
|
||||
inviteErrorMessage,
|
||||
isInviteExpiredError,
|
||||
} from "@/shared/api/inviteHelpers";
|
||||
import { claimInvite } from "@/shared/api/invites";
|
||||
import { validateReposDir } from "@/shared/api/tauri";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
@@ -31,6 +36,8 @@ export function AddWorkspaceDialog({
|
||||
const [name, setName] = React.useState("");
|
||||
const [relayUrl, setRelayUrl] = React.useState("");
|
||||
const [token, setToken] = React.useState("");
|
||||
const [inviteCode, setInviteCode] = React.useState("");
|
||||
const [inviteError, setInviteError] = React.useState<string | null>(null);
|
||||
const [reposDir, setReposDir] = React.useState("");
|
||||
const [reposDirError, setReposDirError] = React.useState<string | null>(null);
|
||||
|
||||
@@ -39,6 +46,8 @@ export function AddWorkspaceDialog({
|
||||
setName("");
|
||||
setRelayUrl("");
|
||||
setToken("");
|
||||
setInviteCode("");
|
||||
setInviteError(null);
|
||||
setReposDir("");
|
||||
setReposDirError(null);
|
||||
}, [onOpenChange]);
|
||||
@@ -62,10 +71,27 @@ export function AddWorkspaceDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// If the relay handed out an invite code, claim it before saving the
|
||||
// workspace — a closed relay would otherwise reject the connection.
|
||||
const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim());
|
||||
if (inviteCode.trim()) {
|
||||
try {
|
||||
await claimInvite(normalizedRelayUrl, inviteCode.trim());
|
||||
} catch (error) {
|
||||
const message = inviteErrorMessage(error);
|
||||
setInviteError(
|
||||
isInviteExpiredError(error)
|
||||
? "This invite code has expired — ask for a new one."
|
||||
: `Invite rejected: ${message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: crypto.randomUUID(),
|
||||
name: name.trim() || deriveWorkspaceName(relayUrl.trim()),
|
||||
relayUrl: normalizeRelayUrl(relayUrl.trim()),
|
||||
relayUrl: normalizedRelayUrl,
|
||||
token: token.trim() || undefined,
|
||||
reposDir: expandedReposDir,
|
||||
addedAt: new Date().toISOString(),
|
||||
@@ -74,7 +100,7 @@ export function AddWorkspaceDialog({
|
||||
onSubmit(workspace);
|
||||
handleClose();
|
||||
},
|
||||
[name, relayUrl, token, reposDir, onSubmit, handleClose],
|
||||
[name, relayUrl, token, inviteCode, reposDir, onSubmit, handleClose],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -143,6 +169,30 @@ export function AddWorkspaceDialog({
|
||||
value={token}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="ws-invite-code"
|
||||
>
|
||||
Invite Code
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="ws-invite-code"
|
||||
onChange={(e) => {
|
||||
setInviteCode(e.target.value);
|
||||
setInviteError(null);
|
||||
}}
|
||||
placeholder="Paste an invite code for a members-only relay"
|
||||
type="text"
|
||||
value={inviteCode}
|
||||
/>
|
||||
{inviteError ? (
|
||||
<p className="text-xs text-destructive">{inviteError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
|
||||
36
desktop/src/shared/api/inviteHelpers.test.mjs
Normal file
36
desktop/src/shared/api/inviteHelpers.test.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
inviteErrorMessage,
|
||||
isInviteExpiredError,
|
||||
relayHttpFromWs,
|
||||
} from "./inviteHelpers.ts";
|
||||
|
||||
test("relayHttpFromWs maps secure and local relay schemes", () => {
|
||||
assert.equal(
|
||||
relayHttpFromWs("wss://relay.example/path"),
|
||||
"https://relay.example/path",
|
||||
);
|
||||
assert.equal(relayHttpFromWs("ws://localhost:7000"), "http://localhost:7000");
|
||||
});
|
||||
|
||||
test("relayHttpFromWs rejects unexpected schemes", () => {
|
||||
assert.throws(
|
||||
() => relayHttpFromWs("https://relay.example"),
|
||||
/Expected ws:\/\/ or wss:\/\//,
|
||||
);
|
||||
assert.throws(
|
||||
() => relayHttpFromWs("relay.example"),
|
||||
/Expected ws:\/\/ or wss:\/\//,
|
||||
);
|
||||
});
|
||||
|
||||
test("invite expiry sentinel is recognized without hiding other errors", () => {
|
||||
assert.equal(isInviteExpiredError(new Error("invite_expired")), true);
|
||||
assert.equal(isInviteExpiredError(new Error("invite_invalid")), false);
|
||||
assert.equal(
|
||||
inviteErrorMessage("network unavailable"),
|
||||
"network unavailable",
|
||||
);
|
||||
});
|
||||
16
desktop/src/shared/api/inviteHelpers.ts
Normal file
16
desktop/src/shared/api/inviteHelpers.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const INVITE_EXPIRED_ERROR = "invite_expired";
|
||||
|
||||
/** Convert a ws(s) relay URL to its http(s) equivalent. */
|
||||
export function relayHttpFromWs(wsUrl: string): string {
|
||||
if (wsUrl.startsWith("wss://")) return `https://${wsUrl.slice(6)}`;
|
||||
if (wsUrl.startsWith("ws://")) return `http://${wsUrl.slice(5)}`;
|
||||
throw new Error(`Expected ws:// or wss:// relay URL, got: ${wsUrl}`);
|
||||
}
|
||||
|
||||
export function inviteErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : `${error}`;
|
||||
}
|
||||
|
||||
export function isInviteExpiredError(error: unknown): boolean {
|
||||
return inviteErrorMessage(error) === INVITE_EXPIRED_ERROR;
|
||||
}
|
||||
121
desktop/src/shared/api/invites.ts
Normal file
121
desktop/src/shared/api/invites.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { relayHttpFromWs } from "@/shared/api/inviteHelpers";
|
||||
import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri";
|
||||
|
||||
// Relay invite data layer. Both endpoints are NIP-98-authed HTTP POSTs
|
||||
// (mirrors the read path in moderation.ts, plus the payload tag the relay
|
||||
// requires for signed POST bodies):
|
||||
//
|
||||
// - POST /api/invites — mint a code (relay checks owner/admin role)
|
||||
// - POST /api/invites/claim — claim a code, signed by the *joining* key.
|
||||
// This one targets an arbitrary relay (the invite's relay, not necessarily
|
||||
// the active workspace), so the claim helper takes an explicit ws URL.
|
||||
|
||||
const NIP98_KIND = 27235;
|
||||
|
||||
export type MintedInvite = {
|
||||
code: string;
|
||||
expiresAt: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ClaimResult = {
|
||||
status: "joined" | "already_member";
|
||||
communityId: string;
|
||||
host: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
async function sha256Hex(text: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(text),
|
||||
);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the NIP-98 `Authorization` header for a POST with a body.
|
||||
*
|
||||
* The relay requires a `payload` tag carrying sha256(body) for signed POSTs
|
||||
* (api/invites.rs passes `require_payload: true`), and verifies the `u` tag
|
||||
* against the exact request URL — so the caller finalizes both before signing.
|
||||
*/
|
||||
async function nip98PostHeader(url: string, body: string): Promise<string> {
|
||||
const authEvent = await signRelayEvent({
|
||||
kind: NIP98_KIND,
|
||||
content: "",
|
||||
tags: [
|
||||
["u", url],
|
||||
["method", "POST"],
|
||||
["payload", await sha256Hex(body)],
|
||||
["nonce", crypto.randomUUID()],
|
||||
],
|
||||
});
|
||||
// NIP-98 events carry empty content and ASCII-only tags, so btoa is safe here.
|
||||
return `Nostr ${btoa(JSON.stringify(authEvent))}`;
|
||||
}
|
||||
|
||||
async function invitePost<T>(
|
||||
httpBase: string,
|
||||
path: string,
|
||||
body: string,
|
||||
): Promise<T> {
|
||||
const url = `${httpBase.replace(/\/+$/, "")}${path}`;
|
||||
const authorization = await nip98PostHeader(url, body);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
});
|
||||
const json = (await response.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof json.error === "string" ? json.error : `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
/** Mint an invite code on the active workspace's relay (owner/admin only). */
|
||||
export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
|
||||
const base = await getRelayHttpUrl();
|
||||
const body = JSON.stringify(ttlSecs ? { ttl_secs: ttlSecs } : {});
|
||||
const raw = await invitePost<{
|
||||
code: string;
|
||||
expires_at: number;
|
||||
url: string;
|
||||
}>(base, "/api/invites", body);
|
||||
return { code: raw.code, expiresAt: raw.expires_at, url: raw.url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim an invite code against `relayWsUrl` (the invite's relay — not
|
||||
* necessarily the active workspace), signed by this app's identity key.
|
||||
*/
|
||||
export async function claimInvite(
|
||||
relayWsUrl: string,
|
||||
code: string,
|
||||
): Promise<ClaimResult> {
|
||||
const base = relayHttpFromWs(relayWsUrl);
|
||||
const body = JSON.stringify({ code });
|
||||
const raw = await invitePost<{
|
||||
status: "joined" | "already_member";
|
||||
community_id: string;
|
||||
host: string;
|
||||
role: string;
|
||||
}>(base, "/api/invites/claim", body);
|
||||
return {
|
||||
status: raw.status,
|
||||
communityId: raw.community_id,
|
||||
host: raw.host,
|
||||
role: raw.role,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
inviteErrorMessage,
|
||||
isInviteExpiredError,
|
||||
} from "@/shared/api/inviteHelpers";
|
||||
import { claimInvite } from "@/shared/api/invites";
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import {
|
||||
deriveWorkspaceName,
|
||||
@@ -36,6 +41,15 @@ export type NostrBindDeepLinkPayload = {
|
||||
returnMode: "clipboard";
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload emitted by the Rust deep-link handler for `buzz://join?…` —
|
||||
* a relay invite from the web landing page (`/invite/<code>`).
|
||||
*/
|
||||
export type JoinDeepLinkPayload = {
|
||||
relayUrl: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register listeners for deep-link events emitted by the Rust backend.
|
||||
*
|
||||
@@ -43,13 +57,18 @@ export type NostrBindDeepLinkPayload = {
|
||||
* adds a workspace for the relay (deduplicating by URL) and switches
|
||||
* to it. Returns an unlisten function to tear down all listeners.
|
||||
*
|
||||
* When a `buzz://join?relay=<url>&code=<invite>` link is opened (relay
|
||||
* invite landing page), the handler first claims the invite against the
|
||||
* relay's HTTP API — signed by this app's identity key — and only adds and
|
||||
* switches to the workspace once the relay has admitted the key.
|
||||
*
|
||||
* `buzz://message?…` is handled separately by `listenForMessageDeepLinks`,
|
||||
* because it needs to dispatch into the router which only exists below the
|
||||
* `RouterProvider` in the component tree.
|
||||
*/
|
||||
export function listenForDeepLinks(deps: DeepLinkDeps): Promise<UnlistenFn> {
|
||||
return listen<string>("deep-link-connect", (event) => {
|
||||
const relayUrl = normalizeRelayUrl(event.payload);
|
||||
const addAndSwitch = (rawRelayUrl: string) => {
|
||||
const relayUrl = normalizeRelayUrl(rawRelayUrl);
|
||||
const name = deriveWorkspaceName(relayUrl);
|
||||
const id = deps.addWorkspace({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -61,8 +80,38 @@ export function listenForDeepLinks(deps: DeepLinkDeps): Promise<UnlistenFn> {
|
||||
// If addWorkspace returned the already-active workspace (same relay URL),
|
||||
// switchWorkspace is a no-op — force re-init so the connection refreshes.
|
||||
deps.reconnectWorkspace();
|
||||
return name;
|
||||
};
|
||||
|
||||
const connectPromise = listen<string>("deep-link-connect", (event) => {
|
||||
const name = addAndSwitch(event.payload);
|
||||
toast.success(`Connected to ${name}`);
|
||||
});
|
||||
|
||||
const joinPromise = listen<JoinDeepLinkPayload>("deep-link-join", (event) => {
|
||||
const { relayUrl, code } = event.payload;
|
||||
void claimInvite(relayUrl, code)
|
||||
.then((result) => {
|
||||
const name = addAndSwitch(relayUrl);
|
||||
toast.success(
|
||||
result.status === "already_member"
|
||||
? `Already a member of ${name}`
|
||||
: `Joined ${name}`,
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = inviteErrorMessage(error);
|
||||
toast.error(
|
||||
isInviteExpiredError(error)
|
||||
? "This invite link has expired — ask for a new one."
|
||||
: `Couldn't accept the invite: ${message}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all([connectPromise, joinPromise]).then((unlistens) => () => {
|
||||
for (const unlisten of unlistens) unlisten();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Route as rootRouteImport } from "./routes/root";
|
||||
import { Route as reposRouteImport } from "./routes/repos";
|
||||
import { Route as indexRouteImport } from "./routes/index";
|
||||
import { Route as reposDotrepoIdRouteImport } from "./routes/repos.$repoId";
|
||||
import { Route as inviteDotcodeRouteImport } from "./routes/invite.$code";
|
||||
import { Route as reposDotrepoIdDotblobDotsplatRouteImport } from "./routes/repos.$repoId.blob.$";
|
||||
|
||||
const reposRoute = reposRouteImport.update({
|
||||
@@ -25,6 +26,11 @@ const reposDotrepoIdRoute = reposDotrepoIdRouteImport.update({
|
||||
path: "/repos/$repoId",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const inviteDotcodeRoute = inviteDotcodeRouteImport.update({
|
||||
id: "/invite/$code",
|
||||
path: "/invite/$code",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const reposDotrepoIdDotblobDotsplatRoute =
|
||||
reposDotrepoIdDotblobDotsplatRouteImport.update({
|
||||
id: "/repos/$repoId/blob/$",
|
||||
@@ -35,12 +41,14 @@ const reposDotrepoIdDotblobDotsplatRoute =
|
||||
export interface FileRoutesByFullPath {
|
||||
"/": typeof indexRoute;
|
||||
"/repos": typeof reposRoute;
|
||||
"/invite/$code": typeof inviteDotcodeRoute;
|
||||
"/repos/$repoId": typeof reposDotrepoIdRoute;
|
||||
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
"/": typeof indexRoute;
|
||||
"/repos": typeof reposRoute;
|
||||
"/invite/$code": typeof inviteDotcodeRoute;
|
||||
"/repos/$repoId": typeof reposDotrepoIdRoute;
|
||||
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
|
||||
}
|
||||
@@ -48,20 +56,38 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport;
|
||||
"/": typeof indexRoute;
|
||||
"/repos": typeof reposRoute;
|
||||
"/invite/$code": typeof inviteDotcodeRoute;
|
||||
"/repos/$repoId": typeof reposDotrepoIdRoute;
|
||||
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath;
|
||||
fullPaths: "/" | "/repos" | "/repos/$repoId" | "/repos/$repoId/blob/$";
|
||||
fullPaths:
|
||||
| "/"
|
||||
| "/repos"
|
||||
| "/invite/$code"
|
||||
| "/repos/$repoId"
|
||||
| "/repos/$repoId/blob/$";
|
||||
fileRoutesByTo: FileRoutesByTo;
|
||||
to: "/" | "/repos" | "/repos/$repoId" | "/repos/$repoId/blob/$";
|
||||
id: "__root__" | "/" | "/repos" | "/repos/$repoId" | "/repos/$repoId/blob/$";
|
||||
to:
|
||||
| "/"
|
||||
| "/repos"
|
||||
| "/invite/$code"
|
||||
| "/repos/$repoId"
|
||||
| "/repos/$repoId/blob/$";
|
||||
id:
|
||||
| "__root__"
|
||||
| "/"
|
||||
| "/repos"
|
||||
| "/invite/$code"
|
||||
| "/repos/$repoId"
|
||||
| "/repos/$repoId/blob/$";
|
||||
fileRoutesById: FileRoutesById;
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
indexRoute: typeof indexRoute;
|
||||
reposRoute: typeof reposRoute;
|
||||
inviteDotcodeRoute: typeof inviteDotcodeRoute;
|
||||
reposDotrepoIdRoute: typeof reposDotrepoIdRoute;
|
||||
reposDotrepoIdDotblobDotsplatRoute: typeof reposDotrepoIdDotblobDotsplatRoute;
|
||||
}
|
||||
@@ -89,6 +115,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof reposDotrepoIdRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/invite/$code": {
|
||||
id: "/invite/$code";
|
||||
path: "/invite/$code";
|
||||
fullPath: "/invite/$code";
|
||||
preLoaderRoute: typeof inviteDotcodeRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/repos/$repoId/blob/$": {
|
||||
id: "/repos/$repoId/blob/$";
|
||||
path: "/repos/$repoId/blob/$";
|
||||
@@ -102,6 +135,7 @@ declare module "@tanstack/react-router" {
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
indexRoute: indexRoute,
|
||||
reposRoute: reposRoute,
|
||||
inviteDotcodeRoute: inviteDotcodeRoute,
|
||||
reposDotrepoIdRoute: reposDotrepoIdRoute,
|
||||
reposDotrepoIdDotblobDotsplatRoute: reposDotrepoIdDotblobDotsplatRoute,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { index, route, rootRoute } from "@tanstack/virtual-file-routes";
|
||||
|
||||
export const routes = rootRoute("root.tsx", [
|
||||
index("index.tsx"),
|
||||
route("/invite/$code", "invite.$code.tsx"),
|
||||
route("/repos", "repos.tsx"),
|
||||
route("/repos/$repoId", "repos.$repoId.tsx"),
|
||||
route("/repos/$repoId/blob/$", "repos.$repoId.blob.$.tsx"),
|
||||
|
||||
11
web/src/app/routes/invite.$code.tsx
Normal file
11
web/src/app/routes/invite.$code.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { InvitePage } from "@/features/invite/ui/InvitePage";
|
||||
|
||||
export const Route = createFileRoute("/invite/$code")({
|
||||
component: InvitePageRoute,
|
||||
});
|
||||
|
||||
function InvitePageRoute() {
|
||||
const { code } = Route.useParams();
|
||||
return <InvitePage code={code} />;
|
||||
}
|
||||
57
web/src/features/invite/ui/InvitePage.tsx
Normal file
57
web/src/features/invite/ui/InvitePage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Download, ExternalLink, Hexagon } from "lucide-react";
|
||||
|
||||
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
|
||||
|
||||
/**
|
||||
* Landing page for a relay invite link (`/invite/<code>`).
|
||||
*
|
||||
* The code is not validated here — validation happens in the desktop app when
|
||||
* the invite is claimed against `POST /api/invites/claim`, signed by the
|
||||
* joining key. This page only hands the code off via the `buzz://join` deep
|
||||
* link (or tells the visitor where to get the app first).
|
||||
*/
|
||||
export function InvitePage({ code }: { code: string }) {
|
||||
const relay = relayWsUrl();
|
||||
const host = relay.replace(/^wss?:\/\//, "");
|
||||
const deepLink = `buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Hexagon className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-2xl font-semibold tracking-tight">
|
||||
You're invited to join
|
||||
</h1>
|
||||
<p className="mt-1 font-mono text-lg text-foreground">{host}</p>
|
||||
<p className="mt-3 max-w-md text-sm leading-relaxed text-muted-foreground">
|
||||
Buzz is a messaging platform for human–agent collaboration. Accept this
|
||||
invite in the desktop app to join the workspace on this relay.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center gap-3 sm:flex-row">
|
||||
<Button asChild size="lg">
|
||||
<a href={deepLink}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open in Buzz
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<a href={DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||
<Download className="h-4 w-4" />
|
||||
Download Buzz
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
Already have Buzz installed? “Open in Buzz” will launch it
|
||||
and accept the invite. Otherwise download the app first, then come back
|
||||
and use this link again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user