From 7334e04b884080f1650ed09e52da2b6d7273aa77 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 14 Aug 2026 20:21:04 +0000 Subject: [PATCH] feat(identity): publish relay owner events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 8 +- CHANGELOG.md | 16 + README.md | 3 +- docs/explanation/architecture.md | 31 +- docs/reference/configuration.md | 33 +- nix/module.nix | 15 +- src/config.rs | 8 +- src/nostr/builder.rs | 88 +++- src/nostr/mod.rs | 1 + src/nostr/policy/announcement.rs | 6 +- src/nostr/relay_identity.rs | 727 +++++++++++++++++++++++++++++++ src/server.rs | 39 +- tests/common/mock_relay.rs | 28 +- tests/common/relay.rs | 101 ++++- tests/private_mode.rs | 29 +- tests/relay_identity.rs | 551 +++++++++++++++++++++++ 16 files changed, 1638 insertions(+), 46 deletions(-) create mode 100644 src/nostr/relay_identity.rs create mode 100644 tests/relay_identity.rs diff --git a/.env.example b/.env.example index 81380df..7c4d7fe 100644 --- a/.env.example +++ b/.env.example @@ -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 # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index cca496f..8789f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 41632c8..9b8a654 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index bca67b1..735ffed 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -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 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index ce192b6..7b298d9 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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 diff --git a/nix/module.nix b/nix/module.nix index ed4712c..f1eea90 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -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 { diff --git a/src/config.rs b/src/config.rs index 358c341..ee4b423 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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", diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 3be99d7..c8df8d7 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -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, 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 + 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, }; diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 979dc1c..4a45765 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -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; diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index 1acf0a1..a311354 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -36,11 +36,7 @@ pub struct AnnouncementPolicy { } impl AnnouncementPolicy { - pub fn new( - ctx: PolicyContext, - config: Config, - private_access: Option, - ) -> Self { + pub fn new(ctx: PolicyContext, config: Config, private_access: Option) -> Self { Self { ctx, config, diff --git a/src/nostr/relay_identity.rs b/src/nostr/relay_identity.rs new file mode 100644 index 0000000..a07a474 --- /dev/null +++ b/src/nostr/relay_identity.rs @@ -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 { + [&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, + generated: Vec, +} + +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 { + 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 { + 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 { + 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> { + 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, + local_relay: LocalRelay, + deletion: DeletionService, +) { + let client = Client::builder() + .authenticator(SignerAuthenticator::new(keys)) + .connect_timeout(INDEX_CONNECT_TIMEOUT) + .build(); + + let mut registered: HashSet = 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 = candidates.iter().map(|(event, _)| event.kind).collect(); + let mut retry_delay = Duration::ZERO; + let publish_events: Vec = 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 = 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> = 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 = 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, + owner: PublicKey, + kind: Kind, +) -> Option { + 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 { + 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::().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::() { + 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::() + .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 + ); + } +} diff --git a/src/server.rs b/src/server.rs index c35bfd0..a8fd6cb 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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/ 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; })); diff --git a/tests/common/mock_relay.rs b/tests/common/mock_relay.rs index 3c8b13c..43befa1 100644 --- a/tests/common/mock_relay.rs +++ b/tests/common/mock_relay.rs @@ -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) -> 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, max_filters: Option, + initial_events: Vec, ) -> 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::<()>(); diff --git a/tests/common/relay.rs b/tests/common/relay.rs index 12d3bfe..3ecfca6 100644 --- a/tests/common/relay.rs +++ b/tests/common/relay.rs @@ -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, @@ -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, bootstrap_relay_url: Option, sync_plus_fallback_relays: Option, disable_negentropy: bool, @@ -88,6 +94,9 @@ struct RelayOptions { relay_max_subscriptions: Option, sync_recursive_descendant_limit: Option, private_members: Option, + /// 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, /// 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, + 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, + 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)) diff --git a/tests/private_mode.rs b/tests/private_mode.rs index bbd9d86..0d7fadd 100644 --- a/tests/private_mode.rs +++ b/tests/private_mode.rs @@ -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}" diff --git a/tests/relay_identity.rs b/tests/relay_identity.rs new file mode 100644 index 0000000..1e920a2 --- /dev/null +++ b/tests/relay_identity.rs @@ -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 { + 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 { + 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 { + 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"); +}