refactor(geode): simplify RelayEngine + trim StaticConfig comments
RelayEngine (224 → 185 lines):
- Extract BanStore <-> RuntimeConfigData mapping helpers
(seedInto, snapshotOf) into geode.config — bidirectional
conversion now has names instead of being inline boilerplate
repeated between the init block and snapshot().
- Collapse the standalone init block into a .apply on the
banStore declaration. Method reference ::snapshot for the
onMutation hook.
- Drop the effectiveAtBoot field's 12-line KDoc (rename to `boot`
locally, terse). Trim verbose property KDocs to the load-bearing
facts (why-not-what).
StaticConfig (255 → 182 lines):
- Drop resolveInfo's unused `advertisedUrl` parameter and the
workaround comment that justified keeping it for "future
fields". YAGNI — add back when actually needed. Two callers
updated.
- Trim trivial KDocs (fromToml/fromFile, field names that
document themselves like require_auth, reject_future_seconds,
supported_nips, database.file).
- Collapse NetworkSection's three thread-pool KDocs (~16 lines)
into a single section-level KDoc.
- Compact parallel_verify, AdminSection, AdminSection.state_file,
and advertisedUrl companion KDocs while keeping the
load-bearing facts (invariants, strfry interop strings,
surprising defaults).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -104,7 +104,7 @@ fun main(args: Array<String>) {
|
||||
|
||||
val info =
|
||||
cliInfoFile?.let { RelayInfo.fromFile(it) }
|
||||
?: config.resolveInfo(advertisedUrl)
|
||||
?: config.resolveInfo()
|
||||
|
||||
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.geode.config.BannedEntry
|
||||
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.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
|
||||
@@ -103,67 +104,40 @@ class RelayEngine(
|
||||
*/
|
||||
adminPubkeys: Set<HexKey> = emptySet(),
|
||||
) : AutoCloseable {
|
||||
/**
|
||||
* The runtime state to install at boot: the persisted snapshot if
|
||||
* the file exists, otherwise the seed baked into [runtimeConfig].
|
||||
* Read once here so the `info` property initializer and the
|
||||
* [banStore] seeding in [init] agree on the same source.
|
||||
*/
|
||||
private val effectiveAtBoot: RuntimeConfigData = runtimeConfig.effective()
|
||||
private val boot: RuntimeConfigData = runtimeConfig.effective()
|
||||
|
||||
/**
|
||||
* NIP-11 doc. Mutable so NIP-86 admin RPCs (`changerelayname`,
|
||||
* `changerelaydescription`, `changerelayicon`) can swap the doc
|
||||
* atomically. Readers (the NIP-11 GET endpoint) re-read on every
|
||||
* request so changes are visible immediately, no restart needed.
|
||||
*
|
||||
* Initialized from [effectiveAtBoot] — a persisted admin override
|
||||
* wins, otherwise we fall back to the seed baked into
|
||||
* [runtimeConfig]. The `!!` is load-bearing: the seed is always
|
||||
* built with a non-null info, so the only way we reach a null
|
||||
* here is a manually corrupted state file, which we'd rather
|
||||
* crash on than serve empty NIP-11 from.
|
||||
* Live NIP-11 doc. Mutable via [updateInfo] so NIP-86 admin RPCs
|
||||
* can swap it atomically; readers (NIP-11 GET) re-read every
|
||||
* request so changes are visible immediately. The `!!` is load-
|
||||
* bearing: the seed always has a non-null info, so a null here
|
||||
* means a manually corrupted state file — fail loud over serving
|
||||
* empty NIP-11.
|
||||
*/
|
||||
@Volatile
|
||||
var info: RelayInfo = RelayInfo(effectiveAtBoot.info!!)
|
||||
var info: RelayInfo = RelayInfo(boot.info!!)
|
||||
private set
|
||||
|
||||
/** Mutates the live NIP-11 doc. Called by [Nip86Server]. */
|
||||
/** Mutates the live NIP-11 doc and persists the snapshot. */
|
||||
fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
|
||||
info = RelayInfo(transform(info.document))
|
||||
snapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-mutable ban / allow lists. NIP-86 RPC handlers in
|
||||
* [Nip86Server] mutate this; the policy stack consults it on
|
||||
* every accept call via [BanListPolicy].
|
||||
* Runtime-mutable ban / allow lists. Mutated by [Nip86Server] via
|
||||
* NIP-86 RPCs; consulted on every EVENT by [BanListPolicy]. Seeded
|
||||
* at boot from the persisted snapshot (or the static [runtimeConfig]
|
||||
* seed on first boot) without firing the mutation hook.
|
||||
*/
|
||||
val banStore: BanStore = BanStore(onMutation = { snapshot() })
|
||||
|
||||
init {
|
||||
// Seed the in-memory ban state from [effectiveAtBoot] *without*
|
||||
// firing [snapshot] on every entry — the snapshot is exactly
|
||||
// what we just loaded (or, on first boot, the static seed we
|
||||
// just synthesized).
|
||||
banStore.seedFromSnapshot(
|
||||
bannedPubkeys = effectiveAtBoot.bannedPubkeys.map { it.key to it.reason },
|
||||
allowedPubkeys = effectiveAtBoot.allowedPubkeys.map { it.key to it.reason },
|
||||
bannedEvents = effectiveAtBoot.bannedEvents.map { it.key to it.reason },
|
||||
allowedKinds = effectiveAtBoot.allowedKinds,
|
||||
disallowedKinds = effectiveAtBoot.disallowedKinds,
|
||||
)
|
||||
}
|
||||
val banStore: BanStore =
|
||||
BanStore(onMutation = ::snapshot)
|
||||
.apply { boot.seedInto(this) }
|
||||
|
||||
/**
|
||||
* NIP-86 admin RPC dispatcher. Owns the ban / allow / kind list
|
||||
* mutations, the NIP-11 `changerelay*` mutations (via an
|
||||
* [Nip86Server.InfoHolder] adapter that wraps [info] / [updateInfo]),
|
||||
* the event-store purge on each ban method (via `store.delete`),
|
||||
* and the admin allow-list ([adminPubkeys]). Transport-agnostic:
|
||||
* `KtorRelay` wraps it with `Nip86HttpHandler` for HTTP/NIP-98;
|
||||
* an in-process tool could call `dispatch(adminPubkey, req)`
|
||||
* directly.
|
||||
* NIP-86 admin RPC dispatcher. Transport-agnostic — `KtorRelay`
|
||||
* wraps it with `Nip86HttpHandler` for HTTP/NIP-98; an in-process
|
||||
* tool could call `dispatch(adminPubkey, req)` directly.
|
||||
*/
|
||||
val nip86Server: Nip86Server =
|
||||
Nip86Server(
|
||||
@@ -179,25 +153,14 @@ class RelayEngine(
|
||||
)
|
||||
|
||||
/**
|
||||
* Writes the current state (NIP-11 doc + ban lists) to disk.
|
||||
* No-op when no `stateFile` was configured (see [RuntimeConfig.save]).
|
||||
*
|
||||
* Best-effort: any I/O failure is logged to stderr and swallowed
|
||||
* so an unwritable disk doesn't take the relay down. Operators
|
||||
* monitor for missing snapshots out-of-band.
|
||||
* Writes the current state (NIP-11 doc + ban lists) to disk via
|
||||
* [RuntimeConfig.save] — no-op when no state file is configured.
|
||||
* Best-effort: I/O failures are logged to stderr and swallowed so
|
||||
* an unwritable disk doesn't take the relay down.
|
||||
*/
|
||||
fun snapshot() {
|
||||
runCatching {
|
||||
runtimeConfig.save(
|
||||
RuntimeConfigData(
|
||||
info = info.document,
|
||||
bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) },
|
||||
allowedPubkeys = banStore.listAllowedPubkeys().map { (k, r) -> BannedEntry(k, r) },
|
||||
bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) },
|
||||
allowedKinds = banStore.listAllowedKinds(),
|
||||
disallowedKinds = banStore.listDisallowedKinds(),
|
||||
),
|
||||
)
|
||||
runtimeConfig.save(snapshotOf(info.document, banStore))
|
||||
}.onFailure {
|
||||
System.err.println("warning: failed to write relay state file: ${it.message}")
|
||||
}
|
||||
@@ -206,10 +169,9 @@ class RelayEngine(
|
||||
val server =
|
||||
NostrServer(
|
||||
store,
|
||||
// Always prepend a BanListPolicy so NIP-86 admin actions
|
||||
// bite. When the operator-supplied builder returns
|
||||
// [EmptyPolicy] we use the dynamic policy alone; otherwise
|
||||
// we stack them so both layers must accept.
|
||||
// Always prepend BanListPolicy so NIP-86 admin actions bite.
|
||||
// EmptyPolicy from the caller means "no extra policies" — use
|
||||
// BanListPolicy alone; otherwise stack so both must accept.
|
||||
policyBuilder = {
|
||||
val user = policyBuilder()
|
||||
if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.geode.config
|
||||
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
@@ -139,3 +140,28 @@ data class BannedEntry(
|
||||
val key: String,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
/** Bulk-load this snapshot into [banStore] without firing its `onMutation` hook. */
|
||||
fun RuntimeConfigData.seedInto(banStore: BanStore) {
|
||||
banStore.seedFromSnapshot(
|
||||
bannedPubkeys = bannedPubkeys.map { it.key to it.reason },
|
||||
allowedPubkeys = allowedPubkeys.map { it.key to it.reason },
|
||||
bannedEvents = bannedEvents.map { it.key to it.reason },
|
||||
allowedKinds = allowedKinds,
|
||||
disallowedKinds = disallowedKinds,
|
||||
)
|
||||
}
|
||||
|
||||
/** Capture the current state of [info] + [banStore] as a fresh snapshot. */
|
||||
fun snapshotOf(
|
||||
info: Nip11RelayInformation,
|
||||
banStore: BanStore,
|
||||
): RuntimeConfigData =
|
||||
RuntimeConfigData(
|
||||
info = info,
|
||||
bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) },
|
||||
allowedPubkeys = banStore.listAllowedPubkeys().map { (k, r) -> BannedEntry(k, r) },
|
||||
bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) },
|
||||
allowedKinds = banStore.listAllowedKinds(),
|
||||
disallowedKinds = banStore.listDisallowedKinds(),
|
||||
)
|
||||
|
||||
@@ -34,10 +34,8 @@ import java.io.File
|
||||
* needs to change while the relay is running lives in [RuntimeConfig].
|
||||
*
|
||||
* Section layout matches nostr-rs-relay's `config.toml` so existing
|
||||
* configs can be ported with little churn.
|
||||
*
|
||||
* Every section is optional; values not set fall back to sensible
|
||||
* defaults (or, for fields also exposed on the CLI, the CLI value wins).
|
||||
* configs can be ported with little churn. Every section is optional;
|
||||
* CLI flags override file values where both exist.
|
||||
*/
|
||||
data class StaticConfig(
|
||||
val info: InfoSection = InfoSection(),
|
||||
@@ -48,11 +46,7 @@ data class StaticConfig(
|
||||
val admin: AdminSection = AdminSection(),
|
||||
val negentropy: NegentropySection = NegentropySection(),
|
||||
) {
|
||||
/**
|
||||
* Maps the `[info]` section into a [RelayInfo] used by the NIP-11
|
||||
* endpoint. `relay_url` and CLI overrides take precedence.
|
||||
*/
|
||||
fun resolveInfo(advertisedUrl: NormalizedRelayUrl): RelayInfo =
|
||||
fun resolveInfo(): RelayInfo =
|
||||
RelayInfo(
|
||||
Nip11RelayInformation(
|
||||
name = info.name ?: RelayInfo.NAME,
|
||||
@@ -71,12 +65,7 @@ data class StaticConfig(
|
||||
language_tags = info.language_tags,
|
||||
tags = info.tags,
|
||||
),
|
||||
).also {
|
||||
// Touch [advertisedUrl] so the parameter isn't unused — we keep
|
||||
// it in the signature because future fields (e.g. self-pubkey
|
||||
// selection, fee URLs) will want it.
|
||||
advertisedUrl.url
|
||||
}
|
||||
)
|
||||
|
||||
data class InfoSection(
|
||||
val relay_url: String? = null,
|
||||
@@ -87,7 +76,6 @@ data class StaticConfig(
|
||||
val icon: String? = null,
|
||||
val software: String? = null,
|
||||
val version: String? = null,
|
||||
/** NIP numbers as ints (e.g. `[1, 9, 11]`). Stringified at render time. */
|
||||
val supported_nips: List<Int>? = null,
|
||||
val privacy_policy: String? = null,
|
||||
val terms_of_service: String? = null,
|
||||
@@ -96,91 +84,50 @@ data class StaticConfig(
|
||||
val tags: List<String>? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bind config + Ktor CIO thread-pool sizes. The three `*_size`
|
||||
* fields default to Ktor's per-CPU sizing; lift them on big-VM
|
||||
* deployments targeting 10k+ concurrent connections.
|
||||
*/
|
||||
data class NetworkSection(
|
||||
val host: String = "0.0.0.0",
|
||||
val port: Int = 7447,
|
||||
val path: String = "/",
|
||||
/**
|
||||
* Ktor CIO acceptor-thread count. `null` (default) keeps Ktor's
|
||||
* default sizing — fine up to a few thousand concurrent
|
||||
* connections. On big-VM deployments targeting 10k+
|
||||
* connections, lift this to roughly half the available cores
|
||||
* so the acceptor doesn't starve workers.
|
||||
*/
|
||||
val connection_group_size: Int? = null,
|
||||
/**
|
||||
* Ktor CIO worker-thread count (handles socket I/O). `null`
|
||||
* keeps Ktor's default. Each connection's WebSocket read/write
|
||||
* is dispatched onto this pool; for many idle long-lived
|
||||
* connections the pool can stay small, but 10k+ connections
|
||||
* benefit from sizing this to the full CPU count.
|
||||
*/
|
||||
val worker_group_size: Int? = null,
|
||||
/**
|
||||
* Ktor CIO call-handling thread count. `null` keeps Ktor's
|
||||
* default. Sized higher than [worker_group_size] because each
|
||||
* call (incl. WebSocket upgrade) may suspend on I/O — at
|
||||
* 10k+ connections, ~4× cores is a reasonable starting point.
|
||||
*/
|
||||
val call_group_size: Int? = null,
|
||||
)
|
||||
|
||||
data class DatabaseSection(
|
||||
/** True keeps an in-memory SQLite db (default — events vanish on restart). */
|
||||
val in_memory: Boolean = true,
|
||||
/** Filesystem path for a persistent SQLite db. Ignored when [in_memory] is true. */
|
||||
val file: String? = null,
|
||||
)
|
||||
|
||||
data class OptionsSection(
|
||||
/** Reject events whose `created_at` is more than this many seconds in the future. */
|
||||
val reject_future_seconds: Int? = null,
|
||||
/** Require NIP-42 AUTH for REQ/EVENT/COUNT. */
|
||||
val require_auth: Boolean = false,
|
||||
/**
|
||||
* Drop events whose Schnorr signature does not verify. **Defaults
|
||||
* to `true`**: any relay accepting traffic from real clients
|
||||
* should verify signatures, and verifying-by-default closes the
|
||||
* footgun of forgetting the flag. Set explicitly to `false` only
|
||||
* for trusted-input scenarios (test fixtures, mirror replays).
|
||||
* Defaults to `true`: any relay accepting real traffic should
|
||||
* verify Schnorr signatures, and verifying-by-default closes
|
||||
* the footgun of forgetting the flag. Set false only for
|
||||
* trusted-input scenarios (test fixtures, mirror replays).
|
||||
*/
|
||||
val verify_signatures: Boolean = true,
|
||||
/**
|
||||
* Run signature verification in parallel inside the IngestQueue
|
||||
* (CPU fan-out across `Dispatchers.Default`) instead of serially
|
||||
* on each connection's WebSocket pump. Tier-3 of the
|
||||
* `event-ingestion-batching` plan. Wins scale with how many
|
||||
* EVENTs a single connection sends back-to-back: ~CPU_COUNT×
|
||||
* verify-step speed-up on burst publishes. Set false to keep
|
||||
* the legacy in-policy verify path.
|
||||
*
|
||||
* Only takes effect when [verify_signatures] is also true.
|
||||
*/
|
||||
/** CPU fan-out verification in the IngestQueue. No-op when [verify_signatures] is false. */
|
||||
val parallel_verify: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* NIP-77 negentropy tuning. Defaults track strfry
|
||||
* (`hoytech/strfry`) so a Geode relay accepts the same workload
|
||||
* shape and exchanges the same NEG-MSG round-trip size as
|
||||
* strfry — the de-facto reference implementation.
|
||||
* NIP-77 negentropy tuning. Defaults track strfry (`hoytech/strfry`)
|
||||
* so a Geode relay accepts the same workload shape and exchanges
|
||||
* the same NEG-MSG round-trip size:
|
||||
*
|
||||
* - [frame_size_limit] mirrors strfry's hard-coded
|
||||
* `Negentropy ne(storage, 500'000)` in `RelayNegentropy.cpp`.
|
||||
* Hex-encoded that's ~1 MB on the wire per NEG-MSG. Ktor's
|
||||
* WebSocket layer does not impose a default frame cap, so this
|
||||
* payload is delivered intact unless a reverse proxy is
|
||||
* misconfigured.
|
||||
* - [max_sync_events] mirrors strfry's
|
||||
* `relay__negentropy__maxSyncEvents`. NEG-OPEN whose snapshot
|
||||
* exceeds this returns
|
||||
* `["NEG-ERR", "<subId>", "blocked: too many query results"]`.
|
||||
* - [max_sessions_per_connection] caps concurrent NEG-OPEN
|
||||
* sessions held by a single connection. strfry shares its
|
||||
* 200-cap with REQ subs via `relay__maxSubsPerConnection`;
|
||||
* Geode counts NEG independently for now (REQ has no cap yet).
|
||||
* Overflow returns NOTICE
|
||||
* `"too many concurrent NEG requests"` (matches strfry).
|
||||
* - [frame_size_limit]: strfry's hard-coded `Negentropy ne(storage, 500'000)`.
|
||||
* - [max_sync_events]: strfry's `relay__negentropy__maxSyncEvents`;
|
||||
* NEG-OPEN over this returns `["NEG-ERR", "<subId>", "blocked: too many query results"]`.
|
||||
* - [max_sessions_per_connection]: NEG sessions held by one connection;
|
||||
* overflow returns NOTICE `"too many concurrent NEG requests"`.
|
||||
*/
|
||||
data class NegentropySection(
|
||||
val frame_size_limit: Long = 500_000L,
|
||||
@@ -196,30 +143,19 @@ data class StaticConfig(
|
||||
)
|
||||
|
||||
/**
|
||||
* NIP-86 relay management API. When [pubkeys] is non-empty,
|
||||
* `KtorRelay` exposes a POST endpoint at the relay path
|
||||
* that accepts JSON-RPC admin requests authenticated via NIP-98
|
||||
* HTTP-Auth. Only requests signed by one of these pubkeys are
|
||||
* dispatched.
|
||||
*
|
||||
* The NIP-98 URL binding compares the signed `u` tag against the
|
||||
* `http(s)://` equivalent of the relay's WebSocket URL — i.e.
|
||||
* `[info].relay_url` with the scheme swapped (per NIP-86's
|
||||
* "same URI as `ws(s)://`, called via `http(s)://`"). Set
|
||||
* `[info].relay_url` to the canonical public URL when running
|
||||
* behind TLS termination or a reverse proxy.
|
||||
* NIP-86 admin. [pubkeys] non-empty opens the POST endpoint at the
|
||||
* relay path; only NIP-98 tokens signed by these pubkeys dispatch.
|
||||
* The NIP-98 URL binding uses `[info].relay_url` with the scheme
|
||||
* swapped (`ws(s)://` → `http(s)://`) per NIP-86 — set
|
||||
* `[info].relay_url` to the canonical public URL when behind TLS
|
||||
* termination or a reverse proxy.
|
||||
*/
|
||||
data class AdminSection(
|
||||
val pubkeys: List<String> = emptyList(),
|
||||
/**
|
||||
* Path for the JSON snapshot that backs [RuntimeConfig] —
|
||||
* NIP-86 admin state (ban lists + the live NIP-11 doc) that
|
||||
* survives restarts. When unset, runtime config is in-memory
|
||||
* only.
|
||||
*
|
||||
* Convention: place next to the SQLite event-store file —
|
||||
* e.g. `[database].file = "/var/lib/geode/events.db"`
|
||||
* pairs with `[admin].state_file = "/var/lib/geode/events.db.admin.json"`.
|
||||
* JSON snapshot path for [RuntimeConfig] (ban lists + live
|
||||
* NIP-11 doc). Convention: place next to the SQLite event file,
|
||||
* e.g. `events.db` ↔ `events.db.admin.json`.
|
||||
*/
|
||||
val state_file: String? = null,
|
||||
)
|
||||
@@ -227,24 +163,16 @@ data class StaticConfig(
|
||||
companion object {
|
||||
private val mapper = tomlMapper { }
|
||||
|
||||
/** Parse a TOML string. */
|
||||
fun fromToml(toml: String): StaticConfig = mapper.decode<StaticConfig>(toml)
|
||||
|
||||
/** Load a TOML config file. */
|
||||
fun fromFile(file: File): StaticConfig = mapper.decode<StaticConfig>(file.toPath())
|
||||
|
||||
/**
|
||||
* Returns the URL the relay advertises in NIP-11 and NIP-42
|
||||
* challenges. Picks (in order):
|
||||
* 1. `info.relay_url` from the config
|
||||
* 2. The `network` section's host/port/path (with 0.0.0.0 → 127.0.0.1)
|
||||
* 3. The CLI override (handled in `Main.kt`).
|
||||
* URL the relay advertises in NIP-11 and NIP-42 challenges:
|
||||
* `info.relay_url` if set, else built from `network.host:port/path`
|
||||
* with `0.0.0.0` rewritten to `127.0.0.1`.
|
||||
*/
|
||||
fun advertisedUrl(config: StaticConfig): NormalizedRelayUrl =
|
||||
(
|
||||
config.info.relay_url
|
||||
?: defaultUrl(config.network)
|
||||
).normalizeRelayUrl()
|
||||
fun advertisedUrl(config: StaticConfig): NormalizedRelayUrl = (config.info.relay_url ?: defaultUrl(config.network)).normalizeRelayUrl()
|
||||
|
||||
private fun defaultUrl(net: NetworkSection): String {
|
||||
val host = if (net.host == "0.0.0.0") "127.0.0.1" else net.host
|
||||
|
||||
@@ -105,13 +105,7 @@ class StaticConfigTest {
|
||||
supported_nips = [1, 11, 42]
|
||||
""".trimIndent(),
|
||||
)
|
||||
val info =
|
||||
c.resolveInfo(
|
||||
"ws://127.0.0.1:7447/".let {
|
||||
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
.normalize(it)
|
||||
},
|
||||
)
|
||||
val info = c.resolveInfo()
|
||||
assertEquals(listOf("1", "11", "42"), info.document.supported_nips)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user