diff --git a/docs/research/pyramid-style-queries-design.md b/docs/research/pyramid-style-queries-design.md new file mode 100644 index 0000000..7450ca9 --- /dev/null +++ b/docs/research/pyramid-style-queries-design.md @@ -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 │ +│ - Broadcast channel shared by all sessions │ +│ - No connection tracking exposed │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ handle_websocket() - Per-connection handler │ +│ - (tx, rx) = ws_stream.split() │ +│ - Session { │ +│ subscriptions: HashMap>, │ +│ 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>, +} + +pub struct ConnectionHandle { + pub addr: SocketAddr, + pub authenticated_pubkey: Option, + tx: mpsc::Sender, // 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, + database: SharedDatabase, + admin_pubkeys: HashSet, +} + +impl AdminQueryHandler { + pub async fn handle_connection(&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 { + // 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> for HttpService { + fn call(&self, req: Request) -> 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, // Comma-separated npubs +} + +impl Config { + pub fn admin_pubkeys(&self) -> HashSet { + 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, + database: SharedDatabase, + admin_pubkeys: &HashSet, +) -> Result>> { + // 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 { + // 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> for HttpService { + fn call(&self, req: Request) -> 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` + - 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 { + 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)