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:
Bradley Axen
2026-07-10 12:06:35 -07:00
committed by GitHub
parent b97777350d
commit 2e529aab75
18 changed files with 1586 additions and 9 deletions

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

View File

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

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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();

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

View File

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

View File

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

View 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",
);
});

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

View 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,
};
}

View File

@@ -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();
});
}
/**

View File

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

View File

@@ -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"),

View 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} />;
}

View 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&apos;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? &ldquo;Open in Buzz&rdquo; will launch it
and accept the invite. Otherwise download the app first, then come back
and use this link again.
</p>
</div>
);
}