feat(identity): publish relay owner events

The relay-owner key is intended to double as the service identity used by
ngit-ci, but clients could only discover it through HTTP and owner-authored
coordinator events without repository roots failed admission.

Sign a minimal kind-0 NIP-05 bot profile at startup — per NIP-24 `name` is
always set, here to the scheme-less public URL — alongside a single
unmarked kind-10002 relay entry. An operator-customized profile is kept
and never overwritten, and no identity event — locally stored or freshly
generated — is published before the local database and at least one
user-index relay have been successfully checked for that kind, so a
database wiped and reseeded during an index outage can never displace a
customized profile surviving on the indexes. Every send is preceded by a
per-relay re-check: an identity found on an index relay is adopted
locally, where replaceable-event semantics keep the newest copy, and is
never overwritten, so publication only fills gaps on index relays that
individually confirm they hold none; propagating a profile update onto an
index that already has one is left to the operator's own client. A kind
with no local copy is not even seeded until a reachable user-index relay
confirms it holds no identity of that kind. Publication never blocks
startup, retries transient failures with a capped backoff, and stops on
terminal protocol rejections. With no user-index relays configured,
missing kinds are seeded locally right away. In private mode (GRASP-08)
identity events are seeded and served locally but never published, so a
private relay does not advertise its existence.

Trust valid owner-signed events only for kinds without a dedicated
admission policy, such as ngit-ci coordinator advertisements that carry
no repository root tag. Owner-signed NIP-34 announcements, state events,
and PRs run the normal announcement validation, ref alignment, and
purgatory git-data handling like any other author, and the relay's own
kind 0/10002 identity is always accepted. The NIP-09/NIP-62 deletion gate
still runs before any owner acceptance, so replaying a retracted owner
event cannot undo its tombstone, and owner deletion/vanish requests keep
their lifecycle handling. Because the event blacklist cannot block the
owner key, rotating the key is the only remediation if it is compromised;
this is documented.

NGIT_DOMAIN is assumed to be the documented bare public authority:
loopback authorities use ws and other hosts advertise wss, with bare IPv6
authorities bracketed. The test fixture reuses relay-owner keys across
TestRelay::restart, and gains caller-provided keys, a private-mode member
setup, and explicit user-index relay lists; MockRelay can start
pre-seeded so a "recovering" index deterministically holds prior state.
Identity retry intervals honor the existing NGIT_TEST fast-timer
convention. No new configuration switches or ngit-ci changes are
included. Includes rustfmt fixes for src/nostr/policy/announcement.rs and
tests/private_mode.rs, which arrived on master unformatted and would
otherwise fail the workspace format gate.

