diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt index db406f30eb..7a7a063cc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt index cf570b2e77..2b5c79e79b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt @@ -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 diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 24e37a2ff2..f665a6590b 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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* diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 5fdf00d2fd..489e604740 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index eeaa80a898..ff0e032bb0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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): 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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt new file mode 100644 index 0000000000..d377861911 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt @@ -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, + ): 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 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt new file mode 100644 index 0000000000..c2dc9eb8a5 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt @@ -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, + ): 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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayManagement/Nip86Retriever.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayManagement/Nip86Retriever.kt index 306dc530a8..f9a07a6e51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayManagement/Nip86Retriever.kt @@ -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