Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion desktop/src-tauri/src/commands/relay_members.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,40 @@
use serde::Deserialize;
use tauri::State;

use crate::{
app_state::AppState,
events, nostr_convert,
relay::{query_relay, submit_event},
relay::{
classify_request_error, parse_json_response, query_relay, relay_api_base_url_with_override,
relay_error_message, submit_event,
},
};

#[derive(Deserialize)]
struct RelayInformationDocument {
#[serde(default)]
supported_nips: Vec<u32>,
}

#[tauri::command]
pub async fn relay_requires_membership(state: State<'_, AppState>) -> Result<bool, String> {
let url = format!("{}/info", relay_api_base_url_with_override(&state));
let response = state
.http_client
.get(url)
.header("Accept", "application/nostr+json")
.send()
.await
.map_err(|error| classify_request_error(&error))?;

if !response.status().is_success() {
return Err(relay_error_message(response).await);
}

let info = parse_json_response::<RelayInformationDocument>(response).await?;
Ok(info.supported_nips.contains(&43))
}

#[tauri::command]
pub async fn list_relay_members(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
// kind:13534 is a single replaceable event on the relay carrying all members.
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// Deep async call chains under Tauri command futures exceed the default query depth when computing layouts.
#![recursion_limit = "256"]
#![recursion_limit = "256"] // Deep Tauri command futures exceed the default layout query depth.
mod app_state;
mod archive;
mod builderlab;
Expand Down Expand Up @@ -749,6 +748,7 @@ pub fn run() {
copy_image_to_clipboard,
copy_text_to_clipboard,
fetch_snapshot_bytes,
relay_requires_membership,
list_relay_members,
get_my_relay_membership,
add_relay_member,
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/features/onboarding/ui/OnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@/features/profile/hooks";
import { relayClient } from "@/shared/api/relayClient";
import { getMyRelayMembershipLookup } from "@/shared/api/relayMembers";
import { isRelayUnreachableError } from "@/shared/lib/relayError";
import {
getIdentity,
importIdentity,
Expand Down Expand Up @@ -57,6 +58,10 @@ async function checkMembershipStatus(): Promise<MembershipCheckResult> {
return "ok";
} catch (error) {
if (isRelayMembershipDeniedError(error)) return "denied";
// Native Tauri commands report connectivity failures with the stable
// "relay unreachable:" prefix (see desktop/src-tauri/src/relay.rs), which
// the legacy browser-fetch substrings below do not match.
if (isRelayUnreachableError(error)) return "unreachable";
if (error instanceof Error) {
const msg = error.message.toLowerCase();
if (
Expand Down
34 changes: 33 additions & 1 deletion desktop/src/shared/api/relayMembers.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";

import { shouldWarnMissingMembershipSnapshot } from "./relayMembers.ts";
import {
loadRelayMembershipLookup,
shouldWarnMissingMembershipSnapshot,
} from "./relayMembers.ts";

test("open relays skip the membership snapshot request", async () => {
const lookup = await loadRelayMembershipLookup("a".repeat(64), false, () =>
assert.fail("open relays must not request a membership snapshot"),
);

assert.deepEqual(lookup, {
snapshotFound: false,
membershipRequired: false,
membership: null,
});
});

test("membership relays request their membership snapshot", async () => {
const pubkey = "a".repeat(64);
let requestCount = 0;
const lookup = await loadRelayMembershipLookup(pubkey, true, async () => {
requestCount += 1;
return {
created_at: 1,
tags: [["member", pubkey, "admin"]],
};
});

assert.equal(requestCount, 1);
assert.equal(lookup.snapshotFound, true);
assert.equal(lookup.membershipRequired, true);
assert.equal(lookup.membership?.role, "admin");
});

test("missing snapshot warns when the relay requires membership", () => {
assert.equal(
Expand Down
35 changes: 17 additions & 18 deletions desktop/src/shared/api/relayMembers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { relayClient } from "@/shared/api/relayClient";
import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri";
import { invokeTauri, signRelayEvent } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import type {
RelayEvent,
Expand Down Expand Up @@ -105,34 +105,33 @@ async function fetchMembershipListEvent(): Promise<RelayEvent | null> {
return events[events.length - 1] ?? null;
}

/** Loads the NIP-43 snapshot only when the relay advertises membership support. */
export async function loadRelayMembershipLookup(
pubkey: string,
membershipRequired: boolean,
fetchSnapshot: () => Promise<RelayEvent | null> = fetchMembershipListEvent,
): Promise<RelayMembershipLookup> {
if (!membershipRequired) {
return relayMembershipLookupFromEvent(null, pubkey, false);
}
return relayMembershipLookupFromEvent(await fetchSnapshot(), pubkey, true);
}

export async function listRelayMembers(): Promise<RelayMember[]> {
const event = await fetchMembershipListEvent();
return event ? relayMembersFromEvent(event) : [];
}

async function relayRequiresMembership(): Promise<boolean> {
const base = (await getRelayHttpUrl()).replace(/\/+$/, "");
const response = await fetch(`${base}/info`, {
headers: { Accept: "application/nostr+json" },
});
if (!response.ok) {
throw new Error(`Relay information request failed (${response.status}).`);
}
const info = (await response.json()) as { supported_nips?: unknown };
return (
Array.isArray(info.supported_nips) &&
info.supported_nips.some((nip) => nip === 43)
);
return invokeTauri<boolean>("relay_requires_membership");
}

export async function getMyRelayMembershipLookup(): Promise<RelayMembershipLookup> {
const [{ pubkey }, event] = await Promise.all([
const [{ pubkey }, membershipRequired] = await Promise.all([
getIdentity(),
fetchMembershipListEvent(),
relayRequiresMembership(),
]);
const membershipRequired =
event !== null || (await relayRequiresMembership());
return relayMembershipLookupFromEvent(event, pubkey, membershipRequired);
return loadRelayMembershipLookup(pubkey, membershipRequired);
}

export async function getMyRelayMembership(): Promise<RelayMember | null> {
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ type E2eConfig = {
// fail open (no mod-DM detection), matching the Rust command's contract.
relaySelf?: string | null;
oaOwnerIsMe?: boolean;
/** Whether the mock relay advertises NIP-43 membership support. Defaults to false. */
relayRequiresMembership?: boolean;
relayRole?: "owner" | "admin" | "member" | null;
// Descriptors returned by the mocked `pick_and_upload_media` /
// `upload_media_bytes` commands. Lets a spec drive the attachment flow
Expand Down Expand Up @@ -9419,6 +9421,8 @@ export function maybeInstallE2eTauriMocks() {
}
case "get_relay_http_url":
return getRelayHttpUrl(activeConfig);
case "relay_requires_membership":
return activeConfig?.mock?.relayRequiresMembership ?? false;
case "discover_acp_providers":
return handleDiscoverAcpRuntimes(activeConfig);
case "discover_acp_auth_methods":
Expand Down
2 changes: 2 additions & 0 deletions desktop/tests/e2e/identity-archive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ test.describe("NIP-IA archive button gate", () => {
}) => {
await installMockBridge(page, {
relayRole: "admin",
relayRequiresMembership: true,
oaOwnerIsMe: false,
archivedIdentities: [],
});
Expand Down Expand Up @@ -122,6 +123,7 @@ test.describe("NIP-IA archive button gate", () => {
}) => {
await installMockBridge(page, {
relayRole: "admin",
relayRequiresMembership: true,
oaOwnerIsMe: false,
archivedIdentities: [ALICE_PUBKEY],
});
Expand Down
61 changes: 39 additions & 22 deletions desktop/tests/e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,14 @@ async function setRelayConnectionState(
page: Page,
state: RelayConnectionState,
) {
if (state !== "connected") {
await page.waitForFunction(() => {
const win = window as Window & {
__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string;
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
};
return (
typeof win.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function" &&
typeof win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ === "function" &&
win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__() === "connected"
);
});
} else {
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function",
);
}
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function",
);
await page.evaluate((nextState) => {
const testWindow = window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (
Expand All @@ -59,6 +45,12 @@ async function setRelayConnectionState(
if (!setConnectionState) {
throw new Error("Mock relay connection state helper is not installed.");
}
// Open-relay onboarding may not start a socket before the test exercises
// connectivity UI. Establish the same connected baseline explicitly so a
// delayed mock handshake cannot overwrite the degraded state.
if (nextState !== "connected") {
setConnectionState("connected");
}
setConnectionState(nextState);
}, state);
}
Expand Down Expand Up @@ -2337,13 +2329,35 @@ test("existing relay profile with display name auto-completes onboarding", async
await expectHomeView(page);
});

test("open relay skips membership gating during onboarding", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
relayRequiresMembership: false,
relayRole: null,
},
{ skipOnboardingSeed: true },
);
await page.goto("/");

await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();

await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await expect(page.getByTestId("membership-denied")).toHaveCount(0);
});

test("membership denial can import a different invited key", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
relayRequiresMembership: true,
relayRole: null,
},
{ skipOnboardingSeed: true },
Expand Down Expand Up @@ -2482,6 +2496,7 @@ test("membership denied shows all four affordances and change-community edits no
await installMockBridge(
page,
{
relayRequiresMembership: true,
relayRole: null,
},
{ skipOnboardingSeed: true },
Expand Down Expand Up @@ -2557,6 +2572,7 @@ test("cancel from profile Back preserves drafts and denied Back returns to inter
await installMockBridge(
page,
{
relayRequiresMembership: true,
relayRole: null,
},
{ skipOnboardingSeed: true },
Expand Down Expand Up @@ -2600,6 +2616,7 @@ test("denied on relay A then paste relay B invite URL switches community to B",
await installMockBridge(
page,
{
relayRequiresMembership: true,
relayRole: null,
},
{ skipOnboardingSeed: true },
Expand Down
2 changes: 2 additions & 0 deletions desktop/tests/helpers/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ type MockBridgeOptions = {
* (owner-path branch of the gate).
*/
oaOwnerIsMe?: boolean;
/** Whether the mock relay advertises NIP-43 membership support. Defaults to false. */
relayRequiresMembership?: boolean;
/**
* Active identity's role in the seeded `mockRelayMembers`. `null` removes
* the active identity from the membership list entirely (admin-path branch
Expand Down