Move relay admin panel to NIP-43 message path (#494)

This commit is contained in:
Bradley Axen
2026-05-07 10:51:46 -07:00
committed by GitHub
parent a76fe529ce
commit c89ef07771
5 changed files with 268 additions and 7 deletions

4
.gitignore vendored
View File

@@ -43,3 +43,7 @@ typesense-data/
.hermit/
doc/
/repos/
# Local identity files
identity.key
**/identity.key

View File

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

View File

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

View 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,
);
}

View File

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