mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
docs: design pyramid-style query responses for rust-nostr
This commit is contained in:
@@ -0,0 +1,824 @@
|
||||
# Pyramid-Style Query Responses Design for rust-nostr
|
||||
|
||||
**Date:** 2026-01-15
|
||||
**Status:** Research and Design
|
||||
**Context:** Administrator observability and management strategy (Issue EC1F)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document explores implementing Pyramid-style admin/user query responses using rust-nostr's `nostr-relay-builder` framework. Pyramid relay sends ephemeral events directly to specific WebSocket connections (not broadcast) using NIP-42 for authentication.
|
||||
|
||||
**Feasibility Verdict:** ⚠️ **Partially Feasible with Significant Limitations**
|
||||
|
||||
The current `nostr-relay-builder` architecture does **not** expose the necessary low-level APIs to send messages to specific WebSocket connections. The framework is designed for broadcast-style relays, not connection-specific messaging. Implementing pyramid-style queries would require either:
|
||||
|
||||
1. **Fork/modify nostr-relay-builder** to expose connection-specific messaging
|
||||
2. **Implement a custom relay handler** bypassing nostr-relay-builder for admin queries
|
||||
3. **Use HTTP-based NIP-86** instead (recommended approach)
|
||||
|
||||
## Background
|
||||
|
||||
### Pyramid Relay Approach
|
||||
|
||||
Pyramid relay implements administrative queries using:
|
||||
- **NIP-42 WebSocket Authentication** - Clients authenticate their identity
|
||||
- **Query Events** - Admin sends query event (not stored, just processed)
|
||||
- **Ephemeral Responses** - Relay sends ephemeral events (kind 20000-29999) directly to the requesting connection only
|
||||
- **No Broadcast** - Responses are connection-specific, not sent to other subscribers
|
||||
|
||||
### Why This Matters for ngit-grasp
|
||||
|
||||
ngit-grasp needs administrator observability:
|
||||
- Query purgatory state (pending events)
|
||||
- View sync status and health metrics
|
||||
- Inspect configuration and operational state
|
||||
- Debug connection/authentication issues
|
||||
|
||||
## Architecture Analysis
|
||||
|
||||
### nostr-relay-builder Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ LocalRelay (public API) │
|
||||
│ - notify_event() - Broadcasts to all connections │
|
||||
│ - No API for connection-specific messaging │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ InnerLocalRelay (internal) │
|
||||
│ - new_event: broadcast::Sender<Event> │
|
||||
│ - Broadcast channel shared by all sessions │
|
||||
│ - No connection tracking exposed │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ handle_websocket() - Per-connection handler │
|
||||
│ - (tx, rx) = ws_stream.split() │
|
||||
│ - Session { │
|
||||
│ subscriptions: HashMap<SubId, Vec<Filter>>, │
|
||||
│ nip42: Nip42Session, │
|
||||
│ } │
|
||||
│ - Loop: recv client msgs, send matching events │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Observations:**
|
||||
|
||||
1. **No Connection Registry** - `InnerLocalRelay` does not maintain a registry of active connections
|
||||
2. **Broadcast Only** - The `new_event` channel broadcasts to all connections via `broadcast::Sender`
|
||||
3. **Session Privacy** - Individual `WsTx` (WebSocket transmitters) are local to `handle_websocket()`, not accessible externally
|
||||
4. **NIP-42 Support Exists** - Authentication is already implemented via `Nip42Session`
|
||||
|
||||
### Current Message Flow
|
||||
|
||||
```
|
||||
Client Message (EVENT)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ handle_client_msg() │
|
||||
│ - WritePolicy │──────► WritePolicyResult::Accept
|
||||
│ - Database save │ │
|
||||
└─────────────────────┘ │
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ database.save(event) │
|
||||
└────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ new_event.send(event) │ ◄─── Broadcast to ALL
|
||||
└────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────┐
|
||||
│ All connections receive event │
|
||||
│ if it matches their filters │
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Problem:** There is no mechanism to send an event to a specific connection only.
|
||||
|
||||
## Design Options
|
||||
|
||||
### Option 1: Fork/Modify nostr-relay-builder (Not Recommended)
|
||||
|
||||
**Approach:** Add connection tracking and connection-specific messaging to `InnerLocalRelay`.
|
||||
|
||||
**Changes Required:**
|
||||
|
||||
```rust
|
||||
// In InnerLocalRelay
|
||||
pub struct InnerLocalRelay {
|
||||
// ... existing fields ...
|
||||
|
||||
// NEW: Track active connections
|
||||
connections: Arc<DashMap<ConnectionId, ConnectionHandle>>,
|
||||
}
|
||||
|
||||
pub struct ConnectionHandle {
|
||||
pub addr: SocketAddr,
|
||||
pub authenticated_pubkey: Option<PublicKey>,
|
||||
tx: mpsc::Sender<RelayMessage>, // Connection-specific channel
|
||||
}
|
||||
|
||||
// NEW: API to send to specific connection
|
||||
pub fn send_to_connection(&self, conn_id: ConnectionId, msg: RelayMessage) -> bool {
|
||||
if let Some(handle) = self.connections.get(&conn_id) {
|
||||
handle.tx.send(msg).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pyramid-Style Query Implementation:**
|
||||
|
||||
```rust
|
||||
// In WritePolicy::admit_event()
|
||||
async fn admit_event(&self, event: &Event, addr: &SocketAddr) -> WritePolicyResult {
|
||||
// Check if this is an admin query event (ephemeral, authenticated)
|
||||
if event.kind == Kind::Custom(20100) { // Admin query kind
|
||||
if let Some(authenticated_pk) = get_authenticated_pubkey(addr) {
|
||||
if is_admin(&authenticated_pk) {
|
||||
// Process query (e.g., get purgatory state)
|
||||
let response_data = self.purgatory.get_state();
|
||||
|
||||
// Create ephemeral response event
|
||||
let response = EventBuilder::new(
|
||||
Kind::Custom(20101), // Admin response kind
|
||||
serde_json::to_string(&response_data)?,
|
||||
).sign(&admin_keys)?;
|
||||
|
||||
// Send ONLY to requesting connection
|
||||
self.relay.send_to_connection(conn_id, response)?;
|
||||
|
||||
// Don't save query event to database
|
||||
return WritePolicyResult::Reject {
|
||||
status: true,
|
||||
message: "query processed".into(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// ... normal event handling ...
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ True pyramid-style queries with connection-specific responses
|
||||
- ✅ Leverages existing NIP-42 authentication
|
||||
- ✅ Ephemeral events don't pollute database
|
||||
|
||||
**Cons:**
|
||||
- ❌ Requires forking/maintaining a modified nostr-relay-builder
|
||||
- ❌ Upstream changes may conflict with our modifications
|
||||
- ❌ Significant complexity (connection registry, lifecycle management)
|
||||
- ❌ Thread safety concerns (DashMap access across async tasks)
|
||||
- ❌ Connection ID generation and management overhead
|
||||
|
||||
**Complexity Estimate:** 🔴 **High** (5-7 days implementation + ongoing maintenance)
|
||||
|
||||
---
|
||||
|
||||
### Option 2: Custom Admin Protocol Handler (Moderate Complexity)
|
||||
|
||||
**Approach:** Implement a separate WebSocket endpoint `/admin` that bypasses nostr-relay-builder entirely.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ HTTP Server (hyper) │
|
||||
└──────────────────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────┐ ┌──────────────────────┐
|
||||
│ WebSocket / │ │ WebSocket /admin │
|
||||
│ (nostr-relay) │ │ (custom handler) │
|
||||
└────────────────────┘ └──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ AdminQueryHandler │
|
||||
│ - NIP-42 auth │
|
||||
│ - Query processing │
|
||||
│ - Direct responses │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
**Implementation Example:**
|
||||
|
||||
```rust
|
||||
// src/admin/handler.rs
|
||||
pub struct AdminQueryHandler {
|
||||
purgatory: Arc<Purgatory>,
|
||||
database: SharedDatabase,
|
||||
admin_pubkeys: HashSet<PublicKey>,
|
||||
}
|
||||
|
||||
impl AdminQueryHandler {
|
||||
pub async fn handle_connection<S>(&self, stream: S, addr: SocketAddr)
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let ws_stream = accept_async(stream).await?;
|
||||
let (mut tx, mut rx) = ws_stream.split();
|
||||
|
||||
let mut auth_session = Nip42Session::default();
|
||||
|
||||
loop {
|
||||
match rx.next().await {
|
||||
Some(Ok(Message::Text(json))) => {
|
||||
let msg = ClientMessage::from_json(&json)?;
|
||||
|
||||
match msg {
|
||||
ClientMessage::Auth(event) => {
|
||||
// Verify NIP-42 auth
|
||||
if auth_session.check_challenge(&event).is_ok() {
|
||||
if !self.admin_pubkeys.contains(&event.pubkey) {
|
||||
send_msg(&mut tx, RelayMessage::Notice(
|
||||
"unauthorized: not an admin".into()
|
||||
)).await?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientMessage::Event(event) if event.kind.is_ephemeral() => {
|
||||
// Process admin query
|
||||
if !auth_session.is_authenticated() {
|
||||
send_auth_challenge(&mut tx, &mut auth_session).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let response = self.process_query(&event).await?;
|
||||
|
||||
// Send ephemeral response ONLY to this connection
|
||||
send_msg(&mut tx, RelayMessage::Event {
|
||||
subscription_id: "admin-response".into(),
|
||||
event: response.into(),
|
||||
}).await?;
|
||||
}
|
||||
_ => {
|
||||
send_msg(&mut tx, RelayMessage::Notice(
|
||||
"only admin queries accepted on this endpoint".into()
|
||||
)).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_query(&self, query: &Event) -> Result<Event> {
|
||||
// Parse query type from tags
|
||||
let query_type = query.tags.iter()
|
||||
.find_map(|t| {
|
||||
if t.kind() == TagKind::Custom("query".into()) {
|
||||
t.content()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| anyhow!("missing query tag"))?;
|
||||
|
||||
let response_data = match query_type.as_ref() {
|
||||
"purgatory-state" => {
|
||||
serde_json::to_value(self.purgatory.get_all_state())?
|
||||
}
|
||||
"purgatory-pr" => {
|
||||
let event_id = query.tags.iter()
|
||||
.find_map(|t| {
|
||||
if t.kind() == TagKind::Custom("event-id".into()) {
|
||||
t.content()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| anyhow!("missing event-id tag"))?;
|
||||
|
||||
serde_json::to_value(self.purgatory.find_pr(event_id))?
|
||||
}
|
||||
"sync-status" => {
|
||||
// Query sync manager status (would need to pass reference)
|
||||
serde_json::json!({ "status": "not implemented" })
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow!("unknown query type: {}", query_type));
|
||||
}
|
||||
};
|
||||
|
||||
// Create ephemeral response event
|
||||
EventBuilder::new(Kind::Custom(20101), response_data.to_string())
|
||||
.custom_tag(TagKind::custom("query-type"), [query_type])
|
||||
.sign(&admin_keys) // Would need admin signing key
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Integration:**
|
||||
|
||||
```rust
|
||||
// src/http/mod.rs
|
||||
impl Service<Request<Incoming>> for HttpService {
|
||||
fn call(&self, req: Request<Incoming>) -> Self::Future {
|
||||
let path = req.uri().path();
|
||||
|
||||
// Check for admin WebSocket endpoint
|
||||
if path == "/admin" && is_websocket_upgrade(&req) {
|
||||
let handler = AdminQueryHandler::new(
|
||||
self.purgatory.clone(),
|
||||
self.database.clone(),
|
||||
self.config.admin_pubkeys(),
|
||||
);
|
||||
|
||||
return Box::pin(async move {
|
||||
match hyper::upgrade::on(req).await {
|
||||
Ok(upgraded) => {
|
||||
handler.handle_connection(TokioIo::new(upgraded), addr).await?;
|
||||
}
|
||||
Err(e) => tracing::error!("Admin upgrade error: {}", e),
|
||||
}
|
||||
Ok(switching_protocols_response())
|
||||
});
|
||||
}
|
||||
|
||||
// ... existing relay WebSocket and HTTP handling ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```rust
|
||||
// src/config.rs
|
||||
pub struct Config {
|
||||
// ... existing fields ...
|
||||
|
||||
/// Admin public keys (npub format) allowed to query via /admin endpoint
|
||||
#[arg(long, env = "NGIT_ADMIN_PUBKEYS")]
|
||||
pub admin_pubkeys: Option<String>, // Comma-separated npubs
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn admin_pubkeys(&self) -> HashSet<PublicKey> {
|
||||
self.admin_pubkeys
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.filter_map(|npub| PublicKey::parse(npub.trim()).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ No modification to nostr-relay-builder required
|
||||
- ✅ Full control over admin protocol
|
||||
- ✅ Can use NIP-42 authentication
|
||||
- ✅ Connection-specific responses (true pyramid style)
|
||||
- ✅ Isolated from normal relay operations
|
||||
|
||||
**Cons:**
|
||||
- ⚠️ Separate endpoint `/admin` (not standard Nostr relay)
|
||||
- ⚠️ Duplicate WebSocket handling code
|
||||
- ⚠️ Need admin signing keys for responses
|
||||
- ⚠️ Additional configuration complexity
|
||||
- ⚠️ Not discoverable via NIP-11
|
||||
|
||||
**Complexity Estimate:** 🟡 **Moderate** (3-4 days implementation)
|
||||
|
||||
---
|
||||
|
||||
### Option 3: HTTP-based NIP-86 (Recommended)
|
||||
|
||||
**Approach:** Implement NIP-86 Administrative Management over HTTP instead of WebSocket.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ HTTP Server (hyper) │
|
||||
└──────────────────────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┬──────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ WebSocket / │ │ GET / │ │ POST │ │ GET │
|
||||
│ (relay) │ │ (landing)│ │ /admin/* │ │ /metrics │
|
||||
└────────────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ NIP-86 Admin Handlers │
|
||||
│ - NIP-98 auth │
|
||||
│ - JSON responses │
|
||||
│ - RESTful queries │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
**Example Endpoints:**
|
||||
|
||||
```
|
||||
POST /admin/purgatory/state
|
||||
Auth: Nostr event (NIP-98)
|
||||
Response: { "state_events": [...], "pr_events": [...] }
|
||||
|
||||
POST /admin/purgatory/pr/:event_id
|
||||
Auth: Nostr event (NIP-98)
|
||||
Response: { "event": {...}, "commit": "...", "status": "..." }
|
||||
|
||||
POST /admin/sync/status
|
||||
Auth: Nostr event (NIP-98)
|
||||
Response: { "relays": [...], "health": {...} }
|
||||
|
||||
POST /admin/config
|
||||
Auth: Nostr event (NIP-98)
|
||||
Response: { "domain": "...", "git_data_path": "...", ... }
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```rust
|
||||
// src/admin/nip86.rs
|
||||
pub async fn handle_admin_request(
|
||||
method: Method,
|
||||
path: &str,
|
||||
req_body: Bytes,
|
||||
purgatory: Arc<Purgatory>,
|
||||
database: SharedDatabase,
|
||||
admin_pubkeys: &HashSet<PublicKey>,
|
||||
) -> Result<Response<Full<Bytes>>> {
|
||||
// Verify NIP-98 HTTP Auth
|
||||
let auth_event = extract_nip98_auth(&req_body)?;
|
||||
auth_event.verify()?;
|
||||
|
||||
if !admin_pubkeys.contains(&auth_event.pubkey) {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.body(Full::new(Bytes::from(r#"{"error":"unauthorized"}"#)))
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
// Route based on path
|
||||
let response_data = match path {
|
||||
"/admin/purgatory/state" => {
|
||||
serde_json::to_value(&purgatory.get_all_state())?
|
||||
}
|
||||
p if p.starts_with("/admin/purgatory/pr/") => {
|
||||
let event_id = p.strip_prefix("/admin/purgatory/pr/").unwrap();
|
||||
serde_json::to_value(&purgatory.find_pr(event_id))?
|
||||
}
|
||||
"/admin/sync/status" => {
|
||||
// Would need sync manager reference
|
||||
serde_json::json!({ "status": "not implemented" })
|
||||
}
|
||||
_ => {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Full::new(Bytes::from(r#"{"error":"not found"}"#)))
|
||||
.unwrap());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "application/json")
|
||||
.body(Full::new(Bytes::from(response_data.to_string())))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
fn extract_nip98_auth(body: &Bytes) -> Result<Event> {
|
||||
// NIP-98: Auth event is base64-encoded in Authorization header OR in body
|
||||
// For POST, it's typically in the body
|
||||
let json: serde_json::Value = serde_json::from_slice(body)?;
|
||||
let auth_b64 = json["authorization"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing authorization"))?;
|
||||
|
||||
let auth_json = base64::decode(auth_b64)?;
|
||||
Event::from_json(&auth_json)
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Service Integration:**
|
||||
|
||||
```rust
|
||||
// src/http/mod.rs
|
||||
impl Service<Request<Incoming>> for HttpService {
|
||||
fn call(&self, req: Request<Incoming>) -> Self::Future {
|
||||
let path = req.uri().path();
|
||||
|
||||
// Check for admin API endpoints
|
||||
if path.starts_with("/admin/") && method == Method::POST {
|
||||
let purgatory = self.purgatory.clone();
|
||||
let database = self.database.clone();
|
||||
let admin_pubkeys = self.config.admin_pubkeys();
|
||||
|
||||
return Box::pin(async move {
|
||||
let body = req.collect().await?.to_bytes();
|
||||
|
||||
match handle_admin_request(
|
||||
method,
|
||||
path,
|
||||
body,
|
||||
purgatory,
|
||||
database,
|
||||
&admin_pubkeys,
|
||||
).await {
|
||||
Ok(response) => Ok(add_cors_headers(response)),
|
||||
Err(e) => {
|
||||
tracing::error!("Admin API error: {}", e);
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(Full::new(Bytes::from(format!(
|
||||
r#"{{"error":"{}"}}"#, e
|
||||
))))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ... existing handlers ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Client Usage:**
|
||||
|
||||
```javascript
|
||||
// Admin client (JavaScript example)
|
||||
import { NostrFetcher, EventBuilder, finalizeEvent, getPublicKey } from 'nostr-tools';
|
||||
|
||||
async function queryPurgatoryState(adminPrivateKey) {
|
||||
// Create NIP-98 auth event
|
||||
const authEvent = finalizeEvent({
|
||||
kind: 27235, // NIP-98 HTTP Auth
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', 'https://relay.example.com/admin/purgatory/state'],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
}, adminPrivateKey);
|
||||
|
||||
const authB64 = btoa(JSON.stringify(authEvent));
|
||||
|
||||
const response = await fetch('https://relay.example.com/admin/purgatory/state', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
authorization: authB64,
|
||||
}),
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ **Standard NIP-86 approach** (used by other relays)
|
||||
- ✅ **Simple HTTP/JSON** (easier to implement and test)
|
||||
- ✅ **NIP-98 authentication** (Nostr-native auth over HTTP)
|
||||
- ✅ **RESTful and discoverable** (can document in NIP-11)
|
||||
- ✅ **No WebSocket complexity**
|
||||
- ✅ **No modification to nostr-relay-builder**
|
||||
- ✅ **Stateless** (no connection tracking needed)
|
||||
|
||||
**Cons:**
|
||||
- ⚠️ Not pyramid-style (HTTP, not WebSocket with ephemeral events)
|
||||
- ⚠️ Separate protocol from Nostr relay
|
||||
- ⚠️ Requires HTTP client (not Nostr client)
|
||||
|
||||
**Complexity Estimate:** 🟢 **Low** (1-2 days implementation)
|
||||
|
||||
---
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| Feature | Option 1: Fork Relay | Option 2: Custom /admin | Option 3: HTTP NIP-86 |
|
||||
|---------|---------------------|------------------------|----------------------|
|
||||
| **Feasibility** | Possible but risky | Feasible | ✅ Recommended |
|
||||
| **Complexity** | 🔴 High | 🟡 Moderate | 🟢 Low |
|
||||
| **Time Estimate** | 5-7 days | 3-4 days | 1-2 days |
|
||||
| **Maintenance** | 🔴 Ongoing | 🟡 Moderate | 🟢 Minimal |
|
||||
| **Pyramid-Style** | ✅ Yes | ✅ Yes | ❌ No (HTTP) |
|
||||
| **Nostr Standards** | Custom | Custom | ✅ NIP-86 |
|
||||
| **Auth Method** | NIP-42 | NIP-42 | NIP-98 |
|
||||
| **Discoverability** | ❌ No | ❌ Limited | ✅ NIP-11 |
|
||||
| **Client Complexity** | 🟡 Moderate | 🟡 Moderate | 🟢 Simple |
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Use Option 3: HTTP-based NIP-86** for the following reasons:
|
||||
|
||||
1. **Industry Standard** - NIP-86 is the established approach for relay administration
|
||||
2. **Low Complexity** - Simple HTTP/JSON is easier to implement, test, and maintain
|
||||
3. **No Relay Modification** - Works with stock nostr-relay-builder
|
||||
4. **Better Tooling** - Standard HTTP clients, curl, Postman, etc.
|
||||
5. **RESTful** - Natural fit for query/response pattern
|
||||
6. **Stateless** - No connection tracking or lifecycle management
|
||||
|
||||
### When to Consider Pyramid-Style (Option 2)
|
||||
|
||||
Use the custom `/admin` WebSocket endpoint (Option 2) if:
|
||||
- You need **real-time push notifications** from the relay to admins
|
||||
- You want **pure Nostr protocol** for all operations
|
||||
- You have **specific requirements** for ephemeral event responses
|
||||
- You're building an **admin dashboard** that maintains a WebSocket connection
|
||||
|
||||
### Never Use Option 1
|
||||
|
||||
Forking nostr-relay-builder is **not recommended** due to:
|
||||
- High maintenance burden
|
||||
- Risk of divergence from upstream
|
||||
- Significant complexity for limited benefit
|
||||
- Option 2 achieves the same goals without forking
|
||||
|
||||
## Implementation Roadmap (Option 3: HTTP NIP-86)
|
||||
|
||||
### Phase 1: Core Infrastructure (Day 1)
|
||||
|
||||
1. **Configuration**
|
||||
- Add `NGIT_ADMIN_PUBKEYS` config field
|
||||
- Parse comma-separated npubs into `HashSet<PublicKey>`
|
||||
- Update `docs/reference/configuration.md`, `nix/module.nix`, `.env.example`
|
||||
|
||||
2. **NIP-98 Authentication**
|
||||
- Implement `extract_nip98_auth()` function
|
||||
- Verify event signature and timestamp
|
||||
- Check authenticated pubkey against admin whitelist
|
||||
|
||||
3. **HTTP Routing**
|
||||
- Add admin path detection in `HttpService::call()`
|
||||
- Route to new `admin::nip86::handle_admin_request()`
|
||||
|
||||
### Phase 2: Query Endpoints (Day 2)
|
||||
|
||||
4. **Purgatory Queries**
|
||||
- `POST /admin/purgatory/state` - Get all state/PR events
|
||||
- `POST /admin/purgatory/pr/:event_id` - Get specific PR details
|
||||
- `POST /admin/purgatory/state/:identifier` - Get state events for repo
|
||||
|
||||
5. **System Queries**
|
||||
- `POST /admin/config` - Get relay configuration
|
||||
- `POST /admin/sync/status` - Get sync manager health
|
||||
- `POST /admin/metrics` - Get internal metrics (if different from Prometheus)
|
||||
|
||||
### Phase 3: Documentation & Testing (Day 2-3)
|
||||
|
||||
6. **Documentation**
|
||||
- Add NIP-86 support to NIP-11 document
|
||||
- Document all endpoints in `docs/reference/admin-api.md`
|
||||
- Add example client code (JavaScript, Rust)
|
||||
|
||||
7. **Testing**
|
||||
- Integration tests for each endpoint
|
||||
- NIP-98 auth verification tests
|
||||
- Authorization failure tests
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Rust Client (using nostr-sdk)
|
||||
|
||||
```rust
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
async fn query_purgatory(admin_keys: &Keys, relay_url: &str) -> Result<Value> {
|
||||
let url = format!("{}/admin/purgatory/state", relay_url);
|
||||
|
||||
// Create NIP-98 auth event
|
||||
let auth_event = EventBuilder::new(
|
||||
Kind::HttpAuth,
|
||||
"",
|
||||
)
|
||||
.custom_tag(TagKind::custom("u"), [&url])
|
||||
.custom_tag(TagKind::custom("method"), ["POST"])
|
||||
.sign(admin_keys)?;
|
||||
|
||||
let auth_b64 = base64::encode(auth_event.as_json());
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"authorization": auth_b64,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(response.json().await?)
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript Client (using nostr-tools)
|
||||
|
||||
```javascript
|
||||
import { finalizeEvent } from 'nostr-tools';
|
||||
import { hexToBytes } from '@noble/hashes/utils';
|
||||
|
||||
async function queryPurgatory(adminPrivateKeyHex, relayUrl) {
|
||||
const url = `${relayUrl}/admin/purgatory/state`;
|
||||
|
||||
const authEvent = finalizeEvent({
|
||||
kind: 27235, // NIP-98 HTTP Auth
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
}, hexToBytes(adminPrivateKeyHex));
|
||||
|
||||
const authB64 = btoa(JSON.stringify(authEvent));
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ authorization: authB64 }),
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
```
|
||||
|
||||
## Limitations and Challenges
|
||||
|
||||
### Discovered Limitations in nostr-relay-builder
|
||||
|
||||
1. **No Connection Tracking** - The framework doesn't expose active connections
|
||||
2. **Broadcast-Only Architecture** - `notify_event()` sends to all connections
|
||||
3. **Session Privacy** - WebSocket transmitters are local variables, not registered
|
||||
4. **No Extension Points** - No hooks for custom connection-specific messaging
|
||||
|
||||
### NIP-42 Support
|
||||
|
||||
✅ **Already Implemented** in nostr-relay-builder:
|
||||
- `Nip42Session` tracks authentication state
|
||||
- Challenge generation and verification
|
||||
- Protected events (author must match authenticated pubkey)
|
||||
- Configurable authentication modes (Read, Write, Both)
|
||||
|
||||
### Ephemeral Events
|
||||
|
||||
✅ **Fully Supported** by nostr-sdk:
|
||||
- `event.kind.is_ephemeral()` checks if kind is in 20000-29999 range
|
||||
- Database can be configured to skip storing ephemeral events
|
||||
- Standard Nostr protocol support
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### If Upstream Adds Connection Tracking
|
||||
|
||||
If `nostr-relay-builder` adds connection-specific messaging in the future:
|
||||
|
||||
1. **Evaluate Migration** from HTTP to WebSocket admin endpoint
|
||||
2. **Keep HTTP API** for backwards compatibility
|
||||
3. **Add WebSocket Push** for real-time admin notifications
|
||||
4. **Hybrid Approach** - HTTP for queries, WebSocket for live updates
|
||||
|
||||
### Admin Dashboard
|
||||
|
||||
An admin dashboard could:
|
||||
- Use HTTP API for initial data fetch
|
||||
- Use WebSocket `/admin` for real-time updates (if implemented)
|
||||
- Show purgatory state, sync health, connection metrics
|
||||
- Trigger manual operations (force sync, clear purgatory, etc.)
|
||||
|
||||
### Multi-Relay Management
|
||||
|
||||
For managing multiple ngit-grasp instances:
|
||||
- Central admin dashboard queries multiple relays via HTTP API
|
||||
- Aggregate purgatory state, sync health across relays
|
||||
- Detect configuration drift or inconsistencies
|
||||
|
||||
## Conclusion
|
||||
|
||||
**The pyramid-style query approach is not directly feasible** with the current `nostr-relay-builder` architecture due to lack of connection-specific messaging APIs. However, **HTTP-based NIP-86 is the recommended alternative** that achieves the same goals (admin queries, authentication, structured responses) with significantly lower complexity.
|
||||
|
||||
The recommended implementation path is:
|
||||
1. ✅ Implement HTTP NIP-86 endpoints (1-2 days)
|
||||
2. ✅ Use NIP-98 for authentication
|
||||
3. ✅ Document in NIP-11 for discoverability
|
||||
4. ⏭️ Consider custom WebSocket `/admin` endpoint only if real-time push is required
|
||||
|
||||
This approach aligns with industry standards (NIP-86), requires no fork/modification of dependencies, and provides a clean, testable API for administrator operations.
|
||||
|
||||
---
|
||||
|
||||
**Next Steps:**
|
||||
1. Get stakeholder approval for HTTP NIP-86 approach
|
||||
2. Create implementation issue with Phase 1-3 tasks
|
||||
3. Update architecture documentation to include admin API
|
||||
4. Begin Phase 1 implementation (configuration + NIP-98 auth)
|
||||
Reference in New Issue
Block a user