diff --git a/docs/research/pyramid-admin-patterns.md b/docs/research/pyramid-admin-patterns.md new file mode 100644 index 0000000..9a7a66a --- /dev/null +++ b/docs/research/pyramid-admin-patterns.md @@ -0,0 +1,624 @@ +# 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 + +### Cookie-Based Authentication (NIP-98) + +Pyramid uses a cookie-based authentication system leveraging NIP-98 authentication events: + +**Location:** `global/utils.go:14-31` + +```go +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` + +```javascript +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` + +```go +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` + +```go +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` + +```go +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` + +```go +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` + +```go +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` + +```go +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` + +```go +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` + +```go +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) + +### 4. Cookie-Based Web Authentication + +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:** + +```go +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: + +```rust +// 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(()) +} +``` + +### Example 2: Cookie-Based HTTP Authentication + +Based on pyramid's GetLoggedUser: + +```rust +fn get_logged_admin(req: &HttpRequest) -> Option { + 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: + +```rust +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 + +5. **Implement subset of NIP-86** - For standardization and CLI tooling +6. **Add HTTP endpoints for quick queries** - Stats, health checks +7. **Broadcast public state changes** - Repo updates, member changes +8. **Document custom event kinds** - Write NIP-XX for ngit-specific events + +### Low Priority + +9. **Build web UI** - Can come after API is stable +10. **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 + +- Pyramid repository: https://github.com/fiatjaf/pyramid +- NIP-86 spec: https://github.com/nostr-protocol/nips/blob/master/86.md +- NIP-42 spec: https://github.com/nostr-protocol/nips/blob/master/42.md +- NIP-98 spec: https://github.com/nostr-protocol/nips/blob/master/98.md +- Khatru framework: https://github.com/fiatjaf/khatru