Skip to content

feat: relay invite links (mint + claim + landing page + deep link)#1668

Open
baxen wants to merge 9 commits into
mainfrom
astro-relay-invites
Open

feat: relay invite links (mint + claim + landing page + deep link)#1668
baxen wants to merge 9 commits into
mainfrom
astro-relay-invites

Conversation

@baxen

@baxen baxen commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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)

  • Stateless HMAC invite tokens (crates/buzz-relay/src/invite_token.rs): payload.mac format, community-scoped, TTL'd (default TTL), key derived server-side — no new DB table. Codes minted for one community fail verification on another.
  • HTTP routes (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_limiter on AppState) bounds brute-force probing. Idempotent: returns joined vs already_member; membership added via add_relay_member (ON CONFLICT DO NOTHING); expired codes return invite_expired.
  • SPA fallback fix in router.rs so /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: Rust parse_join_deep_link + TS listener that claims the invite then adds/switches workspace.
  • "Invite Code (optional)" field in AddWorkspaceDialog for joining additional relays manually; invite_expired mapped to friendly copy.
  • InviteLinkSection create-invite-link UI in RelayMembersSettingsCard.

Testing

  • Invite token: 8 unit tests. Claim/mint routes: 4 Postgres-gated tests (pass with --ignored).
  • Deep link: all 26 deep_link Rust tests pass; desktop TS + tauri tests green.
  • Web typecheck/build/biome green; buzz-db/auth/core/conformance green.
  • Mobile checks not run locally (no dart/flutter) — CI covers them. Mobile app support for invite links is not included; follow-up candidate.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread crates/buzz-relay/src/api/invites.rs Outdated
Comment on lines +218 to +219
.entry(key)
.or_insert((0, now));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@baxen baxen requested a review from a team as a code owner July 9, 2026 21:37
npub1k766u8wq088nkr9vy94uqlh9kmw5956g593a7xk7cks7frhgctmsr00p27 and others added 9 commits July 9, 2026 14:37
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>
@baxen baxen force-pushed the astro-relay-invites branch from 782d03e to 4216128 Compare July 9, 2026 21:42

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.rs is an ideal module boundary — fully isolated from HTTP, self-contained error type, thorough test matrix.
  • Idempotency is explicitly designed and integration-tested (already_member on 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())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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://") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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)}`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants