From dd1582bcab731d095e5576d0d32c7e5b96aab610 Mon Sep 17 00:00:00 2001 From: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27 Date: Wed, 8 Jul 2026 21:56:57 -0700 Subject: [PATCH 01/11] checkpoint: relay invite links design notes (no code yet) Recon + full implementation plan for end-to-end relay invite links: stateless HMAC invite codes, mint/claim HTTP routes, /invite/ web page, buzz://join deep link, members-card mint UI. See INVITES_CHECKPOINT.md for resume state. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- INVITES_CHECKPOINT.md | 107 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 INVITES_CHECKPOINT.md diff --git a/INVITES_CHECKPOINT.md b/INVITES_CHECKPOINT.md new file mode 100644 index 0000000000..5829beff8f --- /dev/null +++ b/INVITES_CHECKPOINT.md @@ -0,0 +1,107 @@ +# CHECKPOINT — Relay Invite Links (end-to-end) — 2026-07-09 + +Branch: `astro-relay-invites` (worktree `REPOS/buzz-worktrees/astro-relay-invites`, based on origin/main e0f76b0e). +Owner: astro (agent). Requested by baxen in buzz-invites channel (thread root f32e88ac…). +Status: **recon complete, design settled, no code written yet.** This file is the resume point. + +## Agreed design (baxen approved in thread) + +Stateless signed invite tokens — **no relay_invites table** (table is a future increment for +single-use/revocation). Anyone with the link can self-onboard until expiry; default expiry short (72h). + +Deliverables (baxen's list): +1. UI to create the link from the relay members settings card (admin/owner). +2. UI acceptance of links to onboard to a relay (deep link + AddWorkspaceDialog paste). +3. Backend checking of the claim event. +4. Website in the middle: `/invite/` page with download option + "Open in Buzz". + +## Token format (decided) + +`code = base64url(payload) + "." + base64url(hmac_sha256(relay_secret, payload))` +payload JSON: `{"c": community_id, "r": "member", "e": expires_at_unix, "n": nonce}` +- Secret: derive from `state.relay_keypair` secret key (HMAC key = sha256(secret_key_bytes || "invite-v1")) so no new config. `relay_keypair` at `crates/buzz-relay/src/state.rs:295`, built in `main.rs:298`. +- hmac/sha2/base64/rand already deps of buzz-relay (Cargo.toml:57,66,67,69). +- Community-scoped: claim handler must check payload community == tenant.community() (multi-tenant conformance). + +## Wire protocol (NIP-43 aligned) + +- **Mint**: kind 28935 (ephemeral range — never stored), sent by admin/owner over WS or HTTP bridge. + Relay validates sender role via `get_relay_member` (same pattern as relay_admin.rs 9030 handler, + incl. ±120s created_at freshness), replies with the code. NOTE: WS OK-message can't carry a payload + well → **decision: mint via HTTP bridge instead**: `POST /invite/mint` (NIP-98 auth, role-gated), + returns `{code, url, expires_at}`. Simpler than stuffing a code into an OK message. Constants go in + `crates/buzz-core/src/kind.rs` next to KIND_NIP43_* (line ~249) if we do event-based mint later. +- **Claim**: kind 28934, user-signed, `["claim", ]` tag. Ephemeral. MUST work pre-membership: + - WS path: handled in `handlers/event.rs` BEFORE the membership gate (like AUTH). Sender = event.pubkey. + - Also add HTTP: `POST /invite/claim` (NIP-98-signed event in body, no membership enforcement) — + desktop can call it before opening WS. Probably ship HTTP-only first; WS optional. + - Handler: verify HMAC + expiry + community match → `add_relay_member(community, pubkey, "member", Some("invite"))` + (db fn at `crates/buzz-db/src/relay_members.rs:97`, idempotent) → publish_nip43_member_added + + publish_nip43_membership_list (`handlers/side_effects.rs:2704-2820`). + - Rate limit: per-IP/per-pubkey simple counter (moka cache on AppState) — claim is reachable unauthed. + +## Relay integration points + +- Router: add routes in `crates/buzz-relay/src/router.rs` api_router block (~line 60): `/invite/mint`, `/invite/claim`. + New module `crates/buzz-relay/src/api/invites.rs`, registered in `api/mod.rs` (pub mod invites;). +- Auth helpers to reuse: `verify_bridge_auth` (bridge.rs:28), `nip98_expected_url` (bridge.rs:161), + `check_nip98_replay` (bridge.rs:102), tenant binding via `crate::tenant::bind_community` (see submit_event bridge.rs:557). +- Mint authz: `state.db.get_relay_member(tenant.community(), sender_hex)` role in (admin,owner) — + mirror relay_admin.rs:133-142. +- Token helper: new `crates/buzz-relay/src/invite_token.rs` (mint/verify + unit tests) or inside api/invites.rs. +- On open relays (require_relay_membership=false): claim still inserts the member row (harmless, keeps roster). + +## Web (`web/`) + +- Routes: `web/src/app/routes.ts` (tanstack virtual-file-routes) — add `route("/invite/$code", "invite.$code.tsx")`. +- Page: install/download links + "Open in Buzz" button → + `buzz://join?relay=&code=`. + Reuse pattern from `web/src/features/repos/ui/ConnectButton.tsx`; `relayWsUrl()` in `web/src/shared/lib/relay-url.ts`. +- SPA fallback already serves unknown paths when BUZZ_WEB_DIR set (`router.rs:89-122`) — no server change needed. +- Download links: point at GitHub releases (check what marketing/download URL exists; placeholder ok). + +## Desktop (`desktop/`) + +- Deep link: extend `desktop/src-tauri/src/deep_link.rs` with `Some("join")` arm → + emit `deep-link-join` with `{relayUrl, code}` (validate ws/wss like connect arm). +- Frontend listener: `desktop/src/shared/deep-link.ts` — new `listenForDeepLinks` case or separate listener: + - Stash pending invite code (module-level or localStorage `buzz-pending-invite`), + - addWorkspace + switchWorkspace (same as connect), + - After workspace applied & identity exists → claim: POST /invite/claim with NIP-98 signed by + identity (via `signRelayEvent` from `@/shared/api/tauri` + `getRelayHttpUrl()`, pattern: + `desktop/src/shared/api/moderation.ts:222-243`). + - Claim call site: onboarding membership check (`OnboardingFlow.tsx` checkMembershipDenied, line ~60) + — if pending invite code exists, claim BEFORE checking membership; also in MembershipDenied add + "Have an invite code?" paste field. +- First-run: `App.tsx:346` registers listenForDeepLinks only after WelcomeSetup completes? — VERIFY: + deep link on cold start comes through as launch arg (`src-tauri/src/lib.rs:77-84`). Need pending-code + persistence across the WelcomeSetup path: if `deep-link-join` arrives while needsSetup, seed + WelcomeSetup relay URL + store code. Check how deep-link-connect behaves on first run today. +- Create-link UI: `desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx` — add + "Create invite link" button (visible to admin/owner, uses `useMyRelayMembershipQuery`), calls + POST /invite/mint, shows copyable `https:///invite/` + expiry note. + New api fn in `desktop/src/shared/api/relayMembers.ts`. +- AddWorkspaceDialog (`desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx`): accept a full + invite link OR code pasted into a new field; parse relay URL from link; claim after connect. + +## Test plan + +- Rust: unit tests for token mint/verify (expiry, tamper, wrong community); handler tests mirroring + existing relay_admin tests; `cargo test -p buzz-relay invite`. +- Desktop: mjs unit test for parsing invite links (pattern: `messageLink.test.mjs`). +- Manual: two-instance flow vs local relay (see TESTING.md / LOCAL_RELAY_MEMBERSHIP_TESTING_RESEARCH.md in nest). + +## Nest references + +- `RESEARCH/RELAY_INVITE_FLOW_2026_07_09.md` — full investigation writeup. +- Thread: channel buzz-invites 68bd2cb8…, root f32e88ac…. + +## Remaining TODO (ordered) + +1. relay: invite_token helper + tests +2. relay: api/invites.rs mint+claim routes + router wiring + tests +3. web: /invite/$code page +4. desktop: deep_link.rs join arm + TS listener + pending-code store +5. desktop: claim call in onboarding + MembershipDenied paste field + AddWorkspaceDialog field +6. desktop: mint UI in RelayMembersSettingsCard +7. e2e sanity + PR From c7e4748365e0bf5aa3c8e6eec0eebd0c1e3b3d17 Mon Sep 17 00:00:00 2001 From: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27 Date: Wed, 8 Jul 2026 22:05:59 -0700 Subject: [PATCH 02/11] feat(relay): stateless HMAC invite token mint/verify Invite codes are base64url(payload).base64url(hmac_sha256) over {community, role, expires_at, nonce}, keyed by sha256(relay secret || domain label). Multi-use until expiry, community-scoped, role capped at member. No schema change; per-code revocation deferred to a future relay_invites table. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/invite_token.rs | 329 ++++++++++++++++++++++++++ crates/buzz-relay/src/lib.rs | 2 + 2 files changed, 331 insertions(+) create mode 100644 crates/buzz-relay/src/invite_token.rs diff --git a/crates/buzz-relay/src/invite_token.rs b/crates/buzz-relay/src/invite_token.rs new file mode 100644 index 0000000000..dda3c495ee --- /dev/null +++ b/crates/buzz-relay/src/invite_token.rs @@ -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":"","r":"member","e":1767000000,"n":""} +//! ``` +//! +//! ## 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; + +/// 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 { + 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 { + 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)); + } +} diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 088177e4de..eab5f63887 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -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. From b5a34edc98d25d3745bf4215e5efea7034605c4d Mon Sep 17 00:00:00 2001 From: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27 Date: Wed, 8 Jul 2026 22:13:51 -0700 Subject: [PATCH 03/11] feat(relay): invite mint + claim HTTP routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/invites (NIP-98, owner/admin only, mirrors kind:9030 authz) returns a code + shareable /invite/ URL on the tenant host. POST /api/invites/claim (NIP-98 by the joining pubkey) verifies the HMAC code and inserts via add_relay_member — deliberately exempt from the relay-membership gate, rate-limited per pubkey, publishing NIP-43 member-added + membership-list events on first join. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/invites.rs | 529 +++++++++++++++++++++++++++ crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/router.rs | 3 + crates/buzz-relay/src/state.rs | 6 + 4 files changed, 539 insertions(+) create mode 100644 crates/buzz-relay/src/api/invites.rs diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs new file mode 100644 index 0000000000..32ad53b341 --- /dev/null +++ b/crates/buzz-relay/src/api/invites.rs @@ -0,0 +1,529 @@ +//! 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::Arc; +use std::time::{Duration, Instant}; + +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}; + +/// Sliding-window size for the per-pubkey claim rate limiter. +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; + +/// 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, +} + +/// 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, + headers: &HeaderMap, + path: &str, + body: &[u8], +) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json)> { + 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>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + 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); + + 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!("https://{}/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>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites/claim", &body).await?; + + if claim_rate_limited(&state, &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, + }))) +} + +/// Sliding-window rate limit on claim attempts, keyed by claimer pubkey. +fn claim_rate_limited(state: &AppState, pubkey: &nostr::PublicKey) -> bool { + let key: [u8; 32] = pubkey.to_bytes(); + let now = Instant::now(); + let mut entry = state + .invite_claim_rate_limiter + .entry(key) + .or_insert((0, now)); + let (count, window_start) = entry.value_mut(); + if now.duration_since(*window_start) >= CLAIM_RATE_WINDOW { + *count = 1; + *window_start = now; + return false; + } + if *count >= CLAIM_RATE_LIMIT { + return true; + } + *count += 1; + false +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + 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<'a>( + &'a self, + _ctx: &'a buzz_core::TenantContext, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + 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 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> { + 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, + 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); + } +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index dbdf94a5c6..f180b32557 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -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; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 69e0368ccd..97c4abb7c4 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -68,6 +68,9 @@ pub fn build_router(state: Arc) -> 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)) diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 299b599aec..c7024a3c36 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -368,6 +368,11 @@ 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, + /// Per-claimer sliding-window rate limiter for invite claim attempts + /// (`POST /api/invites/claim`). Key: claimer pubkey bytes (32). Value: + /// (count, window_start). The claim route sits outside the membership + /// gate by design, so this bounds brute-force probing of invite codes. + pub invite_claim_rate_limiter: Arc>, /// Current in-flight media uploads per (community, uploader pubkey). pub media_uploads_in_flight: Arc>, /// Cache for observer agent-owner authorization (kind 24200). @@ -509,6 +514,7 @@ 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(DashMap::new()), media_uploads_in_flight: Arc::new(DashMap::new()), observer_owner_cache: Arc::new( moka::sync::Cache::builder() From 06fb36dfa354695ad6bbb1d7a3d713ec136bc57f Mon Sep 17 00:00:00 2001 From: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27 Date: Wed, 8 Jul 2026 22:16:17 -0700 Subject: [PATCH 04/11] feat(web): /invite/ landing page Shows the relay host, an 'Open in Buzz' button firing the buzz://join deep link with the relay URL + code, and a download link for visitors without the app. The SPA fallback in the relay router now exempts /invite/ paths from the file-extension 404 rule, since invite codes contain a '.' separator. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/router.rs | 5 +- web/src/app/routeTree.gen.ts | 40 ++++++++++++++-- web/src/app/routes.ts | 1 + web/src/app/routes/invite.$code.tsx | 11 +++++ web/src/features/invite/ui/InvitePage.tsx | 57 +++++++++++++++++++++++ 5 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 web/src/app/routes/invite.$code.tsx create mode 100644 web/src/features/invite/ui/InvitePage.tsx diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 97c4abb7c4..5f4bbee7e2 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -118,7 +118,10 @@ pub fn build_router(state: Arc) -> 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/ — 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 { diff --git a/web/src/app/routeTree.gen.ts b/web/src/app/routeTree.gen.ts index a63ab173ab..2751a5990d 100644 --- a/web/src/app/routeTree.gen.ts +++ b/web/src/app/routeTree.gen.ts @@ -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, }; diff --git a/web/src/app/routes.ts b/web/src/app/routes.ts index 43a3c7f410..24fa8465e2 100644 --- a/web/src/app/routes.ts +++ b/web/src/app/routes.ts @@ -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"), diff --git a/web/src/app/routes/invite.$code.tsx b/web/src/app/routes/invite.$code.tsx new file mode 100644 index 0000000000..4a54dc881c --- /dev/null +++ b/web/src/app/routes/invite.$code.tsx @@ -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 ; +} diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx new file mode 100644 index 0000000000..03e173d428 --- /dev/null +++ b/web/src/features/invite/ui/InvitePage.tsx @@ -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/`). + * + * 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 ( +
+
+ +
+

