Files
ngit-grasp/docs/research/pyramid-admin-patterns.md
T

19 KiB

Pyramid Relay Admin Patterns Research

Overview

Pyramid is a hierarchical relay system written in Go by @fiatjaf that implements a sophisticated multi-relay architecture with admin controls. This document analyzes how pyramid handles admin authentication, queries, and responses to inform ngit-grasp's administrator observability design.

Repository: https://github.com/fiatjaf/pyramid Language: Go Key Features: Multi-relay architecture, hierarchical membership, web UI, NIP-86 support

Authentication Architecture

Pyramid uses a cookie-based authentication system leveraging NIP-98 authentication events:

Location: global/utils.go:14-31

func GetLoggedUser(r *http.Request) (nostr.PubKey, bool) {
    if cookie, _ := r.Cookie("nip98"); cookie != nil {
        if evtj, err := base64.StdEncoding.DecodeString(cookie.Value); err == nil {
            var evt nostr.Event
            if err := json.Unmarshal(evtj, &evt); err == nil {
                if tag := evt.Tags.Find("domain"); tag != nil && tag[1] == Settings.Domain {
                    if evt.VerifySignature() {
                        return evt.PubKey, true
                    }
                }
            }
        }
    }
    return nostr.ZeroPK, false
}

How it works:

  1. User clicks "login" button in web UI
  2. Client uses window.nostr.signEvent() to create a kind 27235 event with domain tag
  3. Event is base64-encoded and stored in browser cookie named "nip98"
  4. Server validates signature and domain tag on each request
  5. Returns user's pubkey if valid, zero pubkey if not

Client-side code: layout/layout.templ:277-304

async handleAuth(event) {
    const isLogin = event.target.innerText.toLowerCase() === 'login';
    if (isLogin) {
        try {
            const event = await window.nostr.signEvent({
                created_at: Math.round(Date.now() / 1000),
                kind: 27235,
                tags: [['domain', location.host]],
                content: ''
            });
            this.setCookie('nip98', btoa(JSON.stringify(event)));
        } catch (e) {
            console.error('login failed:', e);
            return;
        }
    } else {
        this.clearCookie('nip98');
    }
    location.reload();
}

Authorization Levels

Pyramid implements two authorization levels:

  1. Root Users - Can access settings, manage relay configuration, use NIP-86 API
  2. Members - Can access stats, member pages, internal relay

Root check: pyramid/members.go:40-45

func IsRoot(pk nostr.PubKey) bool {
    if pk == AbsoluteKey {
        return true
    }
    member, ok := Members.Load(pk)
    return ok && member.IsRoot()
}

How Sensitive Data is Returned to Admins

1. HTML/HTTP Responses (Primary Method)

Pyramid primarily uses server-side rendered HTML with Go templates (templ) to return admin data via HTTP:

Stats Endpoint

Location: handler.go:601-623

func statsHandler(w http.ResponseWriter, r *http.Request) {
    loggedUser, _ := global.GetLoggedUser(r)
    
    if !pyramid.IsMember(loggedUser) {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }
    
    // compute stats for all IndexingLayer instances
    mainStats, _ := global.IL.Main.ComputeStats(mmm.StatsOptions{})
    systemStats, _ := global.IL.System.ComputeStats(mmm.StatsOptions{})
    groupsStats, _ := global.IL.Groups.ComputeStats(mmm.StatsOptions{})
    favoritesStats, _ := global.IL.Favorites.ComputeStats(mmm.StatsOptions{})
    internalStats, _ := global.IL.Internal.ComputeStats(mmm.StatsOptions{})
    moderatedStats, _ := global.IL.Moderated.ComputeStats(mmm.StatsOptions{})
    popularStats, _ := global.IL.Popular.ComputeStats(mmm.StatsOptions{})
    uppermostStats, _ := global.IL.Uppermost.ComputeStats(mmm.StatsOptions{})
    inboxStats, _ := global.IL.Inbox.ComputeStats(mmm.StatsOptions{})
    
    StatsPage(loggedUser, mainStats, systemStats, groupsStats, favoritesStats, 
              internalStats, moderatedStats, popularStats, uppermostStats, inboxStats)
        .Render(r.Context(), w)
}

Response: HTML page with statistics tables showing:

  • Total events per relay
  • Events by kind
  • Events by author
  • Weekly charts

Settings Endpoint

Location: handler.go:43-224

func settingsHandler(w http.ResponseWriter, r *http.Request) {
    loggedUser, _ := global.GetLoggedUser(r)
    if !pyramid.IsRoot(loggedUser) {
        http.Error(w, "unauthorized", 403)
        return
    }
    
    if r.Method == http.MethodPost {
        // Handle settings updates...
        // Process form data, update global.Settings
        if err := global.SaveUserSettings(); err != nil {
            http.Error(w, "failed to save config: "+err.Error(), 500)
            return
        }
        http.Redirect(w, r, r.Header.Get("Referer"), 302)
        return
    }
    
    settingsPage(loggedUser).Render(r.Context(), w)
}

