mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Move relay admin panel to NIP-43 message path (#494)
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -43,3 +43,7 @@ typesense-data/
|
||||
.hermit/
|
||||
doc/
|
||||
/repos/
|
||||
|
||||
# Local identity files
|
||||
identity.key
|
||||
**/identity.key
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
|
||||
import {
|
||||
getIdentity,
|
||||
getMyRelayMembership,
|
||||
importIdentity as tauriImportIdentity,
|
||||
uploadMediaBytes,
|
||||
} from "@/shared/api/tauri";
|
||||
import { getMyRelayMembershipLookup } from "@/shared/api/relayMembers";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { pubkeyToNpub } from "@/shared/lib/nostrUtils";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
@@ -30,16 +30,15 @@ import type {
|
||||
/**
|
||||
* Check whether the relay denies access due to membership gating.
|
||||
*
|
||||
* Uses the `/api/relay/members/me` endpoint which bypasses the membership
|
||||
* middleware — it returns null (404) when authenticated but not a member.
|
||||
* Uses the standard relay message path to read the NIP-43 membership snapshot.
|
||||
*
|
||||
* Returns `true` if denied, `false` if the user is a member (or if the
|
||||
* relay doesn't enforce membership / isn't reachable).
|
||||
*/
|
||||
async function checkMembershipDenied(): Promise<boolean> {
|
||||
try {
|
||||
const membership = await getMyRelayMembership();
|
||||
return membership === null;
|
||||
const { membership, snapshotFound } = await getMyRelayMembershipLookup();
|
||||
return snapshotFound && membership === null;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getMyRelayMembership,
|
||||
listRelayMembers,
|
||||
removeRelayMember,
|
||||
} from "@/shared/api/tauri";
|
||||
} from "@/shared/api/relayMembers";
|
||||
import type { RelayMember } from "@/shared/api/types";
|
||||
|
||||
export const relayMembersQueryKey = ["relayMembers"] as const;
|
||||
|
||||
154
desktop/src/shared/api/relayMembers.ts
Normal file
154
desktop/src/shared/api/relayMembers.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { getIdentity, signRelayEvent } from "@/shared/api/tauri";
|
||||
import type {
|
||||
RelayEvent,
|
||||
RelayMember,
|
||||
RelayMemberRole,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
const KIND_NIP43_MEMBERSHIP_LIST = 13534;
|
||||
const KIND_RELAY_ADMIN_ADD_MEMBER = 9030;
|
||||
const KIND_RELAY_ADMIN_REMOVE_MEMBER = 9031;
|
||||
const KIND_RELAY_ADMIN_CHANGE_ROLE = 9032;
|
||||
|
||||
function isRelayMemberRole(
|
||||
value: string | undefined,
|
||||
): value is RelayMemberRole {
|
||||
return value === "owner" || value === "admin" || value === "member";
|
||||
}
|
||||
|
||||
function normalizePubkey(pubkey: string): string {
|
||||
return pubkey.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function eventCreatedAtIso(event: RelayEvent): string {
|
||||
return new Date(event.created_at * 1_000).toISOString();
|
||||
}
|
||||
|
||||
export type RelayMembershipLookup = {
|
||||
/**
|
||||
* True when the relay returned a NIP-43 membership snapshot.
|
||||
*
|
||||
* Open relays do not publish kind:13534, so absence of this snapshot must not
|
||||
* be treated as a denial by onboarding.
|
||||
*/
|
||||
snapshotFound: boolean;
|
||||
membership: RelayMember | null;
|
||||
};
|
||||
|
||||
export function relayMembersFromEvent(event: RelayEvent): RelayMember[] {
|
||||
const seen = new Set<string>();
|
||||
const members: RelayMember[] = [];
|
||||
const createdAt = eventCreatedAtIso(event);
|
||||
|
||||
for (const tag of event.tags) {
|
||||
const [name, rawPubkey, maybeRoleOrRelay, maybePTagRole] = tag;
|
||||
if (name !== "member" && name !== "p") continue;
|
||||
if (!rawPubkey) continue;
|
||||
|
||||
const pubkey = normalizePubkey(rawPubkey);
|
||||
if (!/^[0-9a-f]{64}$/.test(pubkey) || seen.has(pubkey)) continue;
|
||||
seen.add(pubkey);
|
||||
|
||||
const rawRole = name === "member" ? maybeRoleOrRelay : maybePTagRole;
|
||||
const role = isRelayMemberRole(rawRole) ? rawRole : "member";
|
||||
|
||||
members.push({
|
||||
pubkey,
|
||||
role,
|
||||
addedBy: null,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
export function relayMembershipLookupFromEvent(
|
||||
event: RelayEvent | null,
|
||||
pubkey: string,
|
||||
): RelayMembershipLookup {
|
||||
if (!event) {
|
||||
return { snapshotFound: false, membership: null };
|
||||
}
|
||||
|
||||
const normalizedPubkey = normalizePubkey(pubkey);
|
||||
return {
|
||||
snapshotFound: true,
|
||||
membership:
|
||||
relayMembersFromEvent(event).find(
|
||||
(member) => normalizePubkey(member.pubkey) === normalizedPubkey,
|
||||
) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMembershipListEvent(): Promise<RelayEvent | null> {
|
||||
const events = await relayClient.fetchEvents({
|
||||
kinds: [KIND_NIP43_MEMBERSHIP_LIST],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
return events[events.length - 1] ?? null;
|
||||
}
|
||||
|
||||
export async function listRelayMembers(): Promise<RelayMember[]> {
|
||||
const event = await fetchMembershipListEvent();
|
||||
return event ? relayMembersFromEvent(event) : [];
|
||||
}
|
||||
|
||||
export async function getMyRelayMembershipLookup(): Promise<RelayMembershipLookup> {
|
||||
const [{ pubkey }, event] = await Promise.all([
|
||||
getIdentity(),
|
||||
fetchMembershipListEvent(),
|
||||
]);
|
||||
return relayMembershipLookupFromEvent(event, pubkey);
|
||||
}
|
||||
|
||||
export async function getMyRelayMembership(): Promise<RelayMember | null> {
|
||||
return (await getMyRelayMembershipLookup()).membership;
|
||||
}
|
||||
|
||||
async function publishRelayAdminEvent(
|
||||
kind: number,
|
||||
targetPubkey: string,
|
||||
role?: string,
|
||||
): Promise<void> {
|
||||
const tags = [["p", normalizePubkey(targetPubkey)]];
|
||||
if (role) {
|
||||
tags.push(["role", role]);
|
||||
}
|
||||
|
||||
const event = await signRelayEvent({
|
||||
kind,
|
||||
content: "",
|
||||
tags,
|
||||
});
|
||||
|
||||
await relayClient.publishEvent(
|
||||
event,
|
||||
"Timed out while updating relay access.",
|
||||
"Failed to update relay access.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function addRelayMember(
|
||||
targetPubkey: string,
|
||||
role: string,
|
||||
): Promise<void> {
|
||||
await publishRelayAdminEvent(KIND_RELAY_ADMIN_ADD_MEMBER, targetPubkey, role);
|
||||
}
|
||||
|
||||
export async function removeRelayMember(targetPubkey: string): Promise<void> {
|
||||
await publishRelayAdminEvent(KIND_RELAY_ADMIN_REMOVE_MEMBER, targetPubkey);
|
||||
}
|
||||
|
||||
export async function changeRelayMemberRole(
|
||||
targetPubkey: string,
|
||||
newRole: string,
|
||||
): Promise<void> {
|
||||
await publishRelayAdminEvent(
|
||||
KIND_RELAY_ADMIN_CHANGE_ROLE,
|
||||
targetPubkey,
|
||||
newRole,
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,13 @@ type RawMockTokenSeed = {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
type RawRelayMember = {
|
||||
pubkey: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
added_by: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type RawProfile = {
|
||||
pubkey: string;
|
||||
display_name: string | null;
|
||||
@@ -407,6 +414,55 @@ type MockSocket = {
|
||||
subscriptions: Map<string, string>;
|
||||
};
|
||||
|
||||
function createMockRelayMembershipEvent(): RelayEvent {
|
||||
return createMockEvent(
|
||||
13534,
|
||||
"",
|
||||
mockRelayMembers.map((member) => ["member", member.pubkey, member.role]),
|
||||
"f".repeat(64),
|
||||
);
|
||||
}
|
||||
|
||||
function updateMockRelayMembershipFromAdminEvent(event: RelayEvent): boolean {
|
||||
const targetPubkey = event.tags
|
||||
.find((tag) => tag[0] === "p")?.[1]
|
||||
?.toLowerCase();
|
||||
if (!targetPubkey) return false;
|
||||
|
||||
if (event.kind === 9030) {
|
||||
const role = event.tags.find((tag) => tag[0] === "role")?.[1] ?? "member";
|
||||
if (role !== "admin" && role !== "member") return false;
|
||||
if (mockRelayMembers.some((member) => member.pubkey === targetPubkey)) {
|
||||
return true;
|
||||
}
|
||||
mockRelayMembers.push({
|
||||
pubkey: targetPubkey,
|
||||
role,
|
||||
added_by: event.pubkey,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.kind === 9031) {
|
||||
mockRelayMembers = mockRelayMembers.filter(
|
||||
(member) => member.pubkey !== targetPubkey,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.kind === 9032) {
|
||||
const role = event.tags.find((tag) => tag[0] === "role")?.[1];
|
||||
if (role !== "admin" && role !== "member") return false;
|
||||
mockRelayMembers = mockRelayMembers.map((member) =>
|
||||
member.pubkey === targetPubkey ? { ...member, role } : member,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SPROUT_E2E__?: E2eConfig;
|
||||
@@ -695,6 +751,30 @@ function resetMockTokens(config: E2eConfig | undefined) {
|
||||
mockMintTokenError = config?.mock?.mintTokenError ?? null;
|
||||
}
|
||||
|
||||
function resetMockRelayMembers(config: E2eConfig | undefined) {
|
||||
const pubkey = getMockMemberPubkey(config);
|
||||
mockRelayMembers = [
|
||||
{
|
||||
pubkey,
|
||||
role: "owner",
|
||||
added_by: null,
|
||||
created_at: isoMinutesAgo(120),
|
||||
},
|
||||
{
|
||||
pubkey: ALICE_PUBKEY,
|
||||
role: "admin",
|
||||
added_by: pubkey,
|
||||
created_at: isoMinutesAgo(90),
|
||||
},
|
||||
{
|
||||
pubkey: BOB_PUBKEY,
|
||||
role: "member",
|
||||
added_by: pubkey,
|
||||
created_at: isoMinutesAgo(60),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function resetMockManagedAgents() {
|
||||
mockManagedAgents = [];
|
||||
syncMockRelayAgentsFromManagedAgents();
|
||||
@@ -1076,6 +1156,7 @@ const mockChannels: MockChannel[] = [
|
||||
];
|
||||
|
||||
const mockMessages = new Map<string, RelayEvent[]>();
|
||||
let mockRelayMembers: RawRelayMember[] = [];
|
||||
const mockSockets = new Map<number, MockSocket>();
|
||||
const realSockets = new Map<number, WebSocket>();
|
||||
let mockTokens: MockToken[] = [];
|
||||
@@ -4524,7 +4605,17 @@ function sendToMockSocket(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = rest[1] as { "#h"?: string[] };
|
||||
const filter = rest[1] as { "#h"?: string[]; kinds?: number[] };
|
||||
if (filter.kinds?.includes(13534)) {
|
||||
sendWsText(socket.handler, [
|
||||
"EVENT",
|
||||
subId,
|
||||
createMockRelayMembershipEvent(),
|
||||
]);
|
||||
sendWsText(socket.handler, ["EOSE", subId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const channelId = filter["#h"]?.[0];
|
||||
if (!channelId) {
|
||||
sendWsText(socket.handler, ["EOSE", subId]);
|
||||
@@ -4543,6 +4634,18 @@ function sendToMockSocket(args: {
|
||||
|
||||
if (type === "EVENT") {
|
||||
const event = rest[0] as RelayEvent;
|
||||
|
||||
if ([9030, 9031, 9032].includes(event.kind)) {
|
||||
const accepted = updateMockRelayMembershipFromAdminEvent(event);
|
||||
sendWsText(socket.handler, [
|
||||
"OK",
|
||||
event.id,
|
||||
accepted,
|
||||
accepted ? "" : "Invalid relay admin event.",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const channelId = getChannelIdFromTags(event.tags);
|
||||
if (!channelId) {
|
||||
sendWsText(socket.handler, [
|
||||
@@ -4581,6 +4684,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
}
|
||||
|
||||
resetMockTokens(config);
|
||||
resetMockRelayMembers(config);
|
||||
resetMockManagedAgents();
|
||||
resetMockPersonas();
|
||||
resetMockTeams();
|
||||
|
||||
Reference in New Issue
Block a user