mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-09-14 04:45:07 +00:00
Merge #cea1ad82: feat(http): serve NIP-05 root identity
nostr:nevent1qgsx2lyl2e4zvfadwcvkd9fkrcwczj7mf858hy85mwqclwgut8wpg2spz3mhxue69uhhyetvv9ujumn8d96zuer9wcq3yamnwvaz7tm8d96xummnw3ezucm0d5q3kamnwvaz7tmwva5hgtnyv9hxxmmwwashjer9wchxxmmdqqsvagddsgtyqp03tjctp3p405ajqq7zxphcrej3mnu4j4kc5rkyflqnxtu05 PR-Author: DanConwayDev's Agent nostr:npub1v47f74n2ycn66asev62nv8sas99akj0g0wg0fkup37u3ckwuzs4q7cwtp0 CoverNote: Relay operators already have a stable Nostr identity for NIP-11 and NIP-42, but clients cannot resolve the domain itself to that key. This proposal serves the NIP-05 root identifier so `_@domain` identifies a root-mounted relay without another key source. It adds an exact root-level `/.well-known/nostr.json` route for GET and HEAD, serializes the relay-owner public key as lowercase hex under `names._`, retains permissive CORS, and gives this route precedence over NIP-11 content negotiation. NIP-11 derives support from the relay request path: only `/` advertises NIP-05, while nested relay paths omit it. The changelog, architecture, README, and relay-owner configuration descriptions are synchronized. The path check assumes the HTTP request path is the relay public mount path; a future explicit base-path setting should remain the source for this decision if proxy routing hides that path. Named identities and recommended relay-hint mappings remain outside this change. Validated with 760 library tests, the root/non-root NIP-05 server integration test, strict all-target Clippy, cargo fmt, git diff checks, and Nix module parsing.
This commit is contained in:
+2
-1
@@ -37,11 +37,12 @@
|
||||
# NGIT_TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
|
||||
|
||||
# ============================================================================
|
||||
# RELAY INFORMATION (NIP-11)
|
||||
# RELAY IDENTITY AND INFORMATION (NIP-05 / NIP-11)
|
||||
# ============================================================================
|
||||
|
||||
# Relay operator's nsec (private key) for signing and authentication
|
||||
# Used for:
|
||||
# - NIP-05 _@domain well-known identity when served at the domain root
|
||||
# - NIP-11 relay information document (pubkey field derived from this nsec)
|
||||
# - NIP-42 authentication when syncing from other relays
|
||||
# - Future: signing events, WoT-based rate limiting of syncing relays
|
||||
|
||||
@@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Serve the relay owner's public key as the NIP-05 `_@domain` identity from
|
||||
`/.well-known/nostr.json`. NIP-11 advertises NIP-05 only when requested at
|
||||
the domain root (`/`); relays mounted below a path do not claim support.
|
||||
- Add fixed-cardinality aggregate metrics for important long-lived sync state
|
||||
and document the producer, cleanup owner, bound, and terminal behavior of
|
||||
every peer-influenced transient subsystem.
|
||||
|
||||
@@ -8,6 +8,7 @@ A [GRASP](https://gitworkshop.dev/danconwaydev.com/grasp) (Git Relays Authorized
|
||||
|
||||
- **Git Smart HTTP Backend**: Serves Git repositories over HTTP
|
||||
- **Nostr Relay**: Stores and validates repository announcements and state events
|
||||
- **Root Nostr Identity**: Serves `_@domain` through the NIP-05 well-known endpoint when mounted at the domain root
|
||||
- **Integrated Authorization**: Validates Git pushes against Nostr state events without requiring external hooks
|
||||
|
||||
Unlike the reference implementation ([ngit-relay](https://gitworkshop.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-relay)) which uses nginx + git-http-backend + pre-receive hooks + Khatru (Go), `ngit-grasp` provides a unified Rust service that handles both Git and Nostr protocols natively.
|
||||
@@ -119,6 +120,7 @@ See [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) for f
|
||||
- ✅ Git capabilities: `allow-tip-sha1-in-want`, `allow-reachable-sha1-in-want`, `uploadpack.allowFilter`
|
||||
- ✅ CORS support for web-based Git clients
|
||||
- ✅ NIP-11 relay information document
|
||||
- ✅ NIP-05 `_@domain` identity backed by the relay operator public key when the relay is mounted at `/`
|
||||
- ✅ **Purgatory**: Events without git data held for 30 minutes, auto-released when data arrives
|
||||
- ✅ **Deletion Lifecycle**: NIP-09/NIP-62 support with GRASP-aware cascade deletion, related git-data archival/removal, holding/archive recovery, and archival-relay disrespector mode
|
||||
|
||||
@@ -672,6 +674,7 @@ ngit-grasp/
|
||||
│ ├── http/
|
||||
│ │ ├── mod.rs # HTTP module
|
||||
│ │ ├── landing.rs # Landing page handler
|
||||
│ │ ├── nip05.rs # NIP-05 root identity document
|
||||
│ │ └── nip11.rs # NIP-11 relay info document
|
||||
│ └── metrics/
|
||||
│ ├── mod.rs # Prometheus metrics (Git, Nostr, Sync)
|
||||
|
||||
@@ -95,7 +95,8 @@ nostr-lmdb = "0.45.0-alpha.3"
|
||||
- Route HTTP requests to appropriate handlers
|
||||
- WebSocket upgrade for Nostr relay at `/`
|
||||
- Git Smart HTTP endpoints at `/<npub>/<identifier>.git/*`
|
||||
- Landing pages and NIP-11 document serving
|
||||
- Landing pages, the root-path-only NIP-05 `_@domain` well-known mapping, and
|
||||
path-aware NIP-11 document serving that omits NIP-05 below the domain root
|
||||
- CORS headers on all responses (GRASP-01 requirement)
|
||||
|
||||
**Key Implementation Details:**
|
||||
|
||||
@@ -148,6 +148,8 @@ NGIT_RELAY_OWNER_NSEC=nsec1...
|
||||
|
||||
**Used for:**
|
||||
|
||||
- Deriving the `_@domain` NIP-05 identity served from `/.well-known/nostr.json`
|
||||
when the relay itself is available at the domain root (`/`)
|
||||
- Deriving the operator pubkey in the NIP-11 relay information document
|
||||
- NIP-42 authentication when synchronizing from other relays
|
||||
|
||||
|
||||
+6
-2
@@ -85,7 +85,9 @@ let
|
||||
default = null;
|
||||
example = "/run/agenix/ngit-grasp-relay-owner-nsec";
|
||||
description = ''
|
||||
Runtime secret file containing the relay owner's nsec (private key).
|
||||
Runtime secret file containing the relay owner's nsec (private key),
|
||||
used for the NIP-05 root identity when served at the domain root,
|
||||
NIP-11 relay information, and relay authentication.
|
||||
The service receives it as a systemd credential named
|
||||
`${relayOwnerNsecCredential}`, so the secret is never placed in the
|
||||
process command line.
|
||||
@@ -103,7 +105,9 @@ let
|
||||
default = null;
|
||||
example = "nsec1...";
|
||||
description = ''
|
||||
Relay owner's nsec (private key) for signing and authentication.
|
||||
Relay owner's nsec (private key) for the NIP-05 root identity when
|
||||
served at the domain root, NIP-11 relay information, signing, and
|
||||
authentication.
|
||||
Less secure than relayOwnerNsecFile as it ends up in nix store.
|
||||
Only used if relayOwnerNsecFile is not set.
|
||||
'';
|
||||
|
||||
@@ -328,6 +328,7 @@ pub struct Config {
|
||||
/// Relay operator's nsec (private key) for signing and authentication.
|
||||
///
|
||||
/// Used for:
|
||||
/// - NIP-05 `_@domain` well-known identity when served at the domain root
|
||||
/// - NIP-11 relay information document (pubkey field derived from this nsec)
|
||||
/// - NIP-42 authentication when syncing from other relays
|
||||
/// - Future: signing events, WoT-based rate limiting of syncing relays
|
||||
|
||||
+39
-1
@@ -3,6 +3,7 @@
|
||||
//! Provides hyper HTTP server with WebSocket upgrade support for the Nostr relay.
|
||||
mod client_ip;
|
||||
pub mod landing;
|
||||
pub mod nip05;
|
||||
pub mod nip11;
|
||||
|
||||
use std::future::Future;
|
||||
@@ -193,6 +194,43 @@ impl Service<Request<Incoming>> for HttpService {
|
||||
});
|
||||
}
|
||||
|
||||
// NIP-05 root identity: `_@domain` resolves to the relay operator's
|
||||
// public key. This exact-path route must remain ahead of generic NIP-11
|
||||
// content negotiation so clients receive the well-known document even
|
||||
// if they send a broad or unusual Accept header.
|
||||
if path == "/.well-known/nostr.json" && (method == Method::GET || method == Method::HEAD) {
|
||||
let method = method.clone();
|
||||
let document = nip05::Nip05Document::from_config(&self.config);
|
||||
|
||||
return Box::pin(async move {
|
||||
match document.and_then(|document| document.to_json()) {
|
||||
Ok(json) => {
|
||||
let body = if method == Method::HEAD {
|
||||
empty_body()
|
||||
} else {
|
||||
full_body(json)
|
||||
};
|
||||
Ok(
|
||||
add_cors_headers(Response::builder().header("server", "ngit-grasp"))
|
||||
.status(200)
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(body)
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "Failed to build NIP-05 root identity document");
|
||||
Ok(
|
||||
add_cors_headers(Response::builder().header("server", "ngit-grasp"))
|
||||
.status(500)
|
||||
.body(full_body("Failed to build NIP-05 document"))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// GRASP-06: route /prs/<npub>/<id>.git/* before the standard git URL
|
||||
// parser. When disabled, the path falls through to existing 404
|
||||
// handling (preserving the discovery-gate contract).
|
||||
@@ -644,7 +682,7 @@ impl Service<Request<Incoming>> for HttpService {
|
||||
.map(|s| s.contains("application/nostr+json"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let doc = nip11::RelayInformationDocument::from_config(&self.config);
|
||||
let doc = nip11::RelayInformationDocument::from_config_at_path(&self.config, &path);
|
||||
let json = doc.to_json().unwrap_or_else(|e| {
|
||||
tracing::error!("Failed to serialize NIP-11 document: {}", e);
|
||||
"{}".to_string()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! NIP-05 root-identifier document.
|
||||
//!
|
||||
//! A relay operator can use `_@domain` as the domain's root Nostr identity.
|
||||
//! The well-known response maps that identifier to the same public key used
|
||||
//! by the relay for NIP-11 and NIP-42.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// NIP-05 mapping served from `/.well-known/nostr.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Nip05Document {
|
||||
pub names: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Nip05Document {
|
||||
/// Build the `_@domain` mapping from the configured relay identity.
|
||||
pub fn from_config(config: &Config) -> Result<Self> {
|
||||
Ok(Self {
|
||||
names: BTreeMap::from([("_".to_string(), config.relay_owner_pubkey_hex()?)]),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Result<String> {
|
||||
Ok(serde_json::to_string(self)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn root_identifier_uses_relay_owner_pubkey() {
|
||||
let config = Config::for_testing();
|
||||
let expected = config.relay_owner_pubkey_hex().unwrap();
|
||||
|
||||
let document = Nip05Document::from_config(&config).unwrap();
|
||||
|
||||
assert_eq!(document.names.len(), 1);
|
||||
assert_eq!(document.names.get("_"), Some(&expected));
|
||||
assert_eq!(expected.len(), 64);
|
||||
assert!(expected
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_digit() || ('a'..='f').contains(&character)));
|
||||
}
|
||||
}
|
||||
+26
-2
@@ -67,8 +67,17 @@ pub struct RelayLimitation {
|
||||
}
|
||||
|
||||
impl RelayInformationDocument {
|
||||
/// Create NIP-11 relay information document from configuration
|
||||
/// Create a NIP-11 document for a relay served at the domain root.
|
||||
pub fn from_config(config: &Config) -> Self {
|
||||
Self::from_config_at_path(config, "/")
|
||||
}
|
||||
|
||||
/// Create a NIP-11 document for the path where the relay was requested.
|
||||
///
|
||||
/// NIP-05 `_@domain` can only resolve a relay served at the domain root,
|
||||
/// because clients always request `/.well-known/nostr.json`. A relay
|
||||
/// mounted below any other path must therefore not advertise NIP-05.
|
||||
pub fn from_config_at_path(config: &Config, relay_path: &str) -> Self {
|
||||
// Get validated configuration (config.validate() must be called at startup)
|
||||
let archive_config = config.archive_config();
|
||||
let archive_enabled = archive_config.enabled();
|
||||
@@ -128,6 +137,9 @@ impl RelayInformationDocument {
|
||||
34, // NIP-34: Git repository announcements
|
||||
77, // NIP-77: Negentropy sync (reconciliation protocol)
|
||||
];
|
||||
if relay_path == "/" {
|
||||
nips.push(5); // NIP-05: Root-domain identity
|
||||
}
|
||||
// NIP-09 (deletion) and NIP-62 (request to vanish) are honoured
|
||||
// only when not running as an archival "disrespector" relay. When
|
||||
// disrespector mode is on the relay stores but ignores deletion /
|
||||
@@ -137,8 +149,8 @@ impl RelayInformationDocument {
|
||||
if !config.deletion_request_disrespector {
|
||||
nips.push(9); // NIP-09: Event deletion requests
|
||||
nips.push(62); // NIP-62: Request to vanish
|
||||
nips.sort_unstable();
|
||||
}
|
||||
nips.sort_unstable();
|
||||
nips
|
||||
},
|
||||
software: "https://gitworkshop.dev/danconwaydev.com/ngit-grasp".to_string(),
|
||||
@@ -193,6 +205,7 @@ mod tests {
|
||||
assert!(pubkey.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
|
||||
assert!(doc.supported_nips.contains(&1));
|
||||
assert!(doc.supported_nips.contains(&5));
|
||||
assert!(doc.supported_nips.contains(&11));
|
||||
assert!(doc.supported_nips.contains(&34));
|
||||
assert!(doc.supported_nips.contains(&77));
|
||||
@@ -400,6 +413,17 @@ mod tests {
|
||||
assert!(doc.supported_nips.contains(&62));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nip11_advertises_nip05_only_at_domain_root() {
|
||||
let config = Config::for_testing();
|
||||
|
||||
let root_doc = RelayInformationDocument::from_config_at_path(&config, "/");
|
||||
let nested_doc = RelayInformationDocument::from_config_at_path(&config, "/relay");
|
||||
|
||||
assert!(root_doc.supported_nips.contains(&5));
|
||||
assert!(!nested_doc.supported_nips.contains(&5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nip11_omits_deletion_in_disrespector_mode() {
|
||||
let mut config = Config::for_testing();
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! NIP-05 root-identifier integration tests.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::TestRelay;
|
||||
|
||||
#[tokio::test]
|
||||
async fn well_known_root_identity_matches_relay_pubkey() {
|
||||
let relay = TestRelay::start().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let response = client
|
||||
.get(format!(
|
||||
"http://{}/.well-known/nostr.json?name=_",
|
||||
relay.domain()
|
||||
))
|
||||
.header("Accept", "application/nostr+json, application/json")
|
||||
.send()
|
||||
.await
|
||||
.expect("request NIP-05 root identity");
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(reqwest::header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("*")
|
||||
);
|
||||
assert!(response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.starts_with("application/json")));
|
||||
|
||||
let nip05: serde_json::Value = response.json().await.expect("parse NIP-05 document");
|
||||
let root_pubkey = nip05["names"]["_"]
|
||||
.as_str()
|
||||
.expect("NIP-05 document should map the root identifier");
|
||||
assert_eq!(root_pubkey.len(), 64);
|
||||
assert!(root_pubkey
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_digit() || ('a'..='f').contains(&character)));
|
||||
|
||||
let nip11: serde_json::Value = client
|
||||
.get(format!("http://{}", relay.domain()))
|
||||
.header("Accept", "application/nostr+json")
|
||||
.send()
|
||||
.await
|
||||
.expect("request NIP-11 document")
|
||||
.json()
|
||||
.await
|
||||
.expect("parse NIP-11 document");
|
||||
|
||||
assert_eq!(nip11["pubkey"], root_pubkey);
|
||||
assert!(nip11["supported_nips"]
|
||||
.as_array()
|
||||
.expect("supported_nips should be an array")
|
||||
.contains(&serde_json::json!(5)));
|
||||
|
||||
let nested_nip11: serde_json::Value = client
|
||||
.get(format!("http://{}/relay", relay.domain()))
|
||||
.header("Accept", "application/nostr+json")
|
||||
.send()
|
||||
.await
|
||||
.expect("request NIP-11 document at a non-root relay path")
|
||||
.json()
|
||||
.await
|
||||
.expect("parse nested-path NIP-11 document");
|
||||
assert!(!nested_nip11["supported_nips"]
|
||||
.as_array()
|
||||
.expect("nested-path supported_nips should be an array")
|
||||
.contains(&serde_json::json!(5)));
|
||||
|
||||
let nested_response = client
|
||||
.get(format!(
|
||||
"http://{}/repository/.well-known/nostr.json?name=_",
|
||||
relay.domain()
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("request a non-root well-known path");
|
||||
assert_eq!(nested_response.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
|
||||
relay.stop().await;
|
||||
}
|
||||
Reference in New Issue
Block a user