Response: HTML form with all relay configuration options

Member List Endpoint

Location: handler.go:632-673

func inviteTreeHandler(w http.ResponseWriter, r *http.Request) {
    loggedUser, _ := global.GetLoggedUser(r)
    var nip05Names map[nostr.PubKey]string
    if global.Settings.NIP05.Enabled {
        nip05Names = make(map[nostr.PubKey]string, pyramid.Members.Size())
        for name, pubkey := range global.Settings.NIP05.Names {
            nip05Names[pubkey] = name
        }
    }
    inviteTreePage(loggedUser, nip05Names).Render(r.Context(), w)
}

Response: HTML page showing hierarchical member tree

2. NIP-86 Management API

Pyramid implements NIP-86 for programmatic management over WebSocket:

Location: main.go:269-278

relay.ManagementAPI.AllowPubKey = allowPubKeyHandler
relay.ManagementAPI.BanEvent = banEventHandler
relay.ManagementAPI.BanPubKey = banPubKeyHandler
relay.ManagementAPI.ListAllowedPubKeys = listAllowedPubKeysHandler
relay.ManagementAPI.ChangeRelayName = changeRelayNameHandler
relay.ManagementAPI.ChangeRelayDescription = changeRelayDescriptionHandler
relay.ManagementAPI.ChangeRelayIcon = changeRelayIconHandler
relay.ManagementAPI.ListAllowedKinds = listAllowedKindsHandler
relay.ManagementAPI.AllowKind = allowKindHandler
relay.ManagementAPI.DisallowKind = disallowKindHandler

Example: ListAllowedPubKeys

Location: management.go:45-64

func listAllowedPubKeysHandler(ctx context.Context) ([]nip86.PubKeyReason, error) {
    log.Info().Msg("management listallowedpubkeys called")
    list := make([]nip86.PubKeyReason, 0, pyramid.Members.Size())
    for pubkey, member := range pyramid.Members.Range {
        if len(member.Parents) == 0 {
            continue
        }
        reason := "invited by "
        for j, inv := range member.Parents {
            if j > 0 {
                reason += ", "
            }
            if inv == pyramid.AbsoluteKey {
                reason += "root"
            } else {
                reason += inv.Hex()
            }
        }
        list = append(list, nip86.PubKeyReason{PubKey: pubkey, Reason: reason})
    }
    return list, nil
}

Authentication for NIP-86: Uses NIP-42 (AUTH) via khatru framework

Location: management.go:7-15

func allowPubKeyHandler(ctx context.Context, pubkey nostr.PubKey, reason string) error {
    caller, ok := khatru.GetAuthed(ctx)
    if !ok {
        return fmt.Errorf("not authenticated")
    }
    log.Info().Str("caller", caller.Hex()).Str("pubkey", pubkey.Hex())
        .Str("reason", reason).Msg("management allowpubkey called")
    
    err := pyramid.AddAction("invite", caller, pubkey)
    // ...
}

How NIP-86 responses work:

  1. Admin connects to relay WebSocket
  2. Relay challenges with AUTH (NIP-42)
  3. Admin signs challenge and sends AUTH event
  4. Admin sends NIP-86 request event (kind 28934)
  5. Relay validates admin is root user
  6. Relay executes command and returns response event
  7. Response is ephemeral event sent directly to admin's connection

3. Direct WebSocket Broadcast for Membership Changes

Pyramid broadcasts certain admin-related events to all connected clients:

Location: core.go:207-247

func publishMembershipChange(pubkey nostr.PubKey, added bool) {
    // publish to main and internal
    for _, c := range []struct {
        store *mmm.IndexingLayer
        relay *khatru.Relay
    }{{global.IL.Main, relay}, {global.IL.Internal, internal.Relay}} {
        if added {
            // publish kind 8000 (member added)
            evt := nostr.Event{
                Kind:      8000,
                CreatedAt: nostr.Now(),
                Tags: nostr.Tags{
                    {"-"},
                    {"p", pubkey.Hex()},
                },
            }
            evt.Sign(global.Settings.RelayInternalSecretKey)
            c.store.SaveEvent(evt)
            c.relay.BroadcastEvent(evt)  // <-- Direct broadcast
        } else {
            // publish kind 8001 (member removed)
            evt := nostr.Event{
                Kind:      8001,
                CreatedAt: nostr.Now(),
                Tags: nostr.Tags{
                    {"-"},
                    {"p", pubkey.Hex()},
                },
            }
            evt.Sign(global.Settings.RelayInternalSecretKey)
            c.store.SaveEvent(evt)
            c.relay.BroadcastEvent(evt)  // <-- Direct broadcast
        }
        
        // Also publish updated member lists...
    }
}

