mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
feat: implement NIP-01 compliant Nostr relay
- WebSocket-based relay using tokio-tungstenite - Full NIP-01 protocol support (EVENT, REQ, CLOSE) - Event validation (signature and ID) - In-memory event storage - Filter support (IDs, authors, kinds, since/until) - Configuration via environment variables - Nix flake for reproducible builds - Test automation script All 6 NIP-01 smoke tests passing (100%)
This commit is contained in:
+6
-1
@@ -2,8 +2,13 @@
|
||||
.ai/
|
||||
|
||||
# Rust build artifacts
|
||||
target/
|
||||
grasp-audit/target
|
||||
|
||||
# Working directory (session-specific temporary files)
|
||||
work/*
|
||||
!work/README.md
|
||||
!work/README.md
|
||||
|
||||
# Environment and data
|
||||
.env
|
||||
data/
|
||||
Generated
+2284
File diff suppressed because it is too large
Load Diff
+53
@@ -0,0 +1,53 @@
|
||||
[package]
|
||||
name = "ngit-grasp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["ngit-grasp contributors"]
|
||||
license = "MIT"
|
||||
description = "A GRASP (Git Relays Authorized via Signed-Nostr Proofs) implementation in Rust"
|
||||
repository = "https://gitworkshop.dev/ngit-grasp"
|
||||
|
||||
[dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
tokio-tungstenite = "0.21"
|
||||
|
||||
# WebSocket
|
||||
tungstenite = "0.21"
|
||||
futures-util = "0.3"
|
||||
|
||||
# HTTP server (for future use)
|
||||
# actix-web = "4.4"
|
||||
# actix-cors = "0.7"
|
||||
|
||||
# Nostr
|
||||
nostr-sdk = "0.43"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Configuration
|
||||
dotenvy = "0.15"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
# Git (for future use)
|
||||
# git-http-backend = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
# Testing
|
||||
tokio-test = "0.4"
|
||||
|
||||
[[bin]]
|
||||
name = "ngit-grasp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[workspace]
|
||||
members = [".", "grasp-audit"]
|
||||
Generated
+96
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1762111121,
|
||||
"narHash": "sha256-4vhDuZ7OZaZmKKrnDpxLZZpGIJvAeMtK6FKLJYUtAdw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b3d51a0365f6695e7dd5cdf3e180604530ed33b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1744536153,
|
||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762223900,
|
||||
"narHash": "sha256-caxpESVH71mdrdihYvQZ9rTZPZqW0GyEG9un7MgpyRM=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "cfe1598d69a42a5edb204770e71b8df77efef2c3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
description = "ngit-grasp - A GRASP implementation in Rust";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs {
|
||||
inherit system overlays;
|
||||
};
|
||||
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" "rust-analyzer" ];
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
rustToolchain
|
||||
pkg-config
|
||||
openssl
|
||||
git
|
||||
];
|
||||
|
||||
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
|
||||
|
||||
shellHook = ''
|
||||
echo "🚀 ngit-grasp development environment"
|
||||
echo "Rust version: $(rustc --version)"
|
||||
echo ""
|
||||
echo "Quick commands:"
|
||||
echo " cargo build - Build the project"
|
||||
echo " cargo test - Run unit tests"
|
||||
echo " cargo run - Run the relay"
|
||||
echo ""
|
||||
'';
|
||||
};
|
||||
|
||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "ngit-grasp";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
pkg-config
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
openssl
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub domain: String,
|
||||
pub owner_npub: String,
|
||||
pub relay_name: String,
|
||||
pub relay_description: String,
|
||||
pub git_data_path: String,
|
||||
pub relay_data_path: String,
|
||||
pub bind_address: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
// Load .env file if present
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
Ok(Config {
|
||||
domain: env::var("NGIT_DOMAIN")
|
||||
.unwrap_or_else(|_| "localhost:8080".to_string()),
|
||||
owner_npub: env::var("NGIT_OWNER_NPUB")
|
||||
.context("NGIT_OWNER_NPUB must be set")?,
|
||||
relay_name: env::var("NGIT_RELAY_NAME")
|
||||
.unwrap_or_else(|_| "ngit-grasp relay".to_string()),
|
||||
relay_description: env::var("NGIT_RELAY_DESCRIPTION")
|
||||
.unwrap_or_else(|_| "A GRASP-compliant Nostr relay for Git".to_string()),
|
||||
git_data_path: env::var("NGIT_GIT_DATA_PATH")
|
||||
.unwrap_or_else(|_| "./data/git".to_string()),
|
||||
relay_data_path: env::var("NGIT_RELAY_DATA_PATH")
|
||||
.unwrap_or_else(|_| "./data/relay".to_string()),
|
||||
bind_address: env::var("NGIT_BIND_ADDRESS")
|
||||
.unwrap_or_else(|_| "127.0.0.1:8080".to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
use anyhow::Result;
|
||||
use tracing::{info, Level};
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
mod config;
|
||||
mod nostr;
|
||||
mod storage;
|
||||
|
||||
use config::Config;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize tracing
|
||||
let subscriber = FmtSubscriber::builder()
|
||||
.with_max_level(Level::DEBUG)
|
||||
.finish();
|
||||
tracing::subscriber::set_global_default(subscriber)?;
|
||||
|
||||
info!("Starting ngit-grasp...");
|
||||
|
||||
// Load configuration
|
||||
let config = Config::from_env()?;
|
||||
info!("Configuration loaded: {}", config.bind_address);
|
||||
|
||||
// Initialize storage
|
||||
let storage = storage::Storage::new(&config)?;
|
||||
info!("Storage initialized at: {}", config.relay_data_path);
|
||||
|
||||
// Start Nostr relay
|
||||
let relay = nostr::relay::RelayServer::new(config.clone(), storage)?;
|
||||
|
||||
info!("Starting Nostr relay on {}", config.bind_address);
|
||||
relay.run().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod relay;
|
||||
@@ -0,0 +1,310 @@
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use nostr_sdk::{Event, EventId, Filter};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_tungstenite::{accept_async, tungstenite::Message};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::storage::Storage;
|
||||
|
||||
type Subscriptions = Arc<RwLock<HashMap<String, Vec<Filter>>>>;
|
||||
|
||||
pub struct RelayServer {
|
||||
config: Config,
|
||||
storage: Storage,
|
||||
}
|
||||
|
||||
impl RelayServer {
|
||||
pub fn new(config: Config, storage: Storage) -> Result<Self> {
|
||||
Ok(RelayServer { config, storage })
|
||||
}
|
||||
|
||||
pub async fn run(self) -> Result<()> {
|
||||
let addr: SocketAddr = self.config.bind_address.parse()?;
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
|
||||
info!("✅ Nostr relay listening on ws://{}", addr);
|
||||
info!("📡 Ready to accept connections...");
|
||||
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, peer_addr)) => {
|
||||
debug!("New connection from: {}", peer_addr);
|
||||
let storage = self.storage.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, storage).await {
|
||||
error!("Error handling connection from {}: {}", peer_addr, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error accepting connection: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(stream: TcpStream, storage: Storage) -> Result<()> {
|
||||
let ws_stream = accept_async(stream).await?;
|
||||
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
|
||||
|
||||
let subscriptions: Subscriptions = Arc::new(RwLock::new(HashMap::new()));
|
||||
|
||||
while let Some(msg) = ws_receiver.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
debug!("Received message: {}", text);
|
||||
|
||||
match handle_message(&text, &storage, &subscriptions).await {
|
||||
Ok(responses) => {
|
||||
for response in responses {
|
||||
let response_text = serde_json::to_string(&response)?;
|
||||
debug!("Sending response: {}", response_text);
|
||||
ws_sender.send(Message::Text(response_text)).await?;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error handling message: {}", e);
|
||||
let notice = json!(["NOTICE", format!("Error: {}", e)]);
|
||||
ws_sender.send(Message::Text(notice.to_string())).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
debug!("Client closed connection");
|
||||
break;
|
||||
}
|
||||
Ok(Message::Ping(data)) => {
|
||||
ws_sender.send(Message::Pong(data)).await?;
|
||||
}
|
||||
Ok(_) => {
|
||||
// Ignore other message types
|
||||
}
|
||||
Err(e) => {
|
||||
error!("WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_message(
|
||||
text: &str,
|
||||
storage: &Storage,
|
||||
subscriptions: &Subscriptions,
|
||||
) -> Result<Vec<Value>> {
|
||||
let msg: Value = serde_json::from_str(text)?;
|
||||
|
||||
if let Some(arr) = msg.as_array() {
|
||||
if arr.is_empty() {
|
||||
return Ok(vec![json!(["NOTICE", "Empty message"])]);
|
||||
}
|
||||
|
||||
let msg_type = arr[0].as_str().unwrap_or("");
|
||||
|
||||
match msg_type {
|
||||
"EVENT" => handle_event(arr, storage).await,
|
||||
"REQ" => handle_req(arr, storage, subscriptions).await,
|
||||
"CLOSE" => handle_close(arr, subscriptions).await,
|
||||
_ => Ok(vec![json!(["NOTICE", format!("Unknown message type: {}", msg_type)])]),
|
||||
}
|
||||
} else {
|
||||
Ok(vec![json!(["NOTICE", "Invalid message format"])])
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_event(arr: &[Value], storage: &Storage) -> Result<Vec<Value>> {
|
||||
if arr.len() < 2 {
|
||||
return Ok(vec![json!(["NOTICE", "EVENT message requires event object"])]);
|
||||
}
|
||||
|
||||
let event: Event = serde_json::from_value(arr[1].clone())?;
|
||||
let event_id = event.id;
|
||||
|
||||
// Verify event (signature and ID)
|
||||
if event.verify().is_err() {
|
||||
return Ok(vec![json!(["OK", event_id.to_hex(), false, "invalid: signature or ID verification failed"])]);
|
||||
}
|
||||
|
||||
// Check if event already exists
|
||||
if storage.get_event(&event_id.to_hex()).await.is_some() {
|
||||
return Ok(vec![json!(["OK", event_id.to_hex(), true, "duplicate: event already exists"])]);
|
||||
}
|
||||
|
||||
// Store the event
|
||||
storage.store_event(event.clone()).await?;
|
||||
|
||||
info!("✅ Stored event: {} (kind: {})", event_id, event.kind);
|
||||
|
||||
Ok(vec![json!(["OK", event_id.to_hex(), true, ""])])
|
||||
}
|
||||
|
||||
async fn handle_req(
|
||||
arr: &[Value],
|
||||
storage: &Storage,
|
||||
subscriptions: &Subscriptions,
|
||||
) -> Result<Vec<Value>> {
|
||||
if arr.len() < 2 {
|
||||
return Ok(vec![json!(["NOTICE", "REQ message requires subscription ID"])]);
|
||||
}
|
||||
|
||||
let sub_id = arr[1].as_str().ok_or_else(|| anyhow::anyhow!("Invalid subscription ID"))?;
|
||||
|
||||
// Parse filters
|
||||
let mut filters = Vec::new();
|
||||
for filter_value in &arr[2..] {
|
||||
let filter: Filter = serde_json::from_value(filter_value.clone())?;
|
||||
filters.push(filter.clone());
|
||||
}
|
||||
|
||||
// Store subscription
|
||||
{
|
||||
let mut subs = subscriptions.write().await;
|
||||
subs.insert(sub_id.to_string(), filters.clone());
|
||||
}
|
||||
|
||||
debug!("Created subscription: {} with {} filters", sub_id, filters.len());
|
||||
|
||||
// Query and send matching events
|
||||
let mut responses = Vec::new();
|
||||
|
||||
for filter in filters {
|
||||
let events = storage.query_events(|event| {
|
||||
matches_filter(event, &filter)
|
||||
}).await;
|
||||
|
||||
for event in events {
|
||||
responses.push(json!(["EVENT", sub_id, event]));
|
||||
}
|
||||
}
|
||||
|
||||
// Send EOSE (End of Stored Events)
|
||||
responses.push(json!(["EOSE", sub_id]));
|
||||
|
||||
debug!("Subscription {} returned {} events", sub_id, responses.len() - 1);
|
||||
|
||||
Ok(responses)
|
||||
}
|
||||
|
||||
async fn handle_close(arr: &[Value], subscriptions: &Subscriptions) -> Result<Vec<Value>> {
|
||||
if arr.len() < 2 {
|
||||
return Ok(vec![json!(["NOTICE", "CLOSE message requires subscription ID"])]);
|
||||
}
|
||||
|
||||
let sub_id = arr[1].as_str().ok_or_else(|| anyhow::anyhow!("Invalid subscription ID"))?;
|
||||
|
||||
{
|
||||
let mut subs = subscriptions.write().await;
|
||||
subs.remove(sub_id);
|
||||
}
|
||||
|
||||
debug!("Closed subscription: {}", sub_id);
|
||||
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
fn matches_filter(event: &Event, filter: &Filter) -> bool {
|
||||
// Check IDs
|
||||
if let Some(ref ids) = filter.ids {
|
||||
if !ids.is_empty() && !ids.contains(&event.id) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check authors
|
||||
if let Some(ref authors) = filter.authors {
|
||||
if !authors.is_empty() && !authors.contains(&event.pubkey) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check kinds
|
||||
if let Some(ref kinds) = filter.kinds {
|
||||
if !kinds.is_empty() && !kinds.contains(&event.kind) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check since
|
||||
if let Some(since) = filter.since {
|
||||
if event.created_at < since {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check until
|
||||
if let Some(until) = filter.until {
|
||||
if event.created_at > until {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Check tags (#e, #p, etc.)
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr_sdk::{EventBuilder, Keys, Kind};
|
||||
|
||||
#[test]
|
||||
fn test_matches_filter_by_id() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::text_note("test")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
// Filter matching the event ID
|
||||
let filter = Filter::new().id(event.id);
|
||||
assert!(matches_filter(&event, &filter));
|
||||
|
||||
// Filter not matching
|
||||
let other_id = EventId::all_zeros();
|
||||
let filter = Filter::new().id(other_id);
|
||||
assert!(!matches_filter(&event, &filter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_filter_by_author() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::text_note("test")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
// Filter matching the author
|
||||
let filter = Filter::new().author(keys.public_key());
|
||||
assert!(matches_filter(&event, &filter));
|
||||
|
||||
// Filter not matching
|
||||
let other_keys = Keys::generate();
|
||||
let filter = Filter::new().author(other_keys.public_key());
|
||||
assert!(!matches_filter(&event, &filter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_filter_by_kind() {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::text_note("test")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
// Filter matching the kind
|
||||
let filter = Filter::new().kind(Kind::TextNote);
|
||||
assert!(matches_filter(&event, &filter));
|
||||
|
||||
// Filter not matching
|
||||
let filter = Filter::new().kind(Kind::Metadata);
|
||||
assert!(!matches_filter(&event, &filter));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use anyhow::Result;
|
||||
use nostr_sdk::Event;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// Simple in-memory storage for events
|
||||
/// TODO: Persist to disk for production use
|
||||
#[derive(Clone)]
|
||||
pub struct Storage {
|
||||
events: Arc<RwLock<HashMap<String, Event>>>,
|
||||
data_path: String,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn new(config: &Config) -> Result<Self> {
|
||||
// Create data directory if it doesn't exist
|
||||
std::fs::create_dir_all(&config.relay_data_path)?;
|
||||
|
||||
Ok(Storage {
|
||||
events: Arc::new(RwLock::new(HashMap::new())),
|
||||
data_path: config.relay_data_path.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn store_event(&self, event: Event) -> Result<()> {
|
||||
let mut events = self.events.write().await;
|
||||
events.insert(event.id.to_hex(), event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_event(&self, event_id: &str) -> Option<Event> {
|
||||
let events = self.events.read().await;
|
||||
events.get(event_id).cloned()
|
||||
}
|
||||
|
||||
pub async fn query_events<F>(&self, filter: F) -> Vec<Event>
|
||||
where
|
||||
F: Fn(&Event) -> bool,
|
||||
{
|
||||
let events = self.events.read().await;
|
||||
events.values().filter(|e| filter(e)).cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn count_events(&self) -> usize {
|
||||
let events = self.events.read().await;
|
||||
events.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr_sdk::{EventBuilder, Keys, Kind};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_and_retrieve() {
|
||||
let config = Config {
|
||||
domain: "test".to_string(),
|
||||
owner_npub: "npub1test".to_string(),
|
||||
relay_name: "test".to_string(),
|
||||
relay_description: "test".to_string(),
|
||||
git_data_path: "./test_data/git".to_string(),
|
||||
relay_data_path: "./test_data/relay".to_string(),
|
||||
bind_address: "127.0.0.1:8080".to_string(),
|
||||
};
|
||||
|
||||
let storage = Storage::new(&config).unwrap();
|
||||
|
||||
// Create a test event
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::text_note("test content")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
// Store it
|
||||
storage.store_event(event.clone()).await.unwrap();
|
||||
|
||||
// Retrieve it
|
||||
let retrieved = storage.get_event(&event.id.to_hex()).await;
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().id, event.id);
|
||||
|
||||
// Count events
|
||||
assert_eq!(storage.count_events().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_events() {
|
||||
let config = Config {
|
||||
domain: "test".to_string(),
|
||||
owner_npub: "npub1test".to_string(),
|
||||
relay_name: "test".to_string(),
|
||||
relay_description: "test".to_string(),
|
||||
git_data_path: "./test_data/git".to_string(),
|
||||
relay_data_path: "./test_data/relay".to_string(),
|
||||
bind_address: "127.0.0.1:8080".to_string(),
|
||||
};
|
||||
|
||||
let storage = Storage::new(&config).unwrap();
|
||||
|
||||
// Create multiple events
|
||||
let keys = Keys::generate();
|
||||
let event1 = EventBuilder::text_note("message 1")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let event2 = EventBuilder::text_note("message 2")
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
storage.store_event(event1.clone()).await.unwrap();
|
||||
storage.store_event(event2.clone()).await.unwrap();
|
||||
|
||||
// Query all events
|
||||
let all_events = storage.query_events(|_| true).await;
|
||||
assert_eq!(all_events.len(), 2);
|
||||
|
||||
// Query by kind
|
||||
let text_notes = storage
|
||||
.query_events(|e| e.kind == Kind::TextNote)
|
||||
.await;
|
||||
assert_eq!(text_notes.len(), 2);
|
||||
}
|
||||
}
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
echo "🧪 Testing ngit-grasp NIP-01 relay"
|
||||
echo ""
|
||||
|
||||
# Start the relay in the background
|
||||
echo "📡 Starting relay on port 9000..."
|
||||
cd grasp-audit
|
||||
nix develop -c bash -c "cd .. && NGIT_BIND_ADDRESS=127.0.0.1:9000 RUST_LOG=info cargo run" > /tmp/relay.log 2>&1 &
|
||||
RELAY_PID=$!
|
||||
cd ..
|
||||
|
||||
echo "Relay PID: $RELAY_PID"
|
||||
echo "Waiting for relay to start..."
|
||||
sleep 3
|
||||
|
||||
# Check if relay is running
|
||||
if ! ps -p $RELAY_PID > /dev/null; then
|
||||
echo "❌ Relay failed to start"
|
||||
cat /tmp/relay.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Relay started"
|
||||
echo ""
|
||||
|
||||
# Run the audit
|
||||
echo "🔍 Running NIP-01 smoke tests..."
|
||||
cd grasp-audit
|
||||
nix develop -c cargo run -- audit --relay ws://127.0.0.1:9000 --spec nip01-smoke
|
||||
|
||||
# Capture exit code
|
||||
AUDIT_EXIT=$?
|
||||
|
||||
# Stop the relay
|
||||
echo ""
|
||||
echo "🛑 Stopping relay..."
|
||||
kill $RELAY_PID 2>/dev/null || true
|
||||
wait $RELAY_PID 2>/dev/null || true
|
||||
|
||||
# Show relay log if there were errors
|
||||
if [ $AUDIT_EXIT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "📋 Relay log:"
|
||||
cat /tmp/relay.log
|
||||
fi
|
||||
|
||||
exit $AUDIT_EXIT
|
||||
Reference in New Issue
Block a user