mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
625 lines
19 KiB
Markdown
625 lines
19 KiB
Markdown
# 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<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:
|
|
|
|
```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
|