fix: recover community access visibility (#2074)

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Kalvin C
2026-07-18 19:01:03 +00:00
committed by GitHub
co-authored by npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent 3afc518c70
commit ca384d082d
13 changed files with 477 additions and 46 deletions
+1
View File
@@ -690,6 +690,7 @@ jobs:
run: |
cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture
env:
RELAY_URL: ws://localhost:3000
GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr
+9 -2
View File
@@ -677,12 +677,13 @@ pub const fn is_command_kind(kind: u32) -> bool {
)
}
/// Returns `true` if `kind` is a relay-only sidecar kind.
/// Returns `true` if `kind` may only be authored by the relay.
/// Client submission of these kinds must be rejected.
pub const fn is_relay_only_kind(kind: u32) -> bool {
matches!(
kind,
KIND_CHANNEL_SUMMARY
KIND_NIP43_MEMBERSHIP_LIST
| KIND_CHANNEL_SUMMARY
| KIND_PRESENCE_SNAPSHOT
| KIND_DM_VISIBILITY
| KIND_THREAD_SUMMARY
@@ -755,6 +756,12 @@ mod tests {
}
}
#[test]
fn nip43_membership_snapshot_is_relay_only() {
assert!(is_relay_only_kind(KIND_NIP43_MEMBERSHIP_LIST));
assert!(!is_relay_only_kind(KIND_NIP43_LEAVE_REQUEST));
}
#[test]
fn parameterized_replaceable_range() {
assert!(!is_parameterized_replaceable(29999));
+47
View File
@@ -3232,6 +3232,53 @@ impl Db {
))
}
/// Returns whether the relay-authored NIP-43 snapshot is absent or differs
/// from the canonical membership rows for `community_id`.
///
/// Snapshot and canonical rows are compared directly rather than by
/// timestamp: relay membership events use whole-second Nostr timestamps,
/// and multiple mutations within one second must still be repaired.
pub async fn nip43_membership_snapshot_needs_reconciliation(
&self,
community_id: CommunityId,
relay_pubkey: &nostr::PublicKey,
) -> Result<bool> {
let snapshot = self
.query_events(&crate::event::EventQuery {
kinds: Some(vec![buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32]),
pubkey: Some(relay_pubkey.to_bytes().to_vec()),
global_only: true,
limit: Some(1),
..crate::event::EventQuery::for_community(community_id)
})
.await?
.into_iter()
.next();
let members = self.list_relay_members(community_id).await?;
let Some(snapshot) = snapshot else {
return Ok(true);
};
let mut snapshot_members = snapshot
.event
.tags
.iter()
.filter_map(|tag| {
let parts = tag.as_slice();
(parts.first().map(String::as_str) == Some("member") && parts.len() >= 3)
.then(|| (parts[1].to_ascii_lowercase(), parts[2].clone()))
})
.collect::<Vec<_>>();
let mut canonical_members = members
.into_iter()
.map(|member| (member.pubkey.to_ascii_lowercase(), member.role))
.collect::<Vec<_>>();
snapshot_members.sort_unstable();
canonical_members.sort_unstable();
Ok(snapshot_members != canonical_members)
}
/// Atomically publish a NIP-43 membership snapshot under a single
/// transaction-scoped advisory lock.
///
+35 -7
View File
@@ -541,7 +541,8 @@ async fn handle_text_message(text: String, conn: Arc<ConnectionState>, state: Ar
let permit = match state.handler_semaphore.clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
conn.send(RelayMessage::notice(
conn.send(request_rejection_message(
Some(&sub_id),
"rate-limited: too many concurrent requests",
));
return;
@@ -583,6 +584,13 @@ async fn handle_text_message(text: String, conn: Arc<ConnectionState>, state: Ar
}
}
fn request_rejection_message(sub_id: Option<&str>, reason: &str) -> String {
match sub_id {
Some(sub_id) => RelayMessage::closed(sub_id, reason),
None => RelayMessage::notice(reason),
}
}
async fn enforce_ws_admission(
msg: &ClientMessage,
conn: &ConnectionState,
@@ -613,7 +621,11 @@ async fn enforce_ws_admission(
ws_limit,
)
.await;
if !send_admission_result(conn, ws_result) {
let sub_id = match msg {
ClientMessage::Req { sub_id, .. } => Some(sub_id.as_str()),
_ => None,
};
if !send_admission_result(conn, ws_result, sub_id) {
return false;
}
@@ -632,7 +644,7 @@ async fn enforce_ws_admission(
message_limit,
)
.await;
if !send_admission_result(conn, message_result) {
if !send_admission_result(conn, message_result, None) {
return false;
}
}
@@ -643,19 +655,22 @@ async fn enforce_ws_admission(
fn send_admission_result(
conn: &ConnectionState,
result: Result<(), crate::admission::AdmissionError>,
sub_id: Option<&str>,
) -> bool {
match result {
Ok(()) => true,
Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => {
metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "quota").increment(1);
conn.send(RelayMessage::notice(&format!(
"rate-limited: quota exceeded; retry in {reset_in_secs}s"
)));
conn.send(request_rejection_message(
sub_id,
&format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"),
));
false
}
Err(crate::admission::AdmissionError::Unavailable) => {
metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "unavailable").increment(1);
conn.send(RelayMessage::notice(
conn.send(request_rejection_message(
sub_id,
"rate-limited: shared admission unavailable",
));
false
@@ -757,6 +772,19 @@ mod tests {
.collect()
}
#[test]
fn req_rejections_are_subscription_scoped() {
let reason = "rate-limited: too many concurrent requests";
let closed: serde_json::Value =
serde_json::from_str(&request_rejection_message(Some("history-123"), reason))
.expect("parse CLOSED");
assert_eq!(closed, serde_json::json!(["CLOSED", "history-123", reason]));
let notice: serde_json::Value =
serde_json::from_str(&request_rejection_message(None, reason)).expect("parse NOTICE");
assert_eq!(notice, serde_json::json!(["NOTICE", reason]));
}
#[tokio::test]
async fn send_loop_batches_queued_data_frames_into_one_flush() {
let (data_tx, data_rx) = mpsc::channel(MAX_WS_SEND_BATCH);
+44 -9
View File
@@ -86,15 +86,21 @@ pub async fn handle_req(
}
};
let mut accessible_channels = match state
.get_accessible_channel_ids_cached(conn.tenant.community(), &pubkey_bytes)
.await
{
Ok(ids) => ids,
Err(e) => {
warn!(conn_id = %conn_id, "Failed to get accessible channels: {e}");
conn.send(RelayMessage::closed(&sub_id, "error: database error"));
return;
let mut accessible_channels = if filters_are_nip43_membership_only(&filters) {
metrics::counter!("buzz_req_global_access_resolution_skips_total", "kind" => "13534")
.increment(1);
Vec::new()
} else {
match state
.get_accessible_channel_ids_cached(conn.tenant.community(), &pubkey_bytes)
.await
{
Ok(ids) => ids,
Err(e) => {
warn!(conn_id = %conn_id, "Failed to get accessible channels: {e}");
conn.send(RelayMessage::closed(&sub_id, "error: database error"));
return;
}
}
};
if let Some(allowed) = token_channel_ids.as_deref() {
@@ -816,6 +822,21 @@ pub fn filter_fully_pushable(filter: &Filter) -> bool {
true
}
/// Return whether every filter exclusively targets the globally stored NIP-43
/// membership snapshot. Such requests cannot return channel-scoped rows, so
/// resolving the caller's complete accessible-channel set is wasted I/O.
fn filters_are_nip43_membership_only(filters: &[Filter]) -> bool {
!filters.is_empty()
&& filters.iter().all(|filter| {
filter.kinds.as_ref().is_some_and(|kinds| {
!kinds.is_empty()
&& kinds.iter().all(|kind| {
kind.as_u16() as u32 == buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST
})
})
})
}
/// Extract a channel UUID from a single filter's `#h` tag.
fn extract_channel_id_from_filter(filter: &Filter) -> Option<uuid::Uuid> {
for (tag_key, tag_values) in filter.generic_tags.iter() {
@@ -1323,6 +1344,20 @@ mod tests {
));
}
#[test]
fn nip43_only_filters_skip_channel_access_resolution() {
let membership = Filter::new().kind(nostr::Kind::Custom(13_534));
assert!(filters_are_nip43_membership_only(&[
membership.clone(),
membership,
]));
assert!(!filters_are_nip43_membership_only(&[]));
assert!(!filters_are_nip43_membership_only(&[Filter::new()]));
assert!(!filters_are_nip43_membership_only(&[
Filter::new().kinds([nostr::Kind::Custom(13_534), nostr::Kind::TextNote]),
]));
}
#[test]
fn test_extract_channel_id_single_channel() {
let channel_id = uuid::Uuid::new_v4();
@@ -2750,6 +2750,53 @@ async fn emit_initial_ref_state(
Ok(())
}
/// Reconcile every community's event-backed NIP-43 membership view.
///
/// `relay_members` is canonical. A snapshot is rebuilt only when it is absent
/// or its member/role set differs from the canonical rows. This makes the sweep
/// safe to run at startup and periodically without producing an event stream
/// when nothing changed. A failure in one community is logged and counted but
/// does not prevent the remaining communities from being repaired.
pub async fn reconcile_nip43_membership_snapshots(state: &Arc<AppState>) -> anyhow::Result<usize> {
let communities = state.db.usage_community_hosts().await?;
let mut reconciled = 0usize;
for community in communities {
let community_id = buzz_core::CommunityId::from_uuid(community.id);
let host = community.host;
let result = async {
if !state
.db
.nip43_membership_snapshot_needs_reconciliation(
community_id,
&state.relay_keypair.public_key(),
)
.await?
{
return Ok::<bool, anyhow::Error>(false);
}
let tenant = TenantContext::resolved(community_id, host.clone());
publish_nip43_membership_list(&tenant, state).await?;
Ok::<bool, anyhow::Error>(true)
}
.await;
match result {
Ok(true) => reconciled += 1,
Ok(false) => {}
Err(error) => {
metrics::counter!("buzz_nip43_membership_reconciliation_failures_total")
.increment(1);
warn!(%community_id, %host, %error, "NIP-43 membership reconciliation failed");
}
}
}
metrics::counter!("buzz_nip43_membership_reconciliations_total").increment(reconciled as u64);
Ok(reconciled)
}
/// Publish a kind:13534 relay membership list event (NIP-43).
///
/// Queries all current relay members and emits a relay-signed, NIP-70-protected
@@ -2763,6 +2810,24 @@ async fn emit_initial_ref_state(
pub async fn publish_nip43_membership_list(
tenant: &TenantContext,
state: &Arc<AppState>,
) -> anyhow::Result<()> {
let started_at = std::time::Instant::now();
metrics::counter!("buzz_nip43_membership_publications_total", "result" => "attempted")
.increment(1);
let result = publish_nip43_membership_list_inner(tenant, state).await;
metrics::histogram!("buzz_nip43_membership_publication_seconds")
.record(started_at.elapsed().as_secs_f64());
metrics::counter!(
"buzz_nip43_membership_publications_total",
"result" => if result.is_ok() { "succeeded" } else { "failed" }
)
.increment(1);
result
}
async fn publish_nip43_membership_list_inner(
tenant: &TenantContext,
state: &Arc<AppState>,
) -> anyhow::Result<()> {
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
let community = tenant.community();
+33 -21
View File
@@ -461,34 +461,46 @@ async fn main() -> anyhow::Result<()> {
);
}
// NIP-43: publish the initial membership list on startup so clients can
// REQ kind:13534 immediately without waiting for the next membership change.
// NIP-43: reconcile the event-backed roster for every provisioned
// community before opening the listener. `relay_members` is canonical;
// this repairs pre-snapshot communities and any publication that failed
// after a membership transaction committed.
if config.require_relay_membership {
// Resolve the deployment's community from the configured relay URL
// host (single-community per deployment), failing closed if the host
// isn't mapped. Await publication before opening the listener so the
// first client query observes the roster.
match buzz_relay::tenant::bind_deployment_community(&state.db, &state.config.relay_url)
.await
match buzz_relay::handlers::side_effects::reconcile_nip43_membership_snapshots(&state).await
{
Ok(tenant) => {
if let Err(e) = buzz_relay::handlers::side_effects::publish_nip43_membership_list(
&tenant, &state,
Ok(count) => info!(count, "NIP-43 membership snapshots reconciled on startup"),
Err(error) => {
tracing::warn!(%error, "NIP-43 membership snapshot startup reconciliation failed")
}
}
let reconcile_state = Arc::clone(&state);
let interval_secs = std::env::var("BUZZ_NIP43_RECONCILE_INTERVAL_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(60)
.max(1);
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
interval.tick().await;
loop {
interval.tick().await;
match buzz_relay::handlers::side_effects::reconcile_nip43_membership_snapshots(
&reconcile_state,
)
.await
{
tracing::warn!(error = %e, "failed to publish initial NIP-43 membership list on startup");
} else {
tracing::info!("NIP-43 membership list published on startup");
Ok(count) if count > 0 => {
info!(count, "NIP-43 membership snapshots repaired")
}
Ok(_) => {}
Err(error) => tracing::warn!(
%error,
"periodic NIP-43 membership snapshot reconciliation failed"
),
}
}
Err(e) => {
tracing::warn!(
error = ?e,
"initial NIP-43 membership list skipped: relay host is not mapped to a community"
);
}
}
});
}
// Emit kind:39000/39002 discovery events for channels that exist in the DB
@@ -214,6 +214,44 @@ async fn test_connect_and_authenticate() {
client.disconnect().await.expect("clean disconnect");
}
#[tokio::test]
#[ignore]
async fn test_client_submitted_nip43_membership_snapshots_are_rejected() {
let url = relay_url();
let keys = Keys::generate();
// Prove this actor can submit a normal event so the rejection below is
// specifically the relay-only invariant, not a broader authorization failure.
create_test_channel(&keys).await;
let forged = EventBuilder::new(Kind::Custom(13_534), "")
.tags([Tag::parse(["member", &keys.public_key().to_hex(), "owner"]).unwrap()])
.sign_with_keys(&keys)
.expect("sign forged membership snapshot");
let mut ws = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let ok = ws
.send_event(forged.clone())
.await
.expect("submit forged snapshot via websocket");
assert!(!ok.accepted, "forged WebSocket snapshot must be rejected");
assert_eq!(ok.message, "restricted: relay-only kind");
ws.disconnect().await.expect("disconnect");
let response = reqwest::Client::new()
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", keys.public_key().to_hex())
.header("Content-Type", "application/json")
.body(serde_json::to_string(&forged).unwrap())
.send()
.await
.expect("submit forged snapshot via HTTP");
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
let body = response.text().await.expect("read HTTP rejection");
assert!(
body.contains("restricted: relay-only kind"),
"unexpected HTTP rejection: {body}"
);
}
#[tokio::test]
#[ignore]
async fn test_invite_mint_and_claim_admits_new_pubkey() {
@@ -4,6 +4,7 @@ import {
addRelayMember,
changeRelayMemberRole,
getMyRelayMembership,
getMyRelayMembershipLookup,
listRelayMembers,
removeRelayMember,
} from "@/shared/api/relayMembers";
@@ -11,6 +12,9 @@ import type { RelayMember } from "@/shared/api/types";
export const relayMembersQueryKey = ["relayMembers"] as const;
export const myRelayMembershipQueryKey = ["myRelayMembership"] as const;
export const myRelayMembershipLookupQueryKey = [
"myRelayMembershipLookup",
] as const;
export function useRelayMembersQuery(enabled = true) {
return useQuery({
@@ -29,6 +33,14 @@ export function useMyRelayMembershipQuery() {
});
}
export function useMyRelayMembershipLookupQuery() {
return useQuery({
queryKey: myRelayMembershipLookupQueryKey,
queryFn: getMyRelayMembershipLookup,
staleTime: 60_000,
});
}
export function useAddRelayMemberMutation() {
const queryClient = useQueryClient();
@@ -61,6 +73,9 @@ export function useAddRelayMemberMutation() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: relayMembersQueryKey }),
queryClient.invalidateQueries({ queryKey: myRelayMembershipQueryKey }),
queryClient.invalidateQueries({
queryKey: myRelayMembershipLookupQueryKey,
}),
]);
},
});
@@ -91,6 +106,9 @@ export function useRemoveRelayMemberMutation() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: relayMembersQueryKey }),
queryClient.invalidateQueries({ queryKey: myRelayMembershipQueryKey }),
queryClient.invalidateQueries({
queryKey: myRelayMembershipLookupQueryKey,
}),
]);
},
});
@@ -134,6 +152,9 @@ export function useChangeRelayMemberRoleMutation() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: relayMembersQueryKey }),
queryClient.invalidateQueries({ queryKey: myRelayMembershipQueryKey }),
queryClient.invalidateQueries({
queryKey: myRelayMembershipLookupQueryKey,
}),
]);
},
});
@@ -1,8 +1,9 @@
import * as React from "react";
import { getVersion } from "@tauri-apps/api/app";
import { ArrowLeft } from "lucide-react";
import { AlertCircle, ArrowLeft, LoaderCircle, RefreshCw } from "lucide-react";
import { useMyRelayMembershipQuery } from "@/features/community-members/hooks";
import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks";
import { shouldWarnMissingMembershipSnapshot } from "@/shared/api/relayMembers";
import { getFeature } from "@/shared/features/manifest";
import {
resolveEnabled,
@@ -129,10 +130,10 @@ export function SettingsView({
section,
}: SettingsViewProps) {
const { isMobile, open: sidebarOpen, setOpen: setSidebarOpen } = useSidebar();
const myMembershipQuery = useMyRelayMembershipQuery();
const myMembershipQuery = useMyRelayMembershipLookupQuery();
const featureState = useFeatureSnapshot();
const visibleSections = React.useMemo(() => {
const membership = myMembershipQuery.data;
const membership = myMembershipQuery.data?.membership;
return settingsSections.filter((s) => {
// Feature gate check. Manifest is preview-only — if the gate id is in
@@ -243,6 +244,44 @@ export function SettingsView({
</SidebarHeader>
<SidebarContent>
{myMembershipQuery.isPending ? (
<div
className="mx-3 flex items-center gap-2 rounded-md border border-sidebar-border px-3 py-2 text-xs text-sidebar-foreground/70"
data-testid="community-access-loading"
>
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
Checking community access
</div>
) : null}
{myMembershipQuery.isError ? (
<div
className="mx-3 space-y-2 rounded-md border border-destructive/40 px-3 py-2 text-xs text-sidebar-foreground"
data-testid="community-access-error"
>
<div className="flex items-center gap-2">
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
Community access could not be checked.
</div>
<button
className="flex items-center gap-1.5 font-medium text-sidebar-foreground underline-offset-2 hover:underline"
onClick={() => void myMembershipQuery.refetch()}
type="button"
>
<RefreshCw className="h-3.5 w-3.5" />
Try again
</button>
</div>
) : null}
{shouldWarnMissingMembershipSnapshot(myMembershipQuery.data) ? (
<div
className="mx-3 flex items-start gap-2 rounded-md border border-amber-500/40 px-3 py-2 text-xs text-sidebar-foreground"
data-testid="community-access-snapshot-missing"
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
Community access data is unavailable. Relay recovery may still be
in progress.
</div>
) : null}
{visibleNavGroups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import { handleRelayClosed } from "./relayClosedRecovery.ts";
test("production CLOSED handler rejects history once and clears its timeout", () => {
const originalWindow = globalThis.window;
const clearedTimeouts = [];
globalThis.window = {
clearTimeout: (timeout) => clearedTimeouts.push(timeout),
};
try {
const errors = [];
const subscriptions = new Map([
[
"history-1",
{
mode: "history",
events: [],
resolve: () => assert.fail("CLOSED must not resolve history"),
reject: (error) => errors.push(error),
timeout: 42,
},
],
]);
const input = {
subscriptions,
subId: "history-1",
sendReq: () => Promise.resolve(),
};
handleRelayClosed({
...input,
message: "rate-limited: too many concurrent requests",
});
handleRelayClosed({ ...input, message: "late CLOSED" });
assert.equal(subscriptions.has("history-1"), false);
assert.deepEqual(clearedTimeouts, [42]);
assert.equal(errors.length, 1);
assert.equal(
errors[0].message,
"rate-limited: too many concurrent requests",
);
} finally {
globalThis.window = originalWindow;
}
});
test("production CLOSED handler removes terminal live subscriptions", () => {
let readyCalls = 0;
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], limit: 50 },
onEvent: () => {},
resolveReady: () => {
readyCalls += 1;
},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "restricted: access revoked",
sendReq: () => Promise.resolve(),
});
assert.equal(subscriptions.has("live-1"), false);
assert.equal(readyCalls, 1);
});
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldWarnMissingMembershipSnapshot } from "./relayMembers.ts";
test("missing snapshot warns when the relay requires membership", () => {
assert.equal(
shouldWarnMissingMembershipSnapshot({
snapshotFound: false,
membershipRequired: true,
membership: null,
}),
true,
);
});
test("missing snapshot is normal on an open relay", () => {
assert.equal(
shouldWarnMissingMembershipSnapshot({
snapshotFound: false,
membershipRequired: false,
membership: null,
}),
false,
);
});
test("an available snapshot never warns", () => {
assert.equal(
shouldWarnMissingMembershipSnapshot({
snapshotFound: true,
membershipRequired: true,
membership: null,
}),
false,
);
});
+33 -3
View File
@@ -1,5 +1,5 @@
import { relayClient } from "@/shared/api/relayClient";
import { signRelayEvent } from "@/shared/api/tauri";
import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import type {
RelayEvent,
@@ -34,9 +34,16 @@ export type RelayMembershipLookup = {
* be treated as a denial by onboarding.
*/
snapshotFound: boolean;
membershipRequired: boolean;
membership: RelayMember | null;
};
export function shouldWarnMissingMembershipSnapshot(
lookup: RelayMembershipLookup | undefined,
): boolean {
return lookup?.membershipRequired === true && !lookup.snapshotFound;
}
export function relayMembersFromEvent(event: RelayEvent): RelayMember[] {
const seen = new Set<string>();
const members: RelayMember[] = [];
@@ -68,14 +75,20 @@ export function relayMembersFromEvent(event: RelayEvent): RelayMember[] {
export function relayMembershipLookupFromEvent(
event: RelayEvent | null,
pubkey: string,
membershipRequired = event !== null,
): RelayMembershipLookup {
if (!event) {
return { snapshotFound: false, membership: null };
return {
snapshotFound: false,
membershipRequired,
membership: null,
};
}
const normalizedPubkey = normalizePubkey(pubkey);
return {
snapshotFound: true,
membershipRequired,
membership:
relayMembersFromEvent(event).find(
(member) => normalizePubkey(member.pubkey) === normalizedPubkey,
@@ -97,12 +110,29 @@ export async function listRelayMembers(): Promise<RelayMember[]> {
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)
);
}
export async function getMyRelayMembershipLookup(): Promise<RelayMembershipLookup> {
const [{ pubkey }, event] = await Promise.all([
getIdentity(),
fetchMembershipListEvent(),
]);
return relayMembershipLookupFromEvent(event, pubkey);
const membershipRequired =
event !== null || (await relayRequiresMembership());
return relayMembershipLookupFromEvent(event, pubkey, membershipRequired);
}
export async function getMyRelayMembership(): Promise<RelayMember | null> {