feat: relay invite links (mint + claim + landing page + deep link)#1668
feat: relay invite links (mint + claim + landing page + deep link)#1668baxen wants to merge 9 commits into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 85a2a8a64f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .entry(key) | ||
| .or_insert((0, now)); |
There was a problem hiding this comment.
Bound invite-claim limiter entries
Because /api/invites/claim is intentionally reachable before relay membership, a remote client can generate fresh NIP-98 keypairs and send one claim attempt per pubkey; each call inserts a new key here and this DashMap never expires or removes entries, so the limiter can be turned into unbounded process memory growth instead of throttling. Please use a bounded/TTL cache and/or an IP/global limiter for this pre-membership path.
Useful? React with 👍 / 👎.
Recon + full implementation plan for end-to-end relay invite links: stateless HMAC invite codes, mint/claim HTTP routes, /invite/<code> web page, buzz://join deep link, members-card mint UI. See INVITES_CHECKPOINT.md for resume state. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
POST /api/invites (NIP-98, owner/admin only, mirrors kind:9030 authz) returns a code + shareable /invite/<code> 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 <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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 <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
buzz://join?relay=<wss>&code=<invite> (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 <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
InviteLinkSection mints a code via POST /api/invites with a selectable expiry (1/3/7/30 days) and shows the shareable /invite/<code> landing page URL with one-click copy. Rendered inside RelayMembersSettingsCard, which already gates on owner/admin. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Superseded by the implementation; design rationale now lives in the invite_token and api/invites module docs. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
ws:// dev deployments now get an http invite URL instead of a broken https one, matching nip98_expected_url's scheme logic. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Expire per-pubkey counters after the claim window and cap retained keys so fresh Nostr identities cannot grow relay memory without bound. Add regression coverage for throttling, expiry, and capacity. Co-authored-by: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27 <b7b5ae1dc079cf3b0cac216bc07ee5b6dd42d348a163df1adec5a1e48ee8c2f7@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27 <b7b5ae1dc079cf3b0cac216bc07ee5b6dd42d348a163df1adec5a1e48ee8c2f7@sprout-oss.stage.blox.sqprod.co>
782d03e to
4216128
Compare
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Reviewed via 3 parallel Claude specialists (Security & Reliability, Design & Simplicity, Functionality & Testing). All 3 reported HIGH confidence. CI is fully green (27/27).
Verdict: Approve — no blocking issues. Well-designed stateless HMAC invite system with sound crypto, proper NIP-98 auth, and clean UX flow.
Strengths:
- Crypto verification order is exemplary — MAC verified before any payload field is trusted, constant-time comparison via
hmac::verify_slice, error oracle is tight. - Key derivation is domain-separated (
sha256(secret || "buzz-invite-v1")), blast radius is clear. invite_token.rsis an ideal module boundary — fully isolated from HTTP, self-contained error type, thorough test matrix.- Idempotency is explicitly designed and integration-tested (
already_memberon re-claim).
| /// 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, pubkey: &nostr::PublicKey) -> bool { | ||
| claim_key_rate_limited(&state.invite_claim_rate_limiter, pubkey.to_bytes()) |
There was a problem hiding this comment.
🤖 Should Fix — Rate limiter not scoped to tenant/community
The invite_claim_rate_limiter is keyed purely by pubkey bytes. In a multi-tenant deployment, an adversary controlling one community can exhaust a pubkey's rate-limit budget across all communities by hammering claims on their own relay — preventing the victim key from claiming an invite on a different community.
Fix: key by (community_id, pubkey_bytes), consistent with how observer_rate_limiter and mesh_connect_rate_limiter are scoped elsewhere in AppState.
|
|
||
| // 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://") { |
There was a problem hiding this comment.
🤖 Should Fix — Inline scheme derivation duplicates nip98_expected_url
This wss → https / ws → http block is the same logic as bridge::nip98_expected_url — the comment on L135 even acknowledges this. Consider calling through the existing helper or extracting a shared one-liner, rather than adding another copy of the pattern.
| // 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; |
There was a problem hiding this comment.
🤖 Should Fix — NIP-98 auth helpers duplicated across invites.ts and moderation.ts
nip98PostHeader, sha256Hex, the NIP98_KIND constant, and the invitePost fetch wrapper are ~80% identical to moderation.ts:222-234. These belong in shared/api/nip98.ts or shared/api/tauri.ts to prevent drift:
// shared/api/nip98.ts
export const NIP98_KIND = 27235;
export async function nip98PostHeader(url: string, body: string): Promise<string>
export async function nip98GetHeader(url: string): Promise<string>| /** 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)}`; |
There was a problem hiding this comment.
🤖 Should Fix — relayHttpFromWs passes through unexpected schemes unchanged
If the input starts with neither wss:// nor ws://, the raw URL is returned unchanged to fetch. The deep-link path is safe (Rust validates ws/wss), but claimInvite is also called from AddWorkspaceDialog with freeform user input via normalizeRelayUrl. As defense-in-depth, add a scheme check here:
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:// URL, got: ${wsUrl}`);
}(Also: this is exported but unused outside invites.ts — consider unexporting or moving to shared/lib/ if intended for reuse.)
| /// 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). |
There was a problem hiding this comment.
🤖 Consider — InvitePayload field names are cryptic in Rust code
Nit: Fields c, r, e, n are compact on the wire but every other public struct in the codebase uses descriptive names. Wire compactness could be preserved with #[serde(rename)] on descriptive field names:
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct InvitePayload {
#[serde(rename = "c")]
pub community: String,
#[serde(rename = "r")]
pub role: String,
#[serde(rename = "e")]
pub expires_at: u64,
#[serde(rename = "n")]
pub nonce: String,
}| async function handleCreate() { | ||
| setMinting(true); | ||
| try { | ||
| const minted = await mintInvite(ttlSecs); |
There was a problem hiding this comment.
🤖 Consider — Stale URL visible during re-mint
Nit: On a second "Create invite link" click, the old invite URL stays visible while the new mint request is in-flight (only the button is disabled). A user could copy the stale URL during this window. Adding setInvite(null) at the start of handleCreate would clear it immediately.
| await claimInvite(normalizedRelayUrl, inviteCode.trim()); | ||
| } catch (error) { | ||
| const message = error instanceof Error ? error.message : `${error}`; | ||
| setInviteError( |
There was a problem hiding this comment.
🤖 Consider — No test for invite_expired string mapping
Nit: The string "invite_expired" is used as a sentinel to branch to a user-friendly message. The same pattern exists in the deep-link handler (deep-link.ts:99-104). A backend rename of this string would silently break the UX with no test catching it. Worth either a shared constant or a test asserting the mapping.
Summary
Adds shareable invite links for relay membership, replacing the manual "install app → find your npub → send it to the owner → owner adds you via NIP-43 admin event → owner sends you the relay URL" dance with a single link.
New flow: owner mints a link in Relay Access settings → shares it → invitee opens
https://<relay-host>/invite/<code>→ landing page offers download + "Open in Buzz" (buzz://join?relay=...&code=...) → desktop claims the invite with the invitee's own key and adds/switches to the workspace.What's included
Relay (Rust)
crates/buzz-relay/src/invite_token.rs):payload.macformat, community-scoped, TTL'd (default TTL), key derived server-side — no new DB table. Codes minted for one community fail verification on another.api/invites.rs):POST /api/invites— mint; owner/admin only, NIP-98 authenticated. Returned landing URL follows deployment TLS posture (https for wss://, http for ws://).POST /api/invites/claim— deliberately outside the relay-membership gate (invitee isn't a member yet); NIP-98 signed by the claimer; per-pubkey sliding-window rate limiter (invite_claim_rate_limiteronAppState) bounds brute-force probing. Idempotent: returnsjoinedvsalready_member; membership added viaadd_relay_member(ON CONFLICT DO NOTHING); expired codes returninvite_expired.router.rsso/invite/*paths containing.aren't 404'd.Web
/invite/<code>landing page (web/src/features/invite/ui/InvitePage.tsx) with download links and "Open in Buzz" deep link.Desktop
buzz://join?relay=...&code=...deep link: Rustparse_join_deep_link+ TS listener that claims the invite then adds/switches workspace.AddWorkspaceDialogfor joining additional relays manually;invite_expiredmapped to friendly copy.InviteLinkSectioncreate-invite-link UI inRelayMembersSettingsCard.Testing
--ignored).deep_linkRust tests pass; desktop TS + tauri tests green.