From 42bff8a2ba8b10b1694fb0d0a8d47701d5e35dc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:42:32 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- cli/README.md | 2 +- geode/README.md | 19 +- geode/config.example.toml | 29 ++ geode/plans/2026-09-27-nip43-membership.md | 108 +++++ .../kotlin/com/vitorpamplona/geode/Main.kt | 21 +- .../com/vitorpamplona/geode/RelayEngine.kt | 146 +++++- .../geode/config/RelayIdentity.kt | 93 ++++ .../geode/config/StaticConfig.kt | 41 ++ .../geode/membership/Nip43MembershipTest.kt | 440 ++++++++++++++++++ 9 files changed, 889 insertions(+), 10 deletions(-) create mode 100644 geode/plans/2026-09-27-nip43-membership.md create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayIdentity.kt create mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/membership/Nip43MembershipTest.kt diff --git a/cli/README.md b/cli/README.md index a7e5518690..d226121f21 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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. | diff --git a/geode/README.md b/geode/README.md index 4c45f710fc..7e80fb4471 100644 --- a/geode/README.md +++ b/geode/README.md @@ -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 diff --git a/geode/config.example.toml b/geode/config.example.toml index 4651105623..0704f6fdcc 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -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 diff --git a/geode/plans/2026-09-27-nip43-membership.md b/geode/plans/2026-09-27-nip43-membership.md new file mode 100644 index 0000000000..be1e0a8c44 --- /dev/null +++ b/geode/plans/2026-09-27-nip43-membership.md @@ -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::`, `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 !"`; +- 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. diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 1ed17bbcdb..199ddb8948 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -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) { 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) { 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) { 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)") diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt index 8e79606e44..0be784be98 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt @@ -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 = 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(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() + 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" + } } diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayIdentity.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayIdentity.kt new file mode 100644 index 0000000000..260bec6045 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayIdentity.kt @@ -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" +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 8cfecb4e41..5535de9a39 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -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 = 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" diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/membership/Nip43MembershipTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/membership/Nip43MembershipTest.kt new file mode 100644 index 0000000000..d94170e8fe --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/membership/Nip43MembershipTest.kt @@ -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() + + @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(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 { + 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(Filter(kinds = listOf(RelayMembershipListEvent.KIND), authors = listOf(relaySigner!!.pubKey))) + .singleOrNull() + + private suspend fun RelayEngine.roles(): List = store.query(Filter(kinds = listOf(RelayRoleEvent.KIND))) + + private suspend fun RelayEngine.added(): List = store.query(Filter(kinds = listOf(RelayAddMemberEvent.KIND))) + + private suspend fun RelayEngine.removed(): List = 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(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(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(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() + assertNotNull(RelayIdentity.resolve(StaticConfig.IdentitySection(), needed = true, stateFile = null, warn = { warnings += it })) + assertEquals(1, warnings.size) + } +}