feat(cli): amy admin (NIP-86) + serve (embed geode relay)

Two new nak-parity commands:

- `amy admin RELAY METHOD [args]` — NIP-86 Relay Management API over NIP-98
  HTTP auth. Full method set: ban/unban + allow/unallow pubkey, ban/allow
  event, allow/disallow kind, block/unblock IP, change name/description/icon,
  and all list-* queries. Reuses quartz's Nip86Client (request build + NIP-98
  auth + parse) and the Nip86Retriever HTTP path — extracted from amethyst to
  commons/jvmAndroid so amy and the Android relay-management screen share it.

- `amy serve [--host --port --path --db --admin]` — runs a Nostr relay by
  embedding geode (the standalone Ktor relay on quartz's relay-server code).
  In-memory by default (ephemeral, like nak serve); --db FILE for SQLite. The
  account's own pubkey is always an admin, so `amy admin` works against it out
  of the box. cli gains a :geode dependency (geode depends only on :quartz) and
  kotlinx-serialization-json (to render NIP-86 JSON results).

Verified end-to-end: `amy serve` + `amy admin ws://127.0.0.1:PORT
supported-methods|change-name|ban-pubkey|list-banned-pubkeys` round-trip
cleanly over real HTTP + NIP-98 against the live geode relay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
This commit is contained in:
Claude
2026-06-22 00:19:29 +00:00
parent 42a82c7e8b
commit 47cc76d9f1
8 changed files with 234 additions and 11 deletions
@@ -78,9 +78,9 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
+11 -8
View File
@@ -100,24 +100,27 @@ vs streaming `subscribe`). Stateless verbs run with no account or network.
| `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. |
| `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. |
| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. |
| `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. |
| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. |
| `serve` | `amy serve` | ✅ | Embeds **geode** (the standalone Ktor relay on quartz's relay-server code) — in-memory by default, `--db FILE` for SQLite, account is admin so `amy admin` works against it. NIP-86 + NIP-77 included. |
| `wallet` (NIP-60 Cashu) | `amy cashu` | ✅ | See the Cashu row above — full NIP-60/61 wallet + nutzaps. |
| `mcp` / `fs` / `spell` | — | 🆕 (niche) | MCP server, FUSE mount, MuSig2/FROST; some pull new deps. |
### Full nak comparison (introspected both binaries)
nak has 34 functional commands. Coverage:
- **Full / equivalent (19):** `event`, `req`(→`fetch`+`subscribe`), `filter`,
- **Full / equivalent (22):** `event`, `req`(→`fetch`+`subscribe`), `filter`,
`count`, `decode`, `encode`, `verify`, `relay`, `bunker`(+nostrconnect+auth_url),
`encrypt`, `decrypt`, `gift`, `publish`, `sync`, `profile`, `podcast`, `nip`,
`kind`, `blossom`. Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49,
`encode`/`decode`) are interop-verified against the real `nak` binary.
`kind`, `blossom`, `admin`(NIP-86), `serve`(geode), `wallet`(NIP-60/61 Cashu).
Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49, `encode`/`decode`,
`admin`) are interop-verified against the real `nak` binary or `amy serve`.
- **Partial / adapted (4):** `key` (no `expand`/`combine`/`validate`/`default`),
`git` (NIP-34 events only — no packfile transport), `outbox` (shows NIP-65 vs
nak's hints DB), `fetch` (filter-based, not nip19-hint resolution).
- **Missing (11):** `admin` (NIP-86),
`serve` (geode is a standalone relay), `dekey` (NIP-4E), `wallet` (NIP-60
Cashu), `mcp`, `curl` (NIP-98), `fs` (FUSE), `group`/`nip29` (NIP-29 — amy has
MLS/Marmot instead), `spell` (MuSig2/FROST), `validate` (RoK schema).
- **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98),
`fs` (FUSE), `group`/`nip29` (NIP-29 — amy has MLS/Marmot instead),
`spell` (MuSig2/FROST), `validate` (RoK schema).
**Design differences (not gaps):** amy is a *stateful client* (accounts,
`~/.amy/`, shared event store) with a stable JSON contract; nak is a *stateless*
+4
View File
@@ -22,8 +22,12 @@ sourceSets {
dependencies {
implementation(project(":quartz"))
implementation(project(":commons"))
// `amy serve` embeds geode (the standalone Ktor relay built on quartz's
// relay-server code). geode depends only on :quartz, never on :amethyst.
implementation(project(":geode"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
implementation(libs.jackson.module.kotlin)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.cli
import com.vitorpamplona.amethyst.cli.commands.AdminCommand
import com.vitorpamplona.amethyst.cli.commands.AwaitCommands
import com.vitorpamplona.amethyst.cli.commands.BlossomCommands
import com.vitorpamplona.amethyst.cli.commands.BunkerCommand
@@ -56,6 +57,7 @@ import com.vitorpamplona.amethyst.cli.commands.ProfileCommands
import com.vitorpamplona.amethyst.cli.commands.PublishCommand
import com.vitorpamplona.amethyst.cli.commands.RelayCommands
import com.vitorpamplona.amethyst.cli.commands.SearchCommand
import com.vitorpamplona.amethyst.cli.commands.ServeCommand
import com.vitorpamplona.amethyst.cli.commands.StoreCommands
import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand
import com.vitorpamplona.amethyst.cli.commands.SyncCommand
@@ -225,6 +227,8 @@ private suspend fun dispatch(argv: Array<String>): Int {
"blossom" -> BlossomCommands.dispatch(dataDir, tail)
"sync" -> SyncCommand.run(dataDir, tail)
"git" -> GitCommands.dispatch(dataDir, tail)
"admin" -> AdminCommand.run(dataDir, tail)
"serve" -> ServeCommand.run(dataDir, tail)
"cashu" -> CashuCommands.dispatch(dataDir, tail)
"podcast" -> PodcastCommands.dispatch(dataDir, tail)
"bunker" -> BunkerCommand.run(dataDir, tail)
@@ -0,0 +1,106 @@
/*
* 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.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import okhttp3.OkHttpClient
/**
* `amy admin RELAY METHOD [args]` — NIP-86 Relay Management API (nak's
* `relay`/admin). Signs a NIP-98 request with the account key and POSTs it to
* the relay's HTTP endpoint. Reuses quartz's `Nip86Client` (request build +
* NIP-98 auth + response parse) and the shared `commons` `Nip86Retriever`
* (the exact HTTP path Amethyst's relay-management screen runs).
*
* admin wss://relay supported-methods
* admin wss://relay ban-pubkey HEX [--reason R] / unban-pubkey HEX
* admin wss://relay allow-pubkey HEX [--reason R] / list-allowed-pubkeys
* admin wss://relay list-banned-pubkeys
* admin wss://relay ban-event ID [--reason R] / allow-event ID / list-banned-events
* admin wss://relay list-needing-moderation
* admin wss://relay change-name S / change-description S / change-icon URL
* admin wss://relay allow-kind N / disallow-kind N / list-allowed-kinds
* admin wss://relay block-ip IP [--reason R] / unblock-ip IP / list-blocked-ips
*/
object AdminCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val relayArg = args.positionalOrNull(0) ?: return Output.error("bad_args", "usage: admin RELAY METHOD [args]")
val method = args.positionalOrNull(1) ?: return Output.error("bad_args", "missing method; e.g. supported-methods")
val relay = RelayUrlNormalizer.normalizeOrNull(relayArg) ?: return Output.error("bad_args", "invalid relay url: $relayArg")
val p2 = args.positionalOrNull(2)
val reason = args.flag("reason")
fun needArg(name: String): String? =
p2 ?: run {
Output.error("bad_args", "$method requires a $name argument")
null
}
val request: Nip86Request =
when (method) {
"supported-methods" -> Nip86Request.supportedMethods()
"ban-pubkey" -> Nip86Request.banPubkey(needArg("pubkey") ?: return 2, reason)
"unban-pubkey" -> Nip86Request.unbanPubkey(needArg("pubkey") ?: return 2, reason)
"list-banned-pubkeys" -> Nip86Request.listBannedPubkeys()
"allow-pubkey" -> Nip86Request.allowPubkey(needArg("pubkey") ?: return 2, reason)
"unallow-pubkey" -> Nip86Request.unallowPubkey(needArg("pubkey") ?: return 2, reason)
"list-allowed-pubkeys" -> Nip86Request.listAllowedPubkeys()
"ban-event" -> Nip86Request.banEvent(needArg("event-id") ?: return 2, reason)
"allow-event" -> Nip86Request.allowEvent(needArg("event-id") ?: return 2, reason)
"list-banned-events" -> Nip86Request.listBannedEvents()
"list-needing-moderation" -> Nip86Request.listEventsNeedingModeration()
"change-name" -> Nip86Request.changeRelayName(needArg("name") ?: return 2)
"change-description" -> Nip86Request.changeRelayDescription(needArg("description") ?: return 2)
"change-icon" -> Nip86Request.changeRelayIcon(needArg("icon-url") ?: return 2)
"allow-kind" -> Nip86Request.allowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer"))
"disallow-kind" -> Nip86Request.disallowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer"))
"list-allowed-kinds" -> Nip86Request.listAllowedKinds()
"block-ip" -> Nip86Request.blockIp(needArg("ip") ?: return 2, reason)
"unblock-ip" -> Nip86Request.unblockIp(needArg("ip") ?: return 2)
"list-blocked-ips" -> Nip86Request.listBlockedIps()
else -> return Output.error("bad_args", "unknown method: $method")
}
Context.open(dataDir).use { ctx ->
val client = Nip86Client(relay, ctx.signer)
val http = OkHttpClient.Builder().build()
val retriever = Nip86Retriever { _ -> http }
val response = retriever.execute(client, request)
if (response.error != null) return Output.error("relay_error", response.error)
// Reparse the kotlinx JSON result through Jackson so it renders as
// structured JSON (text + --json) instead of a toString blob.
val resultNode = response.result?.toString()?.let { Output.mapper.readTree(it) }
Output.emit(mapOf("relay" to relay.url, "method" to method, "result" to resultNode))
}
return 0
}
}
@@ -0,0 +1,106 @@
/*
* 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.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.geode.KtorRelay
import com.vitorpamplona.geode.RelayEngine
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import kotlinx.coroutines.awaitCancellation
/**
* `amy serve [--host H] [--port N] [--path P] [--db FILE] [--admin NPUBS]` —
* run a Nostr relay (nak's `serve`). Embeds **geode** (the standalone Ktor
* relay built on quartz's relay-server code), so amy serves the exact same
* relay implementation, including NIP-86 admin and NIP-77 Negentropy.
*
* In-memory by default (ephemeral, like nak serve); pass `--db FILE` for a
* persistent SQLite store. The account's own pubkey is always an admin so
* `amy admin ws://host:port …` works against it out of the box; `--admin`
* adds more (comma-separated npub/hex). Blocks until interrupted.
*/
object ServeCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val host = args.flag("host") ?: "127.0.0.1"
val port = args.intFlag("port", 7447)
val path = args.flag("path") ?: "/"
val dbFile = args.flag("db")
val extraAdmins =
args
.flag("admin")
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
// Resolve admin pubkeys (self + --admin) up front, then drop the
// Context — the embedded relay owns its own store and needs no account.
val adminPubkeys =
Context.open(dataDir).use { ctx ->
buildSet {
add(ctx.identity.pubKeyHex)
extraAdmins.forEach { add(ctx.requireUserHex(it)) }
}
}
// 0.0.0.0 isn't routable in a NIP-42 challenge; advertise loopback.
val advertisedHost = if (host == "0.0.0.0") "127.0.0.1" else host
val url = "ws://$advertisedHost:$port$path".normalizeRelayUrl()
// In-memory is RelayEngine's default; only build a SQLite store for --db.
val relay =
if (dbFile != null) {
RelayEngine(url, store = EventStore(dbName = dbFile, relay = url), adminPubkeys = adminPubkeys)
} else {
RelayEngine(url, adminPubkeys = adminPubkeys)
}
val server = KtorRelay(relay, host = host, port = port, path = path).start()
Runtime.getRuntime().addShutdownHook(
Thread {
runCatching { server.stop() }
runCatching { relay.close() }
},
)
Output.emit(
mapOf(
"listening" to server.url,
"host" to host,
"port" to port,
"path" to path,
"persistent" to (dbFile != null),
"admin_pubkeys" to adminPubkeys.toList(),
),
)
System.err.println("[serve] relay up at ${server.url} — Ctrl-C to stop")
// Block until the process is interrupted; the shutdown hook tears down.
awaitCancellation()
}
}
@@ -18,7 +18,7 @@
* 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.amethyst.model.nip86RelayManagement
package com.vitorpamplona.amethyst.commons.relayManagement
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client