mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
+1
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user