mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
refactor(cli): noun-first amy relay, outbox/inbox NIP-65 facets
Restructure `amy relay` from verb-first `relay add URL --type T` to noun-first `relay <noun> <verb>`, 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 <add|remove|set|clear|list>`. `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
This commit is contained in:
+32
-17
@@ -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 <noun> add\|remove URL` | For the flat buckets (`dm`, `search`, `blocked`, …): append / drop URL. |
|
||||
| `amy relay <noun> set URL…` / `clear` | Replace a bucket's whole list, or empty it. `set` needs ≥1 URL; use `clear` to empty. |
|
||||
| `amy relay <noun>` | 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
|
||||
|
||||
+1
-1
@@ -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 <noun> 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/` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -47,167 +47,191 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* `amy relay <add|remove|set|list|publish-lists|info>` — 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 (`<data-dir>/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 <outbox|inbox|nip65|dm|key-package|search|private|blocked|trusted|proxy|indexer|broadcast|feeds|add|remove|list|publish-lists|info> …"
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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<String> = emptySet(),
|
||||
val aliases: Set<String>,
|
||||
val read: suspend (Context, HexKey) -> List<NormalizedRelayUrl>,
|
||||
val build: suspend (Context, List<NormalizedRelayUrl>) -> 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<Bucket> =
|
||||
private val FLATS: List<Flat> =
|
||||
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<String> =
|
||||
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<String>,
|
||||
): Int =
|
||||
route(
|
||||
"relay",
|
||||
tail,
|
||||
"relay <add|remove|set|list|publish-lists|info> …",
|
||||
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<String>): 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<String>,
|
||||
): 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<String>()
|
||||
val alreadyPresent = mutableListOf<String>()
|
||||
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<String>()
|
||||
val notPresent = mutableListOf<String>()
|
||||
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<NormalizedRelayUrl>()
|
||||
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<String>()))
|
||||
}
|
||||
"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<String>,
|
||||
): 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<String>,
|
||||
): 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<String>()),
|
||||
"write" to (nip65?.writeRelaysNorm()?.map { it.url } ?: emptyList<String>()),
|
||||
"relays" to (nip65?.relaysNorm()?.map { it.url } ?: emptyList<String>()),
|
||||
),
|
||||
)
|
||||
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<String>()))
|
||||
}
|
||||
"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<String, Boolean>()
|
||||
|
||||
// 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<String, Any?>()
|
||||
out["outbox"] = nip65?.writeRelaysNorm()?.map { it.url } ?: emptyList<String>()
|
||||
out["inbox"] = nip65?.readRelaysNorm()?.map { it.url } ?: emptyList<String>()
|
||||
out["nip65"] = nip65?.relaysNorm()?.map { it.url } ?: emptyList<String>()
|
||||
out["nip65_read"] = nip65?.readRelaysNorm()?.map { it.url } ?: emptyList<String>()
|
||||
out["nip65_write"] = nip65?.writeRelaysNorm()?.map { it.url } ?: emptyList<String>()
|
||||
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<String, Event?>()
|
||||
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<String>): List<NormalizedRelayUrl>? {
|
||||
val out = mutableListOf<NormalizedRelayUrl>()
|
||||
for (raw in raws) out.add(raw.normalizeRelayUrlOrNull() ?: return null)
|
||||
return out.distinctBy { it.url }
|
||||
}
|
||||
|
||||
private suspend fun readNip65(
|
||||
ctx: Context,
|
||||
self: HexKey,
|
||||
): List<AdvertisedRelayInfo> = 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<AdvertisedRelayInfo>,
|
||||
url: NormalizedRelayUrl,
|
||||
facet: Facet,
|
||||
present: Boolean,
|
||||
): List<AdvertisedRelayInfo> {
|
||||
val urls = LinkedHashMap<String, NormalizedRelayUrl>()
|
||||
val flags = LinkedHashMap<String, RW>()
|
||||
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<AdvertisedRelayInfo>,
|
||||
targets: List<NormalizedRelayUrl>,
|
||||
facet: Facet,
|
||||
): List<AdvertisedRelayInfo> {
|
||||
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<AdvertisedRelayInfo>) -> List<AdvertisedRelayInfo>,
|
||||
): 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<AdvertisedRelayInfo>) = list.map { it.relayUrl.url to it.type }
|
||||
|
||||
private suspend fun facetUrls(
|
||||
ctx: Context,
|
||||
self: HexKey,
|
||||
facet: Facet,
|
||||
): List<String> {
|
||||
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<AdvertisedRelayInfo>,
|
||||
url: NormalizedRelayUrl,
|
||||
): String =
|
||||
when (infos.firstOrNull { it.relayUrl.url == url.url }?.type) {
|
||||
AdvertisedRelayType.BOTH -> "both"
|
||||
AdvertisedRelayType.READ -> "read"
|
||||
AdvertisedRelayType.WRITE -> "write"
|
||||
null -> "none"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+9
-8
@@ -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
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user