Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,7 @@ typesense-data/
.hermit/
doc/
/repos/

# Local identity files
identity.key
**/identity.key
9 changes: 4 additions & 5 deletions desktop/src/features/onboarding/ui/OnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 &&
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/relay-members/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
154 changes: 154 additions & 0 deletions desktop/src/shared/api/relayMembers.ts
Original file line number Diff line number Diff line change
@@ -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) : [];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Distinguish missing NIP-43 snapshot from denied membership

Returning an empty list when no kind 13534 event exists makes getMyRelayMembership() resolve to null, which onboarding interprets as “access denied” (checkMembershipDenied in OnboardingFlow.tsx). On relays with membership gating disabled, the server does not publish the startup 13534 snapshot, so this path incorrectly sends normal users to the membership-denied flow before profile save. Please treat “no snapshot available” as a non-denial state (or explicitly surface an unknown state) instead of collapsing it to “not a member.”

Useful? React with 👍 / 👎.

}

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,
);
}
106 changes: 105 additions & 1 deletion desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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]);
Expand All @@ -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, [
Expand Down Expand Up @@ -4581,6 +4684,7 @@ export function maybeInstallE2eTauriMocks() {
}

resetMockTokens(config);
resetMockRelayMembers(config);
resetMockManagedAgents();
resetMockPersonas();
resetMockTeams();
Expand Down
Loading