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
Cookie-Based Authentication (NIP-98)
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:
- User clicks "login" button in web UI
- Client uses
window.nostr.signEvent()to create a kind 27235 event with domain tag - Event is base64-encoded and stored in browser cookie named "nip98"
- Server validates signature and domain tag on each request
- 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:
- Root Users - Can access settings, manage relay configuration, use NIP-86 API
- 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:
- Admin connects to relay WebSocket
- Relay challenges with AUTH (NIP-42)
- Admin signs challenge and sends AUTH event
- Admin sends NIP-86 request event (kind 28934)
- Relay validates admin is root user
- Relay executes command and returns response event
- 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)
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
- Web UI - Full browser-based admin interface (not in NIP-86 spec)
- Cookie auth - Persistent sessions without repeated signing
- HTML responses - Human-readable responses instead of JSON/events
- Broadcast events - Public notifications for membership changes
- 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 memberbanpubkey- Remove memberbanevent- Delete eventlistallowedpubkeys- List memberschangerelayname- Update relay namechangerelaydescription- Update descriptionchangerelayicon- Update iconlistallowedkinds- List allowed event kindsallowkind- Allow event kinddisallowkind- 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(())
}
Example 2: Cookie-Based HTTP Authentication
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
- Implement NIP-42 authentication - Required for any WebSocket admin access
- Use ephemeral events for sensitive responses - Stats, logs, config should not be stored/broadcast
- Support cookie-based auth for HTTP - Makes web UI development easier
- Define clear authorization levels - root, member, viewer
Medium Priority
- Implement subset of NIP-86 - For standardization and CLI tooling
- Add HTTP endpoints for quick queries - Stats, health checks
- Broadcast public state changes - Repo updates, member changes
- Document custom event kinds - Write NIP-XX for ngit-specific events
Low Priority
- Build web UI - Can come after API is stable
- Support multiple interfaces - HTTP + WebSocket + NIP-86
Conclusion
Pyramid demonstrates a pragmatic, multi-interface approach to relay administration:
Key takeaways:
- Authentication - Cookie-based for HTTP, NIP-42 for WebSocket
- Response targeting - Direct responses for sensitive data, broadcast for public updates
- Multiple interfaces - HTTP for humans, WebSocket for programs, NIP-86 for standards
- Authorization - Clear levels with explicit checks
- 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