Event types:

  • Kind 8000: Member added notification
  • Kind 8001: Member removed notification
  • Kind 13534: Full relay member list
  • Kind 19004: Room creation permission list

These are broadcast to all connected clients (not targeted).

Communication Patterns Summary

Method Transport Authentication Response Type Use Case
HTML Pages HTTP GET Cookie (NIP-98) Server-rendered HTML Primary admin UI
HTML Forms HTTP POST Cookie (NIP-98) Redirect/HTML Configuration updates
NIP-86 API WebSocket NIP-42 AUTH Ephemeral events Programmatic management
Membership Events WebSocket None (broadcast) Stored events Real-time notifications

Key Design Patterns

1. Hybrid Approach

Pyramid uses both HTTP and WebSocket:

  • HTTP/HTML for human-friendly admin interface
  • WebSocket/NIP-86 for programmatic/CLI access
  • WebSocket broadcasts for real-time updates

2. Direct Response (Not Broadcast for Sensitive Data)

For sensitive queries like stats:

  • HTTP responses go directly to authenticated admin only
  • NIP-86 responses are ephemeral events sent to requester's connection only
  • No broadcast of sensitive data to all subscribers

3. Public Notifications for State Changes

For non-sensitive state changes (membership):

  • Events are signed by relay's internal key
  • Events are stored and broadcast to all clients
  • Includes protective tags (e.g., {"-"} to prevent republishing)

Instead of requiring NIP-42 AUTH for every HTTP request:

  • Single sign-in creates persistent cookie
  • Cookie contains signed NIP-98 event
  • Validated on each request without new signatures

5. Authorization Hierarchy

Clear separation:

  • Root users can modify settings, use NIP-86
  • Members can view stats, use internal relay
  • Non-members only see public content

Comparison with NIP-86 Approach

What Pyramid Does Beyond NIP-86

  1. Web UI - Full browser-based admin interface (not in NIP-86 spec)
  2. Cookie auth - Persistent sessions without repeated signing
  3. HTML responses - Human-readable responses instead of JSON/events
  4. Broadcast events - Public notifications for membership changes
  5. Multi-relay - Separate relay instances for different purposes

How Pyramid Uses NIP-86

Pyramid implements NIP-86 as one of multiple interfaces:

Supported commands:

  • allowpubkey - Add member
  • banpubkey - Remove member
  • banevent - Delete event
  • listallowedpubkeys - List members
  • changerelayname - Update relay name
  • changerelaydescription - Update description
  • changerelayicon - Update icon
  • listallowedkinds - List allowed event kinds
  • allowkind - Allow event kind
  • disallowkind - Disallow event kind

NOT used for:

  • Statistics (uses HTTP endpoint instead)
  • Complex configuration (uses HTTP forms instead)
  • File uploads (uses HTTP multipart instead)

Lessons for ngit-grasp

1. Authentication Strategy

Pyramid's approach:

Browser (HTTP) → Cookie with signed NIP-98 event → Validate on each request
CLI (WebSocket) → NIP-42 AUTH challenge → Validate once per connection

Applicable to ngit-grasp:

  • Could use same cookie approach for web UI
  • MUST use NIP-42 for WebSocket admin queries
  • Could support both simultaneously

2. Response Targeting

Pyramid's pattern:

  • Sensitive data (stats, config) → Direct HTTP response OR ephemeral event to requester
  • Public notifications (membership) → Broadcast to all subscribers

For ngit-grasp admin queries:

Option A: Direct WebSocket response
  - Admin sends query event
  - Relay sends response event ONLY to that connection
  - Not stored, not broadcast

Option B: Ephemeral event with targeting
  - Admin sends query event
  - Relay creates kind 2XXXX ephemeral response
  - Event includes p-tag with admin's pubkey
  - Sent only to admin's connection (khatru supports this)

Option C: HTTP endpoint (like pyramid)
  - Admin authenticates via cookie or NIP-98
  - GET /admin/stats returns JSON
  - Simple, works in browsers, not Nostr-native

3. Multi-Interface Design

Pyramid shows it's valuable to support multiple interfaces:

┌─────────────┐
│   Admins    │
└──────┬──────┘
       │
   ┌───┴────┬──────────┬─────────┐
   │        │          │         │
  Web UI  CLI Tool  NIP-86   Monitoring
   │        │       Client    Service
   ▼        ▼          ▼         ▼
  HTTP  WebSocket  WebSocket    HTTP

