From 3fa45a47c6b9f6b3ee94cd04adaa635896daa57b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 20:09:11 +0000 Subject: [PATCH 01/11] feat(cli): add amy CLI with Marmot subcommand surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New :cli JVM module producing an `amy` binary that drives Amethyst functionality headlessly. Identity and relay configuration sit at the root (`amy init`, `amy whoami`, `amy relay …`); Marmot/MLS lives under `amy marmot …` so future verbs (dm, feed, profile) can slot in cleanly. Marmot surface covered: - key-package publish / check - group create / list / show / members / admins / add / rename / promote / demote / remove / leave - message send / list (kind:9 inner events) - await key-package / group / member / admin / message / rename / epoch (non-interactive polling with --timeout; exit 124 on timeout) Wiring: - NostrClient + BasicOkHttpWebSocket.Builder, reusing the existing publishAndConfirmDetailed and fetchFirst accessories - MarmotManager from :commons with file-backed MlsGroupStateStore, KeyPackageBundleStore, and MarmotMessageStore (unencrypted — scratch harness use only) - each invocation is one-shot: prepare → syncIncoming → run command → persist → disconnect All commands emit one JSON object on stdout; diagnostics on stderr. Designed so shell harnesses can pipe through jq without bespoke parsing. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq --- cli/build.gradle.kts | 36 ++ .../com/vitorpamplona/amethyst/cli/Args.kt | 103 ++++++ .../com/vitorpamplona/amethyst/cli/Config.kt | 161 +++++++++ .../com/vitorpamplona/amethyst/cli/Context.kt | 319 ++++++++++++++++++ .../com/vitorpamplona/amethyst/cli/Json.kt | 42 +++ .../com/vitorpamplona/amethyst/cli/Main.kt | 210 ++++++++++++ .../amethyst/cli/commands/AwaitCommands.kt | 295 ++++++++++++++++ .../amethyst/cli/commands/Commands.kt | 63 ++++ .../cli/commands/GroupAddMemberCommand.kt | 113 +++++++ .../amethyst/cli/commands/GroupCommands.kt | 48 +++ .../cli/commands/GroupCreateCommand.kt | 76 +++++ .../cli/commands/GroupMembershipCommands.kt | 92 +++++ .../cli/commands/GroupMetadataCommands.kt | 108 ++++++ .../cli/commands/GroupReadCommands.kt | 128 +++++++ .../amethyst/cli/commands/InitCommands.kt | 67 ++++ .../cli/commands/KeyPackageCommands.kt | 97 ++++++ .../amethyst/cli/commands/MessageCommands.kt | 123 +++++++ .../amethyst/cli/commands/RelayCommands.kt | 114 +++++++ .../amethyst/cli/stores/FileStores.kt | 142 ++++++++ .../vitorpamplona/amethyst/cli/util/Npubs.kt | 39 +++ settings.gradle | 1 + 21 files changed, 2377 insertions(+) create mode 100644 cli/build.gradle.kts create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Args.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Json.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/util/Npubs.kt diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts new file mode 100644 index 000000000..30b9a910f --- /dev/null +++ b/cli/build.gradle.kts @@ -0,0 +1,36 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.jetbrainsKotlinJvm) + application +} + +kotlin { + jvmToolchain(21) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } +} + +sourceSets { + main { + kotlin.srcDir("src/main/kotlin") + resources.srcDir("src/main/resources") + } +} + +dependencies { + implementation(project(":quartz")) + implementation(project(":commons")) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.okhttp) + implementation(libs.okhttpCoroutines) + implementation(libs.jackson.module.kotlin) + implementation(libs.slf4j.nop) +} + +application { + mainClass.set("com.vitorpamplona.amethyst.cli.MainKt") + applicationName = "amy" +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Args.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Args.kt new file mode 100644 index 000000000..69db556fd --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Args.kt @@ -0,0 +1,103 @@ +/* + * 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 + +/** + * Minimal argv parser. Splits flags (--key value or --key=value) from positional args. + * Boolean flags are those whose next token starts with "--" or is absent. + * + * The parser is intentionally tiny — this CLI is driven by shell scripts, not humans, + * so we don't need subcommand groups, short flags, or help text generation. + */ +class Args( + argv: Array, +) { + val flags: Map + val booleans: Set + val positional: List + + init { + val f = mutableMapOf() + val b = mutableSetOf() + val p = mutableListOf() + var i = 0 + while (i < argv.size) { + val a = argv[i] + if (a.startsWith("--")) { + val eq = a.indexOf('=') + if (eq >= 0) { + f[a.substring(2, eq)] = a.substring(eq + 1) + i++ + } else { + val key = a.substring(2) + val next = argv.getOrNull(i + 1) + if (next == null || next.startsWith("--")) { + b.add(key) + i++ + } else { + f[key] = next + i += 2 + } + } + } else { + p.add(a) + i++ + } + } + flags = f + booleans = b + positional = p + } + + fun flag( + name: String, + default: String? = null, + ): String? = flags[name] ?: default + + fun intFlag( + name: String, + default: Int, + ): Int = flags[name]?.toIntOrNull() ?: default + + fun longFlag( + name: String, + default: Long, + ): Long = flags[name]?.toLongOrNull() ?: default + + fun requireFlag(name: String): String = + flags[name] ?: run { + System.err.println("missing required flag: --$name") + throw IllegalArgumentException("missing flag $name") + } + + fun bool(name: String): Boolean = name in booleans + + fun positional( + index: Int, + name: String, + ): String = + positional.getOrNull(index) ?: run { + System.err.println("missing positional arg: $name (index $index)") + throw IllegalArgumentException("missing positional $name") + } + + fun positionalOrNull(index: Int): String? = positional.getOrNull(index) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt new file mode 100644 index 000000000..03b1dab0e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -0,0 +1,161 @@ +/* + * 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 + +import com.fasterxml.jackson.module.kotlin.readValue +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.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import java.io.File + +/** Persisted identity (hex keys + cached bech32 forms for convenience). */ +data class Identity( + val privKeyHex: String, + val pubKeyHex: String, + val nsec: String, + val npub: String, +) { + fun keyPair(): KeyPair = KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray()) + + companion object { + fun create(): Identity { + val kp = KeyPair() + val priv = kp.privKey!! + val pub = kp.pubKey + return Identity( + privKeyHex = priv.toHexKey(), + pubKeyHex = pub.toHexKey(), + nsec = priv.toNsec(), + npub = pub.toNpub(), + ) + } + + fun fromNsec(nsec: String): Identity { + val priv = nsec.bechToBytes() + val kp = KeyPair(privKey = priv) + return Identity( + privKeyHex = priv.toHexKey(), + pubKeyHex = kp.pubKey.toHexKey(), + nsec = priv.toNsec(), + npub = kp.pubKey.toNpub(), + ) + } + } +} + +/** + * On-disk relay configuration, bucketed by purpose (mirrors `wn relays add --type`). + * + * - `nip65`: advertised read/write (kind:10002) + * - `inbox`: DM inbox / gift-wrap delivery (kind:10050) + * - `keyPackage`: where this account's KeyPackages (kind:30443) live + */ +data class RelayConfig( + val nip65: MutableList = mutableListOf(), + val inbox: MutableList = mutableListOf(), + val keyPackage: MutableList = mutableListOf(), +) { + fun all(): Set = (nip65 + inbox + keyPackage).toSet() + + fun add( + type: String, + url: String, + ): Boolean { + val list = + when (type) { + "nip65" -> nip65 + "inbox" -> inbox + "key_package", "keyPackage" -> keyPackage + else -> throw IllegalArgumentException("unknown relay type: $type") + } + if (list.contains(url)) return false + list.add(url) + return true + } + + fun normalized(kind: String): Set { + val src = + when (kind) { + "nip65" -> nip65 + "inbox" -> inbox + "key_package" -> keyPackage + "all" -> all().toList() + else -> throw IllegalArgumentException("unknown relay selector: $kind") + } + return src.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + } +} + +/** Opaque per-run state (subscription cursors, etc). Stored alongside identity. */ +data class RunState( + var giftWrapSince: Long? = null, + val groupSince: MutableMap = mutableMapOf(), +) + +/** + * Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or + * `$AMETHYST_CLI_DATA`) — defaults to `./amethyst-cli-data`. + */ +class DataDir( + val root: File, +) { + val identityFile = File(root, "identity.json") + val relaysFile = File(root, "relays.json") + val stateFile = File(root, "state.json") + val groupsDir = File(root, "groups") + val keyPackageBundleFile = File(root, "keypackages.bundle") + + init { + root.mkdirs() + groupsDir.mkdirs() + } + + fun loadIdentityOrNull(): Identity? = if (identityFile.exists()) Json.mapper.readValue(identityFile.readText()) else null + + fun saveIdentity(id: Identity) { + identityFile.writeText(Json.mapper.writeValueAsString(id)) + } + + fun loadRelays(): RelayConfig = if (relaysFile.exists()) Json.mapper.readValue(relaysFile.readText()) else RelayConfig() + + fun saveRelays(r: RelayConfig) { + relaysFile.writeText(Json.mapper.writeValueAsString(r)) + } + + fun loadRunState(): RunState = if (stateFile.exists()) Json.mapper.readValue(stateFile.readText()) else RunState() + + fun saveRunState(s: RunState) { + stateFile.writeText(Json.mapper.writeValueAsString(s)) + } + + companion object { + fun resolve(flag: String?): DataDir { + val envPath = System.getenv("AMETHYST_CLI_DATA") + val path = flag ?: envPath ?: "./amethyst-cli-data" + return DataDir(File(path).absoluteFile) + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt new file mode 100644 index 000000000..1616217aa --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -0,0 +1,319 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore +import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore +import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore +import com.vitorpamplona.amethyst.commons.marmot.MarmotManager +import com.vitorpamplona.quartz.marmot.MarmotFilters +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient + +/** + * Per-invocation wiring. Each CLI run constructs a Context, does its work, + * and then closes it — no daemon. + * + * Responsibilities: + * - load identity + relay + run-state from the data-dir, + * - wire up a [NostrClient] pointing at those relays, + * - wire up the [MarmotManager] pipeline with file-backed stores, + * - expose helpers that every command needs (sync, publish-and-confirm, + * process-incoming, etc). + * + * Closing flushes run-state to disk and disconnects the client. + */ +class Context( + val dataDir: DataDir, + val identity: Identity, + val relays: RelayConfig, + val state: RunState, +) : AutoCloseable { + val signer = NostrSignerInternal(identity.keyPair()) + + private val okhttp = OkHttpClient.Builder().build() + + val client: NostrClient = + NostrClient( + websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp }, + ) + + private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir) + private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile) + private val messageStore = FileMarmotMessageStore(dataDir.groupsDir) + + /** Fully-wired manager. Call [prepare] once before use to load persisted state. */ + val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore) + + private var prepared = false + + /** + * Hydrate MarmotManager from disk (groups + KeyPackage bundles) and + * connect to relays. Safe to call multiple times — subsequent calls are + * no-ops. + */ + suspend fun prepare() { + if (prepared) return + marmot.restoreAll() + client.connect() + prepared = true + } + + fun outboxRelays(): Set = relays.normalized("nip65") + + fun inboxRelays(): Set = relays.normalized("inbox") + + fun keyPackageRelays(): Set = relays.normalized("key_package") + + fun anyRelays(): Set = relays.normalized("all") + + /** + * Publish an event to the given relays and wait for OK confirmations. + * + * Returns the set of relays that ACK'd `true`. Does not throw on rejection — + * callers inspect the map and decide. + */ + suspend fun publish( + event: Event, + relayList: Set, + timeoutSecs: Long = 15, + ): Map { + if (relayList.isEmpty()) return emptyMap() + return client.publishAndConfirmDetailed(event, relayList, timeoutSecs) + } + + /** + * Subscribe to the given filters across the given relays, drain all events + * until either every relay has sent EOSE or the timeout elapses, and + * return them. Used for one-shot catch-up queries — not live subscriptions. + */ + suspend fun drain( + filters: Map>, + timeoutMs: Long = 8_000, + ): List> { + if (filters.isEmpty()) return emptyList() + val eventChannel = Channel>(UNLIMITED) + val doneChannel = Channel(UNLIMITED) + val remaining = filters.keys.toMutableSet() + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eventChannel.trySend(relay to event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + doneChannel.trySend(relay) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + doneChannel.trySend(relay) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + doneChannel.trySend(relay) + } + } + val collected = mutableListOf>() + try { + client.subscribe(subId, filters, listener) + withTimeoutOrNull(timeoutMs) { + while (remaining.isNotEmpty()) { + select { + eventChannel.onReceive { collected.add(it) } + doneChannel.onReceive { r -> remaining.remove(r) } + } + } + // Drain any events that landed after EOSE but before cancel + while (true) { + val r = eventChannel.tryReceive() + if (!r.isSuccess) break + collected.add(r.getOrThrow()) + } + } + } finally { + client.unsubscribe(subId) + eventChannel.close() + doneChannel.close() + } + return collected + } + + /** + * Pull down everything needed to bring local Marmot state current: + * - kind:1059 gift wraps on inbox relays → try to unwrap Welcomes + * - kind:445 group events per active group → feed into inbound processor + * + * Incrementally advances the `since` cursors in [state] so the next run + * only asks relays for newer events. + */ + suspend fun syncIncoming(timeoutMs: Long = 8_000) { + val inbox = inboxRelays().ifEmpty { anyRelays() } + val gwSince = state.giftWrapSince + val gwFilter = + if (gwSince != null) { + MarmotFilters.giftWrapsForUserSince(identity.pubKeyHex, gwSince) + } else { + MarmotFilters.giftWrapsForUser(identity.pubKeyHex) + } + + val activeGroupIds = marmot.subscriptionManager.activeGroupIdsSnapshot().toList() + val perGroupFilters: Map = + activeGroupIds.associateWith { gid -> + val since = state.groupSince[gid] + if (since != null) { + MarmotFilters.groupEventsByGroupIdSince(gid, since) + } else { + MarmotFilters.groupEventsByGroupId(gid) + } + } + + // Group filters go to each group's configured relays, not the user's + // inbox — kind:445 is delivered to the group's relay set advertised in + // its MIP-01 metadata (falls back to our outbox if the group never + // stamped any). + val filterMap = mutableMapOf>() + for (r in inbox) filterMap.getOrPut(r) { mutableListOf() }.add(gwFilter) + for ((gid, filter) in perGroupFilters) { + val groupRelays = marmotGroupRelays(gid).ifEmpty { outboxRelays() } + for (r in groupRelays) filterMap.getOrPut(r) { mutableListOf() }.add(filter) + } + if (filterMap.isEmpty()) return + + val events = drain(filterMap, timeoutMs) + val now = System.currentTimeMillis() / 1000 + + var maxGwSeen = gwSince ?: 0L + val maxGroupSeen = perGroupFilters.keys.associateWith { state.groupSince[it] ?: 0L }.toMutableMap() + + for ((relay, event) in events) { + when (event.kind) { + GiftWrapEvent.KIND -> { + // kind:1059 — try to unwrap. If it's a Welcome, let the + // inbound processor apply it; anything else is ignored. + val gw = event as? GiftWrapEvent ?: continue + try { + val inner = gw.unwrapOrNull(signer) ?: continue + if (inner.kind == WelcomeEvent.KIND && inner is WelcomeEvent) { + val hint = inner.nostrGroupId() + val res = marmot.processWelcome(inner, hint) + System.err.println("[cli] Welcome via $relay → $res") + } + } catch (e: Exception) { + System.err.println("[cli] failed to unwrap giftwrap ${event.id.take(8)}: ${e.message}") + } + if (event.createdAt > maxGwSeen) maxGwSeen = event.createdAt + } + + GroupEvent.KIND -> { + val ge = event as? GroupEvent ?: continue + val gid = ge.groupId() ?: continue + try { + val res = marmot.processGroupEvent(ge) + // Mirror DecryptAndIndexProcessor on Amethyst: application + // messages get persisted at decrypt-time, because MLS ratchet + // advancement makes the ciphertext un-re-decryptable later. + if (res is com.vitorpamplona.quartz.marmot.GroupEventResult.ApplicationMessage) { + marmot.persistDecryptedMessage(res.groupId, res.innerEventJson) + } + System.err.println("[cli] GroupEvent ${event.id.take(8)} → ${res::class.simpleName}") + } catch (e: Exception) { + System.err.println("[cli] processGroupEvent failed: ${e.message}") + } + val prev = maxGroupSeen[gid] ?: 0L + if (event.createdAt > prev) maxGroupSeen[gid] = event.createdAt + } + } + } + + state.giftWrapSince = if (maxGwSeen > 0) maxGwSeen else now + for ((gid, seen) in maxGroupSeen) { + state.groupSince[gid] = if (seen > 0) seen else now + } + } + + fun marmotGroupRelays(nostrGroupId: HexKey): Set { + val m = marmot.groupMetadata(nostrGroupId) ?: return emptySet() + return m.relays + .mapNotNull { + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull(it) + }.toSet() + } + + override fun close() { + dataDir.saveRunState(state) + try { + client.close() + } catch (_: Exception) { + } + } + + companion object { + /** Build a Context but require an identity to already exist — most commands can't run without one. */ + fun open(dataDir: DataDir): Context { + val identity = + dataDir.loadIdentityOrNull() + ?: run { + System.err.println("No identity found at ${dataDir.identityFile}. Run `amethyst-cli init` first.") + throw IllegalStateException("no identity") + } + return Context( + dataDir = dataDir, + identity = identity, + relays = dataDir.loadRelays(), + state = dataDir.loadRunState(), + ) + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Json.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Json.kt new file mode 100644 index 000000000..9efc18647 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Json.kt @@ -0,0 +1,42 @@ +/* + * 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 + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +object Json { + val mapper: ObjectMapper = jacksonObjectMapper() + + fun writeLine(obj: Any) { + println(mapper.writeValueAsString(obj)) + } + + fun error( + code: String, + detail: String? = null, + ): Int { + val payload = mutableMapOf("error" to code) + if (detail != null) payload["detail"] = detail + System.err.println(mapper.writeValueAsString(payload)) + return 1 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt new file mode 100644 index 000000000..b34978a0c --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -0,0 +1,210 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.cli.commands.Commands +import kotlinx.coroutines.runBlocking +import kotlin.system.exitProcess + +/** + * amy — non-interactive command-line interface to Amethyst. + * + * Today this covers the Marmot/MLS surface (`amy marmot …`) plus identity + * and relay configuration at the root level. The layout is intentionally + * extensible — future verbs (`amy dm`, `amy feed`, `amy profile`) slot in + * as new top-level subcommands. + * + * Usage: amy --data-dir PATH SUBCOMMAND ARGS + * + * Exit codes: + * 0 — success + * 1 — runtime error (printed as JSON on stderr: {"error": "...", "detail": "..."}) + * 2 — invalid arguments + * 124 — await timeout + * + * Every command that succeeds prints exactly one JSON object to stdout. + * Diagnostic logs go to stderr and are safe to discard. + */ +fun main(argv: Array) { + val code = + try { + runBlocking { dispatch(argv) } + } catch (e: IllegalArgumentException) { + Json.error("bad_args", e.message) + 2 + } catch (e: AwaitTimeout) { + Json.error("timeout", e.message) + 124 + } catch (e: Exception) { + Json.error("runtime", "${e::class.simpleName}: ${e.message}") + 1 + } + exitProcess(code) +} + +class AwaitTimeout( + message: String, +) : RuntimeException(message) + +private suspend fun dispatch(argv: Array): Int { + if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h") { + printUsage() + return 0 + } + + // Pull --data-dir out of argv before subcommand parsing so subcommands see + // only their own args. + val filteredArgs = mutableListOf() + var dataDirFlag: String? = null + var i = 0 + while (i < argv.size) { + when (val a = argv[i]) { + "--data-dir" -> { + dataDirFlag = argv.getOrNull(i + 1) + i += 2 + } + + else -> { + if (a.startsWith("--data-dir=")) { + dataDirFlag = a.removePrefix("--data-dir=") + i++ + } else { + filteredArgs.add(a) + i++ + } + } + } + } + if (filteredArgs.isEmpty()) { + printUsage() + return 2 + } + + val dataDir = DataDir.resolve(dataDirFlag) + val head = filteredArgs[0] + val tail = filteredArgs.drop(1).toTypedArray() + + return when (head) { + "init" -> { + Commands.init(dataDir, Args(tail)) + } + + "whoami" -> { + Commands.whoami(dataDir) + } + + "relay" -> { + Commands.relay(dataDir, tail) + } + + "marmot" -> { + marmotDispatch(dataDir, tail) + } + + else -> { + System.err.println("unknown subcommand: $head") + printUsage() + 2 + } + } +} + +private suspend fun marmotDispatch( + dataDir: DataDir, + tail: Array, +): Int { + if (tail.isEmpty()) { + printUsage() + return 2 + } + val head = tail[0] + val rest = tail.drop(1).toTypedArray() + return when (head) { + "key-package" -> { + Commands.keyPackage(dataDir, rest) + } + + "group" -> { + Commands.group(dataDir, rest) + } + + "message" -> { + Commands.message(dataDir, rest) + } + + "await" -> { + Commands.await(dataDir, rest) + } + + else -> { + System.err.println("unknown marmot subcommand: $head") + printUsage() + 2 + } + } +} + +private fun printUsage() { + System.err.println( + """ + |amy — Amethyst command-line interface + | + |Usage: + | amy [--data-dir PATH] [args...] + | + |Identity: + | init [--nsec NSEC] create or import identity + | whoami print current identity + | + |Relays: + | relay add URL [--type T] T=nip65|inbox|key_package|all (default all) + | relay list print configured relays + | relay publish-lists publish kind:10002 + kind:10050 + | + |Marmot (MLS group messaging): + | marmot key-package publish publish a fresh KeyPackage + | marmot key-package check NPUB fetch NPUB's KeyPackage from relays + | + | marmot group create [--name NAME] create an empty group (self-only) + | marmot group list list joined groups + | marmot group show GID print full group details + | marmot group members GID print members + | marmot group admins GID print admins + | marmot group add GID NPUB [NPUB...] fetch KPs and invite + | marmot group rename GID NAME commit a rename + | marmot group promote GID NPUB add admin + | marmot group demote GID NPUB remove admin + | marmot group remove GID NPUB remove member + | marmot group leave GID self-remove + | + | marmot message send GID TEXT publish kind:9 inner event into the group + | marmot message list GID [--limit N] dump decrypted inner events + | + | marmot await key-package NPUB (all await verbs take --timeout SECS, default 30; + | marmot await group --name NAME exit 124 on timeout) + | marmot await member GID NPUB + | marmot await admin GID NPUB + | marmot await message GID --match TEXT + | marmot await rename GID --name NAME + | marmot await epoch GID --min N + """.trimMargin(), + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt new file mode 100644 index 000000000..c5e87939a --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt @@ -0,0 +1,295 @@ +/* + * 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.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.AwaitTimeout +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.util.Npubs +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import kotlinx.coroutines.delay + +/** + * `await` subcommands. Each polls until a condition is met or the timeout elapses; + * on timeout we throw [AwaitTimeout] which maps to exit code 124. + */ +object AwaitCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "await ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "key-package" -> awaitKeyPackage(dataDir, rest) + "group" -> awaitGroup(dataDir, rest) + "member" -> awaitMember(dataDir, rest) + "admin" -> awaitAdmin(dataDir, rest) + "message" -> awaitMessage(dataDir, rest) + "rename" -> awaitRename(dataDir, rest) + "epoch" -> awaitEpoch(dataDir, rest) + else -> Json.error("bad_args", "await ${tail[0]}") + } + } + + private suspend fun awaitKeyPackage( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "await key-package ") + val target = Npubs.resolveToHex(rest[0]) + val args = Args(rest.drop(1).toTypedArray()) + val timeoutSecs = args.longFlag("timeout", 30) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target) + val deadline = System.currentTimeMillis() + timeoutSecs * 1000 + while (System.currentTimeMillis() < deadline) { + val relays = ctx.anyRelays() + if (relays.isNotEmpty()) { + val event = + ctx.client.fetchFirst( + filters = relays.associateWith { listOf(filter) }, + timeoutMs = 3_000, + ) + if (event is KeyPackageEvent) { + Json.writeLine(mapOf("event_id" to event.id, "author" to event.pubKey)) + return 0 + } + } + delay(2_000) + } + throw AwaitTimeout("no KeyPackage for $target within ${timeoutSecs}s") + } finally { + ctx.close() + } + } + + private suspend fun awaitGroup( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val wantedName = args.flag("name") + val timeoutSecs = args.longFlag("timeout", 30) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val deadline = System.currentTimeMillis() + timeoutSecs * 1000 + while (System.currentTimeMillis() < deadline) { + ctx.syncIncoming(timeoutMs = 3_000) + val match = + ctx.marmot.activeGroupIds().firstOrNull { gid -> + wantedName == null || ctx.marmot.groupMetadata(gid)?.name == wantedName + } + if (match != null) { + Json.writeLine( + mapOf( + "group_id" to match, + "name" to (ctx.marmot.groupMetadata(match)?.name ?: ""), + "epoch" to ctx.marmot.groupEpoch(match), + ), + ) + return 0 + } + delay(1_500) + } + throw AwaitTimeout("no group with name=$wantedName within ${timeoutSecs}s") + } finally { + ctx.close() + } + } + + private suspend fun awaitMember( + dataDir: DataDir, + rest: Array, + ): Int = + pollCondition(dataDir, rest, "await member ", targetIdx = 1) { ctx, rawArgs -> + val gid = rawArgs[0] + val target = Npubs.resolveToHex(rawArgs[1]) + if (!ctx.marmot.isMember(gid)) { + null + } else if (ctx.marmot.memberPubkeys(gid).any { it.pubkey == target }) { + mapOf("group_id" to gid, "pubkey" to target, "epoch" to ctx.marmot.groupEpoch(gid)) + } else { + null + } + } + + private suspend fun awaitAdmin( + dataDir: DataDir, + rest: Array, + ): Int = + pollCondition(dataDir, rest, "await admin ", targetIdx = 1) { ctx, rawArgs -> + val gid = rawArgs[0] + val target = Npubs.resolveToHex(rawArgs[1]) + if (!ctx.marmot.isMember(gid)) { + null + } else if (ctx.marmot + .groupMetadata(gid) + ?.adminPubkeys + ?.contains(target) == true + ) { + mapOf("group_id" to gid, "pubkey" to target, "epoch" to ctx.marmot.groupEpoch(gid)) + } else { + null + } + } + + private suspend fun awaitRename( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "await rename --name ") + val gid = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val wantedName = args.requireFlag("name") + val timeoutSecs = args.longFlag("timeout", 30) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val deadline = System.currentTimeMillis() + timeoutSecs * 1000 + while (System.currentTimeMillis() < deadline) { + ctx.syncIncoming(timeoutMs = 3_000) + val name = ctx.marmot.groupMetadata(gid)?.name + if (name == wantedName) { + Json.writeLine(mapOf("group_id" to gid, "name" to name, "epoch" to ctx.marmot.groupEpoch(gid))) + return 0 + } + delay(1_500) + } + throw AwaitTimeout("group $gid never renamed to $wantedName within ${timeoutSecs}s") + } finally { + ctx.close() + } + } + + private suspend fun awaitEpoch( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "await epoch --min N") + val gid = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val min = args.longFlag("min", 1) + val timeoutSecs = args.longFlag("timeout", 30) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val deadline = System.currentTimeMillis() + timeoutSecs * 1000 + while (System.currentTimeMillis() < deadline) { + ctx.syncIncoming(timeoutMs = 3_000) + val epoch = ctx.marmot.groupEpoch(gid) + if (epoch != null && epoch >= min) { + Json.writeLine(mapOf("group_id" to gid, "epoch" to epoch)) + return 0 + } + delay(1_500) + } + throw AwaitTimeout("group $gid epoch never reached $min within ${timeoutSecs}s") + } finally { + ctx.close() + } + } + + private suspend fun awaitMessage( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "await message --match STRING") + val gid = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val needle = args.requireFlag("match") + val timeoutSecs = args.longFlag("timeout", 30) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val deadline = System.currentTimeMillis() + timeoutSecs * 1000 + while (System.currentTimeMillis() < deadline) { + ctx.syncIncoming(timeoutMs = 3_000) + val msgs = ctx.marmot.loadStoredMessages(gid) + for (line in msgs.asReversed()) { + val obj = + try { + Json.mapper.readValue>(line) + } catch (_: Exception) { + null + } ?: continue + val content = obj["content"]?.toString() ?: continue + if (content.contains(needle)) { + Json.writeLine( + mapOf( + "group_id" to gid, + "id" to obj["id"], + "author" to obj["pubkey"], + "content" to content, + "kind" to obj["kind"], + ), + ) + return 0 + } + } + delay(1_500) + } + throw AwaitTimeout("no message matching '$needle' in $gid within ${timeoutSecs}s") + } finally { + ctx.close() + } + } + + /** + * Generic poll loop used by [awaitMember] / [awaitAdmin] — both take the + * same ` ` positional shape and differ only in the predicate. + */ + private suspend fun pollCondition( + dataDir: DataDir, + rest: Array, + usage: String, + targetIdx: Int, + check: suspend (Context, Array) -> Map?, + ): Int { + if (rest.size <= targetIdx) return Json.error("bad_args", usage) + val args = Args(rest.drop(targetIdx + 1).toTypedArray()) + val timeoutSecs = args.longFlag("timeout", 30) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val deadline = System.currentTimeMillis() + timeoutSecs * 1000 + while (System.currentTimeMillis() < deadline) { + ctx.syncIncoming(timeoutMs = 3_000) + val hit = check(ctx, rest) + if (hit != null) { + Json.writeLine(hit) + return 0 + } + delay(1_500) + } + throw AwaitTimeout("condition never satisfied within ${timeoutSecs}s ($usage)") + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt new file mode 100644 index 000000000..042b4f387 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -0,0 +1,63 @@ +/* + * 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.DataDir + +/** + * Tiny dispatcher over the per-verb command groups. Each top-level subcommand + * (`init`, `relay`, `group`, `message`, …) gets its own file so no single + * file is too big to edit safely. + */ +object Commands { + suspend fun init( + dataDir: DataDir, + args: Args, + ): Int = InitCommands.init(dataDir, args) + + suspend fun whoami(dataDir: DataDir): Int = InitCommands.whoami(dataDir) + + suspend fun relay( + dataDir: DataDir, + tail: Array, + ): Int = RelayCommands.dispatch(dataDir, tail) + + suspend fun keyPackage( + dataDir: DataDir, + tail: Array, + ): Int = KeyPackageCommands.dispatch(dataDir, tail) + + suspend fun group( + dataDir: DataDir, + tail: Array, + ): Int = GroupCommands.dispatch(dataDir, tail) + + suspend fun message( + dataDir: DataDir, + tail: Array, + ): Int = MessageCommands.dispatch(dataDir, tail) + + suspend fun await( + dataDir: DataDir, + tail: Array, + ): Int = AwaitCommands.dispatch(dataDir, tail) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt new file mode 100644 index 000000000..8c4114283 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt @@ -0,0 +1,113 @@ +/* + * 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.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.util.Npubs +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * `group add [ ...]` — fetch each invitee's + * KeyPackage from the union of (our relays + any known KeyPackage relays + * for them) and run the full add-member flow for each one: build commit, + * publish commit to the group's relays, then wrap + publish the Welcome + * gift wrap. + */ +object GroupAddMemberCommand { + @OptIn(ExperimentalEncodingApi::class) + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "group add [ ...]") + val gid = rest[0] + val invitees = rest.drop(1).map { Npubs.resolveToHex(it) } + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + + val groupRelays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + val report = mutableListOf>() + + for (pub in invitees) { + val filter = ctx.marmot.subscriptionManager.keyPackageFilter(pub) + val relays = ctx.anyRelays() + val filters = relays.associateWith { listOf(filter) } + val kpEvent = ctx.client.fetchFirst(filters = filters, timeoutMs = 10_000) + if (kpEvent == null || kpEvent !is KeyPackageEvent) { + report.add(mapOf("pubkey" to pub, "status" to "no_key_package")) + continue + } + val kpBytes = Base64.decode(kpEvent.keyPackageBase64()) + + val (commitEvent, welcomeDelivery) = + ctx.marmot.addMember( + nostrGroupId = gid, + memberPubKey = pub, + keyPackageBytes = kpBytes, + keyPackageEventId = kpEvent.id, + relays = groupRelays.toList(), + ) + + // Order matters: commit first (so invitee doesn't join at a future epoch), + // then welcome. + val commitAck = ctx.publish(commitEvent.signedEvent, groupRelays) + val welcomeAck = + if (welcomeDelivery != null) { + val inbox = ctx.inboxRelays().ifEmpty { ctx.outboxRelays() } + ctx.publish(welcomeDelivery.giftWrapEvent, inbox) + } else { + emptyMap() + } + + report.add( + mapOf( + "pubkey" to pub, + "status" to "invited", + "key_package_event_id" to kpEvent.id, + "commit_event_id" to commitEvent.signedEvent.id, + "welcome_event_id" to welcomeDelivery?.giftWrapEvent?.id, + "commit_accepted_by" to commitAck.filterValues { it }.keys.map { it.url }, + "welcome_accepted_by" to welcomeAck.filterValues { it }.keys.map { it.url }, + ), + ) + } + + Json.writeLine( + mapOf( + "group_id" to gid, + "epoch" to ctx.marmot.groupEpoch(gid), + "results" to report, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt new file mode 100644 index 000000000..863962b2b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt @@ -0,0 +1,48 @@ +/* + * 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.DataDir +import com.vitorpamplona.amethyst.cli.Json + +object GroupCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "group ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "create" -> GroupCreateCommand.run(dataDir, rest) + "list" -> GroupReadCommands.list(dataDir) + "show" -> GroupReadCommands.show(dataDir, rest) + "members" -> GroupReadCommands.members(dataDir, rest) + "admins" -> GroupReadCommands.admins(dataDir, rest) + "add" -> GroupAddMemberCommand.run(dataDir, rest) + "rename" -> GroupMetadataCommands.rename(dataDir, rest) + "promote" -> GroupMetadataCommands.promote(dataDir, rest) + "demote" -> GroupMetadataCommands.demote(dataDir, rest) + "remove" -> GroupMembershipCommands.remove(dataDir, rest) + "leave" -> GroupMembershipCommands.leave(dataDir, rest) + else -> Json.error("bad_args", "group ${tail[0]}") + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt new file mode 100644 index 000000000..12d64b33e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt @@ -0,0 +1,76 @@ +/* + * 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.Json +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.RandomInstance + +object GroupCreateCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val name = args.flag("name", "")!! + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val gid = RandomInstance.bytes(32).toHexKey() + + ctx.marmot.createGroup(gid) + + // Stamp initial metadata: name + our outbox relays + self as admin. + // Mirrors CreateGroupScreen.proceedWithCreate() on the Amethyst side. + val outboxUrls = ctx.outboxRelays().map { it.url } + val metadata = + MarmotGroupData( + nostrGroupId = gid, + name = name, + description = "", + adminPubkeys = listOf(ctx.identity.pubKeyHex), + relays = outboxUrls, + ) + val commit = ctx.marmot.updateGroupMetadata(gid, metadata) + + // Group relays == what the metadata carries, which on first commit is our outbox. + val targets = ctx.outboxRelays() + val ack = ctx.publish(commit.signedEvent, targets) + + Json.writeLine( + mapOf( + "group_id" to gid, + "name" to name, + "epoch" to ctx.marmot.groupEpoch(gid), + "commit_event_id" to commit.signedEvent.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt new file mode 100644 index 000000000..8c5b8d95b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt @@ -0,0 +1,92 @@ +/* + * 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.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.util.Npubs + +object GroupMembershipCommands { + suspend fun remove( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "group remove ") + val gid = rest[0] + val target = Npubs.resolveToHex(rest[1]) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + + val member = + ctx.marmot.memberPubkeys(gid).firstOrNull { it.pubkey == target } + ?: return Json.error("not_in_group", target) + + val outbound = + ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = member.leafIndex) + val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + val ack = ctx.publish(outbound.signedEvent, targets) + Json.writeLine( + mapOf( + "group_id" to gid, + "removed" to target, + "leaf_index" to member.leafIndex, + "epoch" to ctx.marmot.groupEpoch(gid), + "commit_event_id" to outbound.signedEvent.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + suspend fun leave( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "group leave ") + val gid = rest[0] + val ctx = Context.open(dataDir) + try { + ctx.prepare() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + + val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + val outbound = ctx.marmot.leaveGroup(gid) + val ack = ctx.publish(outbound.signedEvent, targets) + Json.writeLine( + mapOf( + "group_id" to gid, + "proposal_event_id" to outbound.signedEvent.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt new file mode 100644 index 000000000..44d7b08a8 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt @@ -0,0 +1,108 @@ +/* + * 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.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.util.Npubs +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Metadata-only commits: rename, promote/demote. Each loads current metadata, + * edits the right field, publishes a GCE commit to the group relays. + */ +object GroupMetadataCommands { + suspend fun rename( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "group rename ") + return edit(dataDir, rest[0]) { it.copy(name = rest[1]) } + } + + suspend fun promote( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "group promote ") + val newAdmin = Npubs.resolveToHex(rest[1]) + return edit(dataDir, rest[0]) { cur -> + val admins = cur.adminPubkeys.toMutableList() + if (newAdmin !in admins) admins.add(newAdmin) + cur.copy(adminPubkeys = admins) + } + } + + suspend fun demote( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "group demote ") + val target = Npubs.resolveToHex(rest[1]) + return edit(dataDir, rest[0]) { cur -> + val admins = cur.adminPubkeys.filter { it != target } + cur.copy(adminPubkeys = admins) + } + } + + private suspend fun edit( + dataDir: DataDir, + gid: HexKey, + mutate: (MarmotGroupData) -> MarmotGroupData, + ): Int { + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + val cur = + ctx.marmot.groupMetadata(gid) + ?: MarmotGroupData( + nostrGroupId = gid, + name = "", + description = "", + adminPubkeys = listOf(ctx.identity.pubKeyHex), + relays = ctx.outboxRelays().map { it.url }, + ) + val updated = mutate(cur) + + val commit = ctx.marmot.updateGroupMetadata(gid, updated) + val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + val ack = ctx.publish(commit.signedEvent, targets) + + Json.writeLine( + mapOf( + "group_id" to gid, + "name" to updated.name, + "admins" to updated.adminPubkeys, + "epoch" to ctx.marmot.groupEpoch(gid), + "commit_event_id" to commit.signedEvent.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt new file mode 100644 index 000000000..9ac6b78f7 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt @@ -0,0 +1,128 @@ +/* + * 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.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json + +/** + * Read-only queries. None of these publish; they all sync-then-report. + */ +object GroupReadCommands { + suspend fun list(dataDir: DataDir): Int { + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + val ids = ctx.marmot.activeGroupIds() + val items = + ids.map { id -> + val m = ctx.marmot.groupMetadata(id) + mapOf( + "group_id" to id, + "name" to (m?.name ?: ""), + "members" to ctx.marmot.memberCount(id), + "epoch" to ctx.marmot.groupEpoch(id), + ) + } + Json.writeLine(mapOf("groups" to items)) + return 0 + } finally { + ctx.close() + } + } + + suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "group show ") + val gid = rest[0] + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + val meta = ctx.marmot.groupMetadata(gid) + val members = + ctx.marmot.memberPubkeys(gid).map { + mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex) + } + Json.writeLine( + mapOf( + "group_id" to gid, + "name" to (meta?.name ?: ""), + "description" to (meta?.description ?: ""), + "epoch" to ctx.marmot.groupEpoch(gid), + "admins" to (meta?.adminPubkeys ?: emptyList()), + "relays" to (meta?.relays ?: emptyList()), + "members" to members, + "is_admin" to (meta?.adminPubkeys?.contains(ctx.identity.pubKeyHex) == true), + ), + ) + return 0 + } finally { + ctx.close() + } + } + + suspend fun members( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "group members ") + val gid = rest[0] + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + val members = + ctx.marmot.memberPubkeys(gid).map { + mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex) + } + Json.writeLine(mapOf("group_id" to gid, "members" to members)) + return 0 + } finally { + ctx.close() + } + } + + suspend fun admins( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "group admins ") + val gid = rest[0] + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + val m = ctx.marmot.groupMetadata(gid) + Json.writeLine(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList()))) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt new file mode 100644 index 000000000..2c8cf973e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt @@ -0,0 +1,67 @@ +/* + * 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.DataDir +import com.vitorpamplona.amethyst.cli.Identity +import com.vitorpamplona.amethyst.cli.Json + +object InitCommands { + suspend fun init( + dataDir: DataDir, + args: Args, + ): Int { + val existing = dataDir.loadIdentityOrNull() + val id = + existing ?: run { + val nsec = args.flag("nsec") + val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create() + dataDir.saveIdentity(created) + created + } + Json.writeLine( + mapOf( + "npub" to id.npub, + "hex" to id.pubKeyHex, + "nsec" to id.nsec, + "existing" to (existing != null), + "data_dir" to dataDir.root.absolutePath, + ), + ) + return 0 + } + + suspend fun whoami(dataDir: DataDir): Int { + val id = dataDir.loadIdentityOrNull() + if (id == null) { + return Json.error("no_identity", "No identity at ${dataDir.identityFile}. Run `init` first.") + } + Json.writeLine( + mapOf( + "npub" to id.npub, + "hex" to id.pubKeyHex, + "data_dir" to dataDir.root.absolutePath, + ), + ) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt new file mode 100644 index 000000000..8524a4d73 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt @@ -0,0 +1,97 @@ +/* + * 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.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.util.Npubs +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst + +object KeyPackageCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "key-package …") + return when (tail[0]) { + "publish" -> publish(dataDir) + "check" -> check(dataDir, tail.drop(1).toTypedArray()) + else -> Json.error("bad_args", "key-package ${tail[0]}") + } + } + + private suspend fun publish(dataDir: DataDir): Int { + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val relays = ctx.keyPackageRelays().ifEmpty { ctx.outboxRelays() }.ifEmpty { ctx.anyRelays() } + if (relays.isEmpty()) return Json.error("no_relays", "configure relays first") + + val event = ctx.marmot.generateKeyPackageEvent(relays.toList()) + val ack = ctx.publish(event, relays) + Json.writeLine( + mapOf( + "event_id" to event.id, + "kind" to event.kind, + "accepted_by" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun check( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "key-package check ") + val targetHex = Npubs.resolveToHex(rest[0]) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val filter = ctx.marmot.subscriptionManager.keyPackageFilter(targetHex) + val relays = ctx.anyRelays() + if (relays.isEmpty()) return Json.error("no_relays", "configure relays first") + val filtersByRelay = relays.associateWith { listOf(filter) } + val event = ctx.client.fetchFirst(filters = filtersByRelay, timeoutMs = 10_000) + if (event == null || event !is KeyPackageEvent) { + return Json.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)") + } + Json.writeLine( + mapOf( + "event_id" to event.id, + "author" to event.pubKey, + "kind" to event.kind, + "created_at" to event.createdAt, + "has_content" to event.content.isNotBlank(), + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt new file mode 100644 index 000000000..101d5eca2 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt @@ -0,0 +1,123 @@ +/* + * 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.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate + +object MessageCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "message …") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "send" -> send(dataDir, rest) + "list" -> list(dataDir, rest) + else -> Json.error("bad_args", "message ${tail[0]}") + } + } + + private suspend fun send( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "message send ") + val gid = rest[0] + val text = rest[1] + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + + val template = + eventTemplate(kind = 9, description = text) + val innerEvent = ctx.signer.sign(template) + val outbound = ctx.marmot.buildGroupMessage(gid, innerEvent) + + // Persist our own outbound inner event so `message list` includes it + // alongside remote messages. + ctx.marmot.persistDecryptedMessage(gid, innerEvent.toJson()) + + val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } + val ack = ctx.publish(outbound.signedEvent, targets) + + Json.writeLine( + mapOf( + "group_id" to gid, + "inner_event_id" to innerEvent.id, + "outer_event_id" to outbound.signedEvent.id, + "kind" to innerEvent.kind, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "message list ") + val gid = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + val limit = args.intFlag("limit", Int.MAX_VALUE) + val ctx = Context.open(dataDir) + try { + ctx.prepare() + ctx.syncIncoming() + if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + + val raw = ctx.marmot.loadStoredMessages(gid) + val items = + raw + .map { line -> + try { + @Suppress("UNCHECKED_CAST") + val obj = Json.mapper.readValue>(line) + mapOf( + "id" to obj["id"], + "author" to obj["pubkey"], + "kind" to obj["kind"], + "content" to obj["content"], + "created_at" to obj["created_at"], + ) + } catch (_: Exception) { + mapOf("raw" to line) + } + }.takeLast(limit) + + Json.writeLine(mapOf("group_id" to gid, "messages" to items)) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt new file mode 100644 index 000000000..7a34df1c0 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -0,0 +1,114 @@ +/* + * 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.Json +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType + +object RelayCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "relay …") + val sub = tail[0] + val rest = tail.drop(1).toTypedArray() + return when (sub) { + "add" -> add(dataDir, Args(rest)) + "list" -> list(dataDir) + "publish-lists" -> publishLists(dataDir) + else -> Json.error("bad_args", "relay $sub") + } + } + + private fun add( + dataDir: DataDir, + args: Args, + ): Int { + val url = args.positional(0, "url") + val type = args.flag("type", "all") ?: "all" + val cfg = dataDir.loadRelays() + val addedTo = mutableListOf() + val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type) + for (t in targets) { + if (cfg.add(t, url)) addedTo.add(t) + } + dataDir.saveRelays(cfg) + Json.writeLine( + mapOf( + "url" to url, + "added_to" to addedTo, + "already_present" to (targets - addedTo.toSet()), + ), + ) + return 0 + } + + private fun list(dataDir: DataDir): Int { + val cfg = dataDir.loadRelays() + Json.writeLine( + mapOf( + "nip65" to cfg.nip65, + "inbox" to cfg.inbox, + "key_package" to cfg.keyPackage, + ), + ) + return 0 + } + + private suspend fun publishLists(dataDir: DataDir): Int { + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val nip65Relays = ctx.relays.normalized("nip65").toList() + val inboxRelays = ctx.relays.normalized("inbox").toList() + + val nip65Infos = nip65Relays.map { AdvertisedRelayInfo(it, AdvertisedRelayType.BOTH) } + val nip65Event = AdvertisedRelayListEvent.create(nip65Infos, ctx.signer) + val inboxEvent = ChatMessageRelayListEvent.create(inboxRelays, ctx.signer) + + val targets = ctx.anyRelays() + val nip65Result = ctx.publish(nip65Event, targets) + val inboxResult = ctx.publish(inboxEvent, targets) + + Json.writeLine( + mapOf( + "nip65_event_id" to nip65Event.id, + "inbox_event_id" to inboxEvent.id, + "accepted_by" to + mapOf( + "nip65" to nip65Result.filterValues { it }.keys.map { it.url }, + "inbox" to inboxResult.filterValues { it }.keys.map { it.url }, + ), + ), + ) + return 0 + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt new file mode 100644 index 000000000..6aa7f0907 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt @@ -0,0 +1,142 @@ +/* + * 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.stores + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore +import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import java.io.File + +/** + * Test-harness file stores. **Unencrypted** — the production interfaces + * document that implementations MUST encrypt at rest, but this CLI is a + * throwaway interop driver running against scratch keys and local scratch + * state. Do not point it at real account material. + */ + +class FileMlsGroupStateStore( + private val dir: File, +) : MlsGroupStateStore { + init { + dir.mkdirs() + } + + private fun stateFile(id: String) = File(dir, "$id.state") + + private fun retainedFile(id: String) = File(dir, "$id.retained") + + override suspend fun save( + nostrGroupId: String, + state: ByteArray, + ) { + stateFile(nostrGroupId).writeBytes(state) + } + + override suspend fun load(nostrGroupId: String): ByteArray? = stateFile(nostrGroupId).takeIf { it.exists() }?.readBytes() + + override suspend fun delete(nostrGroupId: String) { + stateFile(nostrGroupId).delete() + retainedFile(nostrGroupId).delete() + } + + override suspend fun listGroups(): List = + dir + .listFiles { f -> f.name.endsWith(".state") } + ?.map { it.name.removeSuffix(".state") } + ?: emptyList() + + override suspend fun saveRetainedEpochs( + nostrGroupId: String, + retainedSecrets: List, + ) { + // Layout: [u32 count][(u32 len, bytes) …] — tiny framing so readers + // can recover independent byte arrays without TLS plumbing. + val f = retainedFile(nostrGroupId) + f.outputStream().use { out -> + val buf = java.nio.ByteBuffer.allocate(4) + buf.putInt(retainedSecrets.size) + out.write(buf.array()) + for (secret in retainedSecrets) { + buf.clear() + buf.putInt(secret.size) + out.write(buf.array()) + out.write(secret) + } + } + } + + override suspend fun loadRetainedEpochs(nostrGroupId: String): List { + val f = retainedFile(nostrGroupId) + if (!f.exists()) return emptyList() + val bytes = f.readBytes() + if (bytes.size < 4) return emptyList() + val buf = java.nio.ByteBuffer.wrap(bytes) + val count = buf.int + val result = ArrayList(count) + repeat(count) { + val len = buf.int + val arr = ByteArray(len) + buf.get(arr) + result.add(arr) + } + return result + } +} + +class FileKeyPackageBundleStore( + private val file: File, +) : KeyPackageBundleStore { + override suspend fun save(snapshot: ByteArray) { + file.parentFile?.mkdirs() + file.writeBytes(snapshot) + } + + override suspend fun load(): ByteArray? = file.takeIf { it.exists() }?.readBytes() + + override suspend fun delete() { + file.delete() + } +} + +class FileMarmotMessageStore( + private val dir: File, +) : MarmotMessageStore { + init { + dir.mkdirs() + } + + private fun file(id: String) = File(dir, "$id.messages") + + override suspend fun appendMessage( + nostrGroupId: String, + innerEventJson: String, + ) { + // Each line is one inner event JSON. The store doc tolerates duplicates + // so we don't bother deduping here — readers can do it. + file(nostrGroupId).appendText(innerEventJson.replace("\n", " ") + "\n") + } + + override suspend fun loadMessages(nostrGroupId: String): List = file(nostrGroupId).takeIf { it.exists() }?.readLines()?.filter { it.isNotBlank() } ?: emptyList() + + override suspend fun delete(nostrGroupId: String) { + file(nostrGroupId).delete() + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/util/Npubs.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/util/Npubs.kt new file mode 100644 index 000000000..3796fc1d7 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/util/Npubs.kt @@ -0,0 +1,39 @@ +/* + * 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.util + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes + +/** Accept either an `npub1…` or a raw 64-hex pubkey; always return hex. */ +object Npubs { + fun resolveToHex(input: String): String { + val trimmed = input.trim() + // Already hex? + if (trimmed.length == 64 && trimmed.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) { + return trimmed.lowercase() + } + if (trimmed.startsWith("npub1") || trimmed.startsWith("npub")) { + return trimmed.bechToBytes().toHexKey() + } + throw IllegalArgumentException("expected npub or 64-hex pubkey, got: $input") + } +} diff --git a/settings.gradle b/settings.gradle index 720f73879..ab5462e42 100644 --- a/settings.gradle +++ b/settings.gradle @@ -37,3 +37,4 @@ include ':quartz' include ':commons' include ':ammolite' include ':desktopApp' +include ':cli' From 89198ab24ebf65112b3508fd5fc4cfa066a046ac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 20:43:00 +0000 Subject: [PATCH 02/11] refactor(marmot,cli): lift shared logic into quartz/commons Five extractions so the Amethyst UI and the new amy CLI share one implementation for each piece of Marmot/identity behaviour instead of reimplementing it per platform. quartz additions: - MarmotGroupData.bootstrap(..) + withMergedRelays(..): factory for the metadata shape every inviter stamps on a fresh group and the outbox- merge rule every admin commit carries forward. - KeyPackageFetcher: (a) pure fetchRelaysFor union of target kind:10051, target outbox, my outbox; (b) one-shot fetchKeyPackage; (c) publish- side resolveKeyPackagePublishRelays. - resolveUserHexOrNull: single entry point for npub / nprofile / 64-hex / NIP-05 identifiers. Uses the existing Nip19Parser + Nip05Client infra so NIP-05 resolution is live over HTTP. commons additions: - MarmotManager.leafIndexOf(..): pubkey -> leaf index lookup used by every removeMember caller. - MarmotManager.addMember(nostrGroupId, keyPackageEvent, relays): convenience overload that lifts the base64 decode into the manager. - MarmotManager.buildTextMessage(..) returning a TextMessageBundle; optionally persists the outbound inner event (CLI needs persist, Amethyst relies on LocalCache loopback). - MarmotIngest.ingest(event): routes kind:1059 / kind:445 through unwrap -> processWelcome / processGroupEvent -> persist the decrypted application message. Deliberately platform-agnostic. Retrofits: - Account.addMarmotGroupMember and fetchKeyPackageAndAddMember now take a KeyPackageEvent directly; Account.keyPackagePublishRelays delegates to KeyPackageFetcher. - AccountViewModel.updateMarmotGroupMetadata uses bootstrap + merged. - AccountViewModel.sendMarmotGroupMessage builds the kind:9 template via buildTextMessage(persistOwn = false) to avoid double-persisting. - AccountSessionManager's NIP-05 login branch delegates to resolveUserHexOrNull; no more hand-rolled Nip05Id / nip05Client.get. - CLI Context exposes nip05Client and requireUserHex; syncIncoming now calls MarmotManager.ingest; all command sites use KeyPackageFetcher + the shared identifier resolver. Npubs util deleted. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq --- .../vitorpamplona/amethyst/model/Account.kt | 53 ++----- .../ui/screen/AccountSessionManager.kt | 28 ++-- .../ui/screen/loggedIn/AccountViewModel.kt | 47 ++---- .../com/vitorpamplona/amethyst/cli/Context.kt | 59 +++---- .../amethyst/cli/commands/AwaitCommands.kt | 7 +- .../cli/commands/GroupAddMemberCommand.kt | 29 ++-- .../cli/commands/GroupCreateCommand.kt | 10 +- .../cli/commands/GroupMembershipCommands.kt | 12 +- .../cli/commands/GroupMetadataCommands.kt | 24 ++- .../cli/commands/KeyPackageCommands.kt | 19 +-- .../amethyst/cli/commands/MessageCommands.kt | 19 +-- .../vitorpamplona/amethyst/cli/util/Npubs.kt | 39 ----- .../amethyst/commons/marmot/MarmotIngest.kt | 148 ++++++++++++++++++ .../amethyst/commons/marmot/MarmotManager.kt | 73 +++++++++ .../mip00KeyPackages/KeyPackageFetcher.kt | 91 +++++++++++ .../marmot/mip01Groups/MarmotGroupData.kt | 39 +++++ .../nip05DnsIdentifiers/UserHexResolver.kt | 79 ++++++++++ 17 files changed, 556 insertions(+), 220 deletions(-) delete mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/util/Npubs.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotIngest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFetcher.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/UserHexResolver.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1baaaaffa..59821c709 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1995,9 +1995,6 @@ class Account( val manager = marmotManager ?: return "Error: Marmot not initialized" if (!isWriteable()) return "Error: Account is read-only" - // Build filter for the member's KeyPackages - val filter = manager.subscriptionManager.keyPackageFilter(memberPubKey) - // Per MIP-00, invitees advertise the relays that host their // KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look // there first, then fall back to the invitee's NIP-65 outbox @@ -2019,20 +2016,18 @@ class Account( .outboxRelays() ?.toSet() .orEmpty() - val fetchRelays = memberKeyPackageRelays + memberOutbox + myOutbox + val fetchRelays = + com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher + .fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox) Log.d("MarmotDbg") { "fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " + "(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}" } - // Query across the combined relay set - val filterMap = fetchRelays.associateWith { listOf(filter) } - val event = - client.fetchFirst( - filters = filterMap, - ) + com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher + .fetchKeyPackage(client, memberPubKey, fetchRelays) if (event == null) { Log.w("MarmotDbg") { @@ -2045,21 +2040,12 @@ class Account( "fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}…" } - if (event !is com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent) { - Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: unexpected kind ${event.kind}" } - return "Error: Unexpected event type received" - } - val keyPackageBase64 = event.keyPackageBase64() if (keyPackageBase64.isBlank()) { Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" } return "Error: KeyPackage event has empty content" } - val keyPackageBytes = - kotlin.io.encoding.Base64 - .decode(keyPackageBase64) - val keyPackageEventId = event.id // The relays embedded in the WelcomeEvent tell the new member // where to subscribe for subsequent GroupEvents. Use our own // outbox — that's where we will publish them. @@ -2071,9 +2057,7 @@ class Account( addMarmotGroupMember( nostrGroupId = nostrGroupId, - memberPubKey = memberPubKey, - keyPackageBytes = keyPackageBytes, - keyPackageEventId = keyPackageEventId, + keyPackageEvent = event, groupRelays = groupRelays, ) @@ -2086,14 +2070,13 @@ class Account( */ suspend fun addMarmotGroupMember( nostrGroupId: HexKey, - memberPubKey: HexKey, - keyPackageBytes: ByteArray, - keyPackageEventId: HexKey, + keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent, groupRelays: List, ) { + val memberPubKey = keyPackageEvent.pubKey Log.d("MarmotDbg") { "addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " + - "keyPackageBytes=${keyPackageBytes.size}B groupRelays=${groupRelays.size}" + "groupRelays=${groupRelays.size}" } val manager = marmotManager ?: return if (!isWriteable()) return @@ -2101,9 +2084,7 @@ class Account( val (commitEvent, welcomeDelivery) = manager.addMember( nostrGroupId = nostrGroupId, - memberPubKey = memberPubKey, - keyPackageBytes = keyPackageBytes, - keyPackageEventId = keyPackageEventId, + keyPackageEvent = keyPackageEvent, relays = groupRelays, ) @@ -2167,17 +2148,11 @@ class Account( /** * Relays where this account publishes kind:30443 KeyPackage events. - * - * Per MIP-00, these should match the relays advertised in the user's - * kind:10051 KeyPackage Relay List so that other clients can discover - * and fetch them. Falls back to the standard outbox set when no list - * has been configured yet, since that's also where the user's other - * write-oriented events land. + * Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox. */ - fun keyPackagePublishRelays(): Set { - val list = keyPackageRelayList.flow.value - return if (list.isNotEmpty()) list else outboxRelays.flow.value - } + fun keyPackagePublishRelays(): Set = + com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher + .publishRelaysFor(keyPackageRelayList.flow.value, outboxRelays.flow.value) /** * Publish or rotate KeyPackage events. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 4c3d8c673..cfbdb5cc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -44,7 +44,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client -import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -223,21 +222,20 @@ class AccountSessionManager( loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError) } } else if (EMAIL_PATTERN.matcher(key).matches()) { - val nip05 = Nip05Id.parse(key) - if (nip05 == null) { - onError("Could not parse nip05 address: $nip05") - } else { - try { - val pubkeyInfo = nip05ClientBuilder().get(nip05) - if (pubkeyInfo == null) { - onError("User not found in the nip05 server: $nip05") - } else { - loginSync(Hex.decode(pubkeyInfo.pubkey).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - onError("Could not load nip05 address from the server: $nip05. ${e.message}") + // Delegate to the shared quartz resolver so NIP-05 handling stays in + // lockstep with the CLI and anywhere else we accept user identifiers. + try { + val hex = + com.vitorpamplona.quartz.nip05DnsIdentifiers + .resolveUserHexOrNull(key, nip05ClientBuilder()) + if (hex == null) { + onError("User not found in the nip05 server: $key") + } else { + loginSync(Hex.decode(hex).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError) } + } catch (e: Exception) { + if (e is CancellationException) throw e + onError("Could not load nip05 address from the server: $key. ${e.message}") } } else { loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 3096c8311..b9e9220e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1453,14 +1453,12 @@ class AccountViewModel( nostrGroupId: String, text: String, ) { - val template = - com.vitorpamplona.quartz.nip01Core.signers.eventTemplate( - kind = 9, - description = text, - ) - val innerEvent = account.signer.sign(template) + // Inner event construction lives on MarmotManager so CLI and UI don't drift. + // persistOwn=false because Account.sendMarmotGroupMessage routes the outer + // event through LocalCache which already handles own-message display. + val bundle = account.marmotManager?.buildTextMessage(nostrGroupId, text, persistOwn = false) ?: return val relays = marmotGroupRelays(nostrGroupId) - account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays) + account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays) } suspend fun sendMarmotGroupMediaMessage( @@ -1558,7 +1556,6 @@ class AccountViewModel( name: String, description: String, ) { - val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId) // Stamp the inviter's outbox relays into the group metadata so that // every member ends up with a single canonical relay set for kind:445 // GroupEvents. Without this, both the inviter and the invitee fall @@ -1569,29 +1566,19 @@ class AccountViewModel( val outboxRelayStrings = account.outboxRelays.flow.value .map { it.url } - val mergedRelays = - (currentMetadata?.relays.orEmpty() + outboxRelayStrings) - .distinct() + val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId) val updatedMetadata = - if (currentMetadata != null) { - currentMetadata.copy( - name = name, - description = description, - relays = mergedRelays, - ) - } else { - // No MarmotGroupData extension exists yet — this happens for groups - // created before initial metadata was persisted, or right after a - // fresh `createMarmotGroup`. Build a brand-new extension with the - // creator as the sole admin so the GCE proposal carries valid data. - com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData( - nostrGroupId = nostrGroupId, - name = name, - description = description, - adminPubkeys = listOf(account.signer.pubKey), - relays = mergedRelays, - ) - } + currentMetadata + ?.copy(name = name, description = description) + ?.withMergedRelays(outboxRelayStrings) + ?: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData + .bootstrap( + nostrGroupId = nostrGroupId, + creatorPubKey = account.signer.pubKey, + outboxRelays = outboxRelayStrings, + name = name, + description = description, + ) val relays = marmotGroupRelays(nostrGroupId) account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 1616217aa..638376795 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -24,8 +24,8 @@ import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore import com.vitorpamplona.amethyst.commons.marmot.MarmotManager +import com.vitorpamplona.amethyst.commons.marmot.ingest import com.vitorpamplona.quartz.marmot.MarmotFilters -import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -72,6 +72,18 @@ class Context( websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp }, ) + /** + * NIP-05 resolver for turning `alice@damus.io`-style identifiers into pubkeys. + * Uses the same OkHttp instance as the WebSocket client so we share connection + * pools and TLS sessions. + */ + val nip05Client: com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client = + com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client( + fetcher = + com.vitorpamplona.quartz.nip05DnsIdentifiers + .OkHttpNip05Fetcher { _ -> okhttp }, + ) + private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir) private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile) private val messageStore = FileMarmotMessageStore(dataDir.groupsDir) @@ -93,6 +105,18 @@ class Context( prepared = true } + /** + * Resolve `npub…` / `nprofile…` / 64-hex / `name@domain.tld` to a pubkey hex. + * Delegates to the shared [resolveUserHexOrNull] in quartz so the UI and CLI + * accept the exact same identifier formats. Throws on unrecognised input — + * command handlers catch [IllegalArgumentException] at the top level and + * translate to `{"error": "bad_args"}`. + */ + suspend fun requireUserHex(input: String): com.vitorpamplona.quartz.nip01Core.core.HexKey = + com.vitorpamplona.quartz.nip05DnsIdentifiers + .resolveUserHexOrNull(input, nip05Client) + ?: throw IllegalArgumentException("Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)") + fun outboxRelays(): Set = relays.normalized("nip65") fun inboxRelays(): Set = relays.normalized("inbox") @@ -237,39 +261,18 @@ class Context( val maxGroupSeen = perGroupFilters.keys.associateWith { state.groupSince[it] ?: 0L }.toMutableMap() for ((relay, event) in events) { + // All the MLS/NIP-59 decryption + persistence lives in MarmotIngest — + // we only care about bookkeeping (since-cursors, logging) here. + val result = marmot.ingest(event) + System.err.println("[cli] ingest ${event.kind}/${event.id.take(8)} via $relay → ${result::class.simpleName}") + when (event.kind) { GiftWrapEvent.KIND -> { - // kind:1059 — try to unwrap. If it's a Welcome, let the - // inbound processor apply it; anything else is ignored. - val gw = event as? GiftWrapEvent ?: continue - try { - val inner = gw.unwrapOrNull(signer) ?: continue - if (inner.kind == WelcomeEvent.KIND && inner is WelcomeEvent) { - val hint = inner.nostrGroupId() - val res = marmot.processWelcome(inner, hint) - System.err.println("[cli] Welcome via $relay → $res") - } - } catch (e: Exception) { - System.err.println("[cli] failed to unwrap giftwrap ${event.id.take(8)}: ${e.message}") - } if (event.createdAt > maxGwSeen) maxGwSeen = event.createdAt } GroupEvent.KIND -> { - val ge = event as? GroupEvent ?: continue - val gid = ge.groupId() ?: continue - try { - val res = marmot.processGroupEvent(ge) - // Mirror DecryptAndIndexProcessor on Amethyst: application - // messages get persisted at decrypt-time, because MLS ratchet - // advancement makes the ciphertext un-re-decryptable later. - if (res is com.vitorpamplona.quartz.marmot.GroupEventResult.ApplicationMessage) { - marmot.persistDecryptedMessage(res.groupId, res.innerEventJson) - } - System.err.println("[cli] GroupEvent ${event.id.take(8)} → ${res::class.simpleName}") - } catch (e: Exception) { - System.err.println("[cli] processGroupEvent failed: ${e.message}") - } + val gid = (event as? GroupEvent)?.groupId() ?: continue val prev = maxGroupSeen[gid] ?: 0L if (event.createdAt > prev) maxGroupSeen[gid] = event.createdAt } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt index c5e87939a..35358b75f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.cli.AwaitTimeout import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json -import com.vitorpamplona.amethyst.cli.util.Npubs import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import kotlinx.coroutines.delay @@ -59,12 +58,12 @@ object AwaitCommands { rest: Array, ): Int { if (rest.isEmpty()) return Json.error("bad_args", "await key-package ") - val target = Npubs.resolveToHex(rest[0]) val args = Args(rest.drop(1).toTypedArray()) val timeoutSecs = args.longFlag("timeout", 30) val ctx = Context.open(dataDir) try { ctx.prepare() + val target = ctx.requireUserHex(rest[0]) val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target) val deadline = System.currentTimeMillis() + timeoutSecs * 1000 while (System.currentTimeMillis() < deadline) { @@ -129,7 +128,7 @@ object AwaitCommands { ): Int = pollCondition(dataDir, rest, "await member ", targetIdx = 1) { ctx, rawArgs -> val gid = rawArgs[0] - val target = Npubs.resolveToHex(rawArgs[1]) + val target = ctx.requireUserHex(rawArgs[1]) if (!ctx.marmot.isMember(gid)) { null } else if (ctx.marmot.memberPubkeys(gid).any { it.pubkey == target }) { @@ -145,7 +144,7 @@ object AwaitCommands { ): Int = pollCondition(dataDir, rest, "await admin ", targetIdx = 1) { ctx, rawArgs -> val gid = rawArgs[0] - val target = Npubs.resolveToHex(rawArgs[1]) + val target = ctx.requireUserHex(rawArgs[1]) if (!ctx.marmot.isMember(gid)) { null } else if (ctx.marmot diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt index 8c4114283..be96d891b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt @@ -23,11 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json -import com.vitorpamplona.amethyst.cli.util.Npubs -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi /** * `group add [ ...]` — fetch each invitee's @@ -37,40 +32,42 @@ import kotlin.io.encoding.ExperimentalEncodingApi * gift wrap. */ object GroupAddMemberCommand { - @OptIn(ExperimentalEncodingApi::class) suspend fun run( dataDir: DataDir, rest: Array, ): Int { if (rest.size < 2) return Json.error("bad_args", "group add [ ...]") val gid = rest[0] - val invitees = rest.drop(1).map { Npubs.resolveToHex(it) } val ctx = Context.open(dataDir) try { ctx.prepare() ctx.syncIncoming() if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + // Accept any identifier the UI would: npub1…, nprofile1…, 64-hex, + // NIP-05 (name@domain). Resolution fires NIP-05 HTTP fetches in parallel + // where applicable; bech32/hex stays fully offline. + val invitees = rest.drop(1).map { ctx.requireUserHex(it) } + val groupRelays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val report = mutableListOf>() for (pub in invitees) { - val filter = ctx.marmot.subscriptionManager.keyPackageFilter(pub) - val relays = ctx.anyRelays() - val filters = relays.associateWith { listOf(filter) } - val kpEvent = ctx.client.fetchFirst(filters = filters, timeoutMs = 10_000) - if (kpEvent == null || kpEvent !is KeyPackageEvent) { + val relays = + com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher + .fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays()) + val kpEvent = + com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher + .fetchKeyPackage(ctx.client, pub, relays, timeoutMs = 10_000) + if (kpEvent == null) { report.add(mapOf("pubkey" to pub, "status" to "no_key_package")) continue } - val kpBytes = Base64.decode(kpEvent.keyPackageBase64()) val (commitEvent, welcomeDelivery) = ctx.marmot.addMember( nostrGroupId = gid, - memberPubKey = pub, - keyPackageBytes = kpBytes, - keyPackageEventId = kpEvent.id, + keyPackageEvent = kpEvent, relays = groupRelays.toList(), ) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt index 12d64b33e..35c6d214f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt @@ -42,16 +42,14 @@ object GroupCreateCommand { ctx.marmot.createGroup(gid) - // Stamp initial metadata: name + our outbox relays + self as admin. - // Mirrors CreateGroupScreen.proceedWithCreate() on the Amethyst side. + // Stamp initial metadata via the shared factory so UI + CLI stay byte-identical. val outboxUrls = ctx.outboxRelays().map { it.url } val metadata = - MarmotGroupData( + MarmotGroupData.bootstrap( nostrGroupId = gid, + creatorPubKey = ctx.identity.pubKeyHex, + outboxRelays = outboxUrls, name = name, - description = "", - adminPubkeys = listOf(ctx.identity.pubKeyHex), - relays = outboxUrls, ) val commit = ctx.marmot.updateGroupMetadata(gid, metadata) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt index 8c5b8d95b..a67cc62e8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json -import com.vitorpamplona.amethyst.cli.util.Npubs object GroupMembershipCommands { suspend fun remove( @@ -32,26 +31,25 @@ object GroupMembershipCommands { ): Int { if (rest.size < 2) return Json.error("bad_args", "group remove ") val gid = rest[0] - val target = Npubs.resolveToHex(rest[1]) val ctx = Context.open(dataDir) try { ctx.prepare() + val target = ctx.requireUserHex(rest[1]) ctx.syncIncoming() if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) - val member = - ctx.marmot.memberPubkeys(gid).firstOrNull { it.pubkey == target } + val leafIndex = + ctx.marmot.leafIndexOf(gid, target) ?: return Json.error("not_in_group", target) - val outbound = - ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = member.leafIndex) + val outbound = ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = leafIndex) val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(outbound.signedEvent, targets) Json.writeLine( mapOf( "group_id" to gid, "removed" to target, - "leaf_index" to member.leafIndex, + "leaf_index" to leafIndex, "epoch" to ctx.marmot.groupEpoch(gid), "commit_event_id" to outbound.signedEvent.id, "published_to" to ack.filterValues { it }.keys.map { it.url }, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt index 44d7b08a8..0fb3fa106 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json -import com.vitorpamplona.amethyst.cli.util.Npubs import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -37,7 +36,7 @@ object GroupMetadataCommands { rest: Array, ): Int { if (rest.size < 2) return Json.error("bad_args", "group rename ") - return edit(dataDir, rest[0]) { it.copy(name = rest[1]) } + return edit(dataDir, rest[0]) { _, cur -> cur.copy(name = rest[1]) } } suspend fun promote( @@ -45,8 +44,8 @@ object GroupMetadataCommands { rest: Array, ): Int { if (rest.size < 2) return Json.error("bad_args", "group promote ") - val newAdmin = Npubs.resolveToHex(rest[1]) - return edit(dataDir, rest[0]) { cur -> + return edit(dataDir, rest[0]) { ctx, cur -> + val newAdmin = ctx.requireUserHex(rest[1]) val admins = cur.adminPubkeys.toMutableList() if (newAdmin !in admins) admins.add(newAdmin) cur.copy(adminPubkeys = admins) @@ -58,8 +57,8 @@ object GroupMetadataCommands { rest: Array, ): Int { if (rest.size < 2) return Json.error("bad_args", "group demote ") - val target = Npubs.resolveToHex(rest[1]) - return edit(dataDir, rest[0]) { cur -> + return edit(dataDir, rest[0]) { ctx, cur -> + val target = ctx.requireUserHex(rest[1]) val admins = cur.adminPubkeys.filter { it != target } cur.copy(adminPubkeys = admins) } @@ -68,23 +67,22 @@ object GroupMetadataCommands { private suspend fun edit( dataDir: DataDir, gid: HexKey, - mutate: (MarmotGroupData) -> MarmotGroupData, + mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData, ): Int { val ctx = Context.open(dataDir) try { ctx.prepare() ctx.syncIncoming() if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + val outboxUrls = ctx.outboxRelays().map { it.url } val cur = ctx.marmot.groupMetadata(gid) - ?: MarmotGroupData( + ?: MarmotGroupData.bootstrap( nostrGroupId = gid, - name = "", - description = "", - adminPubkeys = listOf(ctx.identity.pubKeyHex), - relays = ctx.outboxRelays().map { it.url }, + creatorPubKey = ctx.identity.pubKeyHex, + outboxRelays = outboxUrls, ) - val updated = mutate(cur) + val updated = mutate(ctx, cur).withMergedRelays(outboxUrls) val commit = ctx.marmot.updateGroupMetadata(gid, updated) val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt index 8524a4d73..3f647a4d2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt @@ -23,9 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json -import com.vitorpamplona.amethyst.cli.util.Npubs -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst object KeyPackageCommands { suspend fun dispatch( @@ -68,16 +65,20 @@ object KeyPackageCommands { rest: Array, ): Int { if (rest.isEmpty()) return Json.error("bad_args", "key-package check ") - val targetHex = Npubs.resolveToHex(rest[0]) val ctx = Context.open(dataDir) try { ctx.prepare() - val filter = ctx.marmot.subscriptionManager.keyPackageFilter(targetHex) - val relays = ctx.anyRelays() + val targetHex = ctx.requireUserHex(rest[0]) + // CLI doesn't (yet) cache target's kind:10051/10002 — just ask every + // configured relay. Amethyst, which does cache those, passes them in. + val relays = + com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher + .fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays()) if (relays.isEmpty()) return Json.error("no_relays", "configure relays first") - val filtersByRelay = relays.associateWith { listOf(filter) } - val event = ctx.client.fetchFirst(filters = filtersByRelay, timeoutMs = 10_000) - if (event == null || event !is KeyPackageEvent) { + val event = + com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher + .fetchKeyPackage(ctx.client, targetHex, relays, timeoutMs = 10_000) + if (event == null) { return Json.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)") } Json.writeLine( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt index 101d5eca2..f2f2a9fcd 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Json -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate object MessageCommands { suspend fun dispatch( @@ -54,24 +53,16 @@ object MessageCommands { ctx.syncIncoming() if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) - val template = - eventTemplate(kind = 9, description = text) - val innerEvent = ctx.signer.sign(template) - val outbound = ctx.marmot.buildGroupMessage(gid, innerEvent) - - // Persist our own outbound inner event so `message list` includes it - // alongside remote messages. - ctx.marmot.persistDecryptedMessage(gid, innerEvent.toJson()) - + val bundle = ctx.marmot.buildTextMessage(gid, text) val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } - val ack = ctx.publish(outbound.signedEvent, targets) + val ack = ctx.publish(bundle.outbound.signedEvent, targets) Json.writeLine( mapOf( "group_id" to gid, - "inner_event_id" to innerEvent.id, - "outer_event_id" to outbound.signedEvent.id, - "kind" to innerEvent.kind, + "inner_event_id" to bundle.innerEvent.id, + "outer_event_id" to bundle.outbound.signedEvent.id, + "kind" to bundle.innerEvent.kind, "published_to" to ack.filterValues { it }.keys.map { it.url }, ), ) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/util/Npubs.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/util/Npubs.kt deleted file mode 100644 index 3796fc1d7..000000000 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/util/Npubs.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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.util - -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes - -/** Accept either an `npub1…` or a raw 64-hex pubkey; always return hex. */ -object Npubs { - fun resolveToHex(input: String): String { - val trimmed = input.trim() - // Already hex? - if (trimmed.length == 64 && trimmed.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) { - return trimmed.lowercase() - } - if (trimmed.startsWith("npub1") || trimmed.startsWith("npub")) { - return trimmed.bechToBytes().toHexKey() - } - throw IllegalArgumentException("expected npub or 64-hex pubkey, got: $input") - } -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotIngest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotIngest.kt new file mode 100644 index 000000000..2caec99ab --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotIngest.kt @@ -0,0 +1,148 @@ +/* + * 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.commons.marmot + +import com.vitorpamplona.quartz.marmot.GroupEventResult +import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor +import com.vitorpamplona.quartz.marmot.WelcomeResult +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * Outcome of routing a single inbound Nostr event through the Marmot pipeline. + * + * Drives both Amethyst's `DecryptAndIndexProcessor.GroupEventHandler` and the + * CLI's `Context.syncIncoming`. Platform-specific side effects (UI notifications, + * "mark as known", cache hints) happen at the call site based on which variant + * came back; the ingest method handles only the MLS/crypto parts that are the + * same everywhere. + */ +sealed class MarmotIngestResult { + /** A Welcome arrived and we successfully joined a new group. */ + data class JoinedGroup( + val nostrGroupId: HexKey, + val needsKeyPackageRotation: Boolean, + ) : MarmotIngestResult() + + /** A Welcome for a group we're already in — benign replay. */ + data class AlreadyInGroup( + val nostrGroupId: HexKey, + ) : MarmotIngestResult() + + /** A kind:445 carried an application message we decrypted. Already persisted. */ + data class Message( + val inner: GroupEventResult.ApplicationMessage, + ) : MarmotIngestResult() + + /** A kind:445 carried a commit that advanced the group epoch. */ + data class Commit( + val inner: GroupEventResult.CommitProcessed, + ) : MarmotIngestResult() + + /** A kind:445 whose outer layer we couldn't decrypt (pre-join epoch, etc). Debug-only. */ + data class UndecryptableOuter( + val groupId: HexKey, + val retainedEpochCount: Int, + ) : MarmotIngestResult() + + /** Deduplicate / out-of-order commits / unsupported content. Not an error. */ + data object Ignored : MarmotIngestResult() + + /** Something blew up. Callers log. */ + data class Failure( + val message: String, + val cause: Throwable? = null, + ) : MarmotIngestResult() +} + +/** + * Route a single event through the Marmot inbound pipeline. + * + * Kind 1059 (gift wraps): unwrap → if inner kind:444, process Welcome. + * Kind 445 (group events): decrypt + process; on [GroupEventResult.ApplicationMessage], + * persist the decrypted inner JSON so [MarmotManager.loadStoredMessages] sees it. + * + * Other kinds are returned as [MarmotIngestResult.Ignored] — callers decide + * what to do with them (most route through their own feed ingestion). + */ +suspend fun MarmotManager.ingest(event: Event): MarmotIngestResult = + when (event) { + is GiftWrapEvent -> ingestGiftWrap(event) + is GroupEvent -> ingestGroupEvent(event) + else -> MarmotIngestResult.Ignored + } + +private suspend fun MarmotManager.ingestGiftWrap(wrap: GiftWrapEvent): MarmotIngestResult = + try { + val inner = wrap.unwrapOrNull(signer) ?: return MarmotIngestResult.Ignored + if (!MarmotInboundProcessor.isWelcomeEvent(inner) || inner !is WelcomeEvent) { + return MarmotIngestResult.Ignored + } + when (val result = processWelcome(inner, inner.nostrGroupId())) { + is WelcomeResult.Joined -> { + MarmotIngestResult.JoinedGroup( + nostrGroupId = result.nostrGroupId, + needsKeyPackageRotation = result.needsKeyPackageRotation, + ) + } + + is WelcomeResult.AlreadyJoined -> { + MarmotIngestResult.AlreadyInGroup(result.nostrGroupId) + } + + is WelcomeResult.Error -> { + MarmotIngestResult.Failure(result.message, result.cause) + } + } + } catch (e: Exception) { + MarmotIngestResult.Failure("giftwrap unwrap failed: ${e.message}", e) + } + +private suspend fun MarmotManager.ingestGroupEvent(ge: GroupEvent): MarmotIngestResult = + when (val result = processGroupEvent(ge)) { + is GroupEventResult.ApplicationMessage -> { + // MLS ratchets once we decrypt; future reads of the same ciphertext + // would fail — persist the plaintext now so restarts/replays see it. + persistDecryptedMessage(result.groupId, result.innerEventJson) + MarmotIngestResult.Message(result) + } + + is GroupEventResult.CommitProcessed -> { + MarmotIngestResult.Commit(result) + } + + is GroupEventResult.Duplicate, + is GroupEventResult.CommitPending, + -> { + MarmotIngestResult.Ignored + } + + is GroupEventResult.UndecryptableOuterLayer -> { + MarmotIngestResult.UndecryptableOuter(result.groupId, result.retainedEpochCount) + } + + is GroupEventResult.Error -> { + MarmotIngestResult.Failure(result.message, result.cause) + } + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index a7410e557..cac1eed8e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -154,6 +154,56 @@ class MarmotManager( innerEvent: Event, ): OutboundGroupEvent = outboundProcessor.buildGroupEvent(nostrGroupId, innerEvent) + /** + * Build a kind:9 chat-message GroupEvent from plain text. The inner event is + * signed with this manager's signer and optionally persisted to the local + * decrypted-message log so `loadStoredMessages` reflects our own outbound + * immediately (without waiting for relay loopback). + * + * Platform callers that already maintain their own "own event" cache (i.e. + * Amethyst's `LocalCache.justConsumeMyOwnEvent`) should pass `persistOwn = false`. + * Headless callers (CLI) should leave it at the default. + * + * @return the signed kind:445 outer event together with the inner kind:9 + * event id, so the caller can reference it for replies/reactions. + */ + suspend fun buildTextMessage( + nostrGroupId: HexKey, + text: String, + persistOwn: Boolean = true, + ): TextMessageBundle { + val template = + com.vitorpamplona.quartz.nip01Core.signers + .eventTemplate(kind = 9, description = text) + val innerEvent = signer.sign(template) + val outbound = buildGroupMessage(nostrGroupId, innerEvent) + if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) + return TextMessageBundle(outbound = outbound, innerEvent = innerEvent) + } + + /** + * Add a member to a group by consuming their published [KeyPackageEvent]. + * + * Convenience over [addMember] that handles base64 decoding and lifts the + * event id into the WelcomeDelivery. Prefer this overload — both the UI's + * `Account.addMarmotGroupMember` and the CLI's `group add` command call it. + */ + @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) + suspend fun addMember( + nostrGroupId: HexKey, + keyPackageEvent: KeyPackageEvent, + relays: List, + ): Pair = + addMember( + nostrGroupId = nostrGroupId, + memberPubKey = keyPackageEvent.pubKey, + keyPackageBytes = + kotlin.io.encoding.Base64 + .decode(keyPackageEvent.keyPackageBase64()), + keyPackageEventId = keyPackageEvent.id, + relays = relays, + ) + /** * Add a member to a group. * Returns the commit GroupEvent to publish, and the WelcomeDelivery for the new member. @@ -430,6 +480,19 @@ class MarmotManager( */ fun groupEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch + /** + * Resolve the MLS leaf index for a member by Nostr pubkey, or null if that + * pubkey isn't currently in the group. + * + * `removeMember` needs a leaf index; every caller (UI remove-member dialog + * and CLI `group remove`) previously did its own pubkey→leaf lookup through + * [memberPubkeys]. Centralised here so the scan lives in one place. + */ + fun leafIndexOf( + nostrGroupId: HexKey, + pubKey: HexKey, + ): Int? = memberPubkeys(nostrGroupId).firstOrNull { it.pubkey == pubKey }?.leafIndex + /** * Get the MIP-04 media exporter secret for a group. * MLS-Exporter("marmot", "encrypted-media", 32) @@ -477,3 +540,13 @@ data class GroupMemberInfo( val leafIndex: Int, val pubkey: HexKey, ) + +/** + * Result of [MarmotManager.buildTextMessage]: the signed outer kind:445 event + * to publish on group relays, plus the inner kind:9 event (for callers that + * need the inner id to reference it in replies or reactions). + */ +data class TextMessageBundle( + val outbound: OutboundGroupEvent, + val innerEvent: Event, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFetcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFetcher.kt new file mode 100644 index 000000000..a68a46db2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFetcher.kt @@ -0,0 +1,91 @@ +/* + * 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.quartz.marmot.mip00KeyPackages + +import com.vitorpamplona.quartz.marmot.MarmotFilters +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Discovery helpers for MIP-00 KeyPackages. + * + * Pulled out of Amethyst's `Account.fetchKeyPackageAndAddMember` so the CLI + * (and any future non-Android caller) does not re-implement the same union- + * of-relays logic when inviting a user to a Marmot group. + * + * This is the lowest-useful layer — it knows *nothing* about how callers + * discover the target user's kind:10051 (KeyPackage Relay List) or kind:10002 + * (NIP-65 outbox) — those live in platform-specific caches. Callers collect + * those sets themselves and pass them in. + */ +object KeyPackageFetcher { + /** + * Union of the three relay sets we'd ever want to query for a given user's + * KeyPackage, in priority order of specificity: + * + * 1. target's kind:10051 KeyPackage Relay List (most authoritative) + * 2. target's kind:10002 NIP-65 outbox (where the user publishes in general) + * 3. our own outbox (shared-relay fallback — someone publishing and us + * reading often overlap here) + */ + fun fetchRelaysFor( + targetKeyPackageRelays: Collection, + targetOutbox: Collection, + myOutbox: Collection, + ): Set = + buildSet { + addAll(targetKeyPackageRelays) + addAll(targetOutbox) + addAll(myOutbox) + } + + /** + * One-shot fetch of a user's KeyPackage across [relays], returning the first + * matching event or `null` if none of the relays yielded one before timeout. + */ + suspend fun fetchKeyPackage( + client: INostrClient, + targetPubKey: HexKey, + relays: Set, + timeoutMs: Long = 30_000, + ): KeyPackageEvent? { + if (relays.isEmpty()) return null + val filter = MarmotFilters.keyPackagesByAuthor(targetPubKey) + val event = client.fetchFirst(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs) + return event as? KeyPackageEvent + } + + /** + * Resolve which relays this account should publish its OWN KeyPackage to. + * + * Per MIP-00, a user's KeyPackages SHOULD live on the relays listed in their + * kind:10051 KeyPackageRelayListEvent. If they haven't published one yet, + * fall back to their NIP-65 outbox — that's where their other write-oriented + * events land and it keeps discovery working in the common "just starting out" + * case. + */ + fun publishRelaysFor( + keyPackageRelayList: Collection, + myOutbox: Collection, + ): Set = if (keyPackageRelayList.isNotEmpty()) keyPackageRelayList.toSet() else myOutbox.toSet() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt index 3d89264be..142b8fd5d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt @@ -111,6 +111,21 @@ data class MarmotGroupData( /** Whether this group has an encrypted image set */ fun hasImage(): Boolean = imageHash != null && imageKey != null && imageNonce != null + /** + * Return a copy with [newRelays] unioned into [relays], de-duplicated and order-preserving. + * + * Every metadata-updating commit produced by the group creator or an admin SHOULD fold + * its author's own outbox into [relays] so new invitees learn a single canonical relay + * set for kind:445 from the Welcome, even when the inviter's outbox has drifted since + * the last commit. Both the UI and the CLI need this same merge rule — hence living + * on the data class. + */ + fun withMergedRelays(newRelays: Collection): MarmotGroupData { + if (newRelays.isEmpty()) return this + val merged = (relays + newRelays).distinct() + return if (merged == relays) this else copy(relays = merged) + } + /** * Encode this MarmotGroupData to TLS wire format bytes. * Mirrors the [decodeTls] format. @@ -185,6 +200,30 @@ data class MarmotGroupData( const val EXTENSION_ID: UShort = 0xF2EEu const val EXTENSION_ID_INT: Int = 0xF2EE + /** + * Build a freshly-minted [MarmotGroupData] for a group with no prior metadata — + * i.e. right after `MlsGroupManager.createGroup`. Creator becomes the sole admin + * and their outbox relays are stamped as the group's relay set. + * + * Both the Android UI (`AccountViewModel.updateMarmotGroupMetadata`) and the + * headless CLI need this same initial shape; keeping the factory here avoids + * subtle drift between them. + */ + fun bootstrap( + nostrGroupId: HexKey, + creatorPubKey: HexKey, + outboxRelays: Collection, + name: String = "", + description: String = "", + ): MarmotGroupData = + MarmotGroupData( + nostrGroupId = nostrGroupId, + name = name, + description = description, + adminPubkeys = listOf(creatorPubKey), + relays = outboxRelays.distinct(), + ) + /** * Find and decode the MarmotGroupData extension from a list of MLS extensions. * Returns null if no extension with type 0xF2EE is present or if decoding fails. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/UserHexResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/UserHexResolver.kt new file mode 100644 index 000000000..63b0add37 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/UserHexResolver.kt @@ -0,0 +1,79 @@ +/* + * 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.quartz.nip05DnsIdentifiers + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import kotlinx.coroutines.CancellationException + +/** + * Resolve any user identifier the UI or a CLI might throw at us to a 64-hex + * Nostr pubkey. Accepts: + * + * - raw 64-hex pubkey + * - bech32 `npub1…` + * - bech32 `nprofile1…` (extracts the pubkey component) + * - bech32 `nsec1…` (derives the public key from the secret) + * - NIP-05 internet identifier (`name@domain.tld` or bare domain) + * + * Returns null when the input doesn't match any of the above or when a NIP-05 + * lookup fails (network error, no match on server). Throws only on + * [CancellationException] so callers can still use structured concurrency. + * + * Pass a non-null [nip05Client] to enable NIP-05 resolution. When it's null, + * NIP-05-shaped inputs fall through and this returns null — that's the right + * behaviour for call sites that don't have HTTP access (e.g. a pure-offline + * context). + */ +suspend fun resolveUserHexOrNull( + input: String, + nip05Client: INip05Client? = null, +): HexKey? { + val trimmed = input.trim() + if (trimmed.isEmpty()) return null + + // First try the bech32/hex path — it's pure and synchronous. + decodePublicKeyAsHexOrNull(trimmed)?.let { return it } + + // NIP-05: name@domain.tld (bare `@domain` = root "_" account). + if (looksLikeNip05(trimmed) && nip05Client != null) { + try { + val id = Nip05Id.parse(trimmed) ?: return null + return nip05Client.get(id)?.pubkey + } catch (e: Exception) { + if (e is CancellationException) throw e + return null + } + } + return null +} + +/** + * Cheap precheck — a plausible NIP-05 identifier has an `@` followed by at + * least one dot. Keeps us from issuing HTTP fetches on obvious non-matches + * like hex strings or single bech32 tokens. + */ +internal fun looksLikeNip05(value: String): Boolean { + val at = value.indexOf('@') + if (at <= 0 || at == value.length - 1) return false + val afterAt = value.substring(at + 1) + return afterAt.contains('.') && !afterAt.contains('@') +} From 39e11d29c1524a1c51952dc4a48f357d6342fcb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 21:18:48 +0000 Subject: [PATCH 03/11] feat(marmot-interop): add zero-prompt headless harness driving `amy` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the 13 scenarios in marmot-interop.sh but runs A via the new `amy` CLI instead of prompting the human. Identities B and C still go through `wn`/`wnd` from whitenoise-rs. Layout: - marmot-interop-headless.sh — top-level entrypoint - headless/setup.sh — preflight, daemon lifecycle, identities - headless/helpers.sh — amy wrappers + assertion helpers - headless/tests-create.sh — tests 01..05 - headless/tests-manage.sh — tests 06, 07, 08, 11 - headless/tests-extras.sh — tests 09, 10, 12, 13 Reuses lib.sh for colours, record_result, wait_for_invite/message, jq_group_id, and dump_daemon_diagnostics. Keeps the interactive marmot-interop.sh intact for final UI sign-off. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq --- tools/marmot-interop/.gitignore | 1 + tools/marmot-interop/README.md | 21 ++- tools/marmot-interop/headless/helpers.sh | 55 ++++++ tools/marmot-interop/headless/setup.sh | 144 ++++++++++++++ tools/marmot-interop/headless/tests-create.sh | 175 ++++++++++++++++++ tools/marmot-interop/headless/tests-extras.sh | 172 +++++++++++++++++ tools/marmot-interop/headless/tests-manage.sh | 152 +++++++++++++++ .../marmot-interop/marmot-interop-headless.sh | 98 ++++++++++ 8 files changed, 811 insertions(+), 7 deletions(-) create mode 100644 tools/marmot-interop/headless/helpers.sh create mode 100644 tools/marmot-interop/headless/setup.sh create mode 100644 tools/marmot-interop/headless/tests-create.sh create mode 100644 tools/marmot-interop/headless/tests-extras.sh create mode 100644 tools/marmot-interop/headless/tests-manage.sh create mode 100755 tools/marmot-interop/marmot-interop-headless.sh diff --git a/tools/marmot-interop/.gitignore b/tools/marmot-interop/.gitignore index dc3b76b28..1bcd48904 100644 --- a/tools/marmot-interop/.gitignore +++ b/tools/marmot-interop/.gitignore @@ -1 +1,2 @@ state/ +state-headless/ diff --git a/tools/marmot-interop/README.md b/tools/marmot-interop/README.md index b382e79d2..f5fa97e0f 100644 --- a/tools/marmot-interop/README.md +++ b/tools/marmot-interop/README.md @@ -1,13 +1,20 @@ # Marmot Interop Test Harness -Interactive harness that validates Amethyst's Marmot/MLS implementation against -**whitenoise-rs** (https://github.com/marmot-protocol/whitenoise-rs), the -reference Rust implementation that powers the White Noise Flutter app. +Two flavours, same scenarios: -The harness drives two `wn` daemons (Identities **B** and **C**) from the -command line and prompts the human operator to perform the Amethyst-side steps -in the mobile UI as **Identity A**. Every test records a pass/fail/skip result -into a tab-separated log, and the summary is printed at the end of the run. +- **`marmot-interop.sh`** — interactive. Drives B/C via `wn` and **prompts the + human** to perform each Amethyst-side step in the mobile UI (Identity A). + Use this for final UI verification. +- **`marmot-interop-headless.sh`** — zero prompts. Drives A via the `amy` CLI + (`./gradlew :cli:installDist`) and B/C via `wn`. Runs every scenario + end-to-end and exits with a pass/fail summary. Use this for CI and for + iterating on the Nostr/Marmot plumbing without needing to touch a phone. + +Both harnesses validate Amethyst against **whitenoise-rs** +(https://github.com/marmot-protocol/whitenoise-rs), the reference Rust +implementation that powers the White Noise Flutter app. Every test records a +pass/fail/skip result into a tab-separated log, and the summary is printed at +the end of the run. ## What gets tested diff --git a/tools/marmot-interop/headless/helpers.sh b/tools/marmot-interop/headless/helpers.sh new file mode 100644 index 000000000..7a7e10a65 --- /dev/null +++ b/tools/marmot-interop/headless/helpers.sh @@ -0,0 +1,55 @@ +# shellcheck shell=bash +# +# headless/helpers.sh — thin wrappers that keep the per-test code tight. + +# --- amy wrapper ------------------------------------------------------------- +amy_a() { "$AMY_BIN" --data-dir "$A_DIR" "$@"; } + +# Run amy, log stderr, surface JSON on stdout, remember last result. +amy_json() { + local out + if ! out=$(amy_a "$@" 2>>"$LOG_FILE"); then + fail_msg "amy $*: exit $? (see $LOG_FILE)" + printf '%s\n' "$out" >>"$LOG_FILE" + return 1 + fi + printf '%s' "$out" +} + +# Convenience extractors — the CLI emits one JSON object per success so we can +# jq with impunity. +amy_field() { + # usage: amy_field '.group_id' init [args...] + local path="$1"; shift + amy_json "$@" | jq -r "$path" +} + +# --- assertion helpers ------------------------------------------------------- +# Assert a substring is present in a variable; append a failed result on miss +# and return 1. Positive case just logs. +assert_contains() { + local haystack="$1" needle="$2" test_id="$3" note="${4:-}" + if [[ "$haystack" == *"$needle"* ]]; then + info "assertion hit: $test_id contains \"$needle\"" + return 0 + fi + fail_msg "$test_id: missing \"$needle\" (${note:-no note})" + info "actual: $haystack" + record_result "$test_id" fail "${note:-missing \"$needle\"}" + return 1 +} + +# Assert two strings are equal (leniently trimmed). +assert_eq() { + local actual="$1" expected="$2" test_id="$3" note="${4:-}" + if [[ "${actual// /}" == "${expected// /}" ]]; then + info "assertion hit: $test_id \"$actual\" == \"$expected\"" + return 0 + fi + fail_msg "$test_id: expected \"$expected\", got \"$actual\" (${note:-})" + record_result "$test_id" fail "${note:-mismatch}" + return 1 +} + +# --- wn-side pollers (delegates to lib.sh) ----------------------------------- +# Both exist in lib.sh already; this file only adds headless-specific niceties. diff --git a/tools/marmot-interop/headless/setup.sh b/tools/marmot-interop/headless/setup.sh new file mode 100644 index 000000000..686af8be1 --- /dev/null +++ b/tools/marmot-interop/headless/setup.sh @@ -0,0 +1,144 @@ +# shellcheck shell=bash +# +# headless/setup.sh — preflight + daemon lifecycle + identity bootstrap. +# Sourced from marmot-interop-headless.sh. + +# --- preflight --------------------------------------------------------------- +preflight() { + banner "Preflight" + for cmd in jq git curl cargo; do + if ! command -v "$cmd" >/dev/null 2>&1; then + fail_msg "missing required tool: $cmd"; exit 1 + fi + info "$cmd: $(command -v "$cmd")" + done + + # Build `amy` via gradle if missing. + if [[ ! -x "$AMY_BIN" ]]; then + if [[ "$NO_BUILD" -eq 1 ]]; then + fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1 + fi + step "building :cli:installDist" + ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" + fi + [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; } + info "amy: $AMY_BIN" + + # Clone/build whitenoise-rs if needed (shared between both harnesses). + if [[ ! -x "$WN_BIN" || ! -x "$WND_BIN" ]]; then + if [[ "$NO_BUILD" -eq 1 ]]; then + fail_msg "wn/wnd not found and --no-build set"; exit 1 + fi + if [[ ! -d "$WN_REPO/.git" ]]; then + step "cloning whitenoise-rs into $WN_REPO" + git clone --depth 1 https://github.com/marmot-protocol/whitenoise-rs.git "$WN_REPO" \ + 2>&1 | tee -a "$LOG_FILE" + fi + step "building wn + wnd (~5 min first run)" + ( cd "$WN_REPO" && cargo build --release --features cli --bin wn --bin wnd ) \ + 2>&1 | tee -a "$LOG_FILE" + fi + info "wn: $WN_BIN" + info "wnd: $WND_BIN" +} + +# --- daemons ----------------------------------------------------------------- +start_daemon() { + local name="$1" data_dir="$2" socket="$3" + step "starting $name daemon" + if [[ -S "$socket" ]] && "$WN_BIN" --socket "$socket" whoami >/dev/null 2>&1; then + info "$name daemon already running"; return 0 + fi + rm -f "$socket" + mkdir -p "$data_dir/logs" "$data_dir/release" + nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \ + >"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" & + echo "$!" > "$data_dir/pid" + local deadline=$(( $(date +%s) + 30 )) + while [[ $(date +%s) -lt $deadline ]]; do + if [[ -S "$socket" ]] && "$WN_BIN" --socket "$socket" whoami >/dev/null 2>&1; then + info "$name ready"; return 0 + fi + sleep 1 + done + fail_msg "$name daemon failed to start (see $data_dir/logs/stderr.log)" + exit 1 +} + +stop_daemons() { + for d in "$B_DIR" "$C_DIR"; do + if [[ -f "$d/pid" ]]; then + local pid; pid=$(cat "$d/pid" 2>/dev/null || echo "") + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + info "stopping daemon pid $pid" + kill "$pid" 2>/dev/null || true + sleep 1 + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$d/pid" + fi + done +} + +# --- identities -------------------------------------------------------------- +ensure_identity_a() { + step "initialising Identity A (amy)" + local out + out=$(amy_a init) || { fail_msg "amy init failed: $out"; exit 1; } + A_NPUB=$(printf '%s' "$out" | jq -r '.npub') + A_HEX=$(printf '%s' "$out" | jq -r '.hex') + info "A npub: $A_NPUB" + info "A hex: $A_HEX" +} + +ensure_identity() { + local who="$1" cmd npub="" + if [[ "$who" == "B" ]]; then cmd=wn_b; else cmd=wn_c; fi + step "ensuring Identity $who (wn)" + + local raw + raw=$("$cmd" --json whoami 2>/dev/null || true) + npub=$(extract_pubkey "$raw") + if [[ -z "${npub:-}" ]]; then + raw=$("$cmd" create-identity 2>&1 | tee -a "$LOG_FILE") + npub=$(extract_pubkey "$raw") + [[ -n "$npub" ]] || npub=$(extract_pubkey "$("$cmd" whoami 2>/dev/null || true)") + fi + [[ -n "$npub" ]] || { fail_msg "could not determine $who npub"; exit 1; } + + local hex; hex=$(npub_to_hex "$npub") + if [[ "$who" == "B" ]]; then B_NPUB="$npub"; B_HEX="$hex" + else C_NPUB="$npub"; C_HEX="$hex"; fi + info "$who npub: $npub" + [[ "$hex" != "$npub" ]] && info "$who hex: $hex" +} + +# --- relays ------------------------------------------------------------------ +configure_relays() { + banner "Configuring relays" + local relays=() + if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then + relays=( "ws://localhost:8080" ) + else + relays=( "${DEFAULT_RELAYS[@]}" ) + fi + + for r in "${relays[@]}"; do + step "adding $r to A/B/C" + amy_a relay add "$r" --type all >/dev/null + for t in nip65 inbox key_package; do + wn_b relays add --type "$t" "$r" 2>/dev/null || true + wn_c relays add --type "$t" "$r" 2>/dev/null || true + done + done + + # A advertises its NIP-65 + DM inbox lists so B/C can discover where to + # deliver gift wraps. Without this, cross-client welcomes only work + # through relay-set overlap — fine for this harness but still worth + # publishing so we catch regressions in the advertise path. + step "publishing A's NIP-65 + kind:10050 lists" + amy_a relay publish-lists >>"$LOG_FILE" 2>&1 || warn "amy relay publish-lists failed" + + step "publishing A's KeyPackage" + amy_a marmot key-package publish >>"$LOG_FILE" 2>&1 || warn "amy marmot key-package publish failed" +} diff --git a/tools/marmot-interop/headless/tests-create.sh b/tools/marmot-interop/headless/tests-create.sh new file mode 100644 index 000000000..702d6cdab --- /dev/null +++ b/tools/marmot-interop/headless/tests-create.sh @@ -0,0 +1,175 @@ +# shellcheck shell=bash +# +# headless/tests-create.sh — tests 01..05. +# Focus: KeyPackage discovery, group creation, invites, initial messages. + +test_01_keypackage_discovery() { + banner "Test 01 — KeyPackage publish & discovery (MIP-00)" + local id="01 KeyPackage A<->B" + + # B finds A's KP + local raw ev + raw=$(wn_b --json keys check "$A_NPUB" 2>>"$LOG_FILE" || true) + ev=$(printf '%s' "$raw" | jq -r '.result.event_id // .event_id // empty') + if [[ -z "$ev" || "$ev" == "null" ]]; then + record_result "$id (B->A)" fail "wn couldn't find A's KP"; return + fi + info "B saw A's KP $ev" + + # A finds B's KP (wn publishes automatically via create-identity flow? if not, ask). + wn_b keys publish >>"$LOG_FILE" 2>&1 || warn "wn_b keys publish failed" + sleep 3 + local out + out=$(amy_json marmot key-package check "$B_NPUB" 2>/dev/null || true) + if [[ -z "$out" ]]; then + record_result "$id (A->B)" fail "amy couldn't find B's KP"; return + fi + info "A saw B's KP $(printf '%s' "$out" | jq -r .event_id)" + + record_result "$id" pass +} + +test_02_a_creates_group() { + banner "Test 02 — A creates group, invites B" + local id="02 A->B create+invite" + + local gid + gid=$(amy_field '.group_id' marmot group create --name "Interop-02") || { + record_result "$id" fail "create returned no group_id"; return + } + save_state GROUP_02 "$gid" + info "created group $gid" + + amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { + record_result "$id" fail "amy group add failed"; return + } + + # B waits for invite, accepts. + local b_gid + if ! b_gid=$(wait_for_invite B 60); then + record_result "$id" fail "B never received invite"; return + fi + wn_b groups accept "$b_gid" >/dev/null 2>&1 || true + info "B joined $b_gid" + + # A -> B message + amy_json marmot message send "$gid" "hello from amethyst" >/dev/null || { + record_result "$id" fail "amy send failed"; return + } + if ! wait_for_message B "$gid" "hello from amethyst" 30; then + record_result "$id" fail "B didn't receive A's message"; return + fi + + # B -> A message + wn_b messages send "$gid" "hello from wn" >/dev/null 2>&1 || warn "wn send returned nonzero" + if ! amy_json marmot await message "$gid" --match "hello from wn" --timeout 30 >/dev/null; then + record_result "$id" fail "A didn't receive B's reply"; return + fi + record_result "$id" pass +} + +test_03_b_creates_group() { + banner "Test 03 — B creates group, invites A" + local id="03 B->A create+invite" + + local out gid + out=$(wn_b --json groups create "Interop-03" "$A_NPUB" 2>>"$LOG_FILE") || { + record_result "$id" fail "wn groups create failed"; return + } + gid=$(printf '%s' "$out" | jq_group_id) + if [[ -z "$gid" ]]; then + record_result "$id" fail "could not parse group_id"; return + fi + save_state GROUP_03 "$gid" + info "group_id: $gid" + + # A: poll until it joins. + if ! amy_json marmot await group --name "Interop-03" --timeout 30 >/dev/null; then + record_result "$id" fail "A never joined B's group"; return + fi + + # B -> A message + wn_b messages send "$gid" "ping from wn" >/dev/null 2>&1 || true + if ! amy_json marmot await message "$gid" --match "ping from wn" --timeout 30 >/dev/null; then + record_result "$id" fail "A didn't see B's ping"; return + fi + + # A -> B reply + amy_json marmot message send "$gid" "pong from amethyst" >/dev/null || { + record_result "$id" fail "amy send pong failed"; return + } + if wait_for_message B "$gid" "pong from amethyst" 30; then + record_result "$id" pass + else + record_result "$id" fail "B didn't see A's pong" + fi +} + +test_04_three_member_group() { + banner "Test 04 — 3-member group, add C after create" + local id="04 3-member add-after-create" + + wn_c keys publish >/dev/null 2>&1 || true + sleep 3 + + local gid; gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_02"; return + fi + + # A adds C + amy_json marmot group add "$gid" "$C_NPUB" >/dev/null || { + record_result "$id" fail "amy group add C failed"; return + } + + local c_gid + if ! c_gid=$(wait_for_invite C 60); then + record_result "$id" fail "C never received invite"; return + fi + wn_c groups accept "$c_gid" >/dev/null 2>&1 || true + + amy_json marmot message send "$gid" "hello three-member world" >/dev/null || { + record_result "$id" fail "amy send failed"; return + } + if wait_for_message B "$gid" "hello three-member world" 30 \ + && wait_for_message C "$c_gid" "hello three-member world" 30; then + record_result "$id" pass + else + record_result "$id" fail "B or C missed A's post-add message" + fi +} + +test_05_b_adds_a_existing() { + banner "Test 05 — B adds A to an existing B+C group" + local id="05 wn adds A existing" + + local out gid + out=$(wn_b --json groups create "Interop-05" "$C_NPUB" 2>>"$LOG_FILE") || { + record_result "$id" fail "wn create Interop-05 failed"; return + } + gid=$(printf '%s' "$out" | jq_group_id) + if [[ -z "$gid" ]]; then + record_result "$id" fail "no group_id from create"; return + fi + save_state GROUP_05 "$gid" + wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true + + wn_b groups add-members "$gid" "$A_NPUB" >/dev/null 2>&1 || { + record_result "$id" fail "wn add-members A failed"; return + } + + # A joins + if ! amy_json marmot await group --name "Interop-05" --timeout 30 >/dev/null; then + record_result "$id" fail "A never received invite to Interop-05"; return + fi + + amy_json marmot message send "$gid" "joined from amethyst" >/dev/null || { + record_result "$id" fail "amy send failed"; return + } + if wait_for_message B "$gid" "joined from amethyst" 30 \ + && wait_for_message C "$gid" "joined from amethyst" 30; then + record_result "$id" pass + else + record_result "$id" fail "B or C didn't see A's message" + fi +} diff --git a/tools/marmot-interop/headless/tests-extras.sh b/tools/marmot-interop/headless/tests-extras.sh new file mode 100644 index 000000000..2f147c83e --- /dev/null +++ b/tools/marmot-interop/headless/tests-extras.sh @@ -0,0 +1,172 @@ +# shellcheck shell=bash +# +# headless/tests-extras.sh — tests 09, 10, 12, 13. +# Focus: reactions/replies, concurrent commits, offline catchup, KP rotation. + +test_09_reply_react_unreact() { + banner "Test 09 — reply / react / unreact" + local id="09 reply/react" + + local gid; gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_02"; return + fi + + # B anchors. Needs a member to be present — if Test 11 already ran and A left, + # skip cleanly so we don't double-fail. + if ! wn_b --json groups members "$gid" 2>/dev/null \ + | jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \ + >/dev/null 2>&1; then + record_result "$id" skip "A already left GROUP_02"; return + fi + + wn_b messages send "$gid" "anchor for reactions" >/dev/null 2>&1 || true + sleep 3 + local msg_id + msg_id=$(wn_b --json messages list "$gid" --limit 10 2>/dev/null \ + | jq -r '[.[]? | select((.content // .text // "") == "anchor for reactions")][0].id // empty') + if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then + record_result "$id" fail "couldn't find anchor message id"; return + fi + + wn_b messages react "$gid" "$msg_id" "🌮" >/dev/null 2>&1 || true + sleep 3 + # amy reply + amy_json marmot message send "$gid" "replying via amy" >/dev/null || { + record_result "$id" fail "amy send reply failed"; return + } + if wait_for_message B "$gid" "replying via amy" 30; then + record_result "$id" pass + else + record_result "$id" fail "B didn't receive reply" + fi + # NB: react/unreact round-trip verification requires a CLI verb we don't have + # yet (amy marmot message react). Once we add it, expand this test. +} + +test_10_concurrent_commits() { + banner "Test 10 — Concurrent commits race" + local id="10 concurrent commits" + + local gid; gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_02"; return + fi + if ! wn_b --json groups members "$gid" 2>/dev/null \ + | jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \ + >/dev/null 2>&1; then + record_result "$id" skip "A already left GROUP_02"; return + fi + + # Fire both renames in parallel. One wins — MLS single-commit-per-epoch + # guarantees a deterministic outcome. + ( amy_json marmot group rename "$gid" "race-from-amethyst" >/dev/null ) & + local a_pid=$! + ( wn_b groups rename "$gid" "race-from-wn" >/dev/null 2>&1 ) & + local b_pid=$! + wait "$a_pid" "$b_pid" 2>/dev/null || true + + sleep 10 + local b_name + b_name=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '.name // empty') + local a_name + a_name=$(amy_field '.name' marmot group show "$gid" 2>/dev/null || echo "") + + if [[ -n "$a_name" && "$a_name" == "$b_name" ]]; then + info "race converged: both sides see \"$a_name\"" + # Verify encryption still works. + wn_b messages send "$gid" "post-race ping" >/dev/null 2>&1 || true + if amy_json marmot await message "$gid" --match "post-race ping" --timeout 15 >/dev/null; then + record_result "$id" pass + else + record_result "$id" fail "encryption broken after race" + fi + else + record_result "$id" fail "diverged: A sees \"$a_name\", B sees \"$b_name\"" + fi +} + +test_12_offline_catchup() { + banner "Test 12 — Offline catch-up" + local id="12 offline catchup" + + # Fresh group so we don't collide with other tests. + local out gid + out=$(wn_b --json groups create "Interop-12" "$A_NPUB" 2>>"$LOG_FILE") + gid=$(printf '%s' "$out" | jq_group_id) + [[ -n "$gid" ]] || { record_result "$id" fail "couldn't create Interop-12"; return; } + save_state GROUP_12 "$gid" + + # A joins. + amy_json marmot await group --name "Interop-12" --timeout 30 >/dev/null || { + record_result "$id" fail "A never received Interop-12 invite"; return + } + + # "Go offline" == don't invoke amy. Meanwhile B sends 5 messages + adds C + sends 3 more + rename. + for i in 1 2 3 4 5; do + wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true + sleep 1 + done + wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true + wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true + for i in 6 7 8; do + wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true + sleep 1 + done + wn_b groups rename "$gid" "Interop-12-renamed" >/dev/null 2>&1 || true + sleep 3 + + # A comes back online — single sync pulls everything. + local show + show=$(amy_json marmot group show "$gid") || { + record_result "$id" fail "amy group show failed"; return + } + local name; name=$(printf '%s' "$show" | jq -r '.name') + if [[ "$name" != "Interop-12-renamed" ]]; then + record_result "$id" fail "A replay missed rename (saw \"$name\")"; return + fi + + # All 8 messages should be locally stored. + local msgs; msgs=$(amy_json marmot message list "$gid" --limit 100) + local missing=0 + for i in 1 2 3 4 5 6 7 8; do + if ! printf '%s' "$msgs" | jq -e --arg t "offline-msg-$i" \ + '.messages[]? | select((.content // "") == $t)' >/dev/null; then + missing=$((missing+1)) + info "missing offline-msg-$i" + fi + done + if [[ "$missing" -eq 0 ]]; then + record_result "$id" pass + else + record_result "$id" fail "A missed $missing of 8 offline messages" + fi +} + +test_13_keypackage_rotation() { + banner "Test 13 — KeyPackage rotation" + local id="13 keypackage rotation" + + local before + before=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // empty') + if [[ -z "$before" ]]; then + record_result "$id" fail "no prior KP for A"; return + fi + + amy_json marmot key-package publish >/dev/null || { + record_result "$id" fail "amy key-package publish failed"; return + } + + local deadline=$(( $(date +%s) + 60 )) after="" + while [[ $(date +%s) -lt $deadline ]]; do + after=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // empty') + [[ -n "$after" && "$after" != "$before" ]] && break + sleep 3 + done + if [[ -n "$after" && "$after" != "$before" ]]; then + info "KP rotated: ${before:0:8}… → ${after:0:8}…" + record_result "$id" pass + else + record_result "$id" fail "no new KP event_id observed" + fi +} diff --git a/tools/marmot-interop/headless/tests-manage.sh b/tools/marmot-interop/headless/tests-manage.sh new file mode 100644 index 000000000..509313d58 --- /dev/null +++ b/tools/marmot-interop/headless/tests-manage.sh @@ -0,0 +1,152 @@ +# shellcheck shell=bash +# +# headless/tests-manage.sh — tests 06, 07, 08, 11. +# Focus: removal, metadata rename, admin promote/demote, leave. + +test_06_member_removal() { + banner "Test 06 — Member removal + forward secrecy" + local id="06 member removal" + + local gid; gid=$(load_state GROUP_05 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_05"; return + fi + + amy_json marmot group remove "$gid" "$C_NPUB" >/dev/null || { + record_result "$id" fail "amy remove C failed"; return + } + + # C should no longer see the group on its own member view. + local deadline=$(( $(date +%s) + 60 )) removed=0 + while [[ $(date +%s) -lt $deadline ]]; do + if ! wn_c --json groups members "$gid" 2>/dev/null \ + | jq -e --arg p "$C_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \ + >/dev/null 2>&1; then + removed=1; break + fi + sleep 3 + done + if [[ "$removed" -ne 1 ]]; then + warn "C still appears as a member — continuing" + fi + + amy_json marmot message send "$gid" "after removing C" >/dev/null || { + record_result "$id" fail "amy send failed"; return + } + wait_for_message B "$gid" "after removing C" 30 || { + record_result "$id" fail "B lost access after C's removal"; return + } + + # Forward secrecy: C must NOT see the post-removal message. + sleep 5 + if wait_for_message C "$gid" "after removing C" 10; then + record_result "$id" fail "C still decrypted a post-removal message" + else + record_result "$id" pass + fi +} + +test_07_metadata_rename() { + banner "Test 07 — Metadata rename round-trip (MIP-01)" + local id="07 metadata rename" + + local gid; gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_02"; return + fi + + amy_json marmot group rename "$gid" "Interop-02-renamed" >/dev/null || { + record_result "$id" fail "amy rename failed"; return + } + + local deadline=$(( $(date +%s) + 60 )) seen="" + while [[ $(date +%s) -lt $deadline ]]; do + seen=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '.name // empty') + [[ "$seen" == "Interop-02-renamed" ]] && break + sleep 3 + done + [[ "$seen" == "Interop-02-renamed" ]] || { + record_result "$id" fail "B saw name=\"$seen\" not \"Interop-02-renamed\""; return + } + + # Now B renames back and A should pick it up. + wn_b groups rename "$gid" "Interop-02-reverse" >/dev/null 2>&1 || true + if amy_json marmot await rename "$gid" --name "Interop-02-reverse" --timeout 60 >/dev/null; then + record_result "$id" pass + else + record_result "$id" fail "A did not pick up B's rename" + fi +} + +test_08_admin_promote_demote() { + banner "Test 08 — Admin promote / demote" + local id="08 admin promote/demote" + + local gid; gid=$(load_state GROUP_03 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_03"; return + fi + + # Ensure 3 members (add C if missing). + wn_c keys publish >/dev/null 2>&1 || true + sleep 2 + wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true + wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true + + # B promotes A. + wn_b groups promote "$gid" "$A_NPUB" >/dev/null 2>&1 || { + record_result "$id" fail "wn promote failed"; return + } + + # A should reflect the new admin set — poll via amy. + if ! amy_json marmot await admin "$gid" "$A_NPUB" --timeout 30 >/dev/null; then + record_result "$id" fail "A never saw itself promoted"; return + fi + + # A now commits a rename — only possible if we're admin. + amy_json marmot group rename "$gid" "Interop-03-by-A" >/dev/null || { + record_result "$id" fail "A (now admin) could not rename"; return + } + + # B demotes A. + wn_b groups demote "$gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero" + sleep 5 + + local admins + admins=$(wn_b --json groups admins "$gid" 2>/dev/null \ + | jq -r '.[].pubkey // .[].public_key // .[]' | tr '\n' ' ') + if [[ "$admins" == *"$A_HEX"* ]]; then + record_result "$id" fail "A still admin after demote" + else + record_result "$id" pass + fi +} + +test_11_leave_group() { + banner "Test 11 — Leave group" + local id="11 leave group" + + local gid; gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + record_result "$id" skip "no GROUP_02"; return + fi + + amy_json marmot group leave "$gid" >/dev/null || { + record_result "$id" fail "amy leave failed"; return + } + + local deadline=$(( $(date +%s) + 60 )) gone=0 + while [[ $(date +%s) -lt $deadline ]]; do + if ! wn_b --json groups members "$gid" 2>/dev/null \ + | jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \ + >/dev/null 2>&1; then + gone=1; break + fi + sleep 3 + done + if [[ "$gone" -eq 1 ]]; then + record_result "$id" pass + else + record_result "$id" fail "A still in B's member list after leave" + fi +} diff --git a/tools/marmot-interop/marmot-interop-headless.sh b/tools/marmot-interop/marmot-interop-headless.sh new file mode 100755 index 000000000..c4d0e9501 --- /dev/null +++ b/tools/marmot-interop/marmot-interop-headless.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# marmot-interop-headless.sh — zero-prompt interop harness. +# +# Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and +# Identities B/C via whitenoise-rs `wn`/`wnd`. Matches the 13 test +# scenarios in marmot-interop.sh but without any human prompts — all +# checks run to completion and the exit code reflects pass/fail totals. +# +# Usage: ./marmot-interop-headless.sh [--local-relays] [--no-build] +# +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-headless" +LOG_DIR="$STATE_DIR/logs" +A_DIR="$STATE_DIR/A" +B_DIR="$STATE_DIR/B" +C_DIR="$STATE_DIR/C" +B_SOCKET="$B_DIR/release/wnd.sock" +C_SOCKET="$C_DIR/release/wnd.sock" + +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="$LOG_DIR/run-$RUN_TS.log" +RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" + +WN_REPO="${WN_REPO:-$SCRIPT_DIR/state/whitenoise-rs}" +WN_BIN="$WN_REPO/target/release/wn" +WND_BIN="$WN_REPO/target/release/wnd" +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" + +DEFAULT_RELAYS=( "wss://relay.damus.io" "wss://nos.lol" "wss://relay.primal.net" ) +USE_LOCAL_RELAYS=0 +NO_BUILD=0 + +A_NPUB="" +A_HEX="" +B_NPUB="" +B_HEX="" +C_NPUB="" +C_HEX="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --local-relays) USE_LOCAL_RELAYS=1 ;; + --no-build) NO_BUILD=1 ;; + -h|--help) + sed -n '3,12p' "${BASH_SOURCE[0]}" | sed 's/^# \?//' + exit 0 ;; + *) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;; + esac + shift +done + +mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$B_DIR/logs" "$C_DIR/logs" +: >"$LOG_FILE" +: >"$RESULTS_FILE" + +# Reuse colours / logging / dump_daemon_diagnostics from the interactive harness. +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +# shellcheck source=headless/setup.sh +source "$SCRIPT_DIR/headless/setup.sh" +# shellcheck source=headless/helpers.sh +source "$SCRIPT_DIR/headless/helpers.sh" +# shellcheck source=headless/tests-create.sh +source "$SCRIPT_DIR/headless/tests-create.sh" +# shellcheck source=headless/tests-manage.sh +source "$SCRIPT_DIR/headless/tests-manage.sh" +# shellcheck source=headless/tests-extras.sh +source "$SCRIPT_DIR/headless/tests-extras.sh" + +trap 'stop_daemons; print_summary' EXIT + +banner "Marmot headless interop harness ($RUN_TS)" +preflight +start_daemon B "$B_DIR" "$B_SOCKET" +start_daemon C "$C_DIR" "$C_SOCKET" +ensure_identity_a +ensure_identity B +ensure_identity C +configure_relays + +test_01_keypackage_discovery +test_02_a_creates_group +test_03_b_creates_group +test_04_three_member_group +test_05_b_adds_a_existing +test_06_member_removal +test_07_metadata_rename +test_08_admin_promote_demote +test_09_reply_react_unreact +test_10_concurrent_commits +test_11_leave_group +test_12_offline_catchup +test_13_keypackage_rotation From 559c69660f3a7cbc0c8633208ac4ad86dd4a032b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 21:41:47 +0000 Subject: [PATCH 04/11] feat(cli,commons): amy login + amy create, share defaults with Amethyst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the "defaults a new Amethyst account gets seeded with" into commons so the UI and amy agree byte-for-byte on what a fresh account looks like. commons additions: - commons/defaults/Constants.kt — relay URL constants (moved from amethyst/) - commons/defaults/AmethystDefaults.kt — DefaultChannels, DefaultNIP65RelaySet, DefaultNIP65List, DefaultGlobalRelays, DefaultDMRelayList, DefaultSearchRelayList, DefaultIndexerRelayList (moved from amethyst/model/AccountSettings.kt) - commons/account/AccountBootstrapEvents.kt — data class + bootstrapAccountEvents(signer, name) that builds the nine events a new Amethyst account publishes: kind:0, 3, 10002, 10050, 10051, 10099, 50, 51, + channel list. One source of truth. Retrofits: - AccountSessionManager.createNewAccount now delegates event construction to the commons helper; it still packages them into AccountSettings for the Android storage path. - DefaultSignerPermissions stays in AccountSettings.kt — it uses Android NIP-55 Permission/CommandType types. - 19 import updates across amethyst/ to point at the new commons paths. New CLI verbs: - amy create [--name NAME]: mint a keypair, write identity.json, seed relays.json with the Amethyst defaults, publish all nine bootstrap events to DefaultNIP65RelaySet via NostrClient.publishAndConfirmDetailed. - amy login KEY [--password X]: accept any identifier form Amethyst's login screen accepts — nsec1, ncryptsec (+ --password, NIP-49), BIP-39 mnemonic (NIP-06), npub1, nprofile1, 64-hex pubkey (default) or 64-hex privkey (with --private), NIP-05 (name@domain.tld, HTTP lookup). Read-only keys land with privKeyHex=null; Identity now carries that cleanly. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq --- .../relays/eventsync/EventSyncTest.kt | 2 +- .../amethyst/model/AccountSettings.kt | 28 ---- .../indexerRelays/IndexerRelayListState.kt | 2 +- .../searchRelays/SearchRelayListState.kt | 2 +- .../nip65RelayList/Nip65RelayListState.kt | 2 +- .../model/topNavFeeds/OutboxRelayLoader.kt | 2 +- .../follows/FilterFindFollowMetadataForKey.kt | 6 +- .../user/watchers/UserWatcherSubAssembler.kt | 2 +- .../SearchUserWatcherSubAssembler.kt | 2 +- .../amethyst/ui/broadcast/BroadcastBanner.kt | 2 +- .../ui/broadcast/BroadcastDetailsSheet.kt | 2 +- .../ui/screen/AccountSessionManager.kt | 51 +++---- .../feed/types/RenderCreateChannelNote.kt | 2 +- .../FilterFollowingEphemeralChats.kt | 2 +- .../datasource/FilterFollowingPublicChats.kt | 2 +- .../FilterLastMessageFollowingPublicChats.kt | 2 +- .../loggedIn/relays/AllRelayListScreen.kt | 6 +- .../relays/dm/AddDMRelayListDialog.kt | 2 +- .../search/AddSearchRelayListDialog.kt | 2 +- .../com/vitorpamplona/amethyst/cli/Config.kt | 48 ++++--- .../com/vitorpamplona/amethyst/cli/Main.kt | 14 +- .../amethyst/cli/commands/Commands.kt | 10 ++ .../amethyst/cli/commands/CreateCommand.kt | 114 ++++++++++++++++ .../amethyst/cli/commands/LoginCommand.kt | 125 ++++++++++++++++++ .../commons/account/AccountBootstrapEvents.kt | 114 ++++++++++++++++ .../commons/defaults/AmethystDefaults.kt | 63 +++++++++ .../amethyst/commons/defaults}/Constants.kt | 7 +- 27 files changed, 511 insertions(+), 105 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/account/AccountBootstrapEvents.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/AmethystDefaults.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/model => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults}/Constants.kt (90%) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt index f892f6740..bb4feac93 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 51c12ed88..061faf3c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -37,7 +37,6 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent @@ -55,8 +54,6 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo -import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent @@ -68,31 +65,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable -val DefaultChannels = - listOf( - // Anigma's Nostr - ChannelTag("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", Constants.nos), - // Amethyst's Group - ChannelTag("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", Constants.nos), - ) - -val DefaultNIP65RelaySet = setOf(Constants.mom, Constants.nos, Constants.bitcoiner) - -val DefaultNIP65List = - listOf( - AdvertisedRelayInfo(Constants.mom, AdvertisedRelayType.BOTH), - AdvertisedRelayInfo(Constants.nos, AdvertisedRelayType.BOTH), - AdvertisedRelayInfo(Constants.bitcoiner, AdvertisedRelayType.BOTH), - ) - -val DefaultGlobalRelays = listOf(Constants.wine, Constants.news) - -val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos) - -val DefaultSearchRelayList = setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal, Constants.ditto) - -val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Constants.userkinds, Constants.yabu, Constants.nostr1) - val DefaultSignerPermissions = listOf( Permission(CommandType.SIGN_EVENT, RelayAuthEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt index 4f739918e..40f30c1db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays +import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt index 4fbba2ef2..3a227ef30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.model.nip51Lists.searchRelays +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt index 6add6cd3e..fec41fa05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.model.nip65RelayList +import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt index 558a142b1..0a1544f5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds +import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt index fb3457ef6..2d5a301d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows +import com.vitorpamplona.amethyst.commons.defaults.Constants +import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Constants -import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList -import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index 698c93b76..a925ca08f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager -import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchUserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchUserWatcherSubAssembler.kt index 1fa7801e6..84e06563e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchUserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchUserWatcherSubAssembler.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies -import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt index bd221606f..a5b42e108 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -58,7 +58,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.service.broadcast.RelayResult diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt index 34082f631..7d395c5b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt @@ -77,7 +77,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.service.broadcast.RelayResult diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index cfbdb5cc3..ee43b4c4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -23,29 +23,18 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.DefaultChannels -import com.vitorpamplona.amethyst.model.DefaultDMRelayList -import com.vitorpamplona.amethyst.model.DefaultGlobalRelays -import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList -import com.vitorpamplona.amethyst.model.DefaultNIP65List -import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet -import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.ui.navigation.routes.Route -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent 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.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -57,12 +46,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.toNpub -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -303,28 +287,23 @@ class AccountSessionManager( fun createNewAccount(name: String? = null): AccountSettings { val keyPair = KeyPair() - val tempSigner = NostrSignerSync(keyPair) - + val bootstrap = + com.vitorpamplona.amethyst.commons.account.bootstrapAccountEvents( + signer = NostrSignerSync(keyPair), + name = name, + ) return AccountSettings( keyPair = keyPair, transientAccount = false, - backupUserMetadata = tempSigner.sign(MetadataEvent.newUser(name)), - backupContactList = - ContactListEvent.createFromScratch( - followUsers = listOf(ContactTag(keyPair.pubKey.toHexKey(), null, null)), - relayUse = emptyMap(), - signer = tempSigner, - ), - backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner), - backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner), - // MIP-00: advertise the default outbox relays as KeyPackage hosts - // so other users can discover and fetch this account's KeyPackage - // events without having to guess where they were published. - backupKeyPackageRelayList = KeyPackageRelayListEvent.create(DefaultNIP65RelaySet.toList(), tempSigner), - backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner), - backupIndexRelayList = IndexerRelayListEvent.create(DefaultIndexerRelayList.toList(), tempSigner), - backupChannelList = ChannelListEvent.create(emptyList(), DefaultChannels, tempSigner), - backupRelayFeedsList = RelayFeedsListEvent.create(DefaultGlobalRelays, tempSigner), + backupUserMetadata = bootstrap.userMetadata, + backupContactList = bootstrap.contactList, + backupNIP65RelayList = bootstrap.nip65RelayList, + backupDMRelayList = bootstrap.dmRelayList, + backupKeyPackageRelayList = bootstrap.keyPackageRelayList, + backupSearchRelayList = bootstrap.searchRelayList, + backupIndexRelayList = bootstrap.indexerRelayList, + backupChannelList = bootstrap.channelList, + backupRelayFeedsList = bootstrap.relayFeedsList, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index c798e382c..17f41c99f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -48,10 +48,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists -import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt index 083ae5411..20d237195 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource -import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt index 149a8d481..c16e28793 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource -import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt index 26f82e25d..5e898c764 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource -import com.vitorpamplona.amethyst.model.Constants +import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index 19f103fad..50604871b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -49,9 +49,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.DefaultDMRelayList -import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList -import com.vitorpamplona.amethyst.model.DefaultSearchRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt index c99dda469..447b00272 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.DefaultDMRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt index 157d49378..478ed05d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.DefaultSearchRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 03b1dab0e..5469f615c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -31,20 +31,36 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNsec import java.io.File -/** Persisted identity (hex keys + cached bech32 forms for convenience). */ +/** + * Persisted identity. + * + * [privKeyHex] may be null for read-only accounts imported from an `npub`, + * `nprofile` or NIP-05 — in that case [nsec] is also null and any CLI verb + * that needs to sign will fail with a clear error. `keyPair()` materialises + * a [KeyPair] with only `pubKey` set when no private key is available. + */ data class Identity( - val privKeyHex: String, + val privKeyHex: String?, val pubKeyHex: String, - val nsec: String, + val nsec: String?, val npub: String, ) { - fun keyPair(): KeyPair = KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray()) + val hasPrivateKey: Boolean get() = privKeyHex != null + + fun keyPair(): KeyPair = + if (privKeyHex != null) { + KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray()) + } else { + KeyPair(pubKey = pubKeyHex.hexToByteArray()) + } companion object { - fun create(): Identity { - val kp = KeyPair() - val priv = kp.privKey!! - val pub = kp.pubKey + fun create(): Identity = fromPrivateKey(KeyPair().privKey!!) + + fun fromNsec(nsec: String): Identity = fromPrivateKey(nsec.bechToBytes()) + + fun fromPrivateKey(priv: ByteArray): Identity { + val pub = KeyPair(privKey = priv).pubKey return Identity( privKeyHex = priv.toHexKey(), pubKeyHex = pub.toHexKey(), @@ -53,16 +69,14 @@ data class Identity( ) } - fun fromNsec(nsec: String): Identity { - val priv = nsec.bechToBytes() - val kp = KeyPair(privKey = priv) - return Identity( - privKeyHex = priv.toHexKey(), - pubKeyHex = kp.pubKey.toHexKey(), - nsec = priv.toNsec(), - npub = kp.pubKey.toNpub(), + /** Read-only identity (no private key). */ + fun fromPublicKeyHex(pubHex: String): Identity = + Identity( + privKeyHex = null, + pubKeyHex = pubHex.lowercase(), + nsec = null, + npub = pubHex.hexToByteArray().toNpub(), ) - } } } 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 b34978a0c..d9cf303bb 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -107,6 +107,14 @@ private suspend fun dispatch(argv: Array): Int { Commands.init(dataDir, Args(tail)) } + "create" -> { + Commands.create(dataDir, tail) + } + + "login" -> { + Commands.login(dataDir, tail) + } + "whoami" -> { Commands.whoami(dataDir) } @@ -171,8 +179,10 @@ private fun printUsage() { | amy [--data-dir PATH] [args...] | |Identity: - | init [--nsec NSEC] create or import identity - | whoami print current identity + | init [--nsec NSEC] create or import a bare identity (no defaults published) + | create [--name NAME] provision a full Amethyst-style account + publish bootstrap events + | login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05) + | whoami print current identity | |Relays: | relay add URL [--type T] T=nip65|inbox|key_package|all (default all) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 042b4f387..4b287ce96 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -34,6 +34,16 @@ object Commands { args: Args, ): Int = InitCommands.init(dataDir, args) + suspend fun create( + dataDir: DataDir, + tail: Array, + ): Int = CreateCommand.run(dataDir, tail) + + suspend fun login( + dataDir: DataDir, + tail: Array, + ): Int = LoginCommand.run(dataDir, tail) + suspend fun whoami(dataDir: DataDir): Int = InitCommands.whoami(dataDir) suspend fun relay( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt new file mode 100644 index 000000000..b128d8999 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt @@ -0,0 +1,114 @@ +/* + * 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.Identity +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.RelayConfig +import com.vitorpamplona.amethyst.commons.account.bootstrapAccountEvents +import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List +import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync + +/** + * `amy create [--name NAME]` — provision a brand-new Nostr account with the + * same defaults Amethyst uses, publish the nine bootstrap events to the + * default NIP-65 relay set, and seed this data-dir's relay config so + * subsequent `amy marmot …` commands immediately target the right relays. + * + * The heavy lifting (which events to sign, with which defaults) lives in + * `commons/.../AccountBootstrap.kt` so this command stays assembly-thin + * and the on-relay shape matches the in-app flow byte-for-byte. + */ +object CreateCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + if (dataDir.loadIdentityOrNull() != null) { + return Json.error("exists", "identity already exists at ${dataDir.identityFile}") + } + val args = Args(rest) + val name = args.flag("name") + + // 1. Mint identity + seed relay config. + val identity = Identity.create() + dataDir.saveIdentity(identity) + dataDir.saveRelays(defaultRelayConfig()) + + // 2. Build the nine signed bootstrap events via the shared helper. + val signer = NostrSignerSync(identity.keyPair()) + val bootstrap = bootstrapAccountEvents(signer, name) + + // 3. Open a Context so we reuse publishAndConfirmDetailed + the + // NostrClient plumbing instead of reinventing a second client. + val ctx = Context.open(dataDir) + val accepted = mutableMapOf>() + try { + ctx.prepare() + for (event in bootstrap.all()) { + val ack = ctx.publish(event, DefaultNIP65RelaySet) + accepted[event.kind.toString()] = + ack.filterValues { it }.keys.map { it.url } + } + } finally { + ctx.close() + } + + Json.writeLine( + mapOf( + "npub" to identity.npub, + "hex" to identity.pubKeyHex, + "name" to (name ?: ""), + "data_dir" to dataDir.root.absolutePath, + "published_kinds" to bootstrap.all().map { it.kind }, + "accepted_by" to accepted, + "relays" to + mapOf( + "nip65" to DefaultNIP65List.map { it.relayUrl.url }, + "inbox" to DefaultDMRelayList.map { it.url }, + "key_package" to DefaultNIP65RelaySet.map { it.url }, + ), + ), + ) + return 0 + } + + /** + * Mirror of what Amethyst's in-app defaults would write on disk — NIP-65 + * outbox, DM inbox (kind:10050), and KeyPackage host relays (kind:10051). + * Keeping these in sync with the signed events above is load-bearing: + * `amy marmot key-package publish` later reads `relays.json` to decide + * where to publish, and if those diverge from the advertised kind:10051 + * nobody will find the KPs. + */ + private fun defaultRelayConfig(): RelayConfig { + val cfg = RelayConfig() + DefaultNIP65List.forEach { cfg.add("nip65", it.relayUrl.url) } + DefaultDMRelayList.forEach { cfg.add("inbox", it.url) } + DefaultNIP65RelaySet.forEach { cfg.add("key_package", it.url) } + return cfg + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt new file mode 100644 index 000000000..3cbd1f88b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt @@ -0,0 +1,125 @@ +/* + * 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.DataDir +import com.vitorpamplona.amethyst.cli.Identity +import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client +import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher +import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull +import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 +import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 +import okhttp3.OkHttpClient + +/** + * `amy login KEY [--password X]` — import any of the identifier forms + * Amethyst's login screen accepts and persist the identity to the + * data-dir. Mirrors [AccountSessionManager.loginSync] but on the JVM. + * + * Accepted forms (tried in this order): + * - nsec1… → full account + * - ncryptsec… + --password X → NIP-49 decrypt → full + * - BIP-39 mnemonic (space-separated) → NIP-06 derive → full + * - 64-hex private key (with --private) → full + * - npub1… / nprofile1… / 64-hex pubkey → read-only + * - NIP-05 identifier (name@domain.tld) → read-only (HTTP lookup) + */ +object LoginCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) { + return Json.error("bad_args", "login [--password X]") + } + if (dataDir.loadIdentityOrNull() != null) { + return Json.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first") + } + + val key = rest[0].trim() + val args = Args(rest.drop(1).toTypedArray()) + + val identity = + resolveIdentity(key, args) + ?: return Json.error( + "bad_key", + "could not parse '$key' as any supported identifier", + ) + + dataDir.saveIdentity(identity) + Json.writeLine( + mapOf( + "npub" to identity.npub, + "hex" to identity.pubKeyHex, + "read_only" to !identity.hasPrivateKey, + "data_dir" to dataDir.root.absolutePath, + ), + ) + return 0 + } + + private suspend fun resolveIdentity( + key: String, + args: Args, + ): Identity? { + // 1. ncryptsec — password mandatory. + if (key.startsWith("ncryptsec")) { + val pw = + args.flag("password") ?: args.flag("pw") + ?: throw IllegalArgumentException("ncryptsec input requires --password") + val privHex = Nip49().decrypt(key, pw) + return Identity.fromPrivateKey( + com.vitorpamplona.quartz.utils.Hex + .decode(privHex), + ) + } + // 2. nsec + if (key.startsWith("nsec1")) return Identity.fromNsec(key) + // 3. mnemonic (space-separated, 12/24 words) + if (key.contains(' ') && Nip06().isValidMnemonic(key)) { + val priv = Nip06().privateKeyFromMnemonic(key) + return Identity.fromPrivateKey(priv) + } + // 4. 64-hex privkey — only when explicitly asked; otherwise a bare + // hex string is ambiguous with a pubkey and we default to public. + if (args.bool("private") && isHex64(key)) { + return Identity.fromPrivateKey( + com.vitorpamplona.quartz.utils.Hex + .decode(key), + ) + } + // 5. everything else — defer to the shared resolver (npub / nprofile / + // hex pubkey / NIP-05). Read-only. + val pubHex = resolveUserHexOrNull(key, nip05Client()) ?: return null + return Identity.fromPublicKeyHex(pubHex) + } + + private fun isHex64(s: String): Boolean = s.length == 64 && s.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + + private fun nip05Client(): Nip05Client { + // Build a throwaway OkHttp client — we don't hold a Context here and + // login is a one-shot CLI invocation anyway. + val http = OkHttpClient.Builder().build() + return Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> http }) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/account/AccountBootstrapEvents.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/account/AccountBootstrapEvents.kt new file mode 100644 index 000000000..dbfb17605 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/account/AccountBootstrapEvents.kt @@ -0,0 +1,114 @@ +/* + * 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.commons.account + +import com.vitorpamplona.amethyst.commons.defaults.DefaultChannels +import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultGlobalRelays +import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List +import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +/** + * Full set of "new Amethyst account" bootstrap events: the nine signed + * events that [com.vitorpamplona.amethyst.ui.screen.AccountSessionManager.createNewAccount] + * produces for a brand-new user, in the exact same order and with the same + * relay defaults. Used by: + * + * - Amethyst's in-app "Create Account" flow (builds an `AccountSettings` + * from these). + * - `amy create` (writes the keypair to disk, publishes these to the + * default NIP-65 relay set). + * + * Each event is optional in the sense that the builder lets you null out + * individual ones via the args — but the sensible default is "all of them". + */ +data class AccountBootstrapEvents( + val userMetadata: MetadataEvent, + val contactList: ContactListEvent, + val nip65RelayList: AdvertisedRelayListEvent, + val dmRelayList: ChatMessageRelayListEvent, + val keyPackageRelayList: KeyPackageRelayListEvent, + val searchRelayList: SearchRelayListEvent, + val indexerRelayList: IndexerRelayListEvent, + val channelList: ChannelListEvent, + val relayFeedsList: RelayFeedsListEvent, +) { + /** All nine signed events in publication order. */ + fun all(): List = + listOf( + userMetadata, + contactList, + nip65RelayList, + dmRelayList, + keyPackageRelayList, + searchRelayList, + indexerRelayList, + channelList, + relayFeedsList, + ) +} + +/** + * Build and sign the nine events that comprise a freshly-created Amethyst + * account. The caller is responsible for publishing them — typically to + * [DefaultNIP65RelaySet] — after at least a short delay so relay + * connections are established. + * + * [name] is the optional display name; null or blank produces a kind:0 + * event with no `name` field. + */ +fun bootstrapAccountEvents( + signer: NostrSignerSync, + name: String? = null, +): AccountBootstrapEvents = + AccountBootstrapEvents( + userMetadata = signer.sign(MetadataEvent.newUser(name)), + contactList = + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(signer.keyPair.pubKey.toHexKey(), null, null)), + relayUse = emptyMap(), + signer = signer, + ), + nip65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, signer), + dmRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, signer), + // MIP-00: advertise the default outbox relays as KeyPackage hosts + // so other users can discover and fetch this account's KeyPackage + // events without having to guess where they were published. + keyPackageRelayList = KeyPackageRelayListEvent.create(DefaultNIP65RelaySet.toList(), signer), + searchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), signer), + indexerRelayList = IndexerRelayListEvent.create(DefaultIndexerRelayList.toList(), signer), + channelList = ChannelListEvent.create(emptyList(), DefaultChannels, signer), + relayFeedsList = RelayFeedsListEvent.create(DefaultGlobalRelays, signer), + ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/AmethystDefaults.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/AmethystDefaults.kt new file mode 100644 index 000000000..208ee9aeb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/AmethystDefaults.kt @@ -0,0 +1,63 @@ +/* + * 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.commons.defaults + +import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType + +/** + * Default relay/channel bundles applied to every freshly-created Amethyst + * account. Both the Android UI (`AccountSessionManager.createNewAccount`) + * and the `amy` CLI (`amy create`) seed new accounts from these so users + * land in the same connected state regardless of which client they start + * with. + * + * Pure data — no platform deps, no runtime config, no i18n. Change here, + * both clients follow. + */ + +val DefaultChannels = + listOf( + // Anigma's Nostr + ChannelTag("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", Constants.nos), + // Amethyst's Group + ChannelTag("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", Constants.nos), + ) + +val DefaultNIP65RelaySet = setOf(Constants.mom, Constants.nos, Constants.bitcoiner) + +val DefaultNIP65List = + listOf( + AdvertisedRelayInfo(Constants.mom, AdvertisedRelayType.BOTH), + AdvertisedRelayInfo(Constants.nos, AdvertisedRelayType.BOTH), + AdvertisedRelayInfo(Constants.bitcoiner, AdvertisedRelayType.BOTH), + ) + +val DefaultGlobalRelays = listOf(Constants.wine, Constants.news) + +val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos) + +val DefaultSearchRelayList = + setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal, Constants.ditto) + +val DefaultIndexerRelayList = + setOf(Constants.purplepages, Constants.coracle, Constants.userkinds, Constants.yabu, Constants.nostr1) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt index 754080858..089e77541 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt @@ -18,10 +18,15 @@ * 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 +package com.vitorpamplona.amethyst.commons.defaults import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +/** + * Relay URLs that Amethyst (and `amy`) seed new accounts with and use as + * sensible defaults across the app. Pure data — lives in commons so JVM / + * iOS / desktop builds get the same URLs without duplicating the list. + */ object Constants { val nos = RelayUrlNormalizer.normalize("wss://nos.lol") val mom = RelayUrlNormalizer.normalize("wss://nostr.mom") From 51ad472046310c7c5f19b73faab83523da8a5868 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:04:28 +0000 Subject: [PATCH 05/11] feat(marmot-interop): run headless suite against a local nostr-rs-relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every test now flows through one loopback nostr-rs-relay on ws://127.0.0.1:8080 — no public relays, no egress. Addresses the repeated 503s we saw from the sandbox and the kind:445 rejections we hit on public relays even with internet. Changes: - Preflight clones https://github.com/scsibug/nostr-rs-relay into state-headless/nostr-rs-relay and runs `cargo build --release` (~3 min first run). Cache hits on subsequent runs. - New start_local_relay / stop_local_relay functions render a minimal config.toml each run (binds to 127.0.0.1, no kind blacklist, data under state-headless/relay) and wait up to 20s for the port. - configure_relays collapsed to a single-relay path — A/B/C all point at \$RELAY_URL. Kept publish-lists + key-package publish as smoke signals for the advertise path. - Flags pared down: drop --local-relays (always local now), add --port N for when 8080 is taken. --no-build still honoured. - trap wires relay shutdown alongside daemon shutdown. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq --- tools/marmot-interop/headless/setup.sh | 115 +++++++++++++++--- .../marmot-interop/marmot-interop-headless.sh | 31 +++-- 2 files changed, 117 insertions(+), 29 deletions(-) diff --git a/tools/marmot-interop/headless/setup.sh b/tools/marmot-interop/headless/setup.sh index 686af8be1..d66c01cf9 100644 --- a/tools/marmot-interop/headless/setup.sh +++ b/tools/marmot-interop/headless/setup.sh @@ -40,6 +40,93 @@ preflight() { fi info "wn: $WN_BIN" info "wnd: $WND_BIN" + + # Clone/build nostr-rs-relay — the harness's single loopback relay. + if [[ ! -x "$RELAY_BIN" ]]; then + if [[ "$NO_BUILD" -eq 1 ]]; then + fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1 + fi + if [[ ! -d "$RELAY_REPO/.git" ]]; then + step "cloning nostr-rs-relay into $RELAY_REPO" + git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \ + 2>&1 | tee -a "$LOG_FILE" + fi + step "building nostr-rs-relay (~3 min first run)" + ( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \ + 2>&1 | tee -a "$LOG_FILE" + fi + info "relay bin: $RELAY_BIN" +} + +# --- local relay ------------------------------------------------------------- +# Start nostr-rs-relay on $RELAY_PORT with a minimal config. Every test +# runs against this one loopback endpoint — no external network traffic. +start_local_relay() { + banner "Starting local nostr-rs-relay on $RELAY_URL" + mkdir -p "$RELAY_DATA" "$RELAY_DATA/logs" + + # Render a minimal config file each run so port/limits come from the + # harness rather than whatever was left on disk from a previous session. + cat >"$RELAY_DATA/config.toml" </dev/null | awk '{print $4}' | grep -qE "[:.]$RELAY_PORT\$"; then + fail_msg "port $RELAY_PORT already in use — pass --port N or free it" + exit 1 + fi + + nohup "$RELAY_BIN" --db "$RELAY_DATA" --config "$RELAY_DATA/config.toml" \ + >"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" & + echo "$!" > "$RELAY_DATA/pid" + step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL …" + + local deadline=$(( $(date +%s) + 20 )) + while [[ $(date +%s) -lt $deadline ]]; do + if curl -sSf -m 1 "http://127.0.0.1:$RELAY_PORT/" >/dev/null 2>&1; then + info "relay up" + return 0 + fi + sleep 0.5 + done + fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)" + tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true + exit 1 +} + +stop_local_relay() { + local pid_file="$RELAY_DATA/pid" + [[ -f "$pid_file" ]] || return 0 + local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "") + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + info "stopping relay pid $pid" + kill "$pid" 2>/dev/null || true + sleep 1 + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$pid_file" } # --- daemons ----------------------------------------------------------------- @@ -114,28 +201,20 @@ ensure_identity() { } # --- relays ------------------------------------------------------------------ +# Point all three identities at the loopback relay. We never publish any +# test traffic off-box — public relays reject kind:445 anyway and the +# goal here is tight, deterministic iteration. configure_relays() { - banner "Configuring relays" - local relays=() - if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then - relays=( "ws://localhost:8080" ) - else - relays=( "${DEFAULT_RELAYS[@]}" ) - fi - - for r in "${relays[@]}"; do - step "adding $r to A/B/C" - amy_a relay add "$r" --type all >/dev/null - for t in nip65 inbox key_package; do - wn_b relays add --type "$t" "$r" 2>/dev/null || true - wn_c relays add --type "$t" "$r" 2>/dev/null || true - done + banner "Configuring relays → $RELAY_URL" + amy_a relay add "$RELAY_URL" --type all >/dev/null + for t in nip65 inbox key_package; do + wn_b relays add --type "$t" "$RELAY_URL" 2>/dev/null || true + wn_c relays add --type "$t" "$RELAY_URL" 2>/dev/null || true done # A advertises its NIP-65 + DM inbox lists so B/C can discover where to - # deliver gift wraps. Without this, cross-client welcomes only work - # through relay-set overlap — fine for this harness but still worth - # publishing so we catch regressions in the advertise path. + # deliver gift wraps. With a single shared relay the lookup is trivial + # but we still publish so we catch regressions in the advertise path. step "publishing A's NIP-65 + kind:10050 lists" amy_a relay publish-lists >>"$LOG_FILE" 2>&1 || warn "amy relay publish-lists failed" diff --git a/tools/marmot-interop/marmot-interop-headless.sh b/tools/marmot-interop/marmot-interop-headless.sh index c4d0e9501..0cff73d83 100755 --- a/tools/marmot-interop/marmot-interop-headless.sh +++ b/tools/marmot-interop/marmot-interop-headless.sh @@ -1,13 +1,15 @@ #!/usr/bin/env bash # -# marmot-interop-headless.sh — zero-prompt interop harness. +# marmot-interop-headless.sh — zero-prompt, zero-internet interop harness. # # Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and -# Identities B/C via whitenoise-rs `wn`/`wnd`. Matches the 13 test -# scenarios in marmot-interop.sh but without any human prompts — all -# checks run to completion and the exit code reflects pass/fail totals. +# Identities B/C via whitenoise-rs `wn`/`wnd`. Spins up a local +# nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT so nothing ever leaves the +# machine. Matches the 13 test scenarios in marmot-interop.sh but without +# any human prompts — all checks run to completion and the exit code +# reflects pass/fail totals. # -# Usage: ./marmot-interop-headless.sh [--local-relays] [--no-build] +# Usage: ./marmot-interop-headless.sh [--port N] [--no-build] # set -uo pipefail @@ -30,8 +32,14 @@ WN_BIN="$WN_REPO/target/release/wn" WND_BIN="$WN_REPO/target/release/wnd" AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" -DEFAULT_RELAYS=( "wss://relay.damus.io" "wss://nos.lol" "wss://relay.primal.net" ) -USE_LOCAL_RELAYS=0 +# Local relay wiring — cloned + built during preflight, started on +# $RELAY_PORT. The harness never touches the public internet for test +# traffic; wn/wnd/amy all point at this one loopback endpoint. +RELAY_REPO="${RELAY_REPO:-$STATE_DIR/nostr-rs-relay}" +RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay" +RELAY_DATA="$STATE_DIR/relay" +RELAY_PORT="${RELAY_PORT:-8080}" +RELAY_URL="ws://127.0.0.1:$RELAY_PORT" NO_BUILD=0 A_NPUB="" @@ -43,10 +51,10 @@ C_HEX="" while [[ $# -gt 0 ]]; do case "$1" in - --local-relays) USE_LOCAL_RELAYS=1 ;; - --no-build) NO_BUILD=1 ;; + --port) RELAY_PORT="$2"; RELAY_URL="ws://127.0.0.1:$RELAY_PORT"; shift ;; + --no-build) NO_BUILD=1 ;; -h|--help) - sed -n '3,12p' "${BASH_SOURCE[0]}" | sed 's/^# \?//' + sed -n '3,14p' "${BASH_SOURCE[0]}" | sed 's/^# \?//' exit 0 ;; *) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;; esac @@ -72,10 +80,11 @@ source "$SCRIPT_DIR/headless/tests-manage.sh" # shellcheck source=headless/tests-extras.sh source "$SCRIPT_DIR/headless/tests-extras.sh" -trap 'stop_daemons; print_summary' EXIT +trap 'stop_daemons; stop_local_relay; print_summary' EXIT banner "Marmot headless interop harness ($RUN_TS)" preflight +start_local_relay start_daemon B "$B_DIR" "$B_SOCKET" start_daemon C "$C_DIR" "$C_SOCKET" ensure_identity_a From bed351470039e199bb1f0f4f5316eb154c10bfef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:06:59 +0000 Subject: [PATCH 06/11] chore(marmot-interop): check for protoc in preflight nostr-rs-relay's build.rs needs protoc to compile the nauthz gRPC definitions. Without it the build fails deep in prost-build with a panic that's hard to read, so fail up front with a pointer to `apt-get install protobuf-compiler`. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq --- tools/marmot-interop/headless/setup.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/marmot-interop/headless/setup.sh b/tools/marmot-interop/headless/setup.sh index d66c01cf9..985d18521 100644 --- a/tools/marmot-interop/headless/setup.sh +++ b/tools/marmot-interop/headless/setup.sh @@ -6,9 +6,14 @@ # --- preflight --------------------------------------------------------------- preflight() { banner "Preflight" - for cmd in jq git curl cargo; do + for cmd in jq git curl cargo protoc; do if ! command -v "$cmd" >/dev/null 2>&1; then - fail_msg "missing required tool: $cmd"; exit 1 + fail_msg "missing required tool: $cmd" + # protoc is a build-time dep of nostr-rs-relay — give a hint. + if [[ "$cmd" == "protoc" ]]; then + info "hint: apt-get install protobuf-compiler (or brew install protobuf on macOS)" + fi + exit 1 fi info "$cmd: $(command -v "$cmd")" done From 6213fb21977da3a56d0453021a909d1e6dfe737a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:14:16 +0000 Subject: [PATCH 07/11] feat(marmot-interop): patch wnd to honour \$WHITENOISE_DISCOVERY_RELAYS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, wnd exits on startup with NoRelayConnections in any environment that can't reach wnd's baked-in public discovery relay set (nos.lol, relay.damus.io, …). The headless harness runs entirely on a loopback relay, so hitting the public internet is never OK. Changes: - headless/patches/whitenoise-discovery-env.patch: adds an env-var override at the top of DiscoveryPlaneConfig::curated_default_relays. When \$WHITENOISE_DISCOVERY_RELAYS is set (comma-separated) we use that list instead of the hardcoded public one. - headless/setup.sh: applies the patch in preflight (idempotent — checks for a .headless-discovery-patched marker), invalidates the previous wn/wnd build on first patch, rebuilds, and threads \$WHITENOISE_DISCOVERY_RELAYS=\$RELAY_URL into every start_daemon invocation. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq --- .../patches/whitenoise-discovery-env.patch | 23 +++++++++++ tools/marmot-interop/headless/setup.sh | 38 +++++++++++++++---- 2 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 tools/marmot-interop/headless/patches/whitenoise-discovery-env.patch diff --git a/tools/marmot-interop/headless/patches/whitenoise-discovery-env.patch b/tools/marmot-interop/headless/patches/whitenoise-discovery-env.patch new file mode 100644 index 000000000..8fb0fec0a --- /dev/null +++ b/tools/marmot-interop/headless/patches/whitenoise-discovery-env.patch @@ -0,0 +1,23 @@ +--- a/src/relay_control/discovery.rs ++++ b/src/relay_control/discovery.rs +@@ -87,6 +87,20 @@ + + /// Initial curated relay set from the planning doc. + pub(crate) fn curated_default_relays() -> Vec { ++ // marmot-interop-headless patch: honour $WHITENOISE_DISCOVERY_RELAYS ++ // (comma-separated list) when present, so the harness can force wnd ++ // to use a loopback relay instead of the baked-in public set. ++ if let Ok(from_env) = std::env::var("WHITENOISE_DISCOVERY_RELAYS") { ++ let parsed: Vec = from_env ++ .split(',') ++ .map(str::trim) ++ .filter(|s| !s.is_empty()) ++ .filter_map(|u| RelayUrl::parse(u).ok()) ++ .collect(); ++ if !parsed.is_empty() { ++ return parsed; ++ } ++ } + [ + "wss://index.hzrd149.com", + "wss://indexer.coracle.social", diff --git a/tools/marmot-interop/headless/setup.sh b/tools/marmot-interop/headless/setup.sh index 985d18521..9510be85c 100644 --- a/tools/marmot-interop/headless/setup.sh +++ b/tools/marmot-interop/headless/setup.sh @@ -30,16 +30,34 @@ preflight() { info "amy: $AMY_BIN" # Clone/build whitenoise-rs if needed (shared between both harnesses). + if [[ ! -d "$WN_REPO/.git" ]]; then + if [[ "$NO_BUILD" -eq 1 ]]; then + fail_msg "whitenoise-rs checkout missing at $WN_REPO and --no-build set"; exit 1 + fi + step "cloning whitenoise-rs into $WN_REPO" + git clone --depth 1 https://github.com/marmot-protocol/whitenoise-rs.git "$WN_REPO" \ + 2>&1 | tee -a "$LOG_FILE" + fi + + # Patch wnd's hardcoded discovery relays so $WHITENOISE_DISCOVERY_RELAYS + # wins — without this the daemon exits immediately in sandboxes / offline + # environments where public relays are unreachable. + local patch_file="$SCRIPT_DIR/headless/patches/whitenoise-discovery-env.patch" + local patch_marker="$WN_REPO/.headless-discovery-patched" + if [[ ! -f "$patch_marker" ]]; then + step "patching whitenoise-rs: honour WHITENOISE_DISCOVERY_RELAYS" + ( cd "$WN_REPO" && patch -p1 --forward --reject-file=- <"$patch_file" ) \ + 2>&1 | tee -a "$LOG_FILE" + touch "$patch_marker" + # Invalidate the previous build so the patched source is picked up. + rm -f "$WN_BIN" "$WND_BIN" + fi + if [[ ! -x "$WN_BIN" || ! -x "$WND_BIN" ]]; then if [[ "$NO_BUILD" -eq 1 ]]; then fail_msg "wn/wnd not found and --no-build set"; exit 1 fi - if [[ ! -d "$WN_REPO/.git" ]]; then - step "cloning whitenoise-rs into $WN_REPO" - git clone --depth 1 https://github.com/marmot-protocol/whitenoise-rs.git "$WN_REPO" \ - 2>&1 | tee -a "$LOG_FILE" - fi - step "building wn + wnd (~5 min first run)" + step "building wn + wnd (~5 min first run; incremental thereafter)" ( cd "$WN_REPO" && cargo build --release --features cli --bin wn --bin wnd ) \ 2>&1 | tee -a "$LOG_FILE" fi @@ -143,8 +161,12 @@ start_daemon() { fi rm -f "$socket" mkdir -p "$data_dir/logs" "$data_dir/release" - nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \ - >"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" & + # Point wnd's discovery plane at our loopback relay. Needs the env-var + # patch applied in preflight to take effect; without it wnd falls back + # to the baked-in public set and exits with NoRelayConnections. + WHITENOISE_DISCOVERY_RELAYS="$RELAY_URL" \ + nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \ + >"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" & echo "$!" > "$data_dir/pid" local deadline=$(( $(date +%s) + 30 )) while [[ $(date +%s) -lt $deadline ]]; do From 37515ec975fc5826268561bb7f0e0397eea07d9c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:20:54 +0000 Subject: [PATCH 08/11] feat(marmot-interop): let wnd run in sandboxes without kernel keyring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containers / CI often block the keyutils syscalls wnd uses by default to store secret keys (add_key returns EACCES, keyctl_search returns ENOSYS). The integration-tests feature ships a mock keyring store, but the stock wnd binary doesn't wire it up. Add a tiny opt-in patch. Changes: - headless/patches/whitenoise-mock-keyring.patch: if built with the integration-tests feature and \$WHITENOISE_MOCK_KEYRING is set, wnd calls Whitenoise::initialize_mock_keyring_store() before the normal init path. Harmless on production builds. - headless/setup.sh: apply both harness patches generically from a list; build wn/wnd with --features cli,integration-tests; export WHITENOISE_MOCK_KEYRING=1 alongside WHITENOISE_DISCOVERY_RELAYS when launching each daemon. - preflight swaps keyctl for patch in required tools — we no longer need a real session keyring. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq --- .../patches/whitenoise-mock-keyring.patch | 18 ++++++ tools/marmot-interop/headless/setup.sh | 63 ++++++++++++------- 2 files changed, 58 insertions(+), 23 deletions(-) create mode 100644 tools/marmot-interop/headless/patches/whitenoise-mock-keyring.patch diff --git a/tools/marmot-interop/headless/patches/whitenoise-mock-keyring.patch b/tools/marmot-interop/headless/patches/whitenoise-mock-keyring.patch new file mode 100644 index 000000000..f346b4352 --- /dev/null +++ b/tools/marmot-interop/headless/patches/whitenoise-mock-keyring.patch @@ -0,0 +1,18 @@ +--- a/src/bin/wnd.rs ++++ b/src/bin/wnd.rs +@@ -22,6 +22,15 @@ + let args = Args::parse(); + let config = Config::resolve(args.data_dir.as_ref(), args.logs_dir.as_ref()); + ++ // marmot-interop-headless patch: allow sandboxes/CI without a real ++ // kernel keyring to fall back to the integration-tests mock keyring ++ // by setting $WHITENOISE_MOCK_KEYRING=1. Requires the daemon to be ++ // built with --features cli,integration-tests. No effect otherwise. ++ #[cfg(feature = "integration-tests")] ++ if std::env::var("WHITENOISE_MOCK_KEYRING").is_ok() { ++ Whitenoise::initialize_mock_keyring_store(); ++ } ++ + let wn_config = WhitenoiseConfig::new(&config.data_dir, &config.logs_dir, KEYRING_SERVICE_ID); + Whitenoise::initialize_whitenoise(wn_config).await?; + diff --git a/tools/marmot-interop/headless/setup.sh b/tools/marmot-interop/headless/setup.sh index 9510be85c..5999b50c4 100644 --- a/tools/marmot-interop/headless/setup.sh +++ b/tools/marmot-interop/headless/setup.sh @@ -6,13 +6,13 @@ # --- preflight --------------------------------------------------------------- preflight() { banner "Preflight" - for cmd in jq git curl cargo protoc; do + for cmd in jq git curl cargo protoc patch; do if ! command -v "$cmd" >/dev/null 2>&1; then fail_msg "missing required tool: $cmd" - # protoc is a build-time dep of nostr-rs-relay — give a hint. - if [[ "$cmd" == "protoc" ]]; then - info "hint: apt-get install protobuf-compiler (or brew install protobuf on macOS)" - fi + case "$cmd" in + protoc) info "hint: apt-get install protobuf-compiler (or brew install protobuf on macOS)" ;; + patch) info "hint: apt-get install patch" ;; + esac exit 1 fi info "$cmd: $(command -v "$cmd")" @@ -39,26 +39,37 @@ preflight() { 2>&1 | tee -a "$LOG_FILE" fi - # Patch wnd's hardcoded discovery relays so $WHITENOISE_DISCOVERY_RELAYS - # wins — without this the daemon exits immediately in sandboxes / offline - # environments where public relays are unreachable. - local patch_file="$SCRIPT_DIR/headless/patches/whitenoise-discovery-env.patch" - local patch_marker="$WN_REPO/.headless-discovery-patched" - if [[ ! -f "$patch_marker" ]]; then - step "patching whitenoise-rs: honour WHITENOISE_DISCOVERY_RELAYS" - ( cd "$WN_REPO" && patch -p1 --forward --reject-file=- <"$patch_file" ) \ - 2>&1 | tee -a "$LOG_FILE" - touch "$patch_marker" - # Invalidate the previous build so the patched source is picked up. - rm -f "$WN_BIN" "$WND_BIN" - fi + # Two harness-only patches to wnd so it runs fully offline / in + # sandboxes that block outbound + kernel keyring: + # 1. discovery-env: honour $WHITENOISE_DISCOVERY_RELAYS so we can + # point wnd at our loopback relay instead of the baked-in public + # set. Without it wnd exits with NoRelayConnections. + # 2. mock-keyring: honour $WHITENOISE_MOCK_KEYRING so wnd uses the + # integration-tests mock keyring store when the kernel keyutils + # syscalls are blocked (common in containers / CI). + local -a patches=( + "whitenoise-discovery-env.patch" + "whitenoise-mock-keyring.patch" + ) + for name in "${patches[@]}"; do + local marker="$WN_REPO/.headless-patched-${name%.patch}" + if [[ ! -f "$marker" ]]; then + step "patching whitenoise-rs: $name" + ( cd "$WN_REPO" && patch -p1 --forward --reject-file=- \ + <"$SCRIPT_DIR/headless/patches/$name" ) 2>&1 | tee -a "$LOG_FILE" + touch "$marker" + # Invalidate the previous build so the patched source is picked up. + rm -f "$WN_BIN" "$WND_BIN" + fi + done if [[ ! -x "$WN_BIN" || ! -x "$WND_BIN" ]]; then if [[ "$NO_BUILD" -eq 1 ]]; then fail_msg "wn/wnd not found and --no-build set"; exit 1 fi - step "building wn + wnd (~5 min first run; incremental thereafter)" - ( cd "$WN_REPO" && cargo build --release --features cli --bin wn --bin wnd ) \ + step "building wn + wnd with integration-tests feature (~5 min first run)" + ( cd "$WN_REPO" && \ + cargo build --release --features cli,integration-tests --bin wn --bin wnd ) \ 2>&1 | tee -a "$LOG_FILE" fi info "wn: $WN_BIN" @@ -161,10 +172,16 @@ start_daemon() { fi rm -f "$socket" mkdir -p "$data_dir/logs" "$data_dir/release" - # Point wnd's discovery plane at our loopback relay. Needs the env-var - # patch applied in preflight to take effect; without it wnd falls back - # to the baked-in public set and exits with NoRelayConnections. + # Env vars consumed by the two harness-only wnd patches applied in + # preflight: + # WHITENOISE_DISCOVERY_RELAYS — forces the discovery plane at our + # loopback relay (kills the "can't reach nos.lol" exit path). + # WHITENOISE_MOCK_KEYRING — swaps in the integration-tests mock + # secret store so wnd doesn't fall over when the kernel blocks + # keyutils syscalls. + # Both are harmless on a real host with connectivity + a real keyring. WHITENOISE_DISCOVERY_RELAYS="$RELAY_URL" \ + WHITENOISE_MOCK_KEYRING=1 \ nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \ >"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" & echo "$!" > "$data_dir/pid" From 760e682b89e65ef0e32197bc8df7cc6d5c3ad727 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:27:20 +0000 Subject: [PATCH 09/11] fix(marmot-interop): parse post-v0.2 \`wn --json whoami\` shape wn now wraps account listings in {"result": [{...}]} so extract_pubkey's object + array fallbacks both missed it and ensure_identity bailed with "could not determine \$who npub" immediately after create-identity. Also: create-identity on a fresh box can exit non-zero with "failed to connect to any relays" even when the account *was* created locally. Probe --json whoami afterwards before calling that a fatal. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq --- tools/marmot-interop/headless/setup.sh | 7 +++++-- tools/marmot-interop/lib.sh | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tools/marmot-interop/headless/setup.sh b/tools/marmot-interop/headless/setup.sh index 5999b50c4..635e10de1 100644 --- a/tools/marmot-interop/headless/setup.sh +++ b/tools/marmot-interop/headless/setup.sh @@ -231,9 +231,12 @@ ensure_identity() { raw=$("$cmd" --json whoami 2>/dev/null || true) npub=$(extract_pubkey "$raw") if [[ -z "${npub:-}" ]]; then - raw=$("$cmd" create-identity 2>&1 | tee -a "$LOG_FILE") + # create-identity sometimes exits non-zero (e.g. transient "failed to + # connect to any relays") even though the account was created. Probe + # --json whoami afterwards before giving up. + "$cmd" create-identity 2>&1 | tee -a "$LOG_FILE" || true + raw=$("$cmd" --json whoami 2>/dev/null || true) npub=$(extract_pubkey "$raw") - [[ -n "$npub" ]] || npub=$(extract_pubkey "$("$cmd" whoami 2>/dev/null || true)") fi [[ -n "$npub" ]] || { fail_msg "could not determine $who npub"; exit 1; } diff --git a/tools/marmot-interop/lib.sh b/tools/marmot-interop/lib.sh index 4194d3e24..744e9ff95 100644 --- a/tools/marmot-interop/lib.sh +++ b/tools/marmot-interop/lib.sh @@ -271,6 +271,9 @@ extract_pubkey() { # JSON: single object v=$(printf '%s' "$raw" | jq -r '.pubkey // .npub // .public_key // empty' 2>/dev/null || true) if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi + # JSON: {"result": [ {"pubkey": …}, … ]} — post-v0.2 `wn --json whoami` shape + v=$(printf '%s' "$raw" | jq -r '.result[0].pubkey // .result[0].npub // .result[0].public_key // empty' 2>/dev/null || true) + if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi # JSON: array of accounts (whoami may return a list) v=$(printf '%s' "$raw" | jq -r '.[0].pubkey // .[0].npub // .[0].public_key // empty' 2>/dev/null || true) if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi From 020abccf57fcb474ad1ca08531351f5e8a0756b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:30:08 +0000 Subject: [PATCH 10/11] fix(cli): mark Identity.hasPrivateKey @JsonIgnore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jackson was writing the computed \`hasPrivateKey\` into identity.json and then refusing to read it back ("Unrecognized field hasPrivateKey") on the next invocation. All amy commands that reload the data-dir (\`marmot group create\`, \`marmot key-package publish\`, …) exited 1 at the Context.open step. Pure boolean getter, no persistence needed. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq --- cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 5469f615c..989338e54 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -45,6 +45,7 @@ data class Identity( val nsec: String?, val npub: String, ) { + @get:com.fasterxml.jackson.annotation.JsonIgnore val hasPrivateKey: Boolean get() = privKeyHex != null fun keyPair(): KeyPair = From 25781a8815da21fde7b73b33952ec73c76bb8bd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:38:38 +0000 Subject: [PATCH 11/11] fix(quartz): send JVM PlatformLog output to stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the JVM, Log.d / .i / .w / .e were printing through \`println()\` which lands on stdout. The amy CLI uses stdout as its JSON contract — any Marmot/MLS command would restore state, emit a burst of DEBUG lines, then emit the JSON object, and every caller piping through jq failed to parse because the first non-empty line was a log line. Switch to \`System.err.println\` inside PlatformLog.jvm.kt. Conventional for CLIs (data on stdout, diagnostics on stderr), and the desktop app already redirects as part of packaging. Callers who want everything mixed can still \`2>&1\` at the shell level. Fixes amy marmot group create / group add / message send etc. when their output is captured by a shell script. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq --- .../com/vitorpamplona/quartz/utils/PlatformLog.jvm.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/PlatformLog.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/PlatformLog.jvm.kt index c46efd525..367eb8345 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/PlatformLog.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/PlatformLog.jvm.kt @@ -34,10 +34,14 @@ actual object PlatformLog { message: String, throwable: Throwable?, ) { + // Diagnostics go to stderr so callers that pipe stdout (the CLI uses + // stdout for its JSON contract, desktop apps may too) aren't corrupted + // by log interleaving. Consumers that prefer stdout can just redirect + // 2>&1 at the shell level. if (throwable != null) { - println("${time()} $level: [$tag] $message. Throwable: ${throwable.message}") + System.err.println("${time()} $level: [$tag] $message. Throwable: ${throwable.message}") } else { - println("${time()} $level: [$tag] $message") + System.err.println("${time()} $level: [$tag] $message") } }