Files
ngit-grasp/tests/relay_identity.rs
T
DanConwayDev 7334e04b88 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.
2026-08-15 11:17:57 +00:00

552 lines
20 KiB
Rust

//! 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");
}