fix: skip membership lookup for open relays (#2107)

Signed-off-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
Kalvin C
2026-07-20 08:35:40 -07:00
committed by GitHub
parent 144b4f6c8d
commit fc1db46937
9 changed files with 134 additions and 44 deletions

View File

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

View File

@@ -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;
@@ -751,6 +750,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,

View File

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

View File

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

View File

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

View File

@@ -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
@@ -9519,6 +9521,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":

View File

@@ -72,6 +72,7 @@ test.describe("NIP-IA archive button gate", () => {
}) => {
await installMockBridge(page, {
relayRole: "admin",
relayRequiresMembership: true,
oaOwnerIsMe: false,
archivedIdentities: [],
});
@@ -122,6 +123,7 @@ test.describe("NIP-IA archive button gate", () => {
}) => {
await installMockBridge(page, {
relayRole: "admin",
relayRequiresMembership: true,
oaOwnerIsMe: false,
archivedIdentities: [ALICE_PUBKEY],
});

View File

@@ -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__?: (
@@ -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);
}
@@ -2337,6 +2329,27 @@ 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,
}) => {
@@ -2344,6 +2357,7 @@ test("membership denial can import a different invited key", async ({
await installMockBridge(
page,
{
relayRequiresMembership: true,
relayRole: null,
},
{ skipOnboardingSeed: true },
@@ -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 },
@@ -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 },
@@ -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 },

View File

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