Validated with rustfmt, strict workspace Clippy, the relay_identity and
private_mode integration binaries, and the full workspace test suite.
Individual sync/grasp06 integration tests fail intermittently under
parallel full-suite load, each passing in isolation and on rerun; the
same intermittent failures reproduce on origin/master without these
changes.
This commit is contained in:
DanConwayDev
2026-08-15 11:17:57 +00:00
parent e173c58e67
commit 7334e04b88
16 changed files with 1638 additions and 46 deletions
+6 -2
View File
@@ -43,9 +43,12 @@
# Relay operator's nsec (private key) for signing and authentication
# Used for:
# - NIP-05 _@domain well-known identity when served at the domain root
# - Seeding the startup kind-0 bot profile and kind-10002 relay list when
# absent both locally and on the user-index relays
# - Trusted admission of service events authored by this identity (for example, ngit-ci)
# - 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
# - Future: WoT-based rate limiting of syncing relays
#
# Never accepted on the command line, so it cannot appear in process listings.
# systemd: use the relay_owner_nsec credential (highest precedence)
@@ -136,7 +139,8 @@
# Default: true
# NGIT_SYNC_PLUS_ENABLED=true
# Relays used to discover eligible accepted repository participants' NIP-65 relay lists
# Relays receiving the relay-owner kind 0/10002 identity events and used to
# discover eligible accepted repository participants' NIP-65 relay lists
# CLI: --user-index-relays <comma-separated-websocket-urls>
# Default: wss://purplepag.es,wss://index.hzrd149.com,wss://indexer.coracle.social
# NGIT_USER_INDEX_RELAYS=wss://purplepag.es,wss://index.hzrd149.com,wss://indexer.coracle.social
+16
View File
@@ -24,6 +24,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Publish the relay-owner identity on startup as a minimal kind-0 profile with
the scheme-less public URL as `name`, `_@domain` NIP-05, and `bot: true`,
plus a kind-10002 list naming this relay as its sole read/write relay. An
operator-customized profile survives restarts, and no identity event —
stored or generated — is published before the local database and at least
one user-index relay have been checked for that kind. Every send is
preceded by a per-relay re-check: an identity found on a user-index relay
(for example after a local database wipe) is adopted locally and never
overwritten, so the relay only fills identity gaps on the indexes,
retrying transient failures with a capped backoff. In private mode the
identity is seeded locally but never published, so a private relay's
existence is not advertised. The relay also trusts events signed by its
own key so that key can operate an `ngit-ci` coordinator, but only for
kinds without a dedicated admission policy: owner-signed NIP-34 repository
events pass the normal announcement, state, and PR policies, and
NIP-09/NIP-62 tombstones still prevent replaying retracted owner events.
- 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.
+2 -1
View File
@@ -8,7 +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
- **Root Nostr Identity**: Serves `_@domain` through NIP-05, seeds a minimal bot profile and single-relay NIP-65 list, and trusts service events signed by the relay owner
- **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.
@@ -121,6 +121,7 @@ See [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) for f
- ✅ 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 `/`
- ✅ Relay-owner kind-0 bot profile and kind-10002 read/write list seeded locally when absent and published to user-index relays
- ✅ **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
+30 -1
View File
@@ -72,6 +72,16 @@ runtime:
- Initialize Nostr relay builder with custom [`Nip34WritePolicy`](src/nostr/builder.rs:51)
- Set up shared storage (LMDB or Memory), purgatory, sync manager, and
background maintenance tasks
- Seed the relay owner's minimal kind-0 bot profile and single-relay
kind-10002 read/write list when absent — stored identity events are never
overwritten, and a kind with no local copy is seeded only once a
configured user-index relay is reachable and confirms it holds no identity
of that kind — then publish in the background: no identity event, stored
or generated, is sent before at least one user-index relay has been
checked for its kind, every send is preceded by a per-relay re-check, and
an identity found on an index is adopted locally instead of overwritten.
Transient failures retry with a capped backoff, and in private mode the
identity stays local so the relay's existence is never advertised
- Atomically checkpoint purgatory and rejected-event recovery state every 60
seconds without consuming the checkpoint during restore
- Serve HTTP + WebSocket until a caller-supplied shutdown future
@@ -118,6 +128,22 @@ fn add_cors_headers(builder: http::response::Builder) -> http::response::Builder
See [`src/http/mod.rs:29-84`](src/http/mod.rs:29-84) for the full CORS implementation.
The relay-owner key is also a trusted service author in `Nip34WritePolicy`,
with deliberately narrow scope. Owner-signed events skip the event blacklist
but still pass the NIP-09/NIP-62 deletion gate, so a replayed copy of a
retracted owner event cannot undo its tombstone, and every kind with a
dedicated admission policy takes its normal path: owner-signed NIP-34
announcements, state events, and PRs are validated, ref-aligned, and routed
through purgatory exactly like anyone else's, while the relay's own kind
0/10002 identity is always accepted. Trust applies only to owner-signed
kinds that would otherwise fall through to the generic related-event
rejection — `ngit-ci` coordinator advertisements, for example, intentionally
have no repository root tag. Owner-authored NIP-09/NIP-62 requests still run
through their normal lifecycle handlers so CI status retractions and other
deletions retain their protocol effects. Because the blacklist cannot block
the owner key, the only remediation for a compromised relay-owner nsec is
rotating the key.
### 3. Git Module ([`src/git/`](src/git/))
#### [`handlers.rs`](src/git/handlers.rs) - Git HTTP Handlers
@@ -723,7 +749,10 @@ preventing unauthenticated repository enumeration.
Private mode advertises GRASP-08 plus NIP-42 and NIP-98 in NIP-11. It is
incompatible with GRASP-06 because that extension deliberately exposes an
unauthenticated contributor write surface.
unauthenticated contributor write surface. It also suppresses relay-owner
identity publication: the kind 0/10002 events are seeded and served locally
but never sent to the configured user-index relays, so a private relay does
not advertise its existence.
One process currently represents one private collaborator service. Operators
can run several independently configured instances for different groups. Fleet
+31 -2
View File
@@ -150,6 +150,18 @@ NGIT_RELAY_OWNER_NSEC=nsec1...
- Deriving the `_@domain` NIP-05 identity served from `/.well-known/nostr.json`
when the relay itself is available at the domain root (`/`)
- Signing a minimal kind-0 profile containing only the scheme-less public URL
as `name`, `nip05: "_@domain"`, and `bot: true`, plus a kind-10002 list
naming this relay as its sole read/write relay; a kind with a stored local
event keeps the stored version, and nothing is published to
`NGIT_USER_INDEX_RELAYS` before at least one of them has been checked for
the kind (and never when `NGIT_PRIVATE_MODE` is enabled)
- Trusted admission of valid events authored by this identity for kinds
without a dedicated admission policy, such as `ngit-ci` coordinator and
result events that do not reference a repository. Owner-signed NIP-34
repository events pass the normal admission policies, NIP-09/NIP-62
tombstones still apply, and because the event blacklist cannot block this
key, rotating it is the only remediation if it is compromised
- Deriving the operator pubkey in the NIP-11 relay information document
- NIP-42 authentication when synchronizing from other relays
@@ -421,7 +433,7 @@ NGIT_SYNC_BOOTSTRAP_RELAY_URL=ws://127.0.0.1:8081
#### `NGIT_USER_INDEX_RELAYS`
**Description:** Comma-separated relay URLs used to discover eligible accepted repository participants' NIP-65 relay lists
**Description:** Comma-separated relay URLs receiving the relay-owner kind 0/10002 identity events and used to discover eligible accepted repository participants' NIP-65 relay lists
**Type:** String list (comma-separated WebSocket URLs)
**Default:** `wss://purplepag.es,wss://index.hzrd149.com,wss://indexer.coracle.social`
**Required:** No
@@ -432,6 +444,20 @@ NGIT_USER_INDEX_RELAYS=wss://purplepag.es,wss://index.example.com
The corresponding NixOS option is `userIndexRelays`, expressed as a list of
strings. Empty comma-separated entries and surrounding whitespace are ignored.
On startup, ngit-grasp publishes the owner's kind-0 and kind-10002 identity
events to the valid configured user-index relays, gap-filling only: no
identity event — locally stored or freshly generated — is published before
at least one user-index relay has been successfully checked for that kind,
and every send is preceded by a per-relay re-check. An identity found on an
index relay (for example after a local database wipe) is adopted locally and
never overwritten, so events reach only index relays that individually
confirm they hold no identity of that kind. For a kind with no local copy,
nothing is even seeded until a reachable user-index relay confirms it holds
none. Transient failures are retried with a capped backoff; terminal
protocol rejections are logged without retrying. Neither remote outages nor
local rejections block relay startup. With no user-index relays configured —
or when `NGIT_PRIVATE_MODE` is enabled — missing identity events are seeded
locally right away and published nowhere.
---
@@ -1010,7 +1036,10 @@ Operators should review the design tradeoffs in [`docs/explanation/grasp-06-cont
When enabled, the relay requires successful NIP-42 authentication by a
whitelisted member before accepting or serving any Nostr events. Standard Git
repository root, `info/refs`, `git-upload-pack`, and `git-receive-pack`
requests require the GRASP-08 repository-scoped NIP-98 credential.
requests require the GRASP-08 repository-scoped NIP-98 credential. The
relay-owner identity events (kind 0/10002) are still seeded and served
locally but are never published to `NGIT_USER_INDEX_RELAYS`, so a private
relay does not advertise its existence.
```bash
NGIT_PRIVATE_MODE=true
+10 -5
View File
@@ -87,7 +87,8 @@ let
description = ''
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.
startup kind-0/kind-10002 seeding, trusted service-event
admission, 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.
@@ -106,8 +107,9 @@ let
example = "nsec1...";
description = ''
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.
served at the domain root, startup kind-0/kind-10002 seeding,
trusted service-event admission, 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.
'';
@@ -137,8 +139,11 @@ let
"wss://index.hzrd149.com"
"wss://indexer.coracle.social"
];
description =
"Relays used to discover eligible accepted repository participants' NIP-65 relay lists";
description = ''
Relays receiving the relay-owner kind-0/kind-10002 identity events
and used to discover eligible accepted repository participants'
NIP-65 relay lists.
'';
};
syncPlusFallbackRelays = mkOption {
+5 -3
View File
@@ -329,9 +329,11 @@ pub struct Config {
///
/// Used for:
/// - NIP-05 `_@domain` well-known identity when served at the domain root
/// - Signing the minimal kind-0 bot profile and kind-10002 relay list published at startup
/// - Trusted admission of service events authored by this identity (for example, ngit-ci)
/// - 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
/// - Future: WoT-based rate limiting of syncing relays
///
/// Loaded from the `relay_owner_nsec` systemd credential,
/// `NGIT_RELAY_OWNER_NSEC`, or `.relay-owner.nsec`; never accepted on argv.
@@ -408,8 +410,8 @@ pub struct Config {
)]
pub sync_plus_enabled: bool,
/// Comma-separated relays used to discover eligible accepted repository participants'
/// NIP-65 relay lists.
/// Comma-separated relays used to publish the relay-owner identity and to discover
/// eligible accepted repository participants' NIP-65 relay lists.
#[arg(
long,
env = "NGIT_USER_INDEX_RELAYS",
+75 -13
View File
@@ -67,9 +67,18 @@ const MAX_SUBSCRIPTION_STATE_BYTES: usize = 5 * 1024 * 1024;
/// NIP-34 Write Policy — admission and routing for GRASP-01 events
///
/// Acts as the top-level admission gate and router. Each incoming event is:
/// 1. Checked against the event blacklist.
/// 2. Passed through the deletion gate (`DeletionService::gate`).
/// 3. Dispatched to the appropriate sub-policy:
/// 1. Checked against the event blacklist (skipped for the trusted
/// relay-owner key).
/// 2. Passed through the deletion gate (`DeletionService::gate`), which
/// applies to every author — including the relay owner — so replaying a
/// retracted event cannot undo its NIP-09/NIP-62 tombstone.
/// 3. Dispatched to the appropriate sub-policy. Relay-owner trust is scoped:
/// owner-signed events of kinds with a dedicated sub-policy below (NIP-34
/// repository kinds, deletion/vanish requests) take the same validation
/// paths as any other author, while the relay's own kind 0/10002 identity
/// and owner-signed kinds with no dedicated policy (e.g. `ngit-ci`
/// coordinator advertisements) are accepted instead of falling through to
/// the generic related-event rejection.
/// - `AnnouncementPolicy` — repository announcement validation
/// - `StatePolicy` — state event validation + ref alignment
/// - `PrEventPolicy` — PR / PR-Update validation
@@ -82,6 +91,7 @@ const MAX_SUBSCRIPTION_STATE_BYTES: usize = 5 * 1024 * 1024;
#[derive(Clone)]
pub struct Nip34WritePolicy {
ctx: PolicyContext,
relay_owner_pubkey: Option<PublicKey>,
announcement_policy: AnnouncementPolicy,
state_policy: StatePolicy,
pr_event_policy: PrEventPolicy,
@@ -132,7 +142,16 @@ impl Nip34WritePolicy {
let ctx = PolicyContext::new(domain, database, git_data_path, purgatory, config.clone());
Self {
announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone(), private_access),
// RelayServer validates and always supplies this key. Keeping it
// optional here preserves library/test callers that construct a
// standalone policy without running the server configuration
// loader first.
relay_owner_pubkey: config.relay_owner_keys().ok().map(|keys| keys.public_key()),
announcement_policy: AnnouncementPolicy::new(
ctx.clone(),
config.clone(),
private_access,
),
state_policy: StatePolicy::new(ctx.clone()),
pr_event_policy: PrEventPolicy::new(ctx.clone(), repo_init_locks),
related_event_policy: RelatedEventPolicy::new(ctx.clone()),
@@ -849,15 +868,29 @@ impl WritePolicy for Nip34WritePolicy {
addr: &'a SocketAddr,
) -> Pin<Box<dyn Future<Output = WritePolicyResult> + Send + 'a>> {
Box::pin(async move {
// Check event blacklist FIRST - it overrides everything
if let Some(reason) = self.check_event_blacklist(event) {
tracing::debug!(
event_id = %event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()),
author = %event.pubkey.to_hex(),
reason = %reason,
"Rejected event from blacklisted author"
);
return WritePolicyResult::reject(MachineReadablePrefix::Blocked, reason);
// The operator may reuse this service identity with trusted
// automation such as ngit-ci, so owner-signed events skip the
// blacklist — if the key is ever compromised, rotating it is the
// only remediation. They do NOT skip the deletion gate (signed
// events are public, so replaying a retracted CI status event
// must not undo its NIP-09/NIP-62 tombstone) and they do NOT
// skip the dedicated admission policies in the dispatch below:
// owner-signed NIP-34 repository events are validated, aligned,
// and routed through purgatory exactly like anyone else's.
let is_relay_owner = self.relay_owner_pubkey == Some(event.pubkey);
// For all non-owner authors, the event blacklist overrides every
// repository and event-kind policy below.
if !is_relay_owner {
if let Some(reason) = self.check_event_blacklist(event) {
tracing::debug!(
event_id = %event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()),
author = %event.pubkey.to_hex(),
reason = %reason,
"Rejected event from blacklisted author"
);
return WritePolicyResult::reject(MachineReadablePrefix::Blocked, reason);
}
}
// Deletion / vanish gate.
@@ -879,6 +912,19 @@ impl WritePolicy for Nip34WritePolicy {
let is_synced = addr.ip().is_loopback() && addr.port() == 0;
let result = match event.kind {
// The relay's own identity events are always welcome: the
// startup seeding path stores them directly, and the
// operator may customize them through an ordinary client.
// Everyone else's kind 0/10002 goes through the
// proactive-identity policy.
Kind::Metadata | Kind::RelayList if is_relay_owner => {
tracing::debug!(
event_id = %event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()),
kind = event.kind.as_u16(),
"Accepted relay-owner identity event"
);
WritePolicyResult::Accept
}
Kind::Metadata | Kind::RelayList => self.handle_proactive_identity(event).await,
Kind::GitRepoAnnouncement => self.handle_announcement(event).await,
Kind::RepoState => self.handle_state(event, is_synced).await,
@@ -897,6 +943,22 @@ impl WritePolicy for Nip34WritePolicy {
}
Kind::EventDeletion => self.deletion.handle_nip09(event).await,
Kind::RequestToVanish => self.deletion.handle_vanish(event).await,
// Owner trust is scoped to kinds with no dedicated policy
// above: valid owner-signed events that would otherwise fall
// through to the generic related-event rejection — such as
// `ngit-ci` coordinator advertisements with no repository
// root tag — are accepted. NIP-34 repository kinds never
// reach this arm, so owner events cannot bypass announcement
// validation, state ref alignment, or purgatory git-data
// handling.
_ if is_relay_owner => {
tracing::debug!(
event_id = %event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()),
kind = event.kind.as_u16(),
"Accepted event from relay-owner identity"
);
WritePolicyResult::Accept
}
_ => self.handle_related_event(event, "Event").await,
};
+1
View File
@@ -3,6 +3,7 @@ pub mod events;
pub mod lifecycle;
pub mod persistence;
pub mod policy;
pub mod relay_identity;
/// Shared database type used across nostr modules.
pub type SharedDatabase = std::sync::Arc<dyn nostr_sdk::prelude::NostrDatabase>;
+1 -5
View File
@@ -36,11 +36,7 @@ pub struct AnnouncementPolicy {
}
impl AnnouncementPolicy {
pub fn new(
ctx: PolicyContext,
config: Config,
private_access: Option<PrivateAccess>,
) -> Self {
pub fn new(ctx: PolicyContext, config: Config, private_access: Option<PrivateAccess>) -> Self {
Self {
ctx,
config,
+727
View File
@@ -0,0 +1,727 @@
//! Relay-owner identity events and user-index publication.
//!
//! The relay owner key is also useful as a service identity for applications
//! such as `ngit-ci`. On startup the relay signs a minimal NIP-01 profile and
//! a NIP-65 relay list, but a generated event is never allowed to displace an
//! identity the operator already published: kinds with a locally stored event
//! keep the stored version, and kinds with no local copy are held back until
//! at least one configured user-index relay is reachable and confirms it has
//! no identity of that kind either. Publication is gated the same way for
//! every kind — stored or generated, nothing is sent until the local database
//! and at least one user-index relay have been checked for that kind, and
//! each individual send is preceded by a per-relay re-check. An identity
//! found on a user-index relay is adopted locally instead of being
//! overwritten, so this relay never replaces an identity an index already
//! holds. Only confirmed-absent kinds are retried against the user-index
//! relays with a capped backoff. In private mode identity events are seeded
//! locally but never published, so the relay's existence is not advertised.
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use anyhow::{Context, Result};
use nostr::nips::nip01::Metadata;
use nostr::nips::nip65::RelayList;
use nostr_sdk::local_relay::LocalRelay;
use nostr_sdk::prelude::*;
use tokio::task::JoinHandle;
use crate::config::Config;
use crate::nostr::lifecycle::DeletionService;
use crate::nostr::SharedDatabase;
const INDEX_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const INDEX_PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
const INDEX_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
fn in_test_mode() -> bool {
std::env::var("NGIT_TEST").as_deref() == Ok("1")
}
fn index_retry_initial_interval() -> Duration {
if in_test_mode() {
Duration::from_millis(200)
} else {
Duration::from_secs(60)
}
}
fn index_retry_max_interval() -> Duration {
if in_test_mode() {
Duration::from_secs(2)
} else {
Duration::from_secs(15 * 60)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PublicationFailureDisposition {
AlreadyStored,
Terminal,
Retry,
}
/// The replaceable identity events signed by the relay owner.
#[derive(Debug, Clone)]
pub struct RelayIdentityEvents {
pub profile: Event,
pub relay_list: Event,
}
impl RelayIdentityEvents {
fn iter(&self) -> impl Iterator<Item = &Event> {
[&self.profile, &self.relay_list].into_iter()
}
}
/// Identity events split by their local provenance.
///
/// Stored events are the operator-approved local history. Generated events
/// exist only because no local copy was found; they must not even be seeded
/// until the user-index relays confirm no existing identity of that kind.
/// Neither set is published before at least one user-index relay has been
/// successfully queried for the kind: a stored event is not trustworthy on
/// its own either, because a database wipe followed by a boot during an
/// index outage stores a fresh minimal event that must not later displace a
/// customized profile surviving on the indexes.
#[derive(Debug, Default)]
pub struct IdentityPublicationPlan {
stored: Vec<Event>,
generated: Vec<Event>,
}
impl IdentityPublicationPlan {
fn is_empty(&self) -> bool {
self.stored.is_empty() && self.generated.is_empty()
}
}
/// Build the relay owner's minimal profile and single-relay NIP-65 list.
pub fn build(config: &Config) -> Result<RelayIdentityEvents> {
let keys = config.relay_owner_keys()?;
let domain = config.domain.trim().trim_end_matches('/');
let relay_url = public_relay_url(domain)?;
let created_at = Timestamp::now();
let profile = Metadata::new()
// The scheme-less public URL (authority plus any mount path) so
// clients that ignore the NIP-05 fallback still display something
// meaningful. NIP-24 expects `name` to always be set.
.name(domain)
.nip05(format!("_@{domain}"))
.custom_field("bot", true)
.into_event_builder()
.custom_created_at(created_at)
.finalize(&keys)
.context("sign relay-owner kind-0 profile")?;
let relay_list = RelayList::new([(relay_url, None)])
.into_event_builder()
.custom_created_at(created_at)
.finalize(&keys)
.context("sign relay-owner kind-10002 relay list")?;
Ok(RelayIdentityEvents {
profile,
relay_list,
})
}
/// Decide how each identity kind may be published.
///
/// Kinds with a stored local event keep it — a profile the operator
/// customized through another client survives restarts — and republish the
/// stored version to the user-index relays. Kinds with no local copy are
/// deferred to the background publication task, which seeds them only after
/// a reachable user-index relay confirms no existing identity. When no
/// user-index relays are configured — or the relay runs in private mode and
/// must not check or announce anything externally — there is nothing to
/// query, so missing kinds are seeded locally right away. Deletion
/// tombstones and local
/// policy rejections skip a kind with a warning instead of failing startup —
/// identity publication is a convenience, not a precondition for serving
/// traffic. Only genuine database errors are fatal here.
pub async fn prepare(
relay: &LocalRelay,
database: &SharedDatabase,
deletion: &DeletionService,
config: &Config,
generated: &RelayIdentityEvents,
) -> Result<IdentityPublicationPlan> {
let has_index_targets = !config.private_mode
&& config
.parse_user_index_relays()
.iter()
.any(|configured| RelayUrl::parse(configured).is_ok());
let mut plan = IdentityPublicationPlan::default();
for event in generated.iter() {
let existing = database
.query(Filter::new().author(event.pubkey).kind(event.kind).limit(1))
.await
.with_context(|| format!("query stored relay-owner kind {}", event.kind.as_u16()))?
.into_iter()
.next();
if let Some(existing) = existing {
tracing::info!(
kind = existing.kind.as_u16(),
event_id = %existing.id,
"Relay-owner identity already stored; keeping the existing event"
);
plan.stored.push(existing);
continue;
}
if has_index_targets {
plan.generated.push(event.clone());
} else {
seed_local_event(relay, deletion, event, "generated")
.await
.with_context(|| {
format!("seed relay-owner kind {} locally", event.kind.as_u16())
})?;
}
}
Ok(plan)
}
/// Seed an identity event into this relay's database.
///
/// `add_event` writes directly to the database, so respect the same
/// NIP-09/NIP-62 tombstones the write policy enforces for events arriving
/// over WebSocket. Returns whether the event ended up stored; `Err` is
/// reserved for genuine database failures.
async fn seed_local_event(
relay: &LocalRelay,
deletion: &DeletionService,
event: &Event,
provenance: &str,
) -> Result<bool> {
if deletion.gate(event).await.is_some() {
tracing::warn!(
kind = event.kind.as_u16(),
provenance,
"Relay-owner identity event is tombstoned by a deletion request; not seeding"
);
return Ok(false);
}
let status = relay
.add_event(event.clone())
.await
.with_context(|| format!("store relay-owner kind {}", event.kind.as_u16()))?;
match status {
SaveEventStatus::Success | SaveEventStatus::Rejected(RejectedReason::Duplicate) => {
tracing::info!(
kind = event.kind.as_u16(),
event_id = %event.id,
author = %event.pubkey.to_hex(),
provenance,
"Seeded relay-owner identity event locally"
);
Ok(true)
}
SaveEventStatus::Rejected(RejectedReason::Replaced) => {
// Expected when adopting a user-index copy that is older than
// the locally stored identity: replaceable-event semantics keep
// the newest, which is already what we have.
tracing::info!(
kind = event.kind.as_u16(),
event_id = %event.id,
provenance,
"Local database already holds a newer relay-owner identity of this kind"
);
Ok(false)
}
SaveEventStatus::Rejected(reason) => {
tracing::warn!(
kind = event.kind.as_u16(),
event_id = %event.id,
?reason,
provenance,
"Local database rejected relay-owner identity event; continuing"
);
Ok(false)
}
}
}
/// Publish identity events to configured user-index relays in the background.
///
/// Invalid configured URLs are reported and skipped. In private mode nothing
/// is ever published: identity events would advertise the relay's existence.
/// Otherwise nothing is published until at least one user-index relay is
/// reachable and has been successfully queried for every identity kind in
/// the plan. A kind the reachable indexes already hold is adopted locally —
/// replaceable-event semantics keep the newest copy — and is never
/// published, so this relay cannot displace an identity an index holds.
/// Generated kinds the indexes confirm absent are seeded locally and queued;
/// stored kinds confirmed absent are queued as-is. Before any event is sent
/// to an individual index relay, that relay is asked once more for an
/// existing identity of the same kind, so an index that was unreachable
/// during the initial check — perhaps holding a customized profile — still
/// cannot have it displaced. Valid targets remain in the retry set until
/// each acknowledges every event; duplicate replies count as
/// acknowledgement, terminal policy replies are reported without retrying,
/// and transient index failures remain pending with a capped backoff. Remote
/// availability therefore never gates successful server startup.
pub fn spawn_user_index_publication(
config: &Config,
plan: IdentityPublicationPlan,
relay: LocalRelay,
deletion: DeletionService,
) -> Option<JoinHandle<()>> {
if config.private_mode {
// A private relay must not leak its existence through identity
// events on public user-index relays.
tracing::info!(
"Private mode enabled; relay-owner identity stays local and is not published"
);
return None;
}
if plan.is_empty() {
return None;
}
let mut targets = HashSet::new();
for configured in config.parse_user_index_relays() {
match RelayUrl::parse(&configured) {
Ok(relay) => {
targets.insert(relay);
}
Err(error) => tracing::warn!(
relay = %configured,
%error,
"Cannot publish relay-owner identity to invalid user-index relay"
),
}
}
if targets.is_empty() {
return None;
}
let keys = match config.relay_owner_keys() {
Ok(keys) => keys,
Err(error) => {
tracing::error!(%error, "Cannot initialize relay-owner identity publisher");
return None;
}
};
Some(tokio::spawn(async move {
publish_to_user_indexes(keys, plan, targets, relay, deletion).await;
}))
}
async fn publish_to_user_indexes(
keys: Keys,
plan: IdentityPublicationPlan,
targets: HashSet<RelayUrl>,
local_relay: LocalRelay,
deletion: DeletionService,
) {
let client = Client::builder()
.authenticator(SignerAuthenticator::new(keys))
.connect_timeout(INDEX_CONNECT_TIMEOUT)
.build();
let mut registered: HashSet<RelayUrl> = HashSet::new();
for relay in targets {
match client
.add_relay(relay.clone())
.connect_timeout(INDEX_CONNECT_TIMEOUT)
.await
{
Ok(_) => {
registered.insert(relay);
}
Err(error) => tracing::warn!(
relay = %relay,
%error,
"Cannot register user-index relay for relay-owner identity publication"
),
}
}
if registered.is_empty() {
return;
}
// Phase 1: wait for at least one reachable user-index relay and query it
// for every identity kind in the plan — stored kinds included. Until
// then nothing is seeded or published, so an unreachable index fleet can
// never be a reason to sign away an identity the operator may have
// customized elsewhere, and a database wiped and reseeded during an
// index outage cannot push its regenerated identity over a customized
// profile surviving on the indexes. A kind any reachable index already
// holds is adopted locally and never published.
//
// Double the delay toward the cap so a permanently unreachable index
// does not produce a warning every minute for the process lifetime. The
// first attempt runs immediately.
let candidates: Vec<(Event, bool)> = plan
.stored
.into_iter()
.map(|event| (event, false))
.chain(plan.generated.into_iter().map(|event| (event, true)))
.collect();
// `spawn_user_index_publication` only starts this task for a non-empty
// plan, and every identity event is authored by the relay owner.
let owner = candidates[0].0.pubkey;
let kinds: Vec<Kind> = candidates.iter().map(|(event, _)| event.kind).collect();
let mut retry_delay = Duration::ZERO;
let publish_events: Vec<Event> = loop {
tokio::time::sleep(retry_delay).await;
retry_delay =
(retry_delay * 2).clamp(index_retry_initial_interval(), index_retry_max_interval());
let _ = client.try_connect().timeout(INDEX_CONNECT_TIMEOUT).await;
let connected: Vec<RelayUrl> = client
.relays()
.await
.into_iter()
.filter(|(_, relay)| relay.status() == RelayStatus::Connected)
.map(|(url, _)| url)
.collect();
if connected.is_empty() {
tracing::warn!(
"No user-index relay reachable; deferring relay-owner identity publication"
);
continue;
}
let filter = Filter::new().author(owner).kinds(kinds.clone());
let target =
ReqTarget::manual(connected.into_iter().map(|url| (url, vec![filter.clone()])));
let found = match client
.fetch_events(target)
.timeout(INDEX_FETCH_TIMEOUT)
.await
{
Ok(found) => found,
Err(error) => {
tracing::warn!(
%error,
"Cannot check user-index relays for an existing relay-owner identity; retrying"
);
continue;
}
};
let mut publish_events = Vec::new();
for (event, is_generated) in &candidates {
if let Some(remote) = newest_owner_event_of_kind(&found, owner, event.kind) {
tracing::info!(
kind = event.kind.as_u16(),
event_id = %remote.id,
"User-index relays already hold a relay-owner identity of this kind; \
adopting it instead of publishing"
);
if let Err(error) =
seed_local_event(&local_relay, &deletion, &remote, "user-index").await
{
tracing::error!(%error, "Failed to adopt relay-owner identity locally");
}
continue;
}
if *is_generated {
match seed_local_event(&local_relay, &deletion, event, "generated").await {
Ok(true) => publish_events.push(event.clone()),
Ok(false) => {}
Err(error) => {
tracing::error!(%error, "Failed to seed relay-owner identity locally")
}
}
} else {
publish_events.push(event.clone());
}
}
break publish_events;
};
if publish_events.is_empty() {
client.shutdown().await;
return;
}
// Phase 2: retry publication until every registered index acknowledges
// every event or returns a terminal rejection.
let mut pending: HashMap<RelayUrl, HashSet<EventId>> = registered
.into_iter()
.map(|relay| (relay, publish_events.iter().map(|event| event.id).collect()))
.collect();
let mut retry_delay = Duration::ZERO;
while !pending.is_empty() {
tokio::time::sleep(retry_delay).await;
retry_delay =
(retry_delay * 2).clamp(index_retry_initial_interval(), index_retry_max_interval());
let _ = client.try_connect().timeout(INDEX_CONNECT_TIMEOUT).await;
let relays: Vec<RelayUrl> = pending.keys().cloned().collect();
for relay in relays {
for event in publish_events.iter() {
if !pending
.get(&relay)
.is_some_and(|event_ids| event_ids.contains(&event.id))
{
continue;
}
// No publication may displace an identity that already
// exists on an index — including one that was unreachable
// during the phase-1 check and may hold a customized
// profile. Ask this specific relay right before publishing
// any event, stored or generated; an identity found there
// is adopted locally instead, and verification failure
// keeps the event pending rather than risking an overwrite.
let filter = Filter::new().author(event.pubkey).kind(event.kind).limit(1);
match client
.fetch_events(ReqTarget::single(relay.clone(), [filter]))
.timeout(INDEX_FETCH_TIMEOUT)
.await
{
Ok(found) => {
if let Some(remote) =
newest_owner_event_of_kind(&found, event.pubkey, event.kind)
{
if let Some(event_ids) = pending.get_mut(&relay) {
event_ids.remove(&event.id);
}
tracing::info!(
relay = %relay,
kind = event.kind.as_u16(),
event_id = %remote.id,
"User-index relay already has a relay-owner identity of this \
kind; adopting it instead of publishing"
);
if let Err(error) =
seed_local_event(&local_relay, &deletion, &remote, "user-index")
.await
{
tracing::error!(
%error,
"Failed to adopt relay-owner identity locally"
);
}
continue;
}
}
Err(error) => {
tracing::warn!(
relay = %relay,
kind = event.kind.as_u16(),
%error,
"Cannot verify user-index relay before publishing relay-owner \
identity; keeping it pending"
);
continue;
}
}
let send = client.send_event(event).to(std::iter::once(relay.clone()));
match tokio::time::timeout(INDEX_PUBLISH_TIMEOUT, send).await {
Ok(Ok(output)) if output.success.contains_key(&relay) => {
if let Some(event_ids) = pending.get_mut(&relay) {
event_ids.remove(&event.id);
}
tracing::info!(
relay = %relay,
event_id = %event.id,
kind = event.kind.as_u16(),
"Published relay-owner identity event to user-index relay"
);
}
Ok(Ok(output)) => {
let failure = output.failed.get(&relay);
match failure.map(|failure| publication_failure_disposition(failure)) {
Some(PublicationFailureDisposition::AlreadyStored) => {
if let Some(event_ids) = pending.get_mut(&relay) {
event_ids.remove(&event.id);
}
tracing::info!(
relay = %relay,
event_id = %event.id,
kind = event.kind.as_u16(),
"User-index relay already stores relay-owner identity event"
);
}
Some(PublicationFailureDisposition::Terminal) => {
if let Some(event_ids) = pending.get_mut(&relay) {
event_ids.remove(&event.id);
}
tracing::warn!(
relay = %relay,
event_id = %event.id,
kind = event.kind.as_u16(),
failure,
"User-index relay permanently rejected relay-owner identity event"
);
}
Some(PublicationFailureDisposition::Retry) | None => tracing::warn!(
relay = %relay,
event_id = %event.id,
kind = event.kind.as_u16(),
failures = ?output.failed,
"User-index relay did not acknowledge relay-owner identity event"
),
}
}
Ok(Err(error)) => tracing::warn!(
relay = %relay,
event_id = %event.id,
kind = event.kind.as_u16(),
%error,
"Failed to publish relay-owner identity event to user-index relay"
),
Err(_) => tracing::warn!(
relay = %relay,
event_id = %event.id,
kind = event.kind.as_u16(),
"Timed out publishing relay-owner identity event to user-index relay"
),
}
}
if pending.get(&relay).is_some_and(HashSet::is_empty) {
pending.remove(&relay);
}
}
}
client.shutdown().await;
}
/// Newest verified owner-authored event of the given kind in a fetch result.
///
/// The author and kind are re-checked because the filter is only advisory to
/// the remote relay, and the signature is verified before the event can be
/// adopted into the local database.
fn newest_owner_event_of_kind(
events: &std::collections::BTreeSet<Event>,
owner: PublicKey,
kind: Kind,
) -> Option<Event> {
events
.iter()
.filter(|event| event.pubkey == owner && event.kind == kind && event.verify().is_ok())
.max_by(|a, b| {
a.created_at
.cmp(&b.created_at)
.then_with(|| a.id.cmp(&b.id))
})
.cloned()
}
fn publication_failure_disposition(message: &str) -> PublicationFailureDisposition {
match MachineReadablePrefix::parse(message) {
Some(MachineReadablePrefix::Duplicate) => PublicationFailureDisposition::AlreadyStored,
Some(
MachineReadablePrefix::Pow
| MachineReadablePrefix::Blocked
| MachineReadablePrefix::Invalid
| MachineReadablePrefix::Unsupported
| MachineReadablePrefix::Restricted,
) => PublicationFailureDisposition::Terminal,
_ => PublicationFailureDisposition::Retry,
}
}
fn public_relay_url(domain: &str) -> Result<RelayUrl> {
let scheme = if is_loopback_authority(domain) {
"ws"
} else {
"wss"
};
// A bare IPv6 authority must be bracketed to form a valid URL.
let authority = if domain.parse::<std::net::Ipv6Addr>().is_ok() {
format!("[{domain}]")
} else {
domain.to_string()
};
RelayUrl::parse(&format!("{scheme}://{authority}"))
.with_context(|| format!("derive public relay URL from NGIT_DOMAIN={domain}"))
}
fn is_loopback_authority(domain: &str) -> bool {
// A bare IP authority (including unbracketed IPv6 like `::1`) parses
// whole; otherwise strip an optional port from `host:port` / `[v6]:port`.
if let Ok(address) = domain.parse::<std::net::IpAddr>() {
return address.is_loopback();
}
let host = domain
.strip_prefix('[')
.and_then(|value| value.split_once(']').map(|(host, _)| host))
.unwrap_or_else(|| domain.split(':').next().unwrap_or(domain));
host.eq_ignore_ascii_case("localhost")
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback())
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::nips::nip65;
#[test]
fn identity_is_minimal_and_lists_only_this_relay_for_read_and_write() {
let config = Config::for_testing();
let events = build(&config).expect("build relay identity");
let profile: serde_json::Value =
serde_json::from_str(&events.profile.content).expect("parse profile metadata");
let fields = profile.as_object().expect("profile object");
assert_eq!(fields.len(), 3);
assert_eq!(
fields.get("name"),
Some(&serde_json::json!("localhost:7334"))
);
assert_eq!(
fields.get("nip05"),
Some(&serde_json::json!("_@localhost:7334"))
);
assert_eq!(fields.get("bot"), Some(&serde_json::json!(true)));
assert_eq!(events.profile.kind, Kind::Metadata);
assert!(events.profile.tags.is_empty());
let relays: Vec<_> = nip65::extract_relay_list(&events.relay_list).collect();
assert_eq!(events.relay_list.kind, Kind::RelayList);
assert!(events.relay_list.content.is_empty());
assert_eq!(relays.len(), 1);
assert_eq!(relays[0].0.to_string(), "ws://localhost:7334");
assert_eq!(relays[0].1, None, "an unmarked relay is read and write");
}
#[test]
fn production_domain_defaults_to_secure_websocket_url() {
assert_eq!(
public_relay_url("relay.example.com").unwrap().to_string(),
"wss://relay.example.com"
);
}
#[test]
fn loopback_authorities_use_plain_websocket_urls() {
for domain in ["localhost:7334", "127.0.0.1:8080", "[::1]:7334", "::1"] {
let url = public_relay_url(domain).unwrap().to_string();
assert!(url.starts_with("ws://"), "{domain} -> {url}");
}
}
#[test]
fn publication_retries_only_transient_failures() {
assert_eq!(
publication_failure_disposition("duplicate: already stored"),
PublicationFailureDisposition::AlreadyStored
);
assert_eq!(
publication_failure_disposition("restricted: author is not admitted"),
PublicationFailureDisposition::Terminal
);
assert_eq!(
publication_failure_disposition("rate-limited: slow down"),
PublicationFailureDisposition::Retry
);
assert_eq!(
publication_failure_disposition("not connected"),
PublicationFailureDisposition::Retry
);
}
}
+38 -1
View File
@@ -28,7 +28,10 @@ use crate::{
config::{Config, DatabaseBackend},
git, grasp06, http,
metrics::Metrics,
nostr::{self, builder::Nip34WritePolicy, lifecycle::RepositoryLifecycle, SharedDatabase},
nostr::{
self, builder::Nip34WritePolicy, lifecycle::RepositoryLifecycle, relay_identity,
SharedDatabase,
},
outbound::OutboundTargetPolicy,
private::PrivateAccess,
purgatory::{sync::RealSyncContext, sync::ThrottleManager, Purgatory},
@@ -214,6 +217,31 @@ impl RelayServer {
.await
.context("failed deletion lifecycle startup reconciliation")?;
// Make the operator identity discoverable on this relay without
// overwriting identity events the owner already published — locally
// or on the configured user-index relays. Runs after deletion startup
// reconciliation so seeding sees settled tombstones. Kinds with no
// local copy are handed to the background publication task below,
// which seeds them only once a user-index relay is reachable and
// confirms no existing identity. Stored kinds pass the same gate:
// nothing is published before at least one user-index relay has been
// checked for the kind, and an identity found there is adopted
// locally instead of overwritten, so transient network failure never
// blocks relay startup and a wiped database cannot displace a
// customized profile surviving on the indexes. In private mode the
// identity stays local and is never published.
let generated_identity = relay_identity::build(&config)
.context("failed to build relay-owner identity events")?;
let relay_identity_plan = relay_identity::prepare(
&relay_runtime.relay,
&relay_runtime.stores.database,
&relay_runtime.deletion,
&config,
&generated_identity,
)
.await
.context("failed to prepare relay-owner identity publication")?;
// Wire the GRASP-06 `/prs/` filesystem cleanup context into
// purgatory so the standard expiry sweep can delete dangling
// refs/nostr/<event-id> refs (and zero-ref bare repos) when a
@@ -270,6 +298,15 @@ impl RelayServer {
let mut background_tasks = Vec::new();
if let Some(task) = relay_identity::spawn_user_index_publication(
&config,
relay_identity_plan,
relay_runtime.relay.clone(),
relay_runtime.deletion.clone(),
) {
background_tasks.push(task);
}
background_tasks.push(tokio::spawn(async move {
sync_manager.run().await;
}));
+26 -2
View File
@@ -138,16 +138,33 @@ impl MockRelay {
let listener =
TcpListener::from_std(std_listener).expect("Failed to convert to tokio listener");
Self::start_with_listener(listener, port, rate_limit, pagination, max_filters).await
Self::start_with_listener(
listener,
port,
rate_limit,
pagination,
max_filters,
Vec::new(),
)
.await
}
/// Start a mock relay on a specific port.
pub async fn start_on_port(port: u16) -> Self {
Self::start_on_port_with_events(port, Vec::new()).await
}
/// Start a mock relay on a specific port with events already stored.
///
/// The events are stored before the accept loop starts, so a client
/// reaching the freshly (re)bound port deterministically sees them —
/// modelling an index relay recovering with prior state.
pub async fn start_on_port_with_events(port: u16, events: Vec<Event>) -> Self {
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let listener = TcpListener::bind(addr)
.await
.expect("Failed to bind to address");
Self::start_with_listener(listener, port, RateLimit::default(), None, None).await
Self::start_with_listener(listener, port, RateLimit::default(), None, None, events).await
}
/// Internal method to start the relay with an existing listener.
@@ -157,6 +174,7 @@ impl MockRelay {
rate_limit: RateLimit,
pagination: Option<PaginationConfig>,
max_filters: Option<usize>,
initial_events: Vec<Event>,
) -> Self {
// Create a simple relay with no write policy (accepts all events)
let mut builder = LocalRelayBuilder::default().rate_limit(rate_limit);
@@ -170,6 +188,12 @@ impl MockRelay {
builder = builder.max_filters_per_req(max_filters);
}
let relay = builder.build();
for event in initial_events {
relay
.add_event(event)
.await
.expect("Failed to seed initial mock relay event");
}
// Create shutdown channel
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
+97 -4
View File
@@ -12,7 +12,7 @@
//! `start_on_reservation_*` constructors — the listener stays bound for
//! the duration of the reservation, eliminating the same-process race.
use nostr_sdk::prelude::ToBech32;
use nostr_sdk::prelude::{Keys, ToBech32};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
@@ -53,6 +53,8 @@ pub struct TestRelay {
process: Child,
url: String,
port: u16,
/// Relay-owner identity configured in the subprocess.
owner_keys: Keys,
/// Temporary directory for git repositories
/// Kept alive for the lifetime of the relay
_git_data_dir: Option<tempfile::TempDir>,
@@ -72,6 +74,10 @@ pub struct TestRelay {
/// parameters so the call-site changes are mechanical.
#[derive(Default, Clone)]
struct RelayOptions {
/// Owner identity for the subprocess. `try_start_once` fills this in
/// when unset so [`TestRelay::restart`] keeps the same identity, as a
/// production restart would.
owner_keys: Option<Keys>,
bootstrap_relay_url: Option<String>,
sync_plus_fallback_relays: Option<String>,
disable_negentropy: bool,
@@ -88,6 +94,9 @@ struct RelayOptions {
relay_max_subscriptions: Option<usize>,
sync_recursive_descendant_limit: Option<usize>,
private_members: Option<String>,
/// Explicit NGIT_USER_INDEX_RELAYS value (comma-separated), overriding
/// the bootstrap relay's double duty as the sole user-index target.
user_index_relays: Option<String>,
/// Run with the production outbound target policy (reject non-global
/// event-directed sync targets). The fixture default is permissive
/// because the entire test infrastructure lives on loopback.
@@ -173,6 +182,52 @@ impl TestRelay {
.await
}
/// Start a syncing relay with a caller-chosen relay-owner identity.
///
/// Lets tests stage owner-signed events on other relays before this
/// relay boots, e.g. to model an identity that survives on a user-index
/// relay after the local database was wiped.
pub async fn start_with_sync_and_owner_keys(
bootstrap_relay_url: Option<String>,
owner_keys: Keys,
) -> Self {
Self::start_internal(
port::reserve_port(),
RelayOptions {
bootstrap_relay_url,
owner_keys: Some(owner_keys),
..RelayOptions::default()
},
)
.await
}
/// Start a GRASP-08 private relay whose sole configured member is also
/// the relay-owner identity, with user-index relays configured.
///
/// Lets tests observe that a private relay seeds its identity locally
/// (queryable by the authenticated owner) without ever publishing it to
/// the configured user-index relays.
pub async fn start_private_with_sync_and_owner_keys(
bootstrap_relay_url: Option<String>,
owner_keys: Keys,
) -> Self {
let member = owner_keys
.public_key()
.to_bech32()
.expect("Failed to encode private test member");
Self::start_internal(
port::reserve_port(),
RelayOptions {
bootstrap_relay_url,
owner_keys: Some(owner_keys),
private_members: Some(member),
..RelayOptions::default()
},
)
.await
}
/// Start a syncing relay with the production outbound target policy.
///
/// Unlike every other constructor, this does NOT set
@@ -446,6 +501,28 @@ impl TestRelay {
.await
}
/// Start a persistent relay with an explicit user-index relay list and
/// no sync bootstrap, so identity-publication targets can differ from
/// the sync topology and survive [`Self::restart`].
pub async fn start_on_reservation_persistent_user_index_relays(
reservation: PortReservation,
user_index_relays: String,
git_data_path: PathBuf,
relay_data_path: PathBuf,
) -> Self {
Self::start_internal(
reservation,
RelayOptions {
user_index_relays: Some(user_index_relays),
lmdb_backend: true,
git_data_path: Some(git_data_path),
relay_data_path: Some(relay_data_path),
..RelayOptions::default()
},
)
.await
}
/// Start a persistent syncing relay with a small recursive-descendant
/// allowance so saturation and restart reconstruction can be exercised.
pub async fn start_on_reservation_persistent_sync_with_recursive_limit(
@@ -598,8 +675,12 @@ impl TestRelay {
.expect("Failed to get grandparent dir")
.join("ngit-grasp");
// Give the relay a stable signing identity for NIP-11 and NIP-42.
let test_keys = nostr_sdk::prelude::Keys::generate();
// Give the relay a stable signing identity for NIP-11 and NIP-42,
// reusing the caller-provided identity across restarts.
let test_keys = options
.owner_keys
.clone()
.unwrap_or_else(nostr_sdk::prelude::Keys::generate);
let test_nsec = test_keys
.secret_key()
.to_bech32()
@@ -656,6 +737,9 @@ impl TestRelay {
if let Some(ref fallback_relays) = options.sync_plus_fallback_relays {
cmd.env("NGIT_SYNC_PLUS_FALLBACK_RELAYS", fallback_relays);
}
if let Some(ref user_index_relays) = options.user_index_relays {
cmd.env("NGIT_USER_INDEX_RELAYS", user_index_relays);
}
if let Some(ref private_members) = options.private_members {
cmd.env("NGIT_PRIVATE_MODE", "true")
.env("NGIT_PRIVATE_MEMBERS", private_members)
@@ -751,11 +835,15 @@ impl TestRelay {
process,
url,
port,
owner_keys: test_keys.clone(),
_git_data_dir: git_data_dir,
git_data_path,
_relay_data_dir: relay_data_dir,
relay_data_path,
options: options.clone(),
options: RelayOptions {
owner_keys: Some(test_keys),
..options.clone()
},
};
match relay.wait_for_ready_or_early_exit().await {
@@ -780,6 +868,11 @@ impl TestRelay {
format!("127.0.0.1:{}", self.port)
}
/// Keys configured as this test relay's operator identity.
pub fn owner_keys(&self) -> &Keys {
&self.owner_keys
}
/// Get the subprocess log captured by the test harness.
pub fn log_path(&self) -> PathBuf {
PathBuf::from(format!("/tmp/relay-{}.log", self.port))
+22 -7
View File
@@ -67,7 +67,10 @@ async fn connect_and_challenge(relay: &TestRelay) -> (WsStream, String) {
.expect("connect to private relay");
let frame: serde_json::Value =
serde_json::from_str(&next_text(&mut stream).await).expect("relay JSON message");
assert_eq!(frame[0], "AUTH", "first relay message must be the challenge");
assert_eq!(
frame[0], "AUTH",
"first relay message must be the challenge"
);
let challenge = frame[1].as_str().expect("challenge string").to_owned();
(stream, challenge)
}
@@ -255,7 +258,8 @@ async fn private_websocket_rejects_messages_before_authentication() {
assert_eq!(ok[1].as_str(), Some(note.id.to_hex().as_str()));
assert_eq!(ok[2], false);
assert!(
ok[3].as_str()
ok[3]
.as_str()
.expect("OK message")
.starts_with("auth-required:"),
"{ok}"
@@ -283,7 +287,10 @@ async fn private_websocket_admits_member_and_bridges_to_relay() {
let ok: serde_json::Value =
serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON");
assert_eq!(ok[0], "OK");
assert_eq!(ok[2], true, "member NIP-42 authentication must succeed: {ok}");
assert_eq!(
ok[2], true,
"member NIP-42 authentication must succeed: {ok}"
);
// The authenticated session reaches the inner relay: a subscription is
// answered with EOSE instead of an auth-required rejection.
@@ -299,7 +306,10 @@ async fn private_websocket_admits_member_and_bridges_to_relay() {
if frame[0] == "EOSE" && frame[1] == "after-auth" {
return;
}
assert_ne!(frame[0], "CLOSED", "authenticated REQ was rejected: {frame}");
assert_ne!(
frame[0], "CLOSED",
"authenticated REQ was rejected: {frame}"
);
}
})
.await;
@@ -325,7 +335,8 @@ async fn private_websocket_rejects_valid_nonmember_auth_and_closes() {
assert_eq!(ok[0], "OK");
assert_eq!(ok[2], false);
assert!(
ok[3].as_str()
ok[3]
.as_str()
.expect("OK message")
.starts_with("restricted:"),
"valid non-member auth must be restricted: {ok}"
@@ -376,7 +387,10 @@ async fn private_announcement_admission_requires_member_author() {
.await;
let ok: serde_json::Value =
serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON");
assert_eq!(ok[2], true, "member NIP-42 authentication must succeed: {ok}");
assert_eq!(
ok[2], true,
"member NIP-42 authentication must succeed: {ok}"
);
// A member-authored announcement is admitted (OK true, parked in
// purgatory awaiting git data) and its bare repository is created.
@@ -434,7 +448,8 @@ async fn private_websocket_bounds_invalid_authentication_attempts() {
assert_eq!(ok[0], "OK");
assert_eq!(ok[2], false);
assert!(
ok[3].as_str()
ok[3]
.as_str()
.expect("OK message")
.starts_with("auth-required:"),
"{ok}"
+551
View File
@@ -0,0 +1,551 @@
//! Relay-owner identity publication and admission integration tests.
mod common;
use std::collections::BTreeSet;
use std::time::Duration;
use common::{reserve_port, wait_for_event_on_relay, MockRelay, TestClient, TestRelay};
use nostr::nips::nip65;
use nostr_sdk::prelude::*;
const OBSERVATION_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn relay_owner_identity_is_local_indexed_and_trusted_for_undedicated_kinds() {
let index = MockRelay::start().await;
let relay = TestRelay::start_with_sync(Some(index.url().to_string())).await;
let owner = relay.owner_keys().public_key();
let identity_filter = Filter::new()
.author(owner)
.kinds([Kind::Metadata, Kind::RelayList]);
for url in [relay.url(), index.url()] {
for kind in [Kind::Metadata, Kind::RelayList] {
assert!(
wait_for_event_on_relay(
url,
Filter::new().author(owner).kind(kind),
OBSERVATION_TIMEOUT,
)
.await,
"relay-owner kind {} was not published to {url}",
kind.as_u16()
);
}
}
let local_identity = fetch_events(relay.url(), identity_filter.clone()).await;
let indexed_identity = fetch_events(index.url(), identity_filter).await;
assert_eq!(event_ids(&local_identity), event_ids(&indexed_identity));
assert_identity_shape(&relay, &local_identity);
// Kind 19843 has no repository-root reference and no dedicated admission
// policy, so ordinary related-event policy rejects it. The scoped
// relay-owner trust path must still accept it for ngit-ci's coordinator
// advertisement, while NIP-34 repository kinds keep their normal
// policies (covered by owner_signed_repository_events below).
let coordinator_advertisement = EventBuilder::new(Kind::from(19_843), "")
.finalize(relay.owner_keys())
.expect("sign owner-authored coordinator advertisement");
let owner_client = TestClient::new(relay.url(), relay.owner_keys().clone())
.await
.expect("connect owner client");
owner_client
.send_event(&coordinator_advertisement)
.await
.expect("relay owner event should be accepted");
assert!(
wait_for_event_on_relay(
relay.url(),
Filter::new().id(coordinator_advertisement.id),
OBSERVATION_TIMEOUT,
)
.await,
"accepted relay-owner event was not queryable"
);
let deletion = EventBuilder::new(Kind::EventDeletion, "")
.tags([Tag::event(coordinator_advertisement.id)])
.finalize(relay.owner_keys())
.expect("sign coordinator-advertisement deletion");
owner_client
.send_event(&deletion)
.await
.expect("relay-owner deletion should be accepted");
assert!(
fetch_events(relay.url(), Filter::new().id(coordinator_advertisement.id))
.await
.is_empty(),
"relay-owner trust path must retain NIP-09 lifecycle effects"
);
// Owner-signed events are public, so anyone can replay them. The trust
// path must still enforce the tombstone left by the deletion above.
assert!(
owner_client
.send_event(&coordinator_advertisement)
.await
.is_err(),
"replayed deleted relay-owner event must be rejected"
);
assert!(
fetch_events(relay.url(), Filter::new().id(coordinator_advertisement.id))
.await
.is_empty(),
"replayed deleted relay-owner event must not be stored"
);
relay.stop().await;
index.stop().await;
}
#[tokio::test]
async fn restart_preserves_customized_profile_and_never_overwrites_index_copy() {
let index = MockRelay::start().await;
let git_data = tempfile::tempdir().expect("git data dir");
let relay_data = tempfile::tempdir().expect("relay data dir");
let relay = TestRelay::start_on_reservation_persistent_sync(
reserve_port(),
Some(index.url().to_string()),
false,
git_data.path().to_path_buf(),
relay_data.path().to_path_buf(),
)
.await;
let owner = relay.owner_keys().public_key();
let profile_filter = Filter::new().author(owner).kind(Kind::Metadata);
assert!(
wait_for_event_on_relay(index.url(), profile_filter.clone(), OBSERVATION_TIMEOUT).await,
"generated relay-owner profile was not published to the user index"
);
let generated_ids = event_ids(&fetch_events(index.url(), profile_filter.clone()).await);
// The operator customizes the bot profile through an ordinary client.
// Future-dated by a few seconds so it stays newer than anything the
// restarted relay could sign, making the overwrite deterministic if
// seeding ever regressed to unconditional publication.
let customized = EventBuilder::new(Kind::Metadata, r#"{"name":"customized-bot"}"#)
.custom_created_at(nostr_sdk::prelude::Timestamp::now() + 5)
.finalize(relay.owner_keys())
.expect("sign customized profile");
let owner_client = TestClient::new(relay.url(), relay.owner_keys().clone())
.await
.expect("connect owner client");
owner_client
.send_event(&customized)
.await
.expect("customized owner profile should be accepted");
assert!(
wait_for_event_on_relay(
relay.url(),
Filter::new().id(customized.id),
OBSERVATION_TIMEOUT,
)
.await,
"customized profile was not stored"
);
let relay = relay.restart().await;
// Restart seeding must not overwrite the operator-customized profile
// locally, and the copy already on the user index is left untouched:
// identities found on an index are adopted, never replaced, so pushing
// a profile update out to the indexes is the operator's own client's
// job. Non-replacement is stable absence over time, so poll across
// several identity retry cycles (test mode retries every 200ms-2s).
let stored = fetch_events(relay.url(), profile_filter.clone()).await;
assert_eq!(
event_ids(&stored),
BTreeSet::from([customized.id]),
"restart seeding must not overwrite the operator-customized profile"
);
let observation_deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while tokio::time::Instant::now() < observation_deadline {
assert_eq!(
event_ids(&fetch_events(index.url(), profile_filter.clone()).await),
generated_ids,
"restart must not overwrite the identity already on the user index"
);
tokio::time::sleep(Duration::from_millis(200)).await;
}
relay.stop().await;
index.stop().await;
}
#[tokio::test]
async fn wiped_relay_adopts_identity_from_user_index_instead_of_publishing() {
let owner_keys = Keys::generate();
let index = MockRelay::start().await;
let owner = owner_keys.public_key();
// The only surviving copy of the operator-customized profile lives on
// the user index, as after a local database wipe or redeployment onto
// fresh storage with the same nsec.
let customized = EventBuilder::new(Kind::Metadata, r#"{"name":"customized-bot"}"#)
.finalize(&owner_keys)
.expect("sign customized profile");
let staging = TestClient::new(index.url(), owner_keys.clone())
.await
.expect("connect staging client to index");
staging
.send_event(&customized)
.await
.expect("stage customized profile on the user index");
let relay =
TestRelay::start_with_sync_and_owner_keys(Some(index.url().to_string()), owner_keys).await;
// The relay adopts the indexed profile locally instead of seeding a
// fresh minimal one...
assert!(
wait_for_event_on_relay(
relay.url(),
Filter::new().id(customized.id),
OBSERVATION_TIMEOUT,
)
.await,
"customized profile from the user index was not adopted locally"
);
// ...while the relay list, which no index holds, is still generated,
// seeded, and published.
for url in [relay.url(), index.url()] {
assert!(
wait_for_event_on_relay(
url,
Filter::new().author(owner).kind(Kind::RelayList),
OBSERVATION_TIMEOUT,
)
.await,
"generated relay list was not published to {url}"
);
}
// The generated kind-0 must never have been published: the index still
// holds exactly the customized profile. The relay list arriving on the
// index above is the ordering anchor - a wrongly queued generated
// profile would have been attempted in the same publication round.
let indexed_profiles = fetch_events(
index.url(),
Filter::new().author(owner).kind(Kind::Metadata),
)
.await;
assert_eq!(
event_ids(&indexed_profiles),
BTreeSet::from([customized.id]),
"generated profile displaced the customized identity on the user index"
);
relay.stop().await;
index.stop().await;
}
#[tokio::test]
async fn identity_publication_defers_until_a_user_index_relay_is_reachable() {
// Release the reservation so connection attempts fail fast with refused;
// the MockRelay rebinds the same port later in the test.
let port = reserve_port().release();
let index_url = format!("ws://127.0.0.1:{port}");
let relay = TestRelay::start_with_sync(Some(index_url)).await;
let owner = relay.owner_keys().public_key();
let identity_filter = Filter::new()
.author(owner)
.kinds([Kind::Metadata, Kind::RelayList]);
// With no reachable user index, nothing may be seeded locally. Deferral
// is stable absence over time, so observe it across several identity
// retry cycles (test mode retries every 200ms-2s) by polling rather
// than asserting once.
let observation_deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while tokio::time::Instant::now() < observation_deadline {
assert!(
fetch_events(relay.url(), identity_filter.clone())
.await
.is_empty(),
"identity must not be seeded while no user-index relay is reachable"
);
tokio::time::sleep(Duration::from_millis(200)).await;
}
// Once an empty index becomes reachable, the generated identity is
// released: seeded locally and published to the index.
let index = MockRelay::start_on_port(port).await;
for url in [relay.url(), index.url()] {
for kind in [Kind::Metadata, Kind::RelayList] {
assert!(
wait_for_event_on_relay(
url,
Filter::new().author(owner).kind(kind),
OBSERVATION_TIMEOUT,
)
.await,
"kind {} was not published to {url} after the index became reachable",
kind.as_u16()
);
}
}
relay.stop().await;
index.stop().await;
}
#[tokio::test]
async fn stored_identity_is_not_pushed_to_recovering_index_holding_an_identity() {
// Index A stays unreachable until it "recovers" already holding the
// operator's customized profile; index B is reachable so the phase-1
// index check can succeed without A.
let port_a = reserve_port().release();
let port_b = reserve_port().release();
let index_b = MockRelay::start_on_port(port_b).await;
let git_data = tempfile::tempdir().expect("git data dir");
let relay_data = tempfile::tempdir().expect("relay data dir");
let relay = TestRelay::start_on_reservation_persistent_user_index_relays(
reserve_port(),
format!("ws://127.0.0.1:{port_a},ws://127.0.0.1:{port_b}"),
git_data.path().to_path_buf(),
relay_data.path().to_path_buf(),
)
.await;
let owner = relay.owner_keys().public_key();
// First boot: B confirms it holds no identity, so the generated events
// are seeded and published to B. They are now locally *stored*.
for kind in [Kind::Metadata, Kind::RelayList] {
assert!(
wait_for_event_on_relay(
index_b.url(),
Filter::new().author(owner).kind(kind),
OBSERVATION_TIMEOUT,
)
.await,
"generated kind {} was not published to the reachable empty index",
kind.as_u16()
);
}
// The only surviving copy of the operator-customized profile will live
// on index A. Future-dated so it is deterministically newer than the
// stored generated profile.
let customized = EventBuilder::new(Kind::Metadata, r#"{"name":"customized-bot"}"#)
.custom_created_at(Timestamp::now() + 50)
.finalize(relay.owner_keys())
.expect("sign customized profile");
// Restart against a wiped, empty B (and A still down): even stored
// identity events must wait for a successful index check before any
// publication, then reach only relays confirmed to hold nothing.
index_b.stop().await;
let relay = relay.restart().await;
let index_b = MockRelay::start_on_port(port_b).await;
for kind in [Kind::Metadata, Kind::RelayList] {
assert!(
wait_for_event_on_relay(
index_b.url(),
Filter::new().author(owner).kind(kind),
OBSERVATION_TIMEOUT,
)
.await,
"stored kind {} was not republished to the empty index after its check succeeded",
kind.as_u16()
);
}
// A recovers already holding the customized profile. The per-relay
// re-check before every send must adopt it locally instead of pushing
// the stored (formerly generated) profile over it.
let index_a = MockRelay::start_on_port_with_events(port_a, vec![customized.clone()]).await;
assert!(
wait_for_event_on_relay(
relay.url(),
Filter::new().id(customized.id),
OBSERVATION_TIMEOUT,
)
.await,
"customized profile on the recovering index was not adopted locally"
);
// The relay list is still delivered to A, which holds none — proving
// the publication round reached A while the profile was withheld.
assert!(
wait_for_event_on_relay(
index_a.url(),
Filter::new().author(owner).kind(Kind::RelayList),
OBSERVATION_TIMEOUT,
)
.await,
"relay list was not delivered to the recovered index"
);
// Non-displacement is stable absence over time, so poll across several
// identity retry cycles (test mode retries every 200ms-2s).
let observation_deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while tokio::time::Instant::now() < observation_deadline {
let profiles = fetch_events(
index_a.url(),
Filter::new().author(owner).kind(Kind::Metadata),
)
.await;
assert_eq!(
event_ids(&profiles),
BTreeSet::from([customized.id]),
"stored profile displaced the customized identity on the recovering index"
);
tokio::time::sleep(Duration::from_millis(200)).await;
}
relay.stop().await;
index_a.stop().await;
index_b.stop().await;
}
#[tokio::test]
async fn owner_signed_repository_events_use_normal_admission_policies() {
let relay = TestRelay::start().await;
let owner_client = TestClient::new(relay.url(), relay.owner_keys().clone())
.await
.expect("connect owner client");
// A state event for a repository with no accepted announcement is
// rejected by the normal state policy. Relay-owner trust is scoped to
// kinds without a dedicated policy, so it must not detach owner-signed
// NIP-34 events from announcement validation and ref alignment.
let state = EventBuilder::new(Kind::RepoState, "")
.tags([Tag::identifier("nonexistent-repo")])
.finalize(relay.owner_keys())
.expect("sign owner-authored state event");
assert!(
owner_client.send_event(&state).await.is_err(),
"owner-signed state event for a nonexistent repository must be rejected"
);
assert!(
fetch_events(relay.url(), Filter::new().id(state.id))
.await
.is_empty(),
"rejected owner-signed state event must not be stored"
);
relay.stop().await;
}
#[tokio::test]
async fn private_mode_seeds_identity_locally_but_never_publishes_it() {
let index = MockRelay::start().await;
let owner_keys = Keys::generate();
let relay = TestRelay::start_private_with_sync_and_owner_keys(
Some(index.url().to_string()),
owner_keys.clone(),
)
.await;
let identity_filter = Filter::new()
.author(owner_keys.public_key())
.kinds([Kind::Metadata, Kind::RelayList]);
// A private relay must not leak its existence through identity events
// on the user-index relays. Suppression is stable absence over time, so
// poll across several identity retry cycles (test mode retries every
// 200ms-2s) rather than asserting once.
let observation_deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while tokio::time::Instant::now() < observation_deadline {
assert!(
fetch_events(index.url(), identity_filter.clone())
.await
.is_empty(),
"identity must not be published to user-index relays in private mode"
);
tokio::time::sleep(Duration::from_millis(200)).await;
}
// Local seeding and serving still work: the owner — the sole GRASP-08
// member — authenticates with NIP-42 and queries both identity events.
let local_identity =
fetch_events_authenticated(relay.url(), identity_filter, owner_keys.clone()).await;
assert_identity_shape(&relay, &local_identity);
relay.stop().await;
index.stop().await;
}
async fn fetch_events(relay_url: &str, filter: Filter) -> Vec<Event> {
let client = Client::new();
client
.add_relay(relay_url)
.await
.expect("add relay for identity query");
let connected = client.try_connect().timeout(Duration::from_secs(3)).await;
assert!(
!connected.success.is_empty(),
"connect to {relay_url}: {:?}",
connected.failed
);
let events = client
.fetch_events(filter)
.timeout(Duration::from_secs(3))
.await
.expect("fetch relay identity")
.into_iter()
.collect();
client.shutdown().await;
events
}
/// Fetch with NIP-42 authentication, for querying a GRASP-08 private relay.
async fn fetch_events_authenticated(relay_url: &str, filter: Filter, keys: Keys) -> Vec<Event> {
let client = Client::builder()
.authenticator(SignerAuthenticator::new(keys))
.build();
client
.add_relay(relay_url)
.await
.expect("add relay for identity query");
let connected = client.try_connect().timeout(Duration::from_secs(3)).await;
assert!(
!connected.success.is_empty(),
"connect to {relay_url}: {:?}",
connected.failed
);
let events = client
.fetch_events(filter)
.timeout(Duration::from_secs(5))
.await
.expect("fetch relay identity")
.into_iter()
.collect();
client.shutdown().await;
events
}
fn event_ids(events: &[Event]) -> BTreeSet<EventId> {
events.iter().map(|event| event.id).collect()
}
fn assert_identity_shape(relay: &TestRelay, events: &[Event]) {
assert_eq!(events.len(), 2);
let profile = events
.iter()
.find(|event| event.kind == Kind::Metadata)
.expect("kind-0 profile");
let metadata: serde_json::Value =
serde_json::from_str(&profile.content).expect("parse profile content");
let fields = metadata.as_object().expect("profile content object");
assert_eq!(fields.len(), 3);
assert_eq!(
fields.get("name"),
Some(&serde_json::json!(relay.domain())),
"name is the scheme-less public URL"
);
assert_eq!(
fields.get("nip05"),
Some(&serde_json::json!(format!("_@{}", relay.domain())))
);
assert_eq!(fields.get("bot"), Some(&serde_json::json!(true)));
let relay_list = events
.iter()
.find(|event| event.kind == Kind::RelayList)
.expect("kind-10002 relay list");
let advertised: Vec<_> = nip65::extract_relay_list(relay_list).collect();
assert_eq!(advertised.len(), 1);
assert_eq!(advertised[0].0.to_string(), relay.url());
assert_eq!(advertised[0].1, None, "unmarked means read and write");
}