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