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