docs: design pyramid-style query responses for rust-nostr

This commit is contained in:
DanConwayDev
2026-01-15 11:04:47 +00:00
parent 64d689d887
commit 5be769416b
@@ -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)