Compare commits

...

1 Commits

Author SHA1 Message Date
userAdityaa 100c771d55 feat: add GET /relay/{id}/members endpoint 2026-04-22 23:56:02 +05:45
3 changed files with 272 additions and 0 deletions
+1
View File
@@ -138,6 +138,7 @@ Endpoints:
- `PUT /relay/{id}` - Updates an existing virtual relay config. Returns 200 on success, 404 if the id doesn't exist, 409 if the new schema/host conflicts with another relay.
- `PATCH /relay/{id}` - Partially updates an existing virtual relay config by recursively merging the provided JSON. Returns 200 on success, 404 if the id doesn't exist, 409 if the new schema/host conflicts, 400 for invalid config. Use `null` values to remove fields.
- `DELETE /relay/{id}` - Deletes a virtual relay config. Returns 200 on success, 404 if the id doesn't exist.
- `GET /relay/{id}/members` - Returns relay members as JSON (`{"members": ["<pubkey>", ...]}`). Returns 200 on success, 404 if the relay id does not exist.
## Scripts
+111
View File
@@ -8,10 +8,12 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"fiatjaf.com/nostr"
"github.com/BurntSushi/toml"
"github.com/gosimple/slug"
)
// APIHandler handles REST API requests for managing virtual relays
@@ -67,6 +69,24 @@ func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if len(parts) > 2 {
if len(parts) == 3 && parts[2] == "members" {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
api.listRelayMembers(w, id)
return
}
// Keep trailing-slash compatibility for existing /relay/{id}/ calls.
if len(parts) != 3 || parts[2] != "" {
writeError(w, http.StatusNotFound, "not found")
return
}
}
switch r.Method {
case http.MethodPost:
api.createRelay(w, r, id)
@@ -81,6 +101,97 @@ func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
// listRelayMembers returns members for a relay as an array of pubkeys.
func (api *APIHandler) listRelayMembers(w http.ResponseWriter, id string) {
members, err := api.resolveRelayMembers(id)
if err != nil {
if os.IsNotExist(err) {
writeError(w, http.StatusNotFound, "relay not found")
} else {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to load relay members: %v", err))
}
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string][]string{"members": members})
}
func (api *APIHandler) resolveRelayMembers(id string) ([]string, error) {
if members, ok := api.getMembersFromLoadedInstance(id); ok {
return members, nil
}
configPath := api.configPath(id)
if err := api.checkConfigExists(configPath); err != nil {
return nil, err
}
config, err := api.loadConfigFromPath(configPath)
if err != nil {
return nil, err
}
memberSet := make(map[string]struct{})
if owner, err := nostr.PubKeyFromHex(config.Info.Pubkey); err == nil {
memberSet[owner.Hex()] = struct{}{}
}
if config.Secret != "" {
if secret, err := nostr.SecretKeyFromHex(config.Secret); err == nil {
memberSet[secret.Public().Hex()] = struct{}{}
}
}
for _, role := range config.Roles {
for _, pubkey := range role.Pubkeys {
if parsed, err := nostr.PubKeyFromHex(pubkey); err == nil {
memberSet[parsed.Hex()] = struct{}{}
}
}
}
events := &EventStore{
Config: config,
Schema: &Schema{Name: slug.Make(config.Schema)},
}
management := &ManagementStore{
Config: config,
Events: events,
}
for _, pubkey := range management.GetMembers() {
memberSet[pubkey.Hex()] = struct{}{}
}
return sortedMembers(memberSet), nil
}
func (api *APIHandler) getMembersFromLoadedInstance(id string) ([]string, bool) {
instancesMux.RLock()
instance, exists := instancesByName[id+".toml"]
instancesMux.RUnlock()
if !exists || instance == nil || instance.Config == nil || instance.Management == nil {
return nil, false
}
memberSet := make(map[string]struct{})
for _, pubkey := range instance.Management.GetMembers() {
memberSet[pubkey.Hex()] = struct{}{}
}
return sortedMembers(memberSet), true
}
func sortedMembers(memberSet map[string]struct{}) []string {
members := Keys(memberSet)
sort.Strings(members)
return members
}
// writeError writes a JSON error response
func writeError(w http.ResponseWriter, status int, message string) {
w.WriteHeader(status)
+160
View File
@@ -604,6 +604,166 @@ func TestAPIHandler_DeleteRelay(t *testing.T) {
})
}
func TestAPIHandler_ListRelayMembers(t *testing.T) {
configDir := t.TempDir()
secretKey := nostr.Generate()
pubkey := secretKey.Public()
whitelist := pubkey.Hex()
api := NewAPIHandler(whitelist, configDir)
t.Run("list members from loaded relay instance", func(t *testing.T) {
member1 := nostr.Generate().Public()
member2 := nostr.Generate().Public()
management := createTestManagementStore()
if err := management.AllowPubkey(member1); err != nil {
t.Fatalf("failed to add first member: %v", err)
}
if err := management.AllowPubkey(member2); err != nil {
t.Fatalf("failed to add second member: %v", err)
}
instancesMux.Lock()
oldByName := instancesByName
oldByHost := instancesByHost
instancesByName = map[string]*Instance{
"loaded.toml": {
Config: &Config{Inactive: false},
Management: management,
},
}
instancesByHost = map[string]*Instance{}
instancesMux.Unlock()
defer func() {
instancesMux.Lock()
instancesByName = oldByName
instancesByHost = oldByHost
instancesMux.Unlock()
}()
req := createAuthenticatedRequest(http.MethodGet, "http://api.example.com/relay/loaded/members", secretKey, nil)
w := httptest.NewRecorder()
api.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
}
var payload struct {
Members []string `json:"members"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
expected := map[string]struct{}{
member1.Hex(): {},
member2.Hex(): {},
}
if len(payload.Members) != len(expected) {
t.Fatalf("expected %d members, got %d (%v)", len(expected), len(payload.Members), payload.Members)
}
for _, actual := range payload.Members {
if _, ok := expected[actual]; !ok {
t.Fatalf("unexpected member in response: %s", actual)
}
}
})
t.Run("list members from config fallback", func(t *testing.T) {
relaySecret := nostr.Generate()
owner := nostr.Generate().Public()
rolePubkey := nostr.Generate().Public()
config := &Config{
Host: "members.example.com",
Schema: "members_relay",
Secret: relaySecret.Hex(),
Roles: map[string]Role{
"admin": {
Pubkeys: []string{rolePubkey.Hex()},
},
},
}
config.Info.Pubkey = owner.Hex()
if err := api.saveConfig(api.configPath("fallback"), config); err != nil {
t.Fatalf("failed to save config: %v", err)
}
instancesMux.Lock()
oldByName := instancesByName
oldByHost := instancesByHost
instancesByName = map[string]*Instance{}
instancesByHost = map[string]*Instance{}
instancesMux.Unlock()
defer func() {
instancesMux.Lock()
instancesByName = oldByName
instancesByHost = oldByHost
instancesMux.Unlock()
}()
req := createAuthenticatedRequest(http.MethodGet, "http://api.example.com/relay/fallback/members", secretKey, nil)
w := httptest.NewRecorder()
api.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String())
}
var payload struct {
Members []string `json:"members"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
expected := map[string]struct{}{
owner.Hex(): {},
relaySecret.Public().Hex(): {},
rolePubkey.Hex(): {},
}
if len(payload.Members) != len(expected) {
t.Fatalf("expected %d members, got %d (%v)", len(expected), len(payload.Members), payload.Members)
}
for _, actual := range payload.Members {
if _, ok := expected[actual]; !ok {
t.Fatalf("unexpected member in response: %s", actual)
}
}
})
t.Run("non-existent relay returns not found", func(t *testing.T) {
req := createAuthenticatedRequest(http.MethodGet, "http://api.example.com/relay/missing/members", secretKey, nil)
w := httptest.NewRecorder()
api.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, w.Code)
}
})
t.Run("members endpoint rejects non-get methods", func(t *testing.T) {
req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/loaded/members", secretKey, []byte("{}"))
w := httptest.NewRecorder()
api.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
})
}
func TestAPIHandler_MethodNotAllowed(t *testing.T) {
configDir := t.TempDir()