feat(geode): NIP-43 membership, relay identity and NIP-11 self

- [identity] secret_key / secret_key_file (generated if missing, or next to
  [admin].state_file when membership needs a key); NIP-11 self is forced to
  the relay key's pubkey.
- [membership] enabled: members-only writes (allow list == members), join /
  leave requests, relay-signed 13534 / 33534 / 8000 / 8001 stored in the
  relay's own store via NostrServer.ingest (bypassing write policies),
  republished after every NIP-86 change, NIP-11 advertises 43. Off by
  default; while off the role / claim RPCs are no longer advertised.
- Tests for join/leave/claims/roles/persistence/identity; plan doc and
  README / config / cli README updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc
This commit is contained in:
Claude
2026-09-27 22:42:32 +00:00
parent 9aaba7881e
commit 42bff8a2ba
9 changed files with 889 additions and 10 deletions
+1 -1
View File
@@ -457,7 +457,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
| `amy admin RELAY allow-event ID [--reason R]` / `unallow-event ID` / `list-allowed-events` / `list-needing-moderation` | Event allow list (approve an event: it also lifts any ban) and the moderation queue. |
| `amy admin RELAY create-role ID [--label L] [--description D] [--color HUE] [--order N]` / `edit-role ID …` / `delete-role ID` | NIP-43 member roles (kind 33534); `--color` is a hue 0–360. |
| `amy admin RELAY assign-role HEX ROLE` / `unassign-role HEX ROLE` | Give / take a role. |
| `amy admin RELAY create-claim CODE` / `delete-claim CODE` / `list-claims` | NIP-43 invite codes for kind 28934 join requests. |
| `amy admin RELAY create-claim CODE` / `delete-claim CODE` / `list-claims` | NIP-43 invite codes for kind 28934 join requests. The role and claim methods only exist on relays that run NIP-43 (geode: `[membership] enabled = true`, which then publishes the 13534 / 33534 events); elsewhere they fail with `method not supported`. |
| `amy admin RELAY allow-kind N` / `disallow-kind N` / `list-allowed-kinds` / `list-disallowed-kinds` | Kind allow / deny lists. |
| `amy admin RELAY block-ip IP [--reason R]` / `unblock-ip IP` / `list-blocked-ips` | IP block list. |
| `amy admin RELAY change-name S` / `change-description S` / `change-icon URL` | Relay metadata. |
+17 -2
View File
@@ -97,8 +97,23 @@ geode --version
Key sections: `[info]` (NIP-11 doc), `[network]` (bind + thread pools),
`[database]` (SQLite path/tuning), `[options]` (AUTH / verify / search),
`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring), and
`[admin]` (NIP-86 management). See the example file for every knob.
`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring),
`[admin]` (NIP-86 management), `[identity]` (the relay's own key, NIP-11 `self`)
and `[membership]` (NIP-43). See the example file for every knob.
## Membership (NIP-43)
With `[membership] enabled = true` geode is a members-only relay: the NIP-86
pubkey allow list is the member list and gates writes. Admins mint invite codes
with `createclaim` (e.g. `amy admin RELAY create-claim CODE`); a user joins by
sending a kind 28934 request carrying one (codes stay valid until
`deleteclaim`) and leaves with a kind 28936. The relay signs — with its
`[identity]` key, advertised as NIP-11 `self` — and serves kind 13534 (members
and their role ids), 33534 (roles from `createrole` / `editrole`; `deleterole`
publishes a NIP-09 deletion) and 8000 / 8001 (member added / removed), keeping
them in step with every join, leave and admin change. Off by default, and while
off the role / claim RPCs aren't offered. Design notes:
[`plans/2026-09-27-nip43-membership.md`](plans/2026-09-27-nip43-membership.md).
## Verbs
+29
View File
@@ -183,3 +183,32 @@ require_auth = false
# state is in-memory only and forgotten on every restart. Convention
# is to place this next to the SQLite event-store file.
# state_file = "/var/lib/geode/events.db.admin.json"
[identity]
# The relay's own Nostr key — advertised as `self` in the NIP-11 doc and
# used to sign relay-authored events (NIP-43 member lists and roles).
# Either the key itself (nsec1… or 64-char hex; wins if both are set) …
# secret_key = "nsec1..."
# … or a file holding it, created with a fresh key (mode 0600) when
# missing. With neither set, and [membership] enabled, geode keeps a
# generated key at "<[admin].state_file>.relay-key" (in memory only,
# with a warning, when there is no state file).
# secret_key_file = "/var/lib/geode/relay.key"
[membership]
# NIP-43 relay membership. When enabled:
# - writes are members-only; the NIP-86 pubkey allow list IS the member
# list (allowpubkey / unallowpubkey / banpubkey change membership) and
# gates writes even while empty;
# - kind 28934 join requests carrying an invite code (NIP-86
# createclaim; codes stay valid until deleteclaim) add the author,
# kind 28936 leave requests (with a NIP-70 "-" tag) remove them;
# - the relay publishes, signed by [identity]: kind 13534 (members +
# their role ids), 33534 (roles from createrole/editrole; deleterole
# publishes a NIP-09 deletion), 8000 / 8001 (member added / removed);
# - NIP-11 advertises 43, and the NIP-86 role/claim methods are offered
# (they are not advertised at all while this is off).
# See geode/plans/2026-09-27-nip43-membership.md.
# enabled = false
# How far a join/leave request's created_at may be from the relay's clock.
# request_window_seconds = 300
+108
View File
@@ -0,0 +1,108 @@
# NIP-43 relay membership in geode
Status: implemented (2026-09-27).
PR #4236 added the NIP-86 role / invite-code RPCs (`createrole`, `editrole`,
`deleterole`, `assignrole`, `unassignrole`, `listclaims`, `createclaim`,
`deleteclaim`) and persisted their state in `BanStore` / `RuntimeConfig`, but
nothing consumed that state: geode advertised the methods and they were silent
no-ops. This plan makes them do what NIP-43 says.
## Survey
| Piece | Where | Status |
|---|---|---|
| NIP-43 event models (13534, 33534, 8000, 8001, 28934, 28936) | `quartz/nip43RelayMembers/*` | reused as-is |
| Roles, assignments, claims, allow list | `quartz/nip86RelayManagement/server/BanStore` | reused as-is |
| Local ingest bypassing policies | `NostrServer.ingest(event, skipVerify)` | reused (mirror path) |
| Hook for EVENTs addressed to the relay | — | **new**: `EventCommandHandler` in `quartz/nip01Core/relay/server` |
| Join / leave / republish engine | — | **new**: `quartz/nip43RelayMembers/server/RelayMembershipServer` (generic, any Quartz relay can use it) |
| Members-only write gate | `BanListPolicy` | extended: `membersOnly` |
| Role / claim RPC gating + post-RPC hook | `Nip86Server` | extended: `nip43Methods`, `afterMutation` |
| Relay key, config, NIP-11 `self` | geode `RelayEngine`, `StaticConfig`, `RelayIdentity`, `Main` | geode wiring |
## Decisions
**Membership is the NIP-86 pubkey allow list.** NIP-43 defines 13534 as "pubkeys
that have access to a given relay"; NIP-86's allow list is exactly the set of
pubkeys with write access, and NIP-86 already says `banpubkey` removes a pubkey
from it. Keeping one set avoids two lists drifting apart. So:
- join (28934) = `allowpubkey`; leave (28936) = `unallowpubkey`;
- `allowpubkey`, `unallowpubkey` and `banpubkey` over NIP-86 are membership
changes and produce 8000 / 8001 + a fresh 13534;
- `[authorization].pubkey_whitelist` seeds the initial members.
**Members-only means closed even when empty.** Plain NIP-86 treats an empty
allow list as "no restriction". With membership on, that would leave a fresh
relay wide open until its first join, and the first join would suddenly close it.
`BanListPolicy(membersOnly = true)` rejects every non-member write with
`restricted: …` regardless of list size. Relay-authored events use
`NostrServer.ingest` and never see the policy; join / leave requests are
consumed before it. Reads are not gated — use NIP-42 + a read policy for that.
**Invite codes are reusable until revoked.** Neither NIP spells out single-use.
NIP-86 calls `listclaims` "invite codes currently accepted by the relay" and
gives revocation its own method (`deleteclaim`); NIP-43 lets users mint claims
(`createclaim`) to share. Consuming a code on first use would make shared invite
links fail for the second person, so a code keeps working until an admin deletes
it. The allow-list reason records which code admitted each pubkey (audit trail).
**Roles are independent of membership.** Assignments live in `BanStore`
regardless of whether the pubkey is currently a member; 13534 lists only members
(with their roles). Leaving keeps assignments, so a returning member gets them
back — role assignment is an operator decision, not the member's.
**Publishing is a reconcile.** `RelayMembershipServer.sync()` diffs the
`BanStore` against what the relay last published (read back from its own store
on first sync, so restarts don't republish) and signs only the difference:
8000 / 8001 per pubkey entering / leaving, one 13534 when members or roles
changed, one 33534 per new / edited role, one NIP-09 kind 5 (`a` =
`33534:<self>:<id>`, `k` = 33534) per deleted role. Every mutation path — join,
admin RPC, a state file edited while offline — converges on the same events, and
repeated syncs are no-ops. The first sync on a fresh relay emits 8000 for each
seeded member (they were added) plus the initial 13534.
Replaceable events are stamped `max(now, previous + 1)` so two edits within one
second still supersede each other (a `created_at` tie would be won by the lower
id, not the newer event), and a re-created role is stamped after its deletion's
tombstone.
**deleterole → NIP-09.** 33534 is addressable; NIP-43 defines no removal, so the
relay deletes the address with a kind 5 it signs itself — the standard way for an
author to retract an addressable event, and what the store already enforces.
**Join / leave run ahead of the policy chain** (`EventCommandHandler`):
- signature and id verified (parallel verify means nothing upstream checked it);
- `created_at` within `[membership].request_window_seconds` (default 300) of now → else `invalid:`;
- join: `claim` tag required → else `restricted:`; banned → `restricted:`; already a member → `OK true "duplicate: …"`; unknown / revoked code → `restricted: that is an invalid invite code.`; success → `OK true "info: welcome to <url>!"`;
- leave: NIP-70 `-` tag required (the spec's MUST) → else `invalid:`; not a member → `OK true "duplicate: …"`; success → `OK true "info: you have left this relay."`;
- neither request is stored or fanned out — a join carries the invite code, and
both are ephemeral kinds anyway.
They don't require NIP-42 AUTH: the request's signature already proves the
author, and the relay is the recipient rather than a re-publisher (NIP-70's AUTH
rule is about accepting protected events for storage). A captured leave request
could be replayed within the window, which only re-removes the same user.
**Relay identity.** `[identity].secret_key` (nsec or hex) or
`[identity].secret_key_file` (created with a fresh key, mode 0600, if missing).
With membership on and neither set, the key is generated at
`<[admin].state_file>.relay-key`; with no state file either, an in-memory key
is used with a warning. When a key exists, NIP-11 `self` is forced to its pubkey
at boot (overriding a persisted doc). `43` is added to `supported_nips` iff
membership is on — and removed otherwise, since clients only send 28934 to
relays that advertise it.
**Off by default.** `[membership].enabled = false` keeps geode exactly as before
except that the eight role / claim RPCs are no longer advertised or accepted
(`method not supported`) — they were silent no-ops, which is what the review
flagged. Their persisted state is kept untouched in the state file.
## Not done
- Rate limiting join attempts (brute-forcing short invite codes).
- Read gating for members-only relays.
- Kind 28935 (invite request) — deprecated in the current NIP-43.
- Cleaning up 13534 / 33534 signed by a previous relay key after a key change.
@@ -22,6 +22,7 @@ package com.vitorpamplona.geode
import com.vitorpamplona.geode.config.BannedEntry
import com.vitorpamplona.geode.config.MirrorFilterValidator
import com.vitorpamplona.geode.config.RelayIdentity
import com.vitorpamplona.geode.config.RuntimeConfig
import com.vitorpamplona.geode.config.RuntimeConfigData
import com.vitorpamplona.geode.config.StaticConfig
@@ -85,8 +86,9 @@ import java.io.File
*
* Every section is enforced: `[info]` populates the NIP-11 doc,
* `[network]` controls the bind, `[database]` chooses the SQLite path,
* `[options]` toggles AUTH/verify/future-skew, and `[authorization]`
* seeds the runtime
* `[options]` toggles AUTH/verify/future-skew, `[identity]` holds the
* relay's own key (NIP-11 `self`), `[membership]` turns on NIP-43, and
* `[authorization]` seeds the runtime
* [com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore] on
* first boot (see [com.vitorpamplona.geode.config.RuntimeConfig]).
*
@@ -337,6 +339,14 @@ private fun serve(args: Array<String>) {
maxSyncEvents = config.negentropy.max_sync_events,
maxSessionsPerConnection = config.negentropy.max_sessions_per_connection,
)
// The relay's own key (NIP-11 `self`): explicit config, else generated
// next to the admin state file when NIP-43 membership needs one.
val relayKey =
RelayIdentity.resolve(
identity = config.identity,
needed = config.membership.enabled,
stateFile = config.admin.state_file,
)
val relay =
RelayEngine(
advertisedUrl,
@@ -346,6 +356,9 @@ private fun serve(args: Array<String>) {
parallelVerify = parallelVerify,
negentropySettings = negentropySettings,
adminPubkeys = config.admin.pubkeys.toSet(),
relayKey = relayKey,
membership = config.membership.enabled,
membershipRequestWindowSeconds = config.membership.request_window_seconds,
)
val server =
KtorRelay(
@@ -498,6 +511,10 @@ private fun serve(args: Array<String>) {
println("geode listening on ${server.url}")
println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$advertisedHost:$port$path")
relay.relaySigner?.let { println("relay identity (NIP-11 self): ${it.pubKey}") }
if (relay.membership) {
println("NIP-43 membership on: members-only writes; join with a kind 28934 request carrying an invite code (NIP-86 createclaim)")
}
if (upstreams.isNotEmpty()) {
val trusted = upstreams.count { it.trusted }
println("mirroring ${upstreams.size} upstream relay(s), $trusted trusted (signature verification skipped)")
@@ -24,19 +24,28 @@ import com.vitorpamplona.geode.config.RuntimeConfig
import com.vitorpamplona.geode.config.RuntimeConfigData
import com.vitorpamplona.geode.config.seedInto
import com.vitorpamplona.geode.config.snapshotOf
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip43RelayMembers.server.RelayMembershipServer
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlin.coroutines.CoroutineContext
/**
@@ -103,9 +112,37 @@ class RelayEngine(
* owns *who* is admin; the transport owns *how* admins authenticate.
*/
adminPubkeys: Set<HexKey> = emptySet(),
/**
* The relay's own identity. When set, the NIP-11 doc advertises its
* pubkey as `self` (overriding whatever the persisted doc says), and
* it signs the relay-authored NIP-43 events. Null (the default) leaves
* `self` as configured and the relay unable to sign anything.
*/
relayKey: KeyPair? = null,
/**
* NIP-43 membership (see `geode/plans/2026-09-27-nip43-membership.md`).
* When on — requires [relayKey] — the NIP-86 pubkey allow list is the
* member list and gates writes even while empty, kind 28934 / 28936
* join and leave requests are answered by [membershipServer], the
* relay publishes kinds 13534 / 33534 / 8000 / 8001 (and NIP-09
* deletions for removed roles) signed by [relayKey], the NIP-86 role
* and invite-code methods are offered, and NIP-11 advertises 43. Off
* (the default): none of that — the role / claim RPCs are not even
* advertised, and NIP-43 is stripped from `supported_nips`.
*/
val membership: Boolean = false,
/** How far a join / leave request's `created_at` may be from now. */
membershipRequestWindowSeconds: Long = RelayMembershipServer.DEFAULT_REQUEST_WINDOW_SECONDS,
) : AutoCloseable {
init {
require(!membership || relayKey != null) { "NIP-43 membership needs the relay's own key (relayKey) to sign its events" }
}
private val boot: RuntimeConfigData = runtimeConfig.effective()
/** Signs as the relay's NIP-11 `self`; null when no [relayKey] was configured. */
val relaySigner: NostrSignerSync? = relayKey?.let { NostrSignerSync(it) }
/**
* Live NIP-11 doc. Mutable via [updateInfo] so NIP-86 admin RPCs
* can swap it atomically; readers (NIP-11 GET) re-read every
@@ -115,9 +152,35 @@ class RelayEngine(
* empty NIP-11.
*/
@Volatile
var info: RelayInfo = RelayInfo(boot.info!!)
var info: RelayInfo = RelayInfo(boot.info!!.advertisingIdentity())
private set
/**
* Stamps the boot-time NIP-11 doc with what this engine actually runs:
* `self` = [relaySigner]'s pubkey, and NIP-43 in `supported_nips` iff
* [membership] is on — a persisted or operator-written doc may say
* otherwise, and clients only send join requests to relays that
* advertise 43.
*/
private fun Nip11RelayInformation.advertisingIdentity(): Nip11RelayInformation {
val nips = supported_nips
val doc =
when {
membership && (nips == null || NIP_43 !in nips) -> {
copy(supported_nips = ((nips ?: emptyList()) + NIP_43).sortedBy { it.toIntOrNull() ?: Int.MAX_VALUE })
}
!membership && nips != null && NIP_43 in nips -> {
copy(supported_nips = nips - NIP_43)
}
else -> {
this
}
}
return relaySigner?.let { doc.copy(self = it.pubKey) } ?: doc
}
/** Mutates the live NIP-11 doc and persists the snapshot. */
fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
info = RelayInfo(transform(info.document))
@@ -131,8 +194,14 @@ class RelayEngine(
* seed on first boot) without firing the mutation hook.
*/
val banStore: BanStore =
BanStore(onMutation = ::snapshot)
.apply { boot.seedInto(this) }
BanStore(
onMutation = {
snapshot()
// Covers mutations outside the RPC / join paths (which
// sync synchronously) — e.g. direct BanStore calls.
membershipServer?.requestSync()
},
).apply { boot.seedInto(this) }
/**
* NIP-86 admin RPC dispatcher. Transport-agnostic — `KtorRelay`
@@ -150,6 +219,12 @@ class RelayEngine(
},
onBan = { filter -> store.delete(filter) },
allowList = adminPubkeys,
// Without a NIP-43 engine the role / claim methods would be
// silent no-ops, so they're only offered with membership on.
nip43Methods = membership,
// Republish before the RPC answers, so a client that reads the
// relay right after an admin change sees the new events.
afterMutation = { membershipServer?.sync() },
)
/**
@@ -174,12 +249,73 @@ class RelayEngine(
// BanListPolicy alone; otherwise stack so both must accept.
policyBuilder = {
val user = policyBuilder()
if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore)
val banList = BanListPolicy(banStore, membersOnly = membership)
if (user === EmptyPolicy) banList else user + banList
},
parentContext = parentContext,
parallelVerify = parallelVerify,
negentropySettings = negentropySettings,
)
override fun close() = server.close()
/** Background scope for [RelayMembershipServer.requestSync]; cancelled on [close]. */
private val membershipScope = CoroutineScope(parentContext + SupervisorJob(parentContext[Job]))
/**
* The NIP-43 engine, when [membership] is on: answers join / leave
* requests on every connection and republishes the relay-signed
* membership events whenever the [banStore] changes.
*/
val membershipServer: RelayMembershipServer? =
if (membership) {
RelayMembershipServer(
signer = relaySigner!!,
banStore = banStore,
publish = ::publishOwn,
load = { filter -> store.query<Event>(filter) },
scope = membershipScope,
relayName = url.url,
requestWindowSeconds = membershipRequestWindowSeconds,
)
} else {
null
}
init {
membershipServer?.let {
server.eventCommandHandler = it
// Bring the stored 13534 / 33534 in line with the boot state
// (first boot, a key change, a hand-edited state file).
it.requestSync()
}
}
/**
* Stores a relay-authored event: through the group-commit writer and
* live fan-out like any publish, but skipping the policy chain (the
* relay's own events aren't subject to its write rules) and signature
* verification (we just signed it).
*/
private suspend fun publishOwn(event: Event): Boolean {
val outcome = CompletableDeferred<IEventStore.InsertOutcome>()
server.ingest(event, skipVerify = true) { outcome.complete(it) }
return when (val result = outcome.await()) {
IEventStore.InsertOutcome.Accepted -> {
true
}
else -> {
Log.w("RelayEngine") { "relay-signed kind ${event.kind} not stored: $result" }
false
}
}
}
override fun close() {
membershipScope.cancel()
server.close()
}
companion object {
private const val NIP_43 = "43"
}
}
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.config
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.nio.file.attribute.PosixFilePermissions
/**
* Resolves the relay's own key pair (NIP-11 `self`) from
* [StaticConfig.IdentitySection] — see its docs for the precedence.
*/
object RelayIdentity {
/**
* @param needed whether a feature that signs as the relay (NIP-43
* membership) is on; without it and without explicit config there
* is no identity (`null`).
* @param stateFile `[admin].state_file`, next to which a generated key
* is kept when [needed] and nothing else is configured.
* @param warn where to report falling back to an in-memory key.
*/
fun resolve(
identity: StaticConfig.IdentitySection,
needed: Boolean,
stateFile: String?,
warn: (String) -> Unit = { System.err.println("warning: $it") },
): KeyPair? {
identity.secret_key?.let { return parse(it, "[identity].secret_key") }
identity.secret_key_file?.let { return loadOrCreate(File(it)) }
if (!needed) return null
stateFile?.let { return loadOrCreate(File("$it$KEY_FILE_SUFFIX")) }
warn(
"no relay key configured ([identity].secret_key / secret_key_file, or [admin].state_file); " +
"using a throwaway identity — the NIP-11 self pubkey will change on every restart",
)
return KeyPair()
}
/** Reads the key in [file], or writes a newly generated one there (owner-only) when it's missing. */
fun loadOrCreate(file: File): KeyPair {
if (file.exists()) return parse(file.readText(), file.path)
val key = KeyPair()
file.absoluteFile.parentFile?.mkdirs()
val tmp = File(file.absoluteFile.parentFile, "${file.name}.tmp")
tmp.delete()
try {
Files.createFile(tmp.toPath(), PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")))
} catch (_: UnsupportedOperationException) {
// Not a POSIX filesystem (Windows): fall back to the default ACLs.
tmp.createNewFile()
}
tmp.writeText(key.privKey!!.toHexKey() + "\n")
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE)
return key
}
private fun parse(
value: String,
source: String,
): KeyPair {
val hex =
decodePrivateKeyAsHexOrNull(value.trim())?.takeIf { it.length == 64 }
?: throw IllegalArgumentException("$source is not a valid secret key (expected nsec1… or 64-char hex)")
return KeyPair(hex.hexToByteArray())
}
/** Suffix appended to `[admin].state_file` for the generated key file. */
const val KEY_FILE_SUFFIX = ".relay-key"
}
@@ -44,6 +44,8 @@ data class StaticConfig(
val options: OptionsSection = OptionsSection(),
val authorization: AuthorizationSection = AuthorizationSection(),
val admin: AdminSection = AdminSection(),
val identity: IdentitySection = IdentitySection(),
val membership: MembershipSection = MembershipSection(),
val negentropy: NegentropySection = NegentropySection(),
/** `[[mirror]]` entries — upstream relays this relay streams from. */
val mirror: List<MirrorSection> = emptyList(),
@@ -288,6 +290,42 @@ data class StaticConfig(
val state_file: String? = null,
)
/**
* The relay's own Nostr identity — the NIP-11 `self` key that signs
* relay-authored events (NIP-43 membership lists, roles, add/remove).
* Resolved by [RelayIdentity.resolve]:
*
* - [secret_key]: the key itself, `nsec1…` or 64-char hex. Wins over
* [secret_key_file].
* - [secret_key_file]: a file holding the key (nsec or hex). Created
* with a freshly generated key (owner-only permissions) when it
* doesn't exist yet.
*
* Neither set: no identity, unless [MembershipSection.enabled] needs
* one — then the key lives in `<[AdminSection.state_file]>.relay-key`
* (generated on first boot), or, with no state file either, in memory
* only (a new identity every restart, with a warning).
*/
data class IdentitySection(
val secret_key: String? = null,
val secret_key_file: String? = null,
)
/**
* NIP-43 relay membership. [enabled] turns the relay members-only: the
* NIP-86 pubkey allow list becomes the member list (and gates writes
* even while empty), kind 28934 join requests carrying an invite code
* from NIP-86 `createclaim` add members, kind 28936 leave requests
* remove them, and the relay publishes signed kind 13534 / 33534 /
* 8000 / 8001 events. Needs the relay identity ([IdentitySection]).
* [request_window_seconds] bounds how far a join/leave request's
* `created_at` may drift from the relay's clock.
*/
data class MembershipSection(
val enabled: Boolean = false,
val request_window_seconds: Long = 300,
)
/**
* Boot-time sanity check for values the TOML types can't constrain.
* Throws [IllegalArgumentException] (fail-loud at startup) rather
@@ -300,6 +338,9 @@ data class StaticConfig(
database.readers?.let {
require(it >= 1) { "[database].readers must be >= 1 (got $it); a 0/negative pool can never answer a query" }
}
require(membership.request_window_seconds > 0) {
"[membership].request_window_seconds must be > 0 (got ${membership.request_window_seconds})"
}
database.optimize_interval_seconds?.let {
require(it > 0) {
"[database].optimize_interval_seconds must be > 0 (got $it); a non-positive interval busy-loops PRAGMA optimize under the writer mutex"
@@ -0,0 +1,440 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.membership
import com.vitorpamplona.geode.RelayEngine
import com.vitorpamplona.geode.RelayIndexingStrategy
import com.vitorpamplona.geode.RelayInfo
import com.vitorpamplona.geode.config.RelayIdentity
import com.vitorpamplona.geode.config.RuntimeConfig
import com.vitorpamplona.geode.config.RuntimeConfigData
import com.vitorpamplona.geode.config.StaticConfig
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent
import com.vitorpamplona.quartz.nip70ProtectedEvts.isProtected
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* NIP-43 on geode: join / leave requests answered over the wire, NIP-86
* admin changes republished as relay-signed 13534 / 33534 / 8000 / 8001
* events, all signed by the NIP-11 `self` key, and the state surviving a
* restart.
*/
class Nip43MembershipTest {
private val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
private val relayKey = KeyPair()
private val admin = NostrSignerSync(KeyPair())
private val alice = NostrSignerSync(KeyPair())
private val bob = NostrSignerSync(KeyPair())
private lateinit var dir: File
private val engines = mutableListOf<RelayEngine>()
@BeforeTest
fun setup() {
dir = Files.createTempDirectory("geode-nip43-").toFile()
}
@AfterTest
fun teardown() {
engines.forEach { runCatching { it.close() } }
dir.deleteRecursively()
}
private fun relay(
membership: Boolean = true,
stateFile: File? = null,
store: EventStore? = null,
key: KeyPair = relayKey,
): RelayEngine =
RelayEngine(
url = url,
store = store ?: EventStore(dbName = null, relay = url, indexStrategy = RelayIndexingStrategy),
runtimeConfig = RuntimeConfig(stateFile, RuntimeConfigData(info = RelayInfo.default(url).document)),
adminPubkeys = setOf(admin.pubKey),
relayKey = key,
membership = membership,
).also { engines += it }
/** One client connection with a queue of the frames the relay sent it. */
private class Client(
relay: RelayEngine,
) {
private val inbox = Channel<String>(Channel.UNLIMITED)
private val session: RelaySession = relay.server.connect { inbox.trySend(it) }
/** Sends [event] and returns the relay's `OK` as (accepted, message). */
suspend fun publish(event: Event): Pair<Boolean, String> {
session.receive("[\"EVENT\",${event.toJson()}]")
return withTimeout(10_000) {
while (true) {
val frame = Json.parseToJsonElement(inbox.receive()).jsonArray
if (frame[0].jsonPrimitive.content == "OK" && frame[1].jsonPrimitive.content == event.id) {
return@withTimeout frame[2].jsonPrimitive.boolean to frame[3].jsonPrimitive.content
}
}
@Suppress("UNREACHABLE_CODE")
error("unreachable")
}
}
}
private suspend fun RelayEngine.rpc(req: Nip86Request): Nip86Response = nip86Server.dispatch(admin.pubKey, req)
private suspend fun RelayEngine.memberList(): RelayMembershipListEvent? =
store
.query<RelayMembershipListEvent>(Filter(kinds = listOf(RelayMembershipListEvent.KIND), authors = listOf(relaySigner!!.pubKey)))
.singleOrNull()
private suspend fun RelayEngine.roles(): List<RelayRoleEvent> = store.query(Filter(kinds = listOf(RelayRoleEvent.KIND)))
private suspend fun RelayEngine.added(): List<Event> = store.query(Filter(kinds = listOf(RelayAddMemberEvent.KIND)))
private suspend fun RelayEngine.removed(): List<Event> = store.query(Filter(kinds = listOf(RelayRemoveMemberEvent.KIND)))
private fun join(
signer: NostrSignerSync,
claim: String,
createdAt: Long = TimeUtils.now(),
) = signer.sign(RelayJoinRequestEvent.build(claim, createdAt))
private fun leave(
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
) = signer.sign(RelayLeaveRequestEvent.build(createdAt))
@Test
fun nip11SelfIsTheSigningKeyAndAdvertisesNip43() =
runBlocking {
val relay = relay()
val doc = relay.info.document
assertEquals(relayKey.pubKey.toHexKey(), doc.self)
assertEquals(relay.relaySigner!!.pubKey, doc.self)
assertTrue("43" in doc.supported_nips!!)
// Every relay-authored event is signed by that same key.
relay.membershipServer!!.sync()
val list = assertNotNull(relay.memberList())
assertEquals(doc.self, list.pubKey)
assertTrue(list.tags.isProtected(), "13534 must carry the NIP-70 - tag")
}
@Test
fun membershipOffKeepsTodaysBehaviour() =
runBlocking {
val relay = relay(membership = false)
assertFalse("43" in relay.info.document.supported_nips!!)
assertNull(relay.membershipServer)
val methods = (relay.rpc(Nip86Request.supportedMethods()).result as JsonArray).map { it.jsonPrimitive.content }
assertFalse(Nip86Method.CREATE_ROLE in methods, "role RPCs must not be advertised without a NIP-43 engine")
assertFalse(Nip86Method.CREATE_CLAIM in methods)
assertNotNull(relay.rpc(Nip86Request.createClaim("code")).error)
// Open relay: anyone writes; a 28934 is just an ephemeral event.
val client = Client(relay)
assertTrue(client.publish(alice.sign(TextNoteEvent.build("hi"))).first)
assertTrue(client.publish(join(alice, "code")).first)
assertNull(relay.memberList())
}
@Test
fun joinWithValidClaimAddsMemberAndPublishes() =
runBlocking {
val relay = relay()
val client = Client(relay)
assertNull(relay.rpc(Nip86Request.createClaim("invite-1")).error)
// Members-only: an outsider can't write before joining.
val (preOk, preMsg) = client.publish(alice.sign(TextNoteEvent.build("before")))
assertFalse(preOk)
assertTrue(preMsg.startsWith("restricted:"), preMsg)
val request = join(alice, "invite-1")
val (ok, msg) = client.publish(request)
assertTrue(ok, msg)
assertTrue(msg.startsWith("info: welcome"), msg)
assertTrue(relay.banStore.isAllowedPubkey(alice.pubKey))
assertEquals(listOf(RelayMember(alice.pubKey)), relay.memberList()!!.membersWithRoles())
val added = relay.added().single()
assertEquals(relay.relaySigner!!.pubKey, added.pubKey)
assertEquals(listOf(alice.pubKey), (added as RelayAddMemberEvent).memberPubKeys())
// The join request (it carries the invite code) is never stored.
assertTrue(relay.store.query<Event>(Filter(ids = listOf(request.id))).isEmpty())
// Now a member: writes go through.
assertTrue(client.publish(alice.sign(TextNoteEvent.build("after"))).first)
// Invite codes are reusable until revoked.
assertTrue(client.publish(join(bob, "invite-1")).first)
assertEquals(setOf(alice.pubKey, bob.pubKey), relay.memberList()!!.members().toSet())
// Joining again is a no-op answered as a duplicate.
val (dupOk, dupMsg) = client.publish(join(alice, "invite-1"))
assertTrue(dupOk)
assertTrue(dupMsg.startsWith("duplicate:"), dupMsg)
assertEquals(2, relay.added().size)
}
@Test
fun joinWithInvalidRevokedOrStaleClaimIsRejected() =
runBlocking {
val relay = relay()
val client = Client(relay)
relay.rpc(Nip86Request.createClaim("good"))
val (badOk, badMsg) = client.publish(join(alice, "nope"))
assertFalse(badOk)
assertEquals("restricted: that is an invalid invite code.", badMsg)
// Outside the created_at window ("now, plus or minus a few minutes").
val (oldOk, oldMsg) = client.publish(join(alice, "good", createdAt = TimeUtils.now() - 3600))
assertFalse(oldOk)
assertTrue(oldMsg.startsWith("invalid:"), oldMsg)
val (futureOk, _) = client.publish(join(alice, "good", createdAt = TimeUtils.now() + 3600))
assertFalse(futureOk)
// A revoked (deleteclaim) code no longer admits anyone.
relay.rpc(Nip86Request.deleteClaim("good"))
val (revokedOk, revokedMsg) = client.publish(join(alice, "good"))
assertFalse(revokedOk)
assertTrue(revokedMsg.startsWith("restricted:"), revokedMsg)
// A banned pubkey can't join even with a valid code.
relay.rpc(Nip86Request.createClaim("fresh"))
relay.rpc(Nip86Request.banPubkey(bob.pubKey, "spam"))
val (bannedOk, bannedMsg) = client.publish(join(bob, "fresh"))
assertFalse(bannedOk)
assertTrue(bannedMsg.startsWith("restricted:"), bannedMsg)
// A forged signature is refused before anything else.
val forged = join(alice, "fresh").let { Event(it.id, it.pubKey, it.createdAt, it.kind, it.tags, it.content, "0".repeat(128)) }
val (forgedOk, forgedMsg) = client.publish(forged)
assertFalse(forgedOk)
assertTrue(forgedMsg.startsWith("invalid:"), forgedMsg)
assertFalse(relay.banStore.isAllowedPubkey(alice.pubKey))
assertFalse(relay.banStore.isAllowedPubkey(bob.pubKey))
assertTrue(relay.added().isEmpty())
}
@Test
fun leaveRemovesMemberAndPublishes() =
runBlocking {
val relay = relay()
val client = Client(relay)
relay.rpc(Nip86Request.createClaim("c"))
assertTrue(client.publish(join(alice, "c")).first)
assertTrue(client.publish(join(bob, "c")).first)
// The NIP-70 "-" tag is mandatory on leave requests.
val unprotected = alice.sign<Event>(TimeUtils.now(), RelayLeaveRequestEvent.KIND, emptyArray(), "")
val (noTagOk, noTagMsg) = client.publish(unprotected)
assertFalse(noTagOk)
assertTrue(noTagMsg.startsWith("invalid:"), noTagMsg)
val (staleOk, _) = client.publish(leave(alice, createdAt = TimeUtils.now() - 3600))
assertFalse(staleOk)
assertTrue(relay.banStore.isAllowedPubkey(alice.pubKey))
val (ok, msg) = client.publish(leave(alice))
assertTrue(ok, msg)
assertFalse(relay.banStore.isAllowedPubkey(alice.pubKey))
assertEquals(listOf(bob.pubKey), relay.memberList()!!.members())
val removed = relay.removed().single() as RelayRemoveMemberEvent
assertEquals(listOf(alice.pubKey), removed.memberPubKeys())
assertEquals(relay.relaySigner!!.pubKey, removed.pubKey)
// Leaving twice is a duplicate, and a non-member can't write any more.
assertTrue(client.publish(leave(alice)).second.startsWith("duplicate:"))
assertFalse(client.publish(alice.sign(TextNoteEvent.build("still here?"))).first)
}
@Test
fun adminMembershipChangesRepublish() =
runBlocking {
val relay = relay()
assertNull(relay.rpc(Nip86Request.allowPubkey(alice.pubKey)).error)
assertEquals(listOf(alice.pubKey), relay.memberList()!!.members())
assertEquals(1, relay.added().size)
relay.rpc(Nip86Request.allowPubkey(bob.pubKey))
relay.rpc(Nip86Request.banPubkey(alice.pubKey, "spam"))
assertEquals(listOf(bob.pubKey), relay.memberList()!!.members())
assertEquals(listOf(alice.pubKey), (relay.removed().single() as RelayRemoveMemberEvent).memberPubKeys())
relay.rpc(Nip86Request.unallowPubkey(bob.pubKey))
assertEquals(emptyList(), relay.memberList()!!.members())
assertEquals(2, relay.removed().size)
}
@Test
fun rolesArePublishedAndCarriedByTheMemberList() =
runBlocking {
val relay = relay()
relay.rpc(Nip86Request.allowPubkey(alice.pubKey))
assertNull(relay.rpc(Nip86Request.createRole("mod", "Moderator", "keeps order", 200, 2)).error)
val role = relay.roles().single()
assertEquals(relay.relaySigner!!.pubKey, role.pubKey)
assertTrue(role.tags.isProtected())
assertEquals(RelayRole("mod", "Moderator", "keeps order", 200, 2), role.role())
assertNull(relay.rpc(Nip86Request.assignRole(alice.pubKey, "mod")).error)
assertEquals(listOf(RelayMember(alice.pubKey, listOf("mod"))), relay.memberList()!!.membersWithRoles())
// Edits within the same second still supersede (created_at is kept monotonic).
relay.rpc(Nip86Request.editRole("mod", "Mod", null, 10, 1))
relay.rpc(Nip86Request.editRole("mod", "Moderators", null, 11, 1))
assertEquals(RelayRole("mod", "Moderators", null, 11, 1), relay.roles().single().role())
relay.rpc(Nip86Request.createRole("king"))
relay.rpc(Nip86Request.assignRole(alice.pubKey, "king"))
assertEquals(
listOf("mod", "king"),
relay
.memberList()!!
.membersWithRoles()
.single()
.roles,
)
relay.rpc(Nip86Request.unassignRole(alice.pubKey, "king"))
assertEquals(
listOf("mod"),
relay
.memberList()!!
.membersWithRoles()
.single()
.roles,
)
// deleterole: NIP-09 deletion of the 33534, and the id leaves every member.
relay.rpc(Nip86Request.deleteRole("mod"))
assertEquals(listOf("king"), relay.roles().map { it.roleId() })
assertEquals(listOf(RelayMember(alice.pubKey)), relay.memberList()!!.membersWithRoles())
val deletion = relay.store.query<Event>(Filter(kinds = listOf(5), authors = listOf(relay.relaySigner!!.pubKey))).single()
assertTrue(deletion.tags.any { it[0] == "a" && it[1] == "33534:${relay.relaySigner!!.pubKey}:mod" })
// A role re-created under a deleted id is published again, after the tombstone.
relay.rpc(Nip86Request.createRole("mod", "Back"))
assertEquals(setOf("king", "mod"), relay.roles().map { it.roleId() }.toSet())
}
@Test
fun membershipSurvivesARestartWithoutRepublishing() =
runBlocking {
val stateFile = File(dir, "admin.json")
val dbFile = File(dir, "events.db").path
val key = RelayIdentity.loadOrCreate(File(dir, "relay.key"))
val r1 = relay(stateFile = stateFile, store = EventStore(dbName = dbFile, relay = url), key = key)
r1.rpc(Nip86Request.createClaim("c"))
r1.rpc(Nip86Request.createRole("mod", "Moderator"))
assertTrue(Client(r1).publish(join(alice, "c")).first)
r1.rpc(Nip86Request.assignRole(alice.pubKey, "mod"))
val list1 = r1.memberList()!!
r1.close()
engines.remove(r1)
// Same key from disk, same state file, same event store.
val key2 = RelayIdentity.loadOrCreate(File(dir, "relay.key"))
assertEquals(key.pubKey.toHexKey(), key2.pubKey.toHexKey())
val r2 = relay(stateFile = stateFile, store = EventStore(dbName = dbFile, relay = url), key = key2)
assertEquals(key.pubKey.toHexKey(), r2.info.document.self)
assertTrue(r2.banStore.isAllowedPubkey(alice.pubKey))
assertEquals(listOf("mod"), r2.banStore.rolesOf(alice.pubKey))
assertEquals(listOf("c"), r2.banStore.listClaims())
r2.membershipServer!!.sync()
assertEquals(list1.id, r2.memberList()!!.id, "an unchanged member list is not re-signed on boot")
assertEquals(1, r2.added().size)
assertEquals(1, r2.roles().size)
// Still a member after the restart.
assertTrue(Client(r2).publish(alice.sign(TextNoteEvent.build("back"))).first)
}
@Test
fun relayIdentityResolution() {
val nsecKey = KeyPair()
val fromNsec =
RelayIdentity.resolve(StaticConfig.IdentitySection(secret_key = nsecKey.privKey!!.toNsec()), needed = false, stateFile = null)
assertEquals(nsecKey.pubKey.toHexKey(), fromNsec!!.pubKey.toHexKey())
val fromHex =
RelayIdentity.resolve(StaticConfig.IdentitySection(secret_key = nsecKey.privKey!!.toHexKey()), needed = false, stateFile = null)
assertEquals(nsecKey.pubKey.toHexKey(), fromHex!!.pubKey.toHexKey())
// Nothing configured and nothing needs it: no identity.
assertNull(RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = false, stateFile = null))
// Membership needs one: generated next to the state file, stable across boots.
val state = File(dir, "state.json").path
val first = RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = true, stateFile = state)!!
val second = RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = true, stateFile = state)!!
assertEquals(first.pubKey.toHexKey(), second.pubKey.toHexKey())
assertTrue(File(state + RelayIdentity.KEY_FILE_SUFFIX).exists())
// No state file either: an in-memory key, with a warning.
val warnings = mutableListOf<String>()
assertNotNull(RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = true, stateFile = null, warn = { warnings += it }))
assertEquals(1, warnings.size)
}
}