relay-membership.service.ts raw
1 import { Pubkey } from '@/domain'
2 import { sortEventsDesc } from '@/lib/event'
3 import client from '@/services/client.service'
4 import DataLoader from 'dataloader'
5 import { Filter } from 'nostr-tools'
6
7 /**
8 * NIP-43: Relay Access Metadata and Requests
9 * https://github.com/nostr-protocol/nips/blob/master/43.md
10 */
11 class RelayMembershipService {
12 private static instance: RelayMembershipService
13 private membershipListCache: Map<string, Promise<Set<string>>> = new Map()
14 private membershipListDataLoader = new DataLoader<
15 { url: string; pubkey: string },
16 Set<string>,
17 string
18 >(
19 async (params) => {
20 return Promise.all(params.map(({ url, pubkey }) => this.fetchMembershipList(url, pubkey)))
21 },
22 { cacheKeyFn: (key) => key.url, cacheMap: this.membershipListCache }
23 )
24
25 public static getInstance(): RelayMembershipService {
26 if (!RelayMembershipService.instance) {
27 RelayMembershipService.instance = new RelayMembershipService()
28 }
29 return RelayMembershipService.instance
30 }
31
32 /**
33 * Check if a user is a member of a relay that supports NIP-43
34 * @param relayUrl The relay URL
35 * @param userPubkey The user's public key
36 * @param relayPubkey The relay's public key from NIP-11
37 * @returns Membership status
38 */
39 async checkMembership(
40 relayUrl: string,
41 userPubkey: string,
42 relayPubkey?: string
43 ): Promise<boolean> {
44 if (!relayPubkey) {
45 return false
46 }
47
48 const memberSet = await this.membershipListDataLoader.load({
49 url: relayUrl,
50 pubkey: relayPubkey
51 })
52
53 return memberSet.has(userPubkey)
54 }
55
56 private async fetchMembershipList(relayUrl: string, relayPubkey: string): Promise<Set<string>> {
57 try {
58 const filter: Filter = {
59 kinds: [13534],
60 authors: [relayPubkey],
61 limit: 1
62 }
63
64 const events = await client.fetchEvents([relayUrl], filter)
65
66 if (events.length === 0) {
67 return new Set()
68 }
69
70 const membershipEvent = sortEventsDesc(events)[0]
71 const members = membershipEvent.tags
72 .filter((tag) => tag[0] === 'member' && Pubkey.isValidHex(tag[1]))
73 .map((tag) => tag[1])
74
75 return new Set(members)
76 } catch (error) {
77 console.error('Error checking relay membership:', error)
78 return new Set()
79 }
80 }
81
82 /**
83 * Request an invite code from a relay (kind 28935)
84 * @param relayUrl The relay URL
85 * @param relayPubkey The relay's public key from NIP-11
86 * @returns Invite code or null
87 */
88 async requestInviteCode(relayUrl: string, relayPubkey: string): Promise<string | null> {
89 try {
90 const filter: Filter = {
91 kinds: [28935],
92 authors: [relayPubkey],
93 limit: 1
94 }
95
96 const events = await client.fetchEvents([relayUrl], filter)
97
98 if (events.length === 0) {
99 return null
100 }
101
102 const inviteEvent = events[0]
103 const claimTag = inviteEvent.tags.find((tag) => tag[0] === 'claim')
104 return claimTag?.[1] ?? null
105 } catch (error) {
106 console.error('Error requesting invite code:', error)
107 return null
108 }
109 }
110
111 async addNewMember(relayUrl: string, newMemberPubkey: string) {
112 const cache = await this.membershipListCache.get(relayUrl)
113 if (cache) {
114 cache.add(newMemberPubkey)
115 }
116 }
117
118 async removeMember(relayUrl: string, memberPubkey: string) {
119 const cache = await this.membershipListCache.get(relayUrl)
120 if (cache) {
121 cache.delete(memberPubkey)
122 }
123 }
124 }
125
126 const instance = RelayMembershipService.getInstance()
127 export default instance
128