For ngit-grasp:

  • Could implement HTTP endpoints for quick queries
  • Could implement NIP-86 for standardization
  • Could use custom events for ngit-specific data
  • Should support at least 2 methods for flexibility

4. Event Types for Notifications

Pyramid uses custom event kinds for relay-specific events:

Kind 8000  - Member added
Kind 8001  - Member removed
Kind 13534 - Full member list
Kind 19004 - Permission list

For ngit-grasp:

  • Could define custom kinds for git events
  • Could broadcast repo state changes
  • Could publish audit logs as events
  • Should document custom kinds in NIP-XX

5. Authorization Patterns

Pyramid's check before every admin operation:

caller, ok := khatru.GetAuthed(ctx)
if !ok {
    return fmt.Errorf("not authenticated")
}
if !pyramid.IsRoot(caller) {
    return fmt.Errorf("unauthorized")
}

For ngit-grasp:

  • MUST verify authentication for admin queries
  • MUST check authorization level
  • Could support role-based access (root, moderator, viewer)
  • Should log all admin actions

Code Examples for ngit-grasp

Example 1: Direct Response to Admin Query (Ephemeral)

Based on pyramid's pattern:

// In relay query handler
fn handle_admin_query(ctx: &Context, filter: Filter) -> Result<(), Error> {
    // Check if authenticated
    let admin_pubkey = get_authed_pubkey(ctx)?;
    
    // Check authorization
    if !is_admin(admin_pubkey) {
        return Err(Error::Unauthorized);
    }
    
    // Process query and build response
    let stats = compute_repo_stats()?;
    
    // Create ephemeral response event
    let response = Event {
        kind: 20000, // Ephemeral
        pubkey: relay_pubkey(),
        tags: vec![
            Tag::pubkey(admin_pubkey), // Target admin
            Tag::new("stats", serde_json::to_string(&stats)?),
        ],
        content: "".to_string(),
        // ...
    };
    
    // Send ONLY to this admin's connection (not broadcast)
    send_to_connection(ctx, response)?;
    
    Ok(())
}

Based on pyramid's GetLoggedUser:

fn get_logged_admin(req: &HttpRequest) -> Option<PublicKey> {
    let cookie = req.cookie("nip98")?;
    let event_json = base64::decode(cookie.value()).ok()?;
    let event: Event = serde_json::from_slice(&event_json).ok()?;
    
    // Validate domain tag
    if !event.tags.iter().any(|t| {
        t.kind() == "domain" && t.content() == Some(&relay_domain())
    }) {
        return None;
    }
    
    // Verify signature
    if !event.verify_signature() {
        return None;
    }
    
    Some(event.pubkey)
}

Example 3: Broadcast Public Notifications

Based on pyramid's publishMembershipChange:

fn publish_repo_event(repo_id: &str, event_type: &str) {
    let event = Event::new(
        kind: 30001, // Parameterized replaceable
        pubkey: relay_internal_key(),
        tags: vec![
            Tag::new("d", repo_id),
            Tag::new("event_type", event_type),
        ],
        content: "".to_string(),
    ).sign(&relay_secret_key());
    
    // Store event
    store.save_event(&event)?;
    
    // Broadcast to all subscribers
    broadcast_event(&event);
}

Recommendations for ngit-grasp

High Priority

  1. Implement NIP-42 authentication - Required for any WebSocket admin access
  2. Use ephemeral events for sensitive responses - Stats, logs, config should not be stored/broadcast
  3. Support cookie-based auth for HTTP - Makes web UI development easier
  4. Define clear authorization levels - root, member, viewer

Medium Priority

  1. Implement subset of NIP-86 - For standardization and CLI tooling
  2. Add HTTP endpoints for quick queries - Stats, health checks
  3. Broadcast public state changes - Repo updates, member changes
  4. Document custom event kinds - Write NIP-XX for ngit-specific events

Low Priority

  1. Build web UI - Can come after API is stable
  2. Support multiple interfaces - HTTP + WebSocket + NIP-86

Conclusion

Pyramid demonstrates a pragmatic, multi-interface approach to relay administration:

Key takeaways:

  1. Authentication - Cookie-based for HTTP, NIP-42 for WebSocket
  2. Response targeting - Direct responses for sensitive data, broadcast for public updates
  3. Multiple interfaces - HTTP for humans, WebSocket for programs, NIP-86 for standards
  4. Authorization - Clear levels with explicit checks
  5. Event types - Custom kinds for relay-specific notifications

For ngit-grasp:

The most important pattern is the dual-interface approach:

  • Use WebSocket with ephemeral events for admin queries (NIP-42 auth)
  • Use WebSocket with stored events for public notifications (signed by relay)
  • Optionally add HTTP endpoints for convenience

This provides flexibility, security, and follows Nostr conventions while being practical for daily use.

References