+ You're invited to join +

+

{host}

+

+ Buzz is a messaging platform for human–agent collaboration. Accept this + invite in the desktop app to join the workspace on this relay. +

+ + + +

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

+
+ ); +} From ff9ac626f7e505d94b7f1378b381e4aca00726fc Mon Sep 17 00:00:00 2001 From: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27 Date: Wed, 8 Jul 2026 22:21:39 -0700 Subject: [PATCH 05/11] feat(desktop): buzz://join deep link + invite claim on add-workspace buzz://join?relay=&code= (fired by the web landing page) claims the invite via NIP-98-signed POST /api/invites/claim, then adds and switches to the workspace only after the relay admits the key. AddWorkspaceDialog gains an optional invite-code field with the same claim-before-save behavior for manual joins. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/deep_link.rs | 73 +++++++++- .../workspaces/ui/AddWorkspaceDialog.tsx | 50 ++++++- desktop/src/shared/api/invites.ts | 127 ++++++++++++++++++ desktop/src/shared/deep-link.ts | 49 ++++++- 4 files changed, 294 insertions(+), 5 deletions(-) create mode 100644 desktop/src/shared/api/invites.ts diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 0833b777e5..f0df5274a6 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -36,6 +36,35 @@ fn parse_message_deep_link(url: &Url) -> Option { })) } +/// 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 { + let mut relay: Option = None; + let mut code: Option = 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=&code=` — fired by + // the relay's /invite/ 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=&id=[&thread=]` // @@ -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(); diff --git a/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx b/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx index 421c4c900b..9bb07233d7 100644 --- a/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx +++ b/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx @@ -6,6 +6,7 @@ import { expandTilde, normalizeRelayUrl, } from "@/features/workspaces/workspaceStorage"; +import { claimInvite } from "@/shared/api/invites"; import { validateReposDir } from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; import { @@ -31,6 +32,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(null); const [reposDir, setReposDir] = React.useState(""); const [reposDirError, setReposDirError] = React.useState(null); @@ -39,6 +42,8 @@ export function AddWorkspaceDialog({ setName(""); setRelayUrl(""); setToken(""); + setInviteCode(""); + setInviteError(null); setReposDir(""); setReposDirError(null); }, [onOpenChange]); @@ -62,10 +67,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 = error instanceof Error ? error.message : `${error}`; + setInviteError( + message === "invite_expired" + ? "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 +96,7 @@ export function AddWorkspaceDialog({ onSubmit(workspace); handleClose(); }, - [name, relayUrl, token, reposDir, onSubmit, handleClose], + [name, relayUrl, token, inviteCode, reposDir, onSubmit, handleClose], ); return ( @@ -143,6 +165,30 @@ export function AddWorkspaceDialog({ value={token} /> +
+ + { + setInviteCode(e.target.value); + setInviteError(null); + }} + placeholder="Paste an invite code for a members-only relay" + type="text" + value={inviteCode} + /> + {inviteError ? ( +

{inviteError}

+ ) : null} +