From 0319809b8c40512cd0b055a84d2238b8b8ef8094 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 13:59:47 +0000 Subject: [PATCH 1/3] feat(cli): full relay-settings parity for `amy relay` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand `amy relay` from the 3 transport lists (nip65/inbox/key_package) to every relay-list bucket Amethyst's relay-settings screen manages, and add remove/set verbs alongside add/list. Buckets (kind): nip65 (10002, read/write markers), inbox/dm (10050), key_package (10051), search (10007), private (10013), blocked (10006), trusted (10089), proxy (10087), indexer (10086), broadcast (10088), feeds/favorites (10012). The private NIP-51 lists are signed NIP-44-encrypted via the quartz event factories, exactly like the app. Local relays (device pref, no event) and named relay sets (30002) are intentionally out of scope. New/changed commands: - `relay add URL --type T [--marker read|write|both]` — `--marker` sets the nip65 role; `all` still means nip65+inbox+key_package. - `relay remove URL --type T` — new. - `relay set --type T [URL…] [--marker …]` — new; replace a whole bucket (no URLs clears it). - `relay list [--type T]` — lists every bucket, or one. - `relay publish-lists` — now broadcasts every configured list. Thin-assembly only: buckets are a small registry over the existing quartz `create`/`relays` factories; adds one generic `Context.latestReplaceable` helper. `--json` is additive — legacy keys (`nip65`/`inbox`/`key_package`, `nip65_event_id`/…) are unchanged, so the existing test harnesses keep passing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc --- cli/README.md | 25 +- cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Context.kt | 15 + .../com/vitorpamplona/amethyst/cli/Main.kt | 11 +- .../amethyst/cli/commands/RelayCommands.kt | 400 ++++++++++++++---- 5 files changed, 370 insertions(+), 83 deletions(-) diff --git a/cli/README.md b/cli/README.md index 0601d5010a..dc30b1cad1 100644 --- a/cli/README.md +++ b/cli/README.md @@ -190,7 +190,10 @@ added_to: - key_package already_present: (none) -$ amy relay publish-lists # broadcast updated kind:10002/10050/10051 +$ amy relay add wss://relay.nostr.band --type search # search-only bucket +$ amy relay set --type blocked wss://bad.relay # replace the blocked list + +$ amy relay publish-lists # broadcast every updated relay list ``` --- @@ -443,11 +446,25 @@ from "command crashed". ### Relays +amy mirrors Amethyst's relay-settings screen: one bucket per relay-list kind. +`T` is one of `nip65` (10002, read/write markers), `inbox`/`dm` (10050), +`key_package` (10051), `search` (10007), `private` (10013), `blocked` (10006), +`trusted` (10089), `proxy` (10087), `indexer` (10086), `broadcast` (10088), +`feeds`/`favorites` (10012), or `all` (= `nip65`+`inbox`+`key_package`). The +private NIP-51 buckets are stored NIP-44-encrypted, exactly like the app. Local +relays (a device-only preference, no Nostr event) and named relay sets (kind +30002) are out of scope. + +Edits are local-first: `add`/`remove`/`set` build, sign, and store the new list +event but do not broadcast — run `relay publish-lists` to push them. + | Command | What it does | |---|---| -| `amy relay add URL [--type T]` | Add URL to a bucket: `nip65`, `inbox`, `key_package`, or `all`. | -| `amy relay list` | Print the configured relays per bucket. | -| `amy relay publish-lists` | Broadcast your kind:10002 / 10050 / 10051. | +| `amy relay add URL [--type T] [--marker read\|write\|both]` | Append URL to a bucket (default `all`). `--marker` sets the read/write role for `nip65` (default `both`). | +| `amy relay remove URL [--type T]` | Drop URL from a bucket (default `all`). | +| `amy relay set --type T [URL…] [--marker read\|write\|both]` | Replace a bucket's whole list. Passing no URLs clears it. | +| `amy relay list [--type T]` | Print the configured relays for every bucket, or just `T`. | +| `amy relay publish-lists` | Broadcast every configured relay list to the union of your relays. | ### Local store maintenance diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 62c660d076..44fbef4b07 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -43,7 +43,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · |---|---|---| | Identity create / import (`nsec`, `ncryptsec`, mnemonic, `npub`, `nprofile`, hex, NIP-05) | ✅ | `LoginCommand` + Quartz NIP-05 / NIP-06 / NIP-49 | | Account bootstrap (nine events) | ✅ | `commons/account/AccountBootstrapEvents.kt` | -| Relay config + NIP-65 / NIP-10050 publish | ✅ | `RelayCommands` | +| Relay config — every relay-list bucket (nip65 10002, dm 10050, key-package 10051, search 10007, private-outbox 10013, blocked 10006, trusted 10089, proxy 10087, indexer 10086, broadcast 10088, favorite 10012) — add / remove / set / list / publish | ✅ | `RelayCommands`. Mirrors the Android relay-settings screen. Local relays (device pref) + relay sets (30002) intentionally out of scope. | | MLS KeyPackage publish + fetch | ✅ | `commons/marmot/MarmotManager` | | Marmot group create / add / rename / promote / demote / remove / leave | ✅ | `commons/marmot/` | | Marmot message send / list | ✅ | `commons/marmot/` | 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 4ffe1a77fc..dffb600206 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -598,6 +598,21 @@ class Context( Filter(authors = listOf(pubKey), kinds = listOf(KeyPackageRelayListEvent.KIND), limit = 1), ).firstOrNull() as? KeyPackageRelayListEvent + /** + * Latest known replaceable event of [kind] authored by [pubKey] in the + * local store, or `null`. Generic sibling of [relaysOf] / [dmInboxOf] / + * [keyPackageRelaysOf] used by the relay-settings commands to load any of + * the NIP-51 relay lists (blocked / search / proxy / indexer / …) without + * a dedicated typed helper for each kind. + */ + suspend fun latestReplaceable( + pubKey: HexKey, + kind: Int, + ): Event? = + store + .query(Filter(authors = listOf(pubKey), kinds = listOf(kind), limit = 1)) + .firstOrNull() + /** * Assemble a [RecipientRelayFetcher.Lists] from the local store — * the same shape callers get from [RecipientRelayFetcher.fetchRelayLists] 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 4211bd2509..dbe443a71a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -392,9 +392,14 @@ private fun printUsage() { | [--name N] [--timeout SECS] wait for a signer to connect, then persist it | |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 + | relay add URL [--type T] append URL to a bucket (default all=nip65+inbox+key_package) + | [--marker read|write|both] T=nip65|inbox|key_package|search|private|blocked| + | trusted|proxy|indexer|broadcast|feeds|all + | relay remove URL [--type T] drop URL from a bucket (default all) + | relay set --type T [URL…] replace a bucket's whole list (no URLs clears it) + | [--marker read|write|both] + | relay list [--type T] print configured relays (all buckets, or just T) + | relay publish-lists broadcast every configured relay list | relay info URL fetch + print a relay's NIP-11 info document | outbox USER [--refresh] show USER's NIP-65 read/write relays (outbox model) | [--timeout SECS] (USER: npub|nprofile|hex|name@domain) 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 index 5f8b6f43e1..06e1564651 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -25,11 +25,21 @@ import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType @@ -37,21 +47,140 @@ import okhttp3.OkHttpClient import okhttp3.Request /** - * `amy relay ` — manage this account's relay sets. + * `amy relay ` — manage every relay + * list this account maintains, mirroring Amethyst's relay-settings screen. * - * Source of truth is the local event store (`/events-store/`) - * via Context.relaysOf / dmInboxOf / keyPackageRelaysOf. There is no - * `relays.json` any more — the kind:10002 / 10050 / 10051 events ARE - * the relay configuration. + * Source of truth is the local event store (`/events-store/`): the + * kind:10002 / 10050 / 10051 / 10007 / 10013 / 10006 / 10012 / 10086 / 10087 / + * 10088 / 10089 events ARE the relay configuration. There is no `relays.json`. * - * - `relay add URL --type T` builds + signs + ingests a new relay- - * list event for the given bucket. No - * broadcast yet — call `publish-lists`. - * - `relay list` dumps the URLs from the local store. - * - `relay publish-lists` broadcasts the three events to every - * configured relay (union). + * - `relay add URL --type T` append URL to the T bucket(s), building + + * signing + ingesting a new list event. No + * broadcast — call `publish-lists`. + * - `relay remove URL --type T` drop URL from the T bucket(s). + * - `relay set --type T [URL…]` replace the whole T bucket (no URLs clears it). + * - `relay list [--type T]` dump the URLs from the local store. + * - `relay publish-lists` broadcast every configured list to the union + * of all the account's relays. + * - `relay info URL` fetch + print a relay's NIP-11 document. + * + * The `nip65` bucket carries per-relay read/write markers ([AdvertisedRelayType]); + * the others are flat URL lists. The private NIP-51 buckets (blocked, trusted, + * proxy, indexer, broadcast, favorite, private-outbox, and the private half of + * search) store their relays NIP-44-encrypted, exactly like the app. */ object RelayCommands { + /** + * A flat (non-NIP-65) relay-list bucket: one Nostr replaceable event kind, + * a [read] that decodes the current URLs from the local store, and a [build] + * that signs a fresh event holding exactly the given URLs. `nip65` is NOT in + * this table — it carries read/write markers and is handled separately. + */ + private class Bucket( + val key: String, + val kind: Int, + val aliases: Set = emptySet(), + val read: suspend (Context, HexKey) -> List, + val build: suspend (Context, List) -> Event, + ) { + fun matches(type: String) = type == key || type in aliases + } + + private const val NIP65 = "nip65" + + /** Buckets `--type all` fans out to (the transport-critical public lists). */ + private val ALL_TRANSPORT = listOf(NIP65, "inbox", "key_package") + + private val BUCKETS: List = + listOf( + Bucket( + "inbox", + ChatMessageRelayListEvent.KIND, + aliases = setOf("dm"), + read = { c, pk -> c.dmInboxOf(pk)?.relays().orEmpty() }, + build = { c, r -> ChatMessageRelayListEvent.create(r, c.signer) }, + ), + Bucket( + "key_package", + KeyPackageRelayListEvent.KIND, + aliases = setOf("keyPackage", "keypackage"), + read = { c, pk -> c.keyPackageRelaysOf(pk)?.relays().orEmpty() }, + build = { c, r -> KeyPackageRelayListEvent.create(r, c.signer) }, + ), + Bucket( + "search", + SearchRelayListEvent.KIND, + read = { c, pk -> (c.latestReplaceable(pk, SearchRelayListEvent.KIND) as? SearchRelayListEvent)?.relays(c.signer).orEmpty() }, + build = { c, r -> SearchRelayListEvent.create(r, c.signer) }, + ), + Bucket( + "private", + PrivateOutboxRelayListEvent.KIND, + aliases = setOf("private_outbox", "nip37"), + read = { c, pk -> (c.latestReplaceable(pk, PrivateOutboxRelayListEvent.KIND) as? PrivateOutboxRelayListEvent)?.relays(c.signer).orEmpty() }, + build = { c, r -> PrivateOutboxRelayListEvent.create(r, c.signer) }, + ), + Bucket( + "blocked", + BlockedRelayListEvent.KIND, + read = { c, pk -> (c.latestReplaceable(pk, BlockedRelayListEvent.KIND) as? BlockedRelayListEvent)?.relays(c.signer).orEmpty() }, + build = { c, r -> BlockedRelayListEvent.create(r, c.signer) }, + ), + Bucket( + "broadcast", + BroadcastRelayListEvent.KIND, + read = { c, pk -> (c.latestReplaceable(pk, BroadcastRelayListEvent.KIND) as? BroadcastRelayListEvent)?.decryptRelays(c.signer).orEmpty() }, + build = { c, r -> BroadcastRelayListEvent.create(r, c.signer) }, + ), + Bucket( + "proxy", + ProxyRelayListEvent.KIND, + read = { c, pk -> (c.latestReplaceable(pk, ProxyRelayListEvent.KIND) as? ProxyRelayListEvent)?.decryptRelays(c.signer).orEmpty() }, + build = { c, r -> ProxyRelayListEvent.create(r, c.signer) }, + ), + Bucket( + "indexer", + IndexerRelayListEvent.KIND, + read = { c, pk -> (c.latestReplaceable(pk, IndexerRelayListEvent.KIND) as? IndexerRelayListEvent)?.decryptRelays(c.signer).orEmpty() }, + build = { c, r -> IndexerRelayListEvent.create(r, c.signer) }, + ), + Bucket( + "trusted", + TrustedRelayListEvent.KIND, + read = { c, pk -> (c.latestReplaceable(pk, TrustedRelayListEvent.KIND) as? TrustedRelayListEvent)?.decryptRelays(c.signer).orEmpty() }, + build = { c, r -> TrustedRelayListEvent.create(r, c.signer) }, + ), + Bucket( + "feeds", + RelayFeedsListEvent.KIND, + aliases = setOf("favorites", "relay_feeds"), + read = { c, pk -> (c.latestReplaceable(pk, RelayFeedsListEvent.KIND) as? RelayFeedsListEvent)?.decryptRelays(c.signer).orEmpty() }, + build = { c, r -> RelayFeedsListEvent.create(r, c.signer) }, + ), + ) + + private fun bucketFor(type: String): Bucket? = BUCKETS.firstOrNull { it.matches(type) } + + /** Canonicalize a `--type` token to the buckets it targets, or throw on unknown. */ + private fun resolveTypes(type: String): List = + when { + type == "all" -> ALL_TRANSPORT + type == NIP65 -> listOf(NIP65) + bucketFor(type) != null -> listOf(bucketFor(type)!!.key) + else -> + throw IllegalArgumentException( + "unknown relay type: $type (known: nip65, inbox, key_package, search, private, blocked, trusted, proxy, indexer, broadcast, feeds, all)", + ) + } + + private fun parseMarker(raw: String?): AdvertisedRelayType = + when (raw?.lowercase()) { + null, "both", "all", "" -> AdvertisedRelayType.BOTH + "read", "inbox" -> AdvertisedRelayType.READ + "write", "outbox" -> AdvertisedRelayType.WRITE + else -> throw IllegalArgumentException("invalid --marker: $raw (read|write|both)") + } + suspend fun dispatch( dataDir: DataDir, tail: Array, @@ -59,10 +188,13 @@ object RelayCommands { route( "relay", tail, - "relay …", + "relay …", mapOf( "add" to { rest -> add(dataDir, Args(rest)) }, - "list" to { _ -> list(dataDir) }, + "remove" to { rest -> remove(dataDir, Args(rest)) }, + "rm" to { rest -> remove(dataDir, Args(rest)) }, + "set" to { rest -> set(dataDir, Args(rest)) }, + "list" to { rest -> list(dataDir, Args(rest)) }, "publish-lists" to { _ -> publishLists(dataDir) }, // `info` is also intercepted in Main before account resolution // (it needs no account); routed here too for when one exists. @@ -118,17 +250,19 @@ object RelayCommands { args: Args, ): Int { val rawUrl = args.positional(0, "url") - val type = args.flag("type", "all") ?: "all" + val type = args.flag("type", "all")!! + val marker = parseMarker(args.flag("marker")) val normalized = rawUrl.normalizeRelayUrlOrNull() ?: return Output.error("bad_args", "invalid relay url: $rawUrl") - val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type) + val targets = resolveTypes(type) Context.open(dataDir).use { ctx -> + val self = ctx.identity.pubKeyHex val addedTo = mutableListOf() val alreadyPresent = mutableListOf() for (t in targets) { - if (addToBucket(ctx, t, normalized)) addedTo.add(t) else alreadyPresent.add(t) + if (addToBucket(ctx, self, t, normalized, marker)) addedTo.add(t) else alreadyPresent.add(t) } Output.emit( mapOf( @@ -141,72 +275,187 @@ object RelayCommands { } } - /** - * Append [url] to the relay-list event for [type] (creating one if - * absent). Returns `true` when a new event was inserted, `false` - * when [url] was already present in the existing event. - */ - private suspend fun addToBucket( - ctx: Context, - type: String, - url: NormalizedRelayUrl, - ): Boolean { - val self = ctx.identity.pubKeyHex - return when (type) { - "nip65" -> { - val existing = ctx.relaysOf(self)?.relays().orEmpty() - if (existing.any { it.relayUrl.url == url.url }) return false - val combined = existing + AdvertisedRelayInfo(url, AdvertisedRelayType.BOTH) - val event = AdvertisedRelayListEvent.create(combined, ctx.signer) - ctx.verifyAndStore(event) - true - } + private suspend fun remove( + dataDir: DataDir, + args: Args, + ): Int { + val rawUrl = args.positional(0, "url") + val type = args.flag("type", "all")!! + val normalized = + rawUrl.normalizeRelayUrlOrNull() + ?: return Output.error("bad_args", "invalid relay url: $rawUrl") - "inbox" -> { - val existing = ctx.dmInboxOf(self)?.relays().orEmpty() - if (url in existing) return false - val event = ChatMessageRelayListEvent.create(existing + url, ctx.signer) - ctx.verifyAndStore(event) - true - } - - "key_package", "keyPackage" -> { - val existing = ctx.keyPackageRelaysOf(self)?.relays().orEmpty() - if (url in existing) return false - val event = KeyPackageRelayListEvent.create(existing + url, ctx.signer) - ctx.verifyAndStore(event) - true - } - - else -> { - throw IllegalArgumentException("unknown relay type: $type") + val targets = resolveTypes(type) + Context.open(dataDir).use { ctx -> + val self = ctx.identity.pubKeyHex + val removedFrom = mutableListOf() + val notPresent = mutableListOf() + for (t in targets) { + if (removeFromBucket(ctx, self, t, normalized)) removedFrom.add(t) else notPresent.add(t) } + Output.emit( + mapOf( + "url" to rawUrl, + "removed_from" to removedFrom, + "not_present" to notPresent, + ), + ) + return 0 } } - private suspend fun list(dataDir: DataDir): Int { + private suspend fun set( + dataDir: DataDir, + args: Args, + ): Int { + val type = args.flag("type") ?: return Output.error("bad_args", "set requires --type T") + if (type == "all") return Output.error("bad_args", "set needs a single --type, not `all`") + val bucket = if (type == NIP65) null else bucketFor(type) ?: return Output.error("bad_args", "unknown relay type: $type") + val marker = parseMarker(args.flag("marker")) + + val normalized = mutableListOf() + for (raw in args.positional) { + normalized.add( + raw.normalizeRelayUrlOrNull() + ?: return Output.error("bad_args", "invalid relay url: $raw"), + ) + } + // dedupe, preserve order + val relays = normalized.distinctBy { it.url } + Context.open(dataDir).use { ctx -> - val self = ctx.identity.pubKeyHex + val signed: Event = + if (type == NIP65) { + AdvertisedRelayListEvent.create(relays.map { AdvertisedRelayInfo(it, marker) }, ctx.signer) + } else { + bucket!!.build(ctx, relays) + } + ctx.verifyAndStore(signed) Output.emit( mapOf( - "nip65" to (ctx.relaysOf(self)?.relaysNorm()?.map { it.url } ?: emptyList()), - "inbox" to (ctx.dmInboxOf(self)?.relays()?.map { it.url } ?: emptyList()), - "key_package" to (ctx.keyPackageRelaysOf(self)?.relays()?.map { it.url } ?: emptyList()), + "type" to type, + "kind" to signed.kind, + "event_id" to signed.id, + "relays" to relays.map { it.url }, ), ) return 0 } } + /** + * Append [url] to the relay-list event for [type] (creating one if absent). + * Returns `true` when a new event was written, `false` when [url] was + * already present. For `nip65`, [marker] sets the read/write role. + */ + private suspend fun addToBucket( + ctx: Context, + self: HexKey, + type: String, + url: NormalizedRelayUrl, + marker: AdvertisedRelayType, + ): Boolean { + if (type == NIP65) { + val existing = ctx.relaysOf(self)?.relays().orEmpty() + if (existing.any { it.relayUrl.url == url.url }) return false + val combined = existing + AdvertisedRelayInfo(url, marker) + ctx.verifyAndStore(AdvertisedRelayListEvent.create(combined, ctx.signer)) + return true + } + val bucket = bucketFor(type) ?: throw IllegalArgumentException("unknown relay type: $type") + val existing = bucket.read(ctx, self) + if (existing.any { it.url == url.url }) return false + ctx.verifyAndStore(bucket.build(ctx, existing + url)) + return true + } + + /** + * Drop [url] from the relay-list event for [type]. Returns `true` when the + * URL was present (and a new, shorter event was written), `false` otherwise. + */ + private suspend fun removeFromBucket( + ctx: Context, + self: HexKey, + type: String, + url: NormalizedRelayUrl, + ): Boolean { + if (type == NIP65) { + val existing = ctx.relaysOf(self)?.relays().orEmpty() + if (existing.none { it.relayUrl.url == url.url }) return false + val remaining = existing.filterNot { it.relayUrl.url == url.url } + ctx.verifyAndStore(AdvertisedRelayListEvent.create(remaining, ctx.signer)) + return true + } + val bucket = bucketFor(type) ?: throw IllegalArgumentException("unknown relay type: $type") + val existing = bucket.read(ctx, self) + if (existing.none { it.url == url.url }) return false + ctx.verifyAndStore(bucket.build(ctx, existing.filterNot { it.url == url.url })) + return true + } + + private suspend fun list( + dataDir: DataDir, + args: Args, + ): Int { + val type = args.flag("type") + Context.open(dataDir).use { ctx -> + val self = ctx.identity.pubKeyHex + + if (type != null) { + // Single-bucket view. + if (type == NIP65) { + val nip65 = ctx.relaysOf(self) + Output.emit( + mapOf( + "type" to NIP65, + "kind" to AdvertisedRelayListEvent.KIND, + "read" to (nip65?.readRelaysNorm()?.map { it.url } ?: emptyList()), + "write" to (nip65?.writeRelaysNorm()?.map { it.url } ?: emptyList()), + "relays" to (nip65?.relaysNorm()?.map { it.url } ?: emptyList()), + ), + ) + return 0 + } + val bucket = bucketFor(type) ?: return Output.error("bad_args", "unknown relay type: $type") + Output.emit( + mapOf( + "type" to bucket.key, + "kind" to bucket.kind, + "relays" to bucket.read(ctx, self).map { it.url }, + ), + ) + return 0 + } + + // Full view — every bucket. Keys `nip65`/`inbox`/`key_package` are + // kept as flat URL lists for backwards compatibility; `nip65_read`/ + // `nip65_write` add the marker split, and the private lists follow. + val nip65 = ctx.relaysOf(self) + val out = linkedMapOf() + out["nip65"] = nip65?.relaysNorm()?.map { it.url } ?: emptyList() + out["nip65_read"] = nip65?.readRelaysNorm()?.map { it.url } ?: emptyList() + out["nip65_write"] = nip65?.writeRelaysNorm()?.map { it.url } ?: emptyList() + for (bucket in BUCKETS) { + out[bucket.key] = bucket.read(ctx, self).map { it.url } + } + Output.emit(out) + return 0 + } + } + private suspend fun publishLists(dataDir: DataDir): Int { Context.open(dataDir).use { ctx -> ctx.prepare() val self = ctx.identity.pubKeyHex - val nip65Event = ctx.relaysOf(self) - val inboxEvent = ctx.dmInboxOf(self) - val keyPackageEvent = ctx.keyPackageRelaysOf(self) - if (nip65Event == null && inboxEvent == null && keyPackageEvent == null) { + // Collect every configured list event: nip65 plus each flat bucket. + val events = linkedMapOf() + events[NIP65] = ctx.relaysOf(self) + for (bucket in BUCKETS) { + events[bucket.key] = ctx.latestReplaceable(self, bucket.kind) + } + + if (events.values.all { it == null }) { return Output.error( "no_relays", "no relay lists in the local store; run `amy relay add` first or `amy create` to bootstrap defaults", @@ -214,21 +463,22 @@ object RelayCommands { } val targets = ctx.anyRelays() - val nip65Result = nip65Event?.let { ctx.publish(it, targets) }.orEmpty() - val inboxResult = inboxEvent?.let { ctx.publish(it, targets) }.orEmpty() - val keyPackageResult = keyPackageEvent?.let { ctx.publish(it, targets) }.orEmpty() + val eventIds = linkedMapOf() + val acceptedBy = linkedMapOf>() + for ((key, event) in events) { + eventIds[key] = event?.id + val result = event?.let { ctx.publish(it, targets) }.orEmpty() + acceptedBy[key] = result.filterValues { it }.keys.map { it.url } + } Output.emit( mapOf( - "nip65_event_id" to nip65Event?.id, - "inbox_event_id" to inboxEvent?.id, - "key_package_list_event_id" to keyPackageEvent?.id, - "accepted_by" to - mapOf( - "nip65" to nip65Result.filterValues { it }.keys.map { it.url }, - "inbox" to inboxResult.filterValues { it }.keys.map { it.url }, - "key_package_list" to keyPackageResult.filterValues { it }.keys.map { it.url }, - ), + // Legacy keys kept verbatim so existing scripts keep working. + "nip65_event_id" to eventIds[NIP65], + "inbox_event_id" to eventIds["inbox"], + "key_package_list_event_id" to eventIds["key_package"], + "event_ids" to eventIds, + "accepted_by" to acceptedBy, ), ) return 0 From c7bde868e1bd619c63c2940a3e4db917b2fd6576 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:30:08 +0000 Subject: [PATCH 2/3] feat(cli): require --clear to empty a relay bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `relay set --type T` with no URLs is now rejected (bad_args, exit 2) instead of silently wiping the list — a bare empty `set` is almost always a shell variable that expanded to nothing. Emptying a bucket is explicit: pass `--clear` (mutually exclusive with URLs). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc --- cli/README.md | 3 ++- .../main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt | 4 ++-- .../vitorpamplona/amethyst/cli/commands/RelayCommands.kt | 8 ++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cli/README.md b/cli/README.md index dc30b1cad1..e7148f67ab 100644 --- a/cli/README.md +++ b/cli/README.md @@ -462,7 +462,8 @@ event but do not broadcast — run `relay publish-lists` to push them. |---|---| | `amy relay add URL [--type T] [--marker read\|write\|both]` | Append URL to a bucket (default `all`). `--marker` sets the read/write role for `nip65` (default `both`). | | `amy relay remove URL [--type T]` | Drop URL from a bucket (default `all`). | -| `amy relay set --type T [URL…] [--marker read\|write\|both]` | Replace a bucket's whole list. Passing no URLs clears it. | +| `amy relay set --type T URL… [--marker read\|write\|both]` | Replace a bucket's whole list. | +| `amy relay set --type T --clear` | Empty a bucket. Clearing is explicit — a bare `set` with no URLs is rejected (guards against a shell variable that expanded to nothing); `--clear` and URLs are mutually exclusive. | | `amy relay list [--type T]` | Print the configured relays for every bucket, or just `T`. | | `amy relay publish-lists` | Broadcast every configured relay list to the union of your relays. | 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 dbe443a71a..747dffafa3 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -396,8 +396,8 @@ private fun printUsage() { | [--marker read|write|both] T=nip65|inbox|key_package|search|private|blocked| | trusted|proxy|indexer|broadcast|feeds|all | relay remove URL [--type T] drop URL from a bucket (default all) - | relay set --type T [URL…] replace a bucket's whole list (no URLs clears it) - | [--marker read|write|both] + | relay set --type T URL… [--marker read|write|both] replace a bucket's whole list + | relay set --type T --clear empty a bucket (URLs and --clear are mutually exclusive) | relay list [--type T] print configured relays (all buckets, or just T) | relay publish-lists broadcast every configured relay list | relay info URL fetch + print a relay's NIP-11 info document 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 index 06e1564651..bab540dc65 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -323,6 +323,14 @@ object RelayCommands { // dedupe, preserve order val relays = normalized.distinctBy { it.url } + // Clearing a bucket is destructive, so it must be explicit: `--clear` + // with no URLs. A bare `set` with no URLs is almost always a shell + // variable that expanded to nothing — reject it rather than silently + // wiping the list. (require → IllegalArgumentException → bad_args/exit 2.) + val clear = args.bool("clear") + require(relays.isNotEmpty() || clear) { "set needs at least one URL, or --clear to empty the $type list" } + require(relays.isEmpty() || !clear) { "--clear cannot be combined with relay URLs" } + Context.open(dataDir).use { ctx -> val signed: Event = if (type == NIP65) { From 5aae4368da4519ac76cd70e2177b6c6ab3015f96 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 15:02:28 +0000 Subject: [PATCH 3/3] refactor(cli): noun-first `amy relay`, outbox/inbox NIP-65 facets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure `amy relay` from verb-first `relay add URL --type T` to noun-first `relay `, matching amy's `marmot group …` / `cashu mint …` convention. The relay-list type is now a required path segment (no implicit default), and a bare noun lists that bucket. NIP-65 (kind:10002) is fronted by two facet-nouns, `outbox` (write) and `inbox` (read), replacing the `--marker` flag. They edit the single 10002 event and apply the spec's merge rules: - outbox add R on a read-only R → both - inbox add R on a write-only R → both - outbox remove R on a both-R → read (stays in inbox) - inbox remove R on a both-R → write (stays in outbox) - dropping the last facet removes R entirely `relay nip65` shows the combined view; `nip65 remove`/`clear` edit the whole event. Other buckets are noun+verb: `relay dm|key-package|search|private|blocked| trusted|proxy|indexer|broadcast|feeds `. `set` needs ≥1 URL; `clear` empties. `relay add|remove URL` (no noun) stays as the transport fan-out (nip65 both + dm + key-package). BREAKING (cli --json/args): removes `relay add/remove/set --type T` and `--marker`; `relay list` overview now keys nip65 as `outbox`/`inbox`/`nip65` and the DM bucket as `dm` (was `inbox`). In-repo harnesses updated (cache/dm/marmot setup drop `--type all`; cache T5 asserts `.dm`). Verified end-to-end: merge semantics, encrypted NIP-51 round-trips, facet set/clear, fan-out, aliases, and error paths. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc --- cli/README.md | 49 +- cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 20 +- .../amethyst/cli/commands/RelayCommands.kt | 721 +++++++++++------- cli/tests/cache/cache-headless.sh | 17 +- cli/tests/dm/setup.sh | 4 +- cli/tests/marmot/setup.sh | 2 +- 7 files changed, 485 insertions(+), 330 deletions(-) diff --git a/cli/README.md b/cli/README.md index e7148f67ab..13da5dd96d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -190,8 +190,9 @@ added_to: - key_package already_present: (none) -$ amy relay add wss://relay.nostr.band --type search # search-only bucket -$ amy relay set --type blocked wss://bad.relay # replace the blocked list +$ amy relay search add wss://relay.nostr.band # search-only bucket +$ amy relay blocked set wss://bad.relay # replace the blocked list +$ amy relay outbox add wss://nostr.wine # NIP-65 write relay $ amy relay publish-lists # broadcast every updated relay list ``` @@ -446,25 +447,39 @@ from "command crashed". ### Relays -amy mirrors Amethyst's relay-settings screen: one bucket per relay-list kind. -`T` is one of `nip65` (10002, read/write markers), `inbox`/`dm` (10050), -`key_package` (10051), `search` (10007), `private` (10013), `blocked` (10006), -`trusted` (10089), `proxy` (10087), `indexer` (10086), `broadcast` (10088), -`feeds`/`favorites` (10012), or `all` (= `nip65`+`inbox`+`key_package`). The -private NIP-51 buckets are stored NIP-44-encrypted, exactly like the app. Local -relays (a device-only preference, no Nostr event) and named relay sets (kind -30002) are out of scope. +amy mirrors Amethyst's relay-settings screen with one **noun** per relay-list +kind, followed by `add`/`remove`/`set`/`clear` (a bare noun lists it) — the same +noun-first shape as `marmot group …` / `cashu mint …`: -Edits are local-first: `add`/`remove`/`set` build, sign, and store the new list -event but do not broadcast — run `relay publish-lists` to push them. +| Noun | Kind | Notes | +|---|---|---| +| `outbox` / `inbox` / `nip65` | 10002 | NIP-65. `outbox` = write relays, `inbox` = read relays; `nip65` shows the combined read/write view. | +| `dm` | 10050 | NIP-17 DM inbox. | +| `key-package` | 10051 | MIP-00 MLS KeyPackage relays. | +| `search` | 10007 | NIP-50 search relays. | +| `private` | 10013 | NIP-37 private outbox (encrypted). | +| `blocked` `trusted` `proxy` `indexer` `broadcast` `feeds` | 10006 / 10089 / 10087 / 10086 / 10088 / 10012 | NIP-51 lists, stored NIP-44-encrypted exactly like the app. | + +Local relays (a device-only preference, no Nostr event) and named relay sets +(kind 30002) are out of scope. Edits are local-first: they build, sign, and +store the new list event but do not broadcast — run `relay publish-lists`. + +**NIP-65 markers.** `outbox`/`inbox` edit the one kind:10002 event and merge per +the spec: `outbox add R` on a read-only R promotes it to **both**; `outbox +remove R` on a both-R demotes it to **read** (keeps it in the inbox); dropping +the last facet removes R entirely. | Command | What it does | |---|---| -| `amy relay add URL [--type T] [--marker read\|write\|both]` | Append URL to a bucket (default `all`). `--marker` sets the read/write role for `nip65` (default `both`). | -| `amy relay remove URL [--type T]` | Drop URL from a bucket (default `all`). | -| `amy relay set --type T URL… [--marker read\|write\|both]` | Replace a bucket's whole list. | -| `amy relay set --type T --clear` | Empty a bucket. Clearing is explicit — a bare `set` with no URLs is rejected (guards against a shell variable that expanded to nothing); `--clear` and URLs are mutually exclusive. | -| `amy relay list [--type T]` | Print the configured relays for every bucket, or just `T`. | +| `amy relay outbox add URL` / `inbox add URL` | Add URL as a write / read relay (merging to `both` if the other marker is already set). | +| `amy relay outbox remove URL` / `inbox remove URL` | Drop the write / read marker (demoting `both` to the other, or removing R). | +| `amy relay outbox set URL…` / `inbox set URL…` | Make exactly these the write / read relays. | +| `amy relay nip65 [list]` | Show the combined read/write/all view. `nip65 remove URL` drops R entirely; `nip65 clear` wipes kind:10002. | +| `amy relay add\|remove URL` | For the flat buckets (`dm`, `search`, `blocked`, …): append / drop URL. | +| `amy relay set URL…` / `clear` | Replace a bucket's whole list, or empty it. `set` needs ≥1 URL; use `clear` to empty. | +| `amy relay ` | List that bucket. | +| `amy relay add URL` / `remove URL` | Fan-out to the transport lists (nip65 `both` + `dm` + `key-package`). | +| `amy relay list` | Print every configured relay bucket. | | `amy relay publish-lists` | Broadcast every configured relay list to the union of your relays. | ### Local store maintenance diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 44fbef4b07..6e5a7cd979 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -43,7 +43,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · |---|---|---| | Identity create / import (`nsec`, `ncryptsec`, mnemonic, `npub`, `nprofile`, hex, NIP-05) | ✅ | `LoginCommand` + Quartz NIP-05 / NIP-06 / NIP-49 | | Account bootstrap (nine events) | ✅ | `commons/account/AccountBootstrapEvents.kt` | -| Relay config — every relay-list bucket (nip65 10002, dm 10050, key-package 10051, search 10007, private-outbox 10013, blocked 10006, trusted 10089, proxy 10087, indexer 10086, broadcast 10088, favorite 10012) — add / remove / set / list / publish | ✅ | `RelayCommands`. Mirrors the Android relay-settings screen. Local relays (device pref) + relay sets (30002) intentionally out of scope. | +| Relay config — every relay-list bucket (nip65 10002 via `outbox`/`inbox`/`nip65` nouns with spec read/write merge, dm 10050, key-package 10051, search 10007, private-outbox 10013, blocked 10006, trusted 10089, proxy 10087, indexer 10086, broadcast 10088, favorite 10012) — noun-first `relay add/remove/set/clear/list` + fan-out `relay add/remove` + publish | ✅ | `RelayCommands`. Mirrors the Android relay-settings screen. Local relays (device pref) + relay sets (30002) intentionally out of scope. | | MLS KeyPackage publish + fetch | ✅ | `commons/marmot/MarmotManager` | | Marmot group create / add / rename / promote / demote / remove / leave | ✅ | `commons/marmot/` | | Marmot message send / list | ✅ | `commons/marmot/` | 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 747dffafa3..fc85fa7494 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -391,14 +391,18 @@ private fun printUsage() { | login --nostrconnect [--relay URL[,URL…]] client-initiated: print a nostrconnect:// offer, | [--name N] [--timeout SECS] wait for a signer to connect, then persist it | - |Relays: - | relay add URL [--type T] append URL to a bucket (default all=nip65+inbox+key_package) - | [--marker read|write|both] T=nip65|inbox|key_package|search|private|blocked| - | trusted|proxy|indexer|broadcast|feeds|all - | relay remove URL [--type T] drop URL from a bucket (default all) - | relay set --type T URL… [--marker read|write|both] replace a bucket's whole list - | relay set --type T --clear empty a bucket (URLs and --clear are mutually exclusive) - | relay list [--type T] print configured relays (all buckets, or just T) + |Relays: `relay NOUN [add|remove|set|clear|list] …` (bare NOUN lists it) + | NOUN = outbox|inbox|nip65 (kind:10002) dm (10050) key-package (10051) + | search (10007) private (10013) blocked|trusted|proxy|indexer| + | broadcast|feeds (NIP-51, encrypted) + | relay outbox add URL add URL as a write relay (read-only → both) + | relay inbox add URL add URL as a read relay (write-only → both) + | relay outbox remove URL drop write (both → read; write-only → gone) + | relay blocked add URL e.g. private lists: add/remove/set/clear + | relay nip65 show the combined read/write list + | relay add URL fan-out: nip65(both)+dm+key-package + | relay remove URL fan-out remove from those three + | relay list print every configured relay bucket | relay publish-lists broadcast every configured relay list | relay info URL fetch + print a relay's NIP-11 info document | outbox USER [--refresh] show USER's NIP-65 read/write relays (outbox model) 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 index bab540dc65..1ea2698858 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -47,167 +47,191 @@ import okhttp3.OkHttpClient import okhttp3.Request /** - * `amy relay ` — manage every relay - * list this account maintains, mirroring Amethyst's relay-settings screen. + * `amy relay …` — manage every relay list this account maintains, mirroring + * Amethyst's relay-settings screen. The structure is noun-first, matching the + * rest of amy (`marmot group create`, `cashu mint ping`): one relay-list bucket + * per noun, with `add`/`remove`/`set`/`clear` sub-verbs (a bare noun lists it). * - * Source of truth is the local event store (`/events-store/`): the - * kind:10002 / 10050 / 10051 / 10007 / 10013 / 10006 / 10012 / 10086 / 10087 / - * 10088 / 10089 events ARE the relay configuration. There is no `relays.json`. + * ``` + * relay outbox|inbox|nip65 … NIP-65 kind:10002 (see below) + * relay dm … kind:10050 NIP-17 DM inbox + * relay key-package … kind:10051 MIP-00 KeyPackage relays + * relay search … kind:10007 NIP-50 search relays + * relay private … kind:10013 NIP-37 private outbox (encrypted) + * relay blocked|trusted|proxy| kind:10006/10089/10087/ + * indexer|broadcast|feeds … 10086/10088/10012 (NIP-51, encrypted) + * relay add|remove URL fan-out to the transport lists (nip65+dm+key-package) + * relay list overview of every bucket + * relay publish-lists broadcast every configured list + * relay info URL NIP-11 document fetch (stateless) + * ``` * - * - `relay add URL --type T` append URL to the T bucket(s), building + - * signing + ingesting a new list event. No - * broadcast — call `publish-lists`. - * - `relay remove URL --type T` drop URL from the T bucket(s). - * - `relay set --type T [URL…]` replace the whole T bucket (no URLs clears it). - * - `relay list [--type T]` dump the URLs from the local store. - * - `relay publish-lists` broadcast every configured list to the union - * of all the account's relays. - * - `relay info URL` fetch + print a relay's NIP-11 document. + * # NIP-65 read/write markers * - * The `nip65` bucket carries per-relay read/write markers ([AdvertisedRelayType]); - * the others are flat URL lists. The private NIP-51 buckets (blocked, trusted, - * proxy, indexer, broadcast, favorite, private-outbox, and the private half of - * search) store their relays NIP-44-encrypted, exactly like the app. + * kind:10002 stores one entry per relay with a read/write marker. amy fronts it + * with two facet-nouns — `outbox` (write) and `inbox` (read) — that edit the one + * event while honouring the merge rules from the spec: + * + * - `outbox add R` when R is already read-only → R becomes **both**. + * - `inbox add R` when R is already write-only → R becomes **both**. + * - `outbox remove R` when R is **both** → R stays as **read** (inbox). + * - `inbox remove R` when R is **both** → R stays as **write** (outbox). + * - removing the last remaining facet drops R from the list entirely. + * + * `relay nip65` shows the combined read/write view; `relay nip65 remove R` drops + * R regardless of marker and `relay nip65 clear` wipes the whole event. + * + * Edits are local-first: they build, sign, and ingest the new list event into + * the local store but do not broadcast — run `relay publish-lists` to push them. */ object RelayCommands { - /** - * A flat (non-NIP-65) relay-list bucket: one Nostr replaceable event kind, - * a [read] that decodes the current URLs from the local store, and a [build] - * that signs a fresh event holding exactly the given URLs. `nip65` is NOT in - * this table — it carries read/write markers and is handled separately. - */ - private class Bucket( - val key: String, + private const val USAGE = + "relay …" + + // ------------------------------------------------------------------ + // Flat buckets — a plain list of relay URLs, one Nostr replaceable kind. + // ------------------------------------------------------------------ + + private class Flat( + /** Command noun (kebab-case) and its aliases. */ + val noun: String, + /** Key used in the `relay list` overview JSON (snake_case). */ + val jsonKey: String, val kind: Int, - val aliases: Set = emptySet(), + val aliases: Set, val read: suspend (Context, HexKey) -> List, val build: suspend (Context, List) -> Event, ) { - fun matches(type: String) = type == key || type in aliases + fun matches(token: String) = token == noun || token in aliases } - private const val NIP65 = "nip65" - - /** Buckets `--type all` fans out to (the transport-critical public lists). */ - private val ALL_TRANSPORT = listOf(NIP65, "inbox", "key_package") - - private val BUCKETS: List = + private val FLATS: List = listOf( - Bucket( - "inbox", + Flat( + "dm", + "dm", ChatMessageRelayListEvent.KIND, - aliases = setOf("dm"), + setOf("chat", "inbox-dm"), read = { c, pk -> c.dmInboxOf(pk)?.relays().orEmpty() }, build = { c, r -> ChatMessageRelayListEvent.create(r, c.signer) }, ), - Bucket( + Flat( + "key-package", "key_package", KeyPackageRelayListEvent.KIND, - aliases = setOf("keyPackage", "keypackage"), + setOf("keypackage", "key_package"), read = { c, pk -> c.keyPackageRelaysOf(pk)?.relays().orEmpty() }, build = { c, r -> KeyPackageRelayListEvent.create(r, c.signer) }, ), - Bucket( + Flat( + "search", "search", SearchRelayListEvent.KIND, + emptySet(), read = { c, pk -> (c.latestReplaceable(pk, SearchRelayListEvent.KIND) as? SearchRelayListEvent)?.relays(c.signer).orEmpty() }, build = { c, r -> SearchRelayListEvent.create(r, c.signer) }, ), - Bucket( + Flat( + "private", "private", PrivateOutboxRelayListEvent.KIND, - aliases = setOf("private_outbox", "nip37"), + setOf("private-outbox", "private_outbox", "nip37"), read = { c, pk -> (c.latestReplaceable(pk, PrivateOutboxRelayListEvent.KIND) as? PrivateOutboxRelayListEvent)?.relays(c.signer).orEmpty() }, build = { c, r -> PrivateOutboxRelayListEvent.create(r, c.signer) }, ), - Bucket( + Flat( + "blocked", "blocked", BlockedRelayListEvent.KIND, + emptySet(), read = { c, pk -> (c.latestReplaceable(pk, BlockedRelayListEvent.KIND) as? BlockedRelayListEvent)?.relays(c.signer).orEmpty() }, build = { c, r -> BlockedRelayListEvent.create(r, c.signer) }, ), - Bucket( - "broadcast", - BroadcastRelayListEvent.KIND, - read = { c, pk -> (c.latestReplaceable(pk, BroadcastRelayListEvent.KIND) as? BroadcastRelayListEvent)?.decryptRelays(c.signer).orEmpty() }, - build = { c, r -> BroadcastRelayListEvent.create(r, c.signer) }, - ), - Bucket( - "proxy", - ProxyRelayListEvent.KIND, - read = { c, pk -> (c.latestReplaceable(pk, ProxyRelayListEvent.KIND) as? ProxyRelayListEvent)?.decryptRelays(c.signer).orEmpty() }, - build = { c, r -> ProxyRelayListEvent.create(r, c.signer) }, - ), - Bucket( - "indexer", - IndexerRelayListEvent.KIND, - read = { c, pk -> (c.latestReplaceable(pk, IndexerRelayListEvent.KIND) as? IndexerRelayListEvent)?.decryptRelays(c.signer).orEmpty() }, - build = { c, r -> IndexerRelayListEvent.create(r, c.signer) }, - ), - Bucket( + Flat( + "trusted", "trusted", TrustedRelayListEvent.KIND, + emptySet(), read = { c, pk -> (c.latestReplaceable(pk, TrustedRelayListEvent.KIND) as? TrustedRelayListEvent)?.decryptRelays(c.signer).orEmpty() }, build = { c, r -> TrustedRelayListEvent.create(r, c.signer) }, ), - Bucket( + Flat( + "proxy", + "proxy", + ProxyRelayListEvent.KIND, + emptySet(), + read = { c, pk -> (c.latestReplaceable(pk, ProxyRelayListEvent.KIND) as? ProxyRelayListEvent)?.decryptRelays(c.signer).orEmpty() }, + build = { c, r -> ProxyRelayListEvent.create(r, c.signer) }, + ), + Flat( + "indexer", + "indexer", + IndexerRelayListEvent.KIND, + emptySet(), + read = { c, pk -> (c.latestReplaceable(pk, IndexerRelayListEvent.KIND) as? IndexerRelayListEvent)?.decryptRelays(c.signer).orEmpty() }, + build = { c, r -> IndexerRelayListEvent.create(r, c.signer) }, + ), + Flat( + "broadcast", + "broadcast", + BroadcastRelayListEvent.KIND, + emptySet(), + read = { c, pk -> (c.latestReplaceable(pk, BroadcastRelayListEvent.KIND) as? BroadcastRelayListEvent)?.decryptRelays(c.signer).orEmpty() }, + build = { c, r -> BroadcastRelayListEvent.create(r, c.signer) }, + ), + Flat( + "feeds", "feeds", RelayFeedsListEvent.KIND, - aliases = setOf("favorites", "relay_feeds"), + setOf("favorites", "relay_feeds"), read = { c, pk -> (c.latestReplaceable(pk, RelayFeedsListEvent.KIND) as? RelayFeedsListEvent)?.decryptRelays(c.signer).orEmpty() }, build = { c, r -> RelayFeedsListEvent.create(r, c.signer) }, ), ) - private fun bucketFor(type: String): Bucket? = BUCKETS.firstOrNull { it.matches(type) } + private fun flatFor(token: String): Flat? = FLATS.firstOrNull { it.matches(token) } - /** Canonicalize a `--type` token to the buckets it targets, or throw on unknown. */ - private fun resolveTypes(type: String): List = - when { - type == "all" -> ALL_TRANSPORT - type == NIP65 -> listOf(NIP65) - bucketFor(type) != null -> listOf(bucketFor(type)!!.key) - else -> - throw IllegalArgumentException( - "unknown relay type: $type (known: nip65, inbox, key_package, search, private, blocked, trusted, proxy, indexer, broadcast, feeds, all)", - ) - } + /** The two facet-nouns that edit the read/write markers of kind:10002. */ + private enum class Facet( + val noun: String, + ) { + OUTBOX("outbox"), + INBOX("inbox"), + } - private fun parseMarker(raw: String?): AdvertisedRelayType = - when (raw?.lowercase()) { - null, "both", "all", "" -> AdvertisedRelayType.BOTH - "read", "inbox" -> AdvertisedRelayType.READ - "write", "outbox" -> AdvertisedRelayType.WRITE - else -> throw IllegalArgumentException("invalid --marker: $raw (read|write|both)") - } + // ------------------------------------------------------------------ + // Dispatch + // ------------------------------------------------------------------ suspend fun dispatch( dataDir: DataDir, tail: Array, - ): Int = - route( - "relay", - tail, - "relay …", - mapOf( - "add" to { rest -> add(dataDir, Args(rest)) }, - "remove" to { rest -> remove(dataDir, Args(rest)) }, - "rm" to { rest -> remove(dataDir, Args(rest)) }, - "set" to { rest -> set(dataDir, Args(rest)) }, - "list" to { rest -> list(dataDir, Args(rest)) }, - "publish-lists" to { _ -> publishLists(dataDir) }, - // `info` is also intercepted in Main before account resolution - // (it needs no account); routed here too for when one exists. - "info" to { rest -> info(rest) }, - ), - ) + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", USAGE) + val head = tail[0] + val rest = tail.drop(1).toTypedArray() + return when (head) { + "list" -> listAll(dataDir) + "publish-lists" -> publishLists(dataDir) + // `info` is also intercepted in Main before account resolution + // (it needs no account); routed here too for when one exists. + "info" -> info(rest) + "add" -> fanOut(dataDir, Args(rest), add = true) + "remove", "rm" -> fanOut(dataDir, Args(rest), add = false) + "outbox" -> facetVerb(dataDir, Facet.OUTBOX, rest) + "inbox" -> facetVerb(dataDir, Facet.INBOX, rest) + "nip65" -> nip65Verb(dataDir, rest) + else -> { + val flat = flatFor(head) ?: return Output.error("bad_args", "unknown relay noun: $head ($USAGE)") + flatVerb(dataDir, flat, rest) + } + } + } + + // ------------------------------------------------------------------ + // relay info URL — stateless NIP-11 fetch + // ------------------------------------------------------------------ - /** - * `relay info URL` — fetch a relay's NIP-11 information document over - * HTTP (`Accept: application/nostr+json`) and print it. Local/stateless: - * no account, no websocket. Parsing lives in quartz - * ([Nip11RelayInformation.fromJson]); this only does the GET. - */ fun info(rest: Array): Int { val args = Args(rest) val raw = args.positional(0, "relay-url") @@ -230,12 +254,12 @@ object RelayCommands { return Output.error("http_error", "relay returned HTTP ${response.code} for $httpUrl") } val body = response.body.string() - val info = Nip11RelayInformation.fromJson(body) + val relayInfo = Nip11RelayInformation.fromJson(body) Output.emit( mapOf( "relay" to normalized.url, "url" to httpUrl, - "info" to Output.mapper.readTree(info.toJson()), + "info" to Output.mapper.readTree(relayInfo.toJson()), ), ) 0 @@ -245,206 +269,208 @@ object RelayCommands { } } - private suspend fun add( - dataDir: DataDir, - args: Args, - ): Int { - val rawUrl = args.positional(0, "url") - val type = args.flag("type", "all")!! - val marker = parseMarker(args.flag("marker")) - val normalized = - rawUrl.normalizeRelayUrlOrNull() - ?: return Output.error("bad_args", "invalid relay url: $rawUrl") + // ------------------------------------------------------------------ + // Flat-bucket verbs + // ------------------------------------------------------------------ + + private suspend fun flatVerb( + dataDir: DataDir, + flat: Flat, + rest: Array, + ): Int { + val verb = rest.getOrNull(0) ?: "list" + val args = Args(rest.drop(1).toTypedArray()) + if (verb !in VERBS) return Output.error("bad_args", "relay ${flat.noun} $verb ($VERB_LIST)") - val targets = resolveTypes(type) Context.open(dataDir).use { ctx -> val self = ctx.identity.pubKeyHex - val addedTo = mutableListOf() - val alreadyPresent = mutableListOf() - for (t in targets) { - if (addToBucket(ctx, self, t, normalized, marker)) addedTo.add(t) else alreadyPresent.add(t) - } - Output.emit( - mapOf( - "url" to rawUrl, - "added_to" to addedTo, - "already_present" to alreadyPresent, - ), - ) - return 0 - } - } - - private suspend fun remove( - dataDir: DataDir, - args: Args, - ): Int { - val rawUrl = args.positional(0, "url") - val type = args.flag("type", "all")!! - val normalized = - rawUrl.normalizeRelayUrlOrNull() - ?: return Output.error("bad_args", "invalid relay url: $rawUrl") - - val targets = resolveTypes(type) - Context.open(dataDir).use { ctx -> - val self = ctx.identity.pubKeyHex - val removedFrom = mutableListOf() - val notPresent = mutableListOf() - for (t in targets) { - if (removeFromBucket(ctx, self, t, normalized)) removedFrom.add(t) else notPresent.add(t) - } - Output.emit( - mapOf( - "url" to rawUrl, - "removed_from" to removedFrom, - "not_present" to notPresent, - ), - ) - return 0 - } - } - - private suspend fun set( - dataDir: DataDir, - args: Args, - ): Int { - val type = args.flag("type") ?: return Output.error("bad_args", "set requires --type T") - if (type == "all") return Output.error("bad_args", "set needs a single --type, not `all`") - val bucket = if (type == NIP65) null else bucketFor(type) ?: return Output.error("bad_args", "unknown relay type: $type") - val marker = parseMarker(args.flag("marker")) - - val normalized = mutableListOf() - for (raw in args.positional) { - normalized.add( - raw.normalizeRelayUrlOrNull() - ?: return Output.error("bad_args", "invalid relay url: $raw"), - ) - } - // dedupe, preserve order - val relays = normalized.distinctBy { it.url } - - // Clearing a bucket is destructive, so it must be explicit: `--clear` - // with no URLs. A bare `set` with no URLs is almost always a shell - // variable that expanded to nothing — reject it rather than silently - // wiping the list. (require → IllegalArgumentException → bad_args/exit 2.) - val clear = args.bool("clear") - require(relays.isNotEmpty() || clear) { "set needs at least one URL, or --clear to empty the $type list" } - require(relays.isEmpty() || !clear) { "--clear cannot be combined with relay URLs" } - - Context.open(dataDir).use { ctx -> - val signed: Event = - if (type == NIP65) { - AdvertisedRelayListEvent.create(relays.map { AdvertisedRelayInfo(it, marker) }, ctx.signer) - } else { - bucket!!.build(ctx, relays) + when (verb) { + "add" -> { + val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url") + val existing = flat.read(ctx, self) + val added = existing.none { it.url == url.url } + if (added) ctx.verifyAndStore(flat.build(ctx, existing + url)) + Output.emit(mapOf("noun" to flat.noun, "kind" to flat.kind, "url" to url.url, "added" to added)) } - ctx.verifyAndStore(signed) - Output.emit( - mapOf( - "type" to type, - "kind" to signed.kind, - "event_id" to signed.id, - "relays" to relays.map { it.url }, - ), - ) + "remove", "rm" -> { + val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url") + val existing = flat.read(ctx, self) + val removed = existing.any { it.url == url.url } + if (removed) ctx.verifyAndStore(flat.build(ctx, existing.filterNot { it.url == url.url })) + Output.emit(mapOf("noun" to flat.noun, "kind" to flat.kind, "url" to url.url, "removed" to removed)) + } + "set" -> { + val relays = parseUrls(args.positional) ?: return Output.error("bad_args", "invalid relay url") + if (relays.isEmpty()) return Output.error("bad_args", "set needs at least one URL; use `relay ${flat.noun} clear` to empty it") + val signed = flat.build(ctx, relays) + ctx.verifyAndStore(signed) + Output.emit(mapOf("noun" to flat.noun, "kind" to flat.kind, "event_id" to signed.id, "relays" to relays.map { it.url })) + } + "clear" -> { + val signed = flat.build(ctx, emptyList()) + ctx.verifyAndStore(signed) + Output.emit(mapOf("noun" to flat.noun, "kind" to flat.kind, "event_id" to signed.id, "relays" to emptyList())) + } + "list" -> Output.emit(mapOf("noun" to flat.noun, "kind" to flat.kind, "relays" to flat.read(ctx, self).map { it.url })) + } return 0 } } - /** - * Append [url] to the relay-list event for [type] (creating one if absent). - * Returns `true` when a new event was written, `false` when [url] was - * already present. For `nip65`, [marker] sets the read/write role. - */ - private suspend fun addToBucket( - ctx: Context, - self: HexKey, - type: String, - url: NormalizedRelayUrl, - marker: AdvertisedRelayType, - ): Boolean { - if (type == NIP65) { - val existing = ctx.relaysOf(self)?.relays().orEmpty() - if (existing.any { it.relayUrl.url == url.url }) return false - val combined = existing + AdvertisedRelayInfo(url, marker) - ctx.verifyAndStore(AdvertisedRelayListEvent.create(combined, ctx.signer)) - return true - } - val bucket = bucketFor(type) ?: throw IllegalArgumentException("unknown relay type: $type") - val existing = bucket.read(ctx, self) - if (existing.any { it.url == url.url }) return false - ctx.verifyAndStore(bucket.build(ctx, existing + url)) - return true - } + // ------------------------------------------------------------------ + // NIP-65 facet verbs (outbox / inbox) + // ------------------------------------------------------------------ - /** - * Drop [url] from the relay-list event for [type]. Returns `true` when the - * URL was present (and a new, shorter event was written), `false` otherwise. - */ - private suspend fun removeFromBucket( - ctx: Context, - self: HexKey, - type: String, - url: NormalizedRelayUrl, - ): Boolean { - if (type == NIP65) { - val existing = ctx.relaysOf(self)?.relays().orEmpty() - if (existing.none { it.relayUrl.url == url.url }) return false - val remaining = existing.filterNot { it.relayUrl.url == url.url } - ctx.verifyAndStore(AdvertisedRelayListEvent.create(remaining, ctx.signer)) - return true - } - val bucket = bucketFor(type) ?: throw IllegalArgumentException("unknown relay type: $type") - val existing = bucket.read(ctx, self) - if (existing.none { it.url == url.url }) return false - ctx.verifyAndStore(bucket.build(ctx, existing.filterNot { it.url == url.url })) - return true - } - - private suspend fun list( + private suspend fun facetVerb( dataDir: DataDir, - args: Args, + facet: Facet, + rest: Array, ): Int { - val type = args.flag("type") + val verb = rest.getOrNull(0) ?: "list" + val args = Args(rest.drop(1).toTypedArray()) + if (verb !in VERBS) return Output.error("bad_args", "relay ${facet.noun} $verb ($VERB_LIST)") + Context.open(dataDir).use { ctx -> val self = ctx.identity.pubKeyHex + when (verb) { + "add", "remove", "rm" -> { + val present = verb == "add" + val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url") + val changed = mutateNip65(ctx, self) { applyFacet(it, url, facet, present) } + Output.emit( + mapOf( + "noun" to facet.noun, + "kind" to AdvertisedRelayListEvent.KIND, + "url" to url.url, + (if (present) "added" else "removed") to changed, + "marker" to markerLabel(readNip65(ctx, self), url), + ), + ) + } + "set", "clear" -> { + val relays = + if (verb == "clear") { + emptyList() + } else { + val parsed = parseUrls(args.positional) ?: return Output.error("bad_args", "invalid relay url") + if (parsed.isEmpty()) return Output.error("bad_args", "set needs at least one URL; use `relay ${facet.noun} clear` to empty it") + parsed + } + mutateNip65(ctx, self) { facetSet(it, relays, facet) } + Output.emit(mapOf("noun" to facet.noun, "kind" to AdvertisedRelayListEvent.KIND, "relays" to facetUrls(ctx, self, facet))) + } + "list" -> Output.emit(mapOf("noun" to facet.noun, "kind" to AdvertisedRelayListEvent.KIND, "relays" to facetUrls(ctx, self, facet))) + } + return 0 + } + } - if (type != null) { - // Single-bucket view. - if (type == NIP65) { + /** `relay nip65 …` — combined read/write view; edits limited to remove/clear. */ + private suspend fun nip65Verb( + dataDir: DataDir, + rest: Array, + ): Int { + val verb = rest.getOrNull(0) ?: "list" + val args = Args(rest.drop(1).toTypedArray()) + Context.open(dataDir).use { ctx -> + val self = ctx.identity.pubKeyHex + when (verb) { + "list" -> { val nip65 = ctx.relaysOf(self) Output.emit( mapOf( - "type" to NIP65, + "noun" to "nip65", "kind" to AdvertisedRelayListEvent.KIND, "read" to (nip65?.readRelaysNorm()?.map { it.url } ?: emptyList()), "write" to (nip65?.writeRelaysNorm()?.map { it.url } ?: emptyList()), "relays" to (nip65?.relaysNorm()?.map { it.url } ?: emptyList()), ), ) - return 0 } - val bucket = bucketFor(type) ?: return Output.error("bad_args", "unknown relay type: $type") - Output.emit( - mapOf( - "type" to bucket.key, - "kind" to bucket.kind, - "relays" to bucket.read(ctx, self).map { it.url }, - ), - ) - return 0 + "remove", "rm" -> { + val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url") + val removed = mutateNip65(ctx, self) { infos -> infos.filterNot { it.relayUrl.url == url.url } } + Output.emit(mapOf("noun" to "nip65", "kind" to AdvertisedRelayListEvent.KIND, "url" to url.url, "removed" to removed)) + } + "clear" -> { + mutateNip65(ctx, self) { emptyList() } + Output.emit(mapOf("noun" to "nip65", "kind" to AdvertisedRelayListEvent.KIND, "relays" to emptyList())) + } + "add", "set" -> + return Output.error( + "bad_args", + "nip65 carries read/write markers — use `relay outbox $verb` (write) or `relay inbox $verb` (read)", + ) + else -> return Output.error("bad_args", "relay nip65 $verb (list|remove|clear)") + } + return 0 + } + } + + // ------------------------------------------------------------------ + // relay add/remove URL — fan-out to the transport lists + // ------------------------------------------------------------------ + + private suspend fun fanOut( + dataDir: DataDir, + args: Args, + add: Boolean, + ): Int { + val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url") + Context.open(dataDir).use { ctx -> + val self = ctx.identity.pubKeyHex + val changed = linkedMapOf() + + // nip65 as read+write (both). + changed["nip65"] = + if (add) { + mutateNip65(ctx, self) { applyFacet(applyFacet(it, url, Facet.OUTBOX, true), url, Facet.INBOX, true) } + } else { + mutateNip65(ctx, self) { infos -> infos.filterNot { it.relayUrl.url == url.url } } + } + + for (noun in listOf("dm", "key-package")) { + val flat = flatFor(noun)!! + val existing = flat.read(ctx, self) + changed[flat.jsonKey] = + if (add) { + val doAdd = existing.none { it.url == url.url } + if (doAdd) ctx.verifyAndStore(flat.build(ctx, existing + url)) + doAdd + } else { + val doRemove = existing.any { it.url == url.url } + if (doRemove) ctx.verifyAndStore(flat.build(ctx, existing.filterNot { it.url == url.url })) + doRemove + } } - // Full view — every bucket. Keys `nip65`/`inbox`/`key_package` are - // kept as flat URL lists for backwards compatibility; `nip65_read`/ - // `nip65_write` add the marker split, and the private lists follow. + val hit = changed.filterValues { it }.keys.toList() + val miss = changed.filterValues { !it }.keys.toList() + Output.emit( + if (add) { + mapOf("url" to url.url, "added_to" to hit, "already_present" to miss) + } else { + mapOf("url" to url.url, "removed_from" to hit, "not_present" to miss) + }, + ) + return 0 + } + } + + // ------------------------------------------------------------------ + // relay list / publish-lists + // ------------------------------------------------------------------ + + private suspend fun listAll(dataDir: DataDir): Int { + Context.open(dataDir).use { ctx -> + val self = ctx.identity.pubKeyHex val nip65 = ctx.relaysOf(self) val out = linkedMapOf() + out["outbox"] = nip65?.writeRelaysNorm()?.map { it.url } ?: emptyList() + out["inbox"] = nip65?.readRelaysNorm()?.map { it.url } ?: emptyList() out["nip65"] = nip65?.relaysNorm()?.map { it.url } ?: emptyList() - out["nip65_read"] = nip65?.readRelaysNorm()?.map { it.url } ?: emptyList() - out["nip65_write"] = nip65?.writeRelaysNorm()?.map { it.url } ?: emptyList() - for (bucket in BUCKETS) { - out[bucket.key] = bucket.read(ctx, self).map { it.url } + for (flat in FLATS) { + out[flat.jsonKey] = flat.read(ctx, self).map { it.url } } Output.emit(out) return 0 @@ -456,11 +482,10 @@ object RelayCommands { ctx.prepare() val self = ctx.identity.pubKeyHex - // Collect every configured list event: nip65 plus each flat bucket. val events = linkedMapOf() - events[NIP65] = ctx.relaysOf(self) - for (bucket in BUCKETS) { - events[bucket.key] = ctx.latestReplaceable(self, bucket.kind) + events["nip65"] = ctx.relaysOf(self) + for (flat in FLATS) { + events[flat.jsonKey] = ctx.latestReplaceable(self, flat.kind) } if (events.values.all { it == null }) { @@ -479,17 +504,127 @@ object RelayCommands { acceptedBy[key] = result.filterValues { it }.keys.map { it.url } } - Output.emit( - mapOf( - // Legacy keys kept verbatim so existing scripts keep working. - "nip65_event_id" to eventIds[NIP65], - "inbox_event_id" to eventIds["inbox"], - "key_package_list_event_id" to eventIds["key_package"], - "event_ids" to eventIds, - "accepted_by" to acceptedBy, - ), - ) + Output.emit(mapOf("event_ids" to eventIds, "accepted_by" to acceptedBy)) return 0 } } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private val VERBS = setOf("add", "remove", "rm", "set", "clear", "list") + private const val VERB_LIST = "add|remove|set|clear|list" + + private fun parseUrl(raw: String): NormalizedRelayUrl? = raw.normalizeRelayUrlOrNull() + + /** Normalize + dedupe (order-preserving) a list of raw URLs, or null on any bad one. */ + private fun parseUrls(raws: List): List? { + val out = mutableListOf() + for (raw in raws) out.add(raw.normalizeRelayUrlOrNull() ?: return null) + return out.distinctBy { it.url } + } + + private suspend fun readNip65( + ctx: Context, + self: HexKey, + ): List = ctx.relaysOf(self)?.relays().orEmpty() + + /** Read/write flags for one relay, split out from [AdvertisedRelayType]. */ + private data class RW( + val read: Boolean, + val write: Boolean, + ) + + private fun AdvertisedRelayType.rw() = RW(isRead(), isWrite()) + + private fun RW.toTypeOrNull(): AdvertisedRelayType? = + when { + read && write -> AdvertisedRelayType.BOTH + read -> AdvertisedRelayType.READ + write -> AdvertisedRelayType.WRITE + else -> null + } + + /** + * Toggle one [facet] on/off for [url] within the kind:10002 entry list, + * applying the NIP-65 merge rules: turning a facet on merges into `both` + * when the other facet is set; turning the last facet off drops the relay. + * Order is preserved. + */ + private fun applyFacet( + infos: List, + url: NormalizedRelayUrl, + facet: Facet, + present: Boolean, + ): List { + val urls = LinkedHashMap() + val flags = LinkedHashMap() + for (i in infos) { + urls[i.relayUrl.url] = i.relayUrl + flags[i.relayUrl.url] = i.type.rw() + } + val cur = flags[url.url] ?: RW(read = false, write = false) + val next = if (facet == Facet.OUTBOX) cur.copy(write = present) else cur.copy(read = present) + if (next.read || next.write) { + urls[url.url] = url + flags[url.url] = next + } else { + urls.remove(url.url) + flags.remove(url.url) + } + return flags.entries.map { AdvertisedRelayInfo(urls[it.key]!!, it.value.toTypeOrNull()!!) } + } + + /** Make exactly [targets] carry [facet], demoting/removing any relay that currently does but shouldn't. */ + private fun facetSet( + infos: List, + targets: List, + facet: Facet, + ): List { + val keep = targets.map { it.url }.toSet() + var result = infos + for (i in infos) { + val has = if (facet == Facet.OUTBOX) i.type.isWrite() else i.type.isRead() + if (has && i.relayUrl.url !in keep) result = applyFacet(result, i.relayUrl, facet, present = false) + } + for (u in targets) result = applyFacet(result, u, facet, present = true) + return result + } + + /** Read, transform, and (only if it changed) re-sign + store the kind:10002. Returns whether it changed. */ + private suspend fun mutateNip65( + ctx: Context, + self: HexKey, + transform: (List) -> List, + ): Boolean { + val before = readNip65(ctx, self) + val after = transform(before) + if (infoKey(before) == infoKey(after)) return false + ctx.verifyAndStore(AdvertisedRelayListEvent.create(after, ctx.signer)) + return true + } + + private fun infoKey(list: List) = list.map { it.relayUrl.url to it.type } + + private suspend fun facetUrls( + ctx: Context, + self: HexKey, + facet: Facet, + ): List { + val nip65 = ctx.relaysOf(self) + val urls = if (facet == Facet.OUTBOX) nip65?.writeRelaysNorm() else nip65?.readRelaysNorm() + return urls?.map { it.url } ?: emptyList() + } + + private fun markerLabel( + infos: List, + url: NormalizedRelayUrl, + ): String = + when (infos.firstOrNull { it.relayUrl.url == url.url }?.type) { + AdvertisedRelayType.BOTH -> "both" + AdvertisedRelayType.READ -> "read" + AdvertisedRelayType.WRITE -> "write" + null -> "none" + } } diff --git a/cli/tests/cache/cache-headless.sh b/cli/tests/cache/cache-headless.sh index df674d36d5..b1944678ce 100755 --- a/cli/tests/cache/cache-headless.sh +++ b/cli/tests/cache/cache-headless.sh @@ -116,7 +116,7 @@ start_local_relay # Identity A: full bootstrap so the shared store has kind:0 / 3 / 10002 / … ensure_identity_for A banner "Bootstrapping A's account (publishes kind:0 + bootstrap events)" -amy_a relay add "$RELAY_URL" --type all >>"$LOG_FILE" 2>&1 +amy_a relay add "$RELAY_URL" >>"$LOG_FILE" 2>&1 # `amy create` here would mint a *second* identity; A already exists from # `init`. Build the bootstrap events ourselves by publishing a minimal # kind:0 + the relay lists, all of which land in the shared store via @@ -129,7 +129,7 @@ amy_a profile edit --name "AAA" --about "cache test subject" \ # Identity B: same fake $HOME, separate per-account dir, but A and B # both read/write to $STATE_DIR/.amy/shared/events-store/. ensure_identity_for B -amy_b relay add "$RELAY_URL" --type all >>"$LOG_FILE" 2>&1 +amy_b relay add "$RELAY_URL" >>"$LOG_FILE" 2>&1 amy_b relay publish-lists >>"$LOG_FILE" 2>&1 # ---------------------------------------------------------------------- @@ -211,26 +211,27 @@ assert_eq "$T4_NAME" "AAA" T4.name "A's profile metadata must be readable from B # ---------------------------------------------------------------------- # 5. relay list reads URLs back from the local kind:10002 / 10050 / 10051. +# `relay add URL` fans out to the transport lists (nip65 both, dm, key-package). # ---------------------------------------------------------------------- banner "T5 — relay list reads from store" T5=$(amy_a relay list) T5_NIP65=$(printf '%s' "$T5" | jq -r '.nip65 | length') -T5_INBOX=$(printf '%s' "$T5" | jq -r '.inbox | length') +T5_DM=$(printf '%s' "$T5" | jq -r '.dm | length') T5_KP=$(printf '%s' "$T5" | jq -r '.key_package | length') if [[ "$T5_NIP65" -ge 1 ]]; then record_result T5.nip65 pass "$T5_NIP65 nip65 url(s)" else - record_result T5.nip65 fail "nip65 bucket empty after relay add --type all" + record_result T5.nip65 fail "nip65 bucket empty after relay add" fi -if [[ "$T5_INBOX" -ge 1 ]]; then - record_result T5.inbox pass "$T5_INBOX inbox url(s)" +if [[ "$T5_DM" -ge 1 ]]; then + record_result T5.dm pass "$T5_DM dm url(s)" else - record_result T5.inbox fail "inbox bucket empty after relay add --type all" + record_result T5.dm fail "dm bucket empty after relay add" fi if [[ "$T5_KP" -ge 1 ]]; then record_result T5.key_package pass "$T5_KP key_package url(s)" else - record_result T5.key_package fail "key_package bucket empty after relay add --type all" + record_result T5.key_package fail "key_package bucket empty after relay add" fi # ---------------------------------------------------------------------- diff --git a/cli/tests/dm/setup.sh b/cli/tests/dm/setup.sh index 8693ae7f41..5af3b5605d 100644 --- a/cli/tests/dm/setup.sh +++ b/cli/tests/dm/setup.sh @@ -106,8 +106,8 @@ ensure_identity_for() { # so the DM strict-relay routing has something to resolve to. configure_relays_dm() { banner "Configuring relays → $RELAY_URL" - amy_a relay add "$RELAY_URL" --type all >/dev/null - amy_d relay add "$RELAY_URL" --type all >/dev/null + amy_a relay add "$RELAY_URL" >/dev/null + amy_d relay add "$RELAY_URL" >/dev/null step "publishing A's NIP-65 + kind:10050 lists" amy_a relay publish-lists >>"$LOG_FILE" 2>&1 \ diff --git a/cli/tests/marmot/setup.sh b/cli/tests/marmot/setup.sh index 61d8abbb38..386a2bf3f1 100644 --- a/cli/tests/marmot/setup.sh +++ b/cli/tests/marmot/setup.sh @@ -348,7 +348,7 @@ ensure_identity() { # goal here is tight, deterministic iteration. configure_relays() { banner "Configuring relays → $RELAY_URL" - amy_a relay add "$RELAY_URL" --type all >/dev/null + amy_a relay add "$RELAY_URL" >/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