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:
DanConwayDev
2026-08-14 20:28:20 +01:00
11 changed files with 221 additions and 7 deletions
+2 -1
View File
@@ -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
+3
View File
@@ -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.
+3
View File
@@ -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)
+2 -1
View File
@@ -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:**
+2
View File
@@ -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
View File
@@ -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.
'';
+1
View File
@@ -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
View File
@@ -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()
+51
View File
@@ -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
View File
@@ -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();
+86
View File
@@ -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;
}