mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user