feat(cli): drop relays.json — kind:10002/10050/10051 in the store ARE the config
The local relay configuration was redundant once the event store
became the source of truth: every account already publishes signed
kind:10002 (NIP-65), kind:10050 (DM inbox), and kind:10051
(KeyPackage relays) events, and Context now reads each set straight
out of the local store.
Removed:
- data class RelayConfig (nip65/inbox/keyPackage buckets)
- DataDir.relaysFile / loadRelays() / saveRelays()
- Context.relays parameter — Context.open() no longer reads JSON
- CreateCommand.defaultRelayConfig() helper
- CreateCommand's saveRelays() call (redundant: ctx.publish persists
the bootstrap events into the store anyway)
Context now serves the four relay accessors from the store with sane
fallbacks:
outboxRelays() = relaysOf(self)?.writeRelaysNorm() ?: DefaultNIP65RelaySet
inboxRelays() = dmInboxOf(self)?.relays() ?: DefaultDMRelayList.toSet()
keyPackageRelays() = keyPackageRelaysOf(self)?.relays() ?: outboxRelays()
anyRelays() = union of the three
bootstrapRelays() = anyRelays() ∪ DefaultNIP65RelaySet ∪ DefaultDMRelayList
RelayCommands rewritten:
- `relay add URL --type T` — read existing relay-list event from the
store, append URL, build + sign + ingest a new event of the
matching kind. The replaceable slot mechanism replaces the old
winner atomically.
- `relay list` — dump URLs from the latest event in each bucket.
- `relay publish-lists` — broadcast whichever events are present in
the store; errors out cleanly when none exist (suggests `relay add`
or `create`).
No migration: existing data dirs that still have `relays.json` will
ignore it. Run `amy relay add` or `amy create` to repopulate. Per
discussion this is a clean break, not a backwards-compat shim.
README + DEVELOPMENT updated to reflect the new on-disk layout (no
more `relays.json`; events-store/ is the relay configuration).
This commit is contained in:
+2
-2
@@ -43,7 +43,7 @@ cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/
|
|||||||
├── Main.kt # argv → subcommand dispatch
|
├── Main.kt # argv → subcommand dispatch
|
||||||
├── Args.kt # tiny flag parser (no framework)
|
├── Args.kt # tiny flag parser (no framework)
|
||||||
├── Json.kt # single-line stdout + error printer
|
├── Json.kt # single-line stdout + error printer
|
||||||
├── Config.kt # Identity, RelayConfig, RunState, DataDir
|
├── Config.kt # Identity, RunState, DataDir
|
||||||
├── Context.kt # per-run wiring: signer + NostrClient +
|
├── Context.kt # per-run wiring: signer + NostrClient +
|
||||||
│ # MarmotManager + publish/drain/sync helpers
|
│ # MarmotManager + publish/drain/sync helpers
|
||||||
├── stores/FileStores.kt # File-backed MLS / KP / message stores
|
├── stores/FileStores.kt # File-backed MLS / KP / message stores
|
||||||
@@ -196,7 +196,7 @@ Amy-specific layer still needs its own coverage:
|
|||||||
| Argument parsing (`Args`, flag forms, `--data-dir=…` vs `--data-dir …`) | Plain JVM unit tests in `cli/src/test/kotlin/`. |
|
| Argument parsing (`Args`, flag forms, `--data-dir=…` vs `--data-dir …`) | Plain JVM unit tests in `cli/src/test/kotlin/`. |
|
||||||
| Error / exit-code contract (bad args → 2, await timeout → 124, runtime → 1) | Table-driven tests invoking `main(argv)` with captured stdout/stderr. |
|
| Error / exit-code contract (bad args → 2, await timeout → 124, runtime → 1) | Table-driven tests invoking `main(argv)` with captured stdout/stderr. |
|
||||||
| JSON output shape (each command's keys and types) | Snapshot tests: run a command against a throwaway data-dir, assert the JSON matches a golden file. |
|
| JSON output shape (each command's keys and types) | Snapshot tests: run a command against a throwaway data-dir, assert the JSON matches a golden file. |
|
||||||
| File layout on disk (`identity.json`, `relays.json`, `marmot/groups/*.mls`, `marmot/keypackages.bundle`) | Structural assertions after a command sequence. |
|
| File layout on disk (`identity.json`, `events-store/…`, `marmot/groups/*.mls`, `marmot/keypackages.bundle`) | Structural assertions after a command sequence. |
|
||||||
| Round-trip between two data-dirs on a local relay | End-to-end shell harnesses under `cli/tests/`. Each harness spins up a local `nostr-rs-relay`, bootstraps two or more fresh identities in their own `--data-dir`s, and drives a scenario via `amy` (+ `wn` for Marmot interop against whitenoise-rs). Today there are two suites: `cli/tests/marmot/` (13 MLS scenarios vs whitenoise-rs) and `cli/tests/dm/` (NIP-17 DM round-trips between two `amy` clients). |
|
| Round-trip between two data-dirs on a local relay | End-to-end shell harnesses under `cli/tests/`. Each harness spins up a local `nostr-rs-relay`, bootstraps two or more fresh identities in their own `--data-dir`s, and drives a scenario via `amy` (+ `wn` for Marmot interop against whitenoise-rs). Today there are two suites: `cli/tests/marmot/` (13 MLS scenarios vs whitenoise-rs) and `cli/tests/dm/` (NIP-17 DM round-trips between two `amy` clients). |
|
||||||
| Interop with other clients | Covered by `cli/tests/marmot/marmot-interop-headless.sh` (drives Amy against whitenoise-rs `wn`/`wnd`). Add new scenarios there or start a new sibling under `cli/tests/`. |
|
| Interop with other clients | Covered by `cli/tests/marmot/marmot-interop-headless.sh` (drives Amy against whitenoise-rs `wn`/`wnd`). Add new scenarios there or start a new sibling under `cli/tests/`. |
|
||||||
|
|
||||||
|
|||||||
+18
-5
@@ -260,8 +260,13 @@ events.
|
|||||||
```
|
```
|
||||||
<data-dir>/
|
<data-dir>/
|
||||||
├── identity.json # nsec/npub/hex — the account
|
├── identity.json # nsec/npub/hex — the account
|
||||||
├── relays.json # nip65 / inbox / key_package buckets
|
|
||||||
├── state.json # sync cursors (giftWrapSince, groupSince)
|
├── state.json # sync cursors (giftWrapSince, groupSince)
|
||||||
|
├── events-store/ # FsEventStore — every observed Nostr event
|
||||||
|
│ ├── events/<aa>/<bb>/… # canonical kind:0 / 3 / 10002 / 10050 / 10051 / 1 / 5 / 1059 / …
|
||||||
|
│ ├── replaceable/<k>/… # one slot per (kind, pubkey) for kind:0/3/10000-19999
|
||||||
|
│ ├── addressable/… # one slot per (kind, pubkey, d-tag) for kind:30000-39999
|
||||||
|
│ ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at)
|
||||||
|
│ └── tombstones/ # NIP-09 / NIP-62 enforcement
|
||||||
└── marmot/
|
└── marmot/
|
||||||
├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
|
├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
|
||||||
└── groups/
|
└── groups/
|
||||||
@@ -272,6 +277,13 @@ events.
|
|||||||
All files are plain JSON or framed binary — human-inspectable, easy to
|
All files are plain JSON or framed binary — human-inspectable, easy to
|
||||||
diff across two data-dirs in a test run.
|
diff across two data-dirs in a test run.
|
||||||
|
|
||||||
|
The local relay configuration (kind:10002 / 10050 / 10051) is **not** a
|
||||||
|
separate file — it lives in `events-store/` as signed events.
|
||||||
|
`amy relay add` builds + signs + ingests a new relay-list event;
|
||||||
|
`amy relay list` reads URLs straight out of the latest event for each
|
||||||
|
kind; `amy relay publish-lists` broadcasts those events to upstream
|
||||||
|
relays. There is no `relays.json`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -281,9 +293,10 @@ diff across two data-dirs in a test run.
|
|||||||
- **`not_member`** — the group GID is unknown to this data-dir. Run
|
- **`not_member`** — the group GID is unknown to this data-dir. Run
|
||||||
`marmot group list` to confirm, or `marmot await group --name …` to
|
`marmot group list` to confirm, or `marmot await group --name …` to
|
||||||
wait for an invite to arrive.
|
wait for an invite to arrive.
|
||||||
- **Hang on a network verb** — Amy connects to the relays in
|
- **Hang on a network verb** — Amy connects to the relays advertised
|
||||||
`relays.json`; verify with `amy relay list`. Every network-bound
|
in your local kind:10002 / 10050 / 10051 events; inspect with
|
||||||
operation has a timeout — use `--timeout` for `await`, or wrap the
|
`amy relay list`. Every network-bound operation has a timeout — use
|
||||||
whole command in `timeout(1)` if you're scripting.
|
`--timeout` for `await`, or wrap the whole command in `timeout(1)`
|
||||||
|
if you're scripting.
|
||||||
- **Nothing seems to publish** — inspect stderr; each publish prints
|
- **Nothing seems to publish** — inspect stderr; each publish prints
|
||||||
per-relay `OK` / `REJECT` via the `[cli] …` traces.
|
per-relay `OK` / `REJECT` via the `[cli] …` traces.
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import com.fasterxml.jackson.module.kotlin.readValue
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||||
@@ -81,49 +79,6 @@ data class Identity(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* On-disk relay configuration, bucketed by purpose (mirrors `wn relays add --type`).
|
|
||||||
*
|
|
||||||
* - `nip65`: advertised read/write (kind:10002)
|
|
||||||
* - `inbox`: DM inbox / gift-wrap delivery (kind:10050)
|
|
||||||
* - `keyPackage`: where this account's KeyPackages (kind:30443) live
|
|
||||||
*/
|
|
||||||
data class RelayConfig(
|
|
||||||
val nip65: MutableList<String> = mutableListOf(),
|
|
||||||
val inbox: MutableList<String> = mutableListOf(),
|
|
||||||
val keyPackage: MutableList<String> = mutableListOf(),
|
|
||||||
) {
|
|
||||||
fun all(): Set<String> = (nip65 + inbox + keyPackage).toSet()
|
|
||||||
|
|
||||||
fun add(
|
|
||||||
type: String,
|
|
||||||
url: String,
|
|
||||||
): Boolean {
|
|
||||||
val list =
|
|
||||||
when (type) {
|
|
||||||
"nip65" -> nip65
|
|
||||||
"inbox" -> inbox
|
|
||||||
"key_package", "keyPackage" -> keyPackage
|
|
||||||
else -> throw IllegalArgumentException("unknown relay type: $type")
|
|
||||||
}
|
|
||||||
if (list.contains(url)) return false
|
|
||||||
list.add(url)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun normalized(kind: String): Set<NormalizedRelayUrl> {
|
|
||||||
val src =
|
|
||||||
when (kind) {
|
|
||||||
"nip65" -> nip65
|
|
||||||
"inbox" -> inbox
|
|
||||||
"key_package" -> keyPackage
|
|
||||||
"all" -> all().toList()
|
|
||||||
else -> throw IllegalArgumentException("unknown relay selector: $kind")
|
|
||||||
}
|
|
||||||
return src.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Opaque per-run state (subscription cursors, etc). Stored alongside identity. */
|
/** Opaque per-run state (subscription cursors, etc). Stored alongside identity. */
|
||||||
data class RunState(
|
data class RunState(
|
||||||
var giftWrapSince: Long? = null,
|
var giftWrapSince: Long? = null,
|
||||||
@@ -138,7 +93,6 @@ class DataDir(
|
|||||||
val root: File,
|
val root: File,
|
||||||
) {
|
) {
|
||||||
val identityFile = File(root, "identity.json")
|
val identityFile = File(root, "identity.json")
|
||||||
val relaysFile = File(root, "relays.json")
|
|
||||||
val stateFile = File(root, "state.json")
|
val stateFile = File(root, "state.json")
|
||||||
val marmotDir = File(root, "marmot")
|
val marmotDir = File(root, "marmot")
|
||||||
val groupsDir = File(marmotDir, "groups")
|
val groupsDir = File(marmotDir, "groups")
|
||||||
@@ -158,12 +112,6 @@ class DataDir(
|
|||||||
identityFile.writeText(Json.mapper.writeValueAsString(id))
|
identityFile.writeText(Json.mapper.writeValueAsString(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadRelays(): RelayConfig = if (relaysFile.exists()) Json.mapper.readValue(relaysFile.readText()) else RelayConfig()
|
|
||||||
|
|
||||||
fun saveRelays(r: RelayConfig) {
|
|
||||||
relaysFile.writeText(Json.mapper.writeValueAsString(r))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadRunState(): RunState = if (stateFile.exists()) Json.mapper.readValue(stateFile.readText()) else RunState()
|
fun loadRunState(): RunState = if (stateFile.exists()) Json.mapper.readValue(stateFile.readText()) else RunState()
|
||||||
|
|
||||||
fun saveRunState(s: RunState) {
|
fun saveRunState(s: RunState) {
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ import okhttp3.OkHttpClient
|
|||||||
class Context(
|
class Context(
|
||||||
val dataDir: DataDir,
|
val dataDir: DataDir,
|
||||||
val identity: Identity,
|
val identity: Identity,
|
||||||
val relays: RelayConfig,
|
|
||||||
val state: RunState,
|
val state: RunState,
|
||||||
) : AutoCloseable {
|
) : AutoCloseable {
|
||||||
val signer = NostrSignerInternal(identity.keyPair())
|
val signer = NostrSignerInternal(identity.keyPair())
|
||||||
@@ -156,13 +155,39 @@ class Context(
|
|||||||
.resolveUserHexOrNull(input, nip05Client)
|
.resolveUserHexOrNull(input, nip05Client)
|
||||||
?: throw IllegalArgumentException("Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)")
|
?: throw IllegalArgumentException("Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)")
|
||||||
|
|
||||||
fun outboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("nip65")
|
/**
|
||||||
|
* Outbox / NIP-65 write relays for this account. Read from the
|
||||||
|
* local kind:10002 (after `amy create` or `amy relay publish-lists`
|
||||||
|
* has written one); falls back to [DefaultNIP65RelaySet] when no
|
||||||
|
* advertised list exists yet.
|
||||||
|
*
|
||||||
|
* Returns *write*-marked URLs only — the semantic is "where I
|
||||||
|
* publish from", which mirrors `User.outboxRelays()` in the
|
||||||
|
* Android app.
|
||||||
|
*/
|
||||||
|
fun outboxRelays(): Set<NormalizedRelayUrl> =
|
||||||
|
relaysOf(identity.pubKeyHex)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet()
|
||||||
|
?: DefaultNIP65RelaySet
|
||||||
|
|
||||||
fun inboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("inbox")
|
/**
|
||||||
|
* DM inbox relays (NIP-17 kind:10050) for this account. Falls back
|
||||||
|
* to [DefaultDMRelayList] when no kind:10050 has been seen.
|
||||||
|
*/
|
||||||
|
fun inboxRelays(): Set<NormalizedRelayUrl> =
|
||||||
|
dmInboxOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet()
|
||||||
|
?: DefaultDMRelayList.toSet()
|
||||||
|
|
||||||
fun keyPackageRelays(): Set<NormalizedRelayUrl> = relays.normalized("key_package")
|
/**
|
||||||
|
* KeyPackage relays (MIP-00 kind:10051) for this account. Falls
|
||||||
|
* back to [outboxRelays] when no kind:10051 has been seen — same
|
||||||
|
* fallback the Android app uses for KeyPackage discovery.
|
||||||
|
*/
|
||||||
|
fun keyPackageRelays(): Set<NormalizedRelayUrl> =
|
||||||
|
keyPackageRelaysOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet()
|
||||||
|
?: outboxRelays()
|
||||||
|
|
||||||
fun anyRelays(): Set<NormalizedRelayUrl> = relays.normalized("all")
|
/** Union of all three buckets. */
|
||||||
|
fun anyRelays(): Set<NormalizedRelayUrl> = outboxRelays() + inboxRelays() + keyPackageRelays()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed relays for "look up someone we know nothing about" queries —
|
* Seed relays for "look up someone we know nothing about" queries —
|
||||||
@@ -578,7 +603,6 @@ class Context(
|
|||||||
return Context(
|
return Context(
|
||||||
dataDir = dataDir,
|
dataDir = dataDir,
|
||||||
identity = identity,
|
identity = identity,
|
||||||
relays = dataDir.loadRelays(),
|
|
||||||
state = dataDir.loadRunState(),
|
state = dataDir.loadRunState(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.cli.Context
|
|||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Identity
|
import com.vitorpamplona.amethyst.cli.Identity
|
||||||
import com.vitorpamplona.amethyst.cli.Json
|
import com.vitorpamplona.amethyst.cli.Json
|
||||||
import com.vitorpamplona.amethyst.cli.RelayConfig
|
|
||||||
import com.vitorpamplona.amethyst.commons.account.bootstrapAccountEvents
|
import com.vitorpamplona.amethyst.commons.account.bootstrapAccountEvents
|
||||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
|
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
|
||||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List
|
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List
|
||||||
@@ -34,9 +33,14 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `amy create [--name NAME]` — provision a brand-new Nostr account with the
|
* `amy create [--name NAME]` — provision a brand-new Nostr account with the
|
||||||
* same defaults Amethyst uses, publish the nine bootstrap events to the
|
* same defaults Amethyst uses and publish the nine bootstrap events to the
|
||||||
* default NIP-65 relay set, and seed this data-dir's relay config so
|
* default NIP-65 relay set.
|
||||||
* subsequent `amy marmot …` commands immediately target the right relays.
|
*
|
||||||
|
* The bootstrap events include kind:10002 / 10050 / 10051, which are
|
||||||
|
* persisted into [Context.store] as a side effect of [Context.publish].
|
||||||
|
* From that point on, [Context.outboxRelays], [Context.inboxRelays], and
|
||||||
|
* [Context.keyPackageRelays] read straight from the local store — no
|
||||||
|
* separate `relays.json` is needed.
|
||||||
*
|
*
|
||||||
* The heavy lifting (which events to sign, with which defaults) lives in
|
* The heavy lifting (which events to sign, with which defaults) lives in
|
||||||
* `commons/.../AccountBootstrap.kt` so this command stays assembly-thin
|
* `commons/.../AccountBootstrap.kt` so this command stays assembly-thin
|
||||||
@@ -53,10 +57,9 @@ object CreateCommand {
|
|||||||
val args = Args(rest)
|
val args = Args(rest)
|
||||||
val name = args.flag("name")
|
val name = args.flag("name")
|
||||||
|
|
||||||
// 1. Mint identity + seed relay config.
|
// 1. Mint identity.
|
||||||
val identity = Identity.create()
|
val identity = Identity.create()
|
||||||
dataDir.saveIdentity(identity)
|
dataDir.saveIdentity(identity)
|
||||||
dataDir.saveRelays(defaultRelayConfig())
|
|
||||||
|
|
||||||
// 2. Build the nine signed bootstrap events via the shared helper.
|
// 2. Build the nine signed bootstrap events via the shared helper.
|
||||||
val signer = NostrSignerSync(identity.keyPair())
|
val signer = NostrSignerSync(identity.keyPair())
|
||||||
@@ -64,6 +67,10 @@ object CreateCommand {
|
|||||||
|
|
||||||
// 3. Open a Context so we reuse publishAndConfirmDetailed + the
|
// 3. Open a Context so we reuse publishAndConfirmDetailed + the
|
||||||
// NostrClient plumbing instead of reinventing a second client.
|
// NostrClient plumbing instead of reinventing a second client.
|
||||||
|
// Each ctx.publish call also persists the event into the local
|
||||||
|
// store via verifyAndStore — so kind:10002 / 10050 / 10051 are
|
||||||
|
// immediately readable by outboxRelays() / inboxRelays() /
|
||||||
|
// keyPackageRelays() without a separate config file.
|
||||||
val ctx = Context.open(dataDir)
|
val ctx = Context.open(dataDir)
|
||||||
val accepted = mutableMapOf<String, List<String>>()
|
val accepted = mutableMapOf<String, List<String>>()
|
||||||
try {
|
try {
|
||||||
@@ -95,20 +102,4 @@ object CreateCommand {
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirror of what Amethyst's in-app defaults would write on disk — NIP-65
|
|
||||||
* outbox, DM inbox (kind:10050), and KeyPackage host relays (kind:10051).
|
|
||||||
* Keeping these in sync with the signed events above is load-bearing:
|
|
||||||
* `amy marmot key-package publish` later reads `relays.json` to decide
|
|
||||||
* where to publish, and if those diverge from the advertised kind:10051
|
|
||||||
* nobody will find the KPs.
|
|
||||||
*/
|
|
||||||
private fun defaultRelayConfig(): RelayConfig {
|
|
||||||
val cfg = RelayConfig()
|
|
||||||
DefaultNIP65List.forEach { cfg.add("nip65", it.relayUrl.url) }
|
|
||||||
DefaultDMRelayList.forEach { cfg.add("inbox", it.url) }
|
|
||||||
DefaultNIP65RelaySet.forEach { cfg.add("key_package", it.url) }
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,28 @@ import com.vitorpamplona.amethyst.cli.Context
|
|||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Json
|
import com.vitorpamplona.amethyst.cli.Json
|
||||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
|
||||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
|
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
|
||||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
|
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `amy relay <add|list|publish-lists>` — manage this account's relay sets.
|
||||||
|
*
|
||||||
|
* Source of truth is the local event store (`<data-dir>/events-store/`)
|
||||||
|
* via Context.relaysOf / dmInboxOf / keyPackageRelaysOf. There is no
|
||||||
|
* `relays.json` any more — the kind:10002 / 10050 / 10051 events ARE
|
||||||
|
* the relay configuration.
|
||||||
|
*
|
||||||
|
* - `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).
|
||||||
|
*/
|
||||||
object RelayCommands {
|
object RelayCommands {
|
||||||
suspend fun dispatch(
|
suspend fun dispatch(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
@@ -46,78 +63,128 @@ object RelayCommands {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun add(
|
private suspend fun add(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
args: Args,
|
args: Args,
|
||||||
): Int {
|
): Int {
|
||||||
val url = args.positional(0, "url")
|
val rawUrl = args.positional(0, "url")
|
||||||
val type = args.flag("type", "all") ?: "all"
|
val type = args.flag("type", "all") ?: "all"
|
||||||
val cfg = dataDir.loadRelays()
|
val normalized =
|
||||||
val addedTo = mutableListOf<String>()
|
rawUrl.normalizeRelayUrlOrNull()
|
||||||
|
?: return Json.error("bad_args", "invalid relay url: $rawUrl")
|
||||||
|
|
||||||
val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type)
|
val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type)
|
||||||
for (t in targets) {
|
val ctx = Context.open(dataDir)
|
||||||
if (cfg.add(t, url)) addedTo.add(t)
|
try {
|
||||||
|
val addedTo = mutableListOf<String>()
|
||||||
|
val alreadyPresent = mutableListOf<String>()
|
||||||
|
for (t in targets) {
|
||||||
|
if (addToBucket(ctx, t, normalized)) addedTo.add(t) else alreadyPresent.add(t)
|
||||||
|
}
|
||||||
|
Json.writeLine(
|
||||||
|
mapOf(
|
||||||
|
"url" to rawUrl,
|
||||||
|
"added_to" to addedTo,
|
||||||
|
"already_present" to alreadyPresent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
} finally {
|
||||||
|
ctx.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
|
"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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
dataDir.saveRelays(cfg)
|
|
||||||
Json.writeLine(
|
|
||||||
mapOf(
|
|
||||||
"url" to url,
|
|
||||||
"added_to" to addedTo,
|
|
||||||
"already_present" to (targets - addedTo.toSet()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun list(dataDir: DataDir): Int {
|
private fun list(dataDir: DataDir): Int {
|
||||||
val cfg = dataDir.loadRelays()
|
val ctx = Context.open(dataDir)
|
||||||
Json.writeLine(
|
try {
|
||||||
mapOf(
|
val self = ctx.identity.pubKeyHex
|
||||||
"nip65" to cfg.nip65,
|
Json.writeLine(
|
||||||
"inbox" to cfg.inbox,
|
mapOf(
|
||||||
"key_package" to cfg.keyPackage,
|
"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()),
|
||||||
return 0
|
),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
} finally {
|
||||||
|
ctx.close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun publishLists(dataDir: DataDir): Int {
|
private suspend fun publishLists(dataDir: DataDir): Int {
|
||||||
val ctx = Context.open(dataDir)
|
val ctx = Context.open(dataDir)
|
||||||
try {
|
try {
|
||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
val nip65Relays = ctx.relays.normalized("nip65").toList()
|
val self = ctx.identity.pubKeyHex
|
||||||
val inboxRelays = ctx.relays.normalized("inbox").toList()
|
val nip65Event = ctx.relaysOf(self)
|
||||||
// MIP-00: other clients discover our KeyPackages by querying the
|
val inboxEvent = ctx.dmInboxOf(self)
|
||||||
// relays advertised in our kind:10051 event. If no key_package
|
val keyPackageEvent = ctx.keyPackageRelaysOf(self)
|
||||||
// bucket is configured, fall back to the NIP-65 set so we always
|
|
||||||
// publish a non-empty list — an empty 10051 would make us
|
|
||||||
// undiscoverable by other Marmot clients.
|
|
||||||
val keyPackageRelays =
|
|
||||||
ctx.relays
|
|
||||||
.normalized("key_package")
|
|
||||||
.ifEmpty { ctx.outboxRelays() }
|
|
||||||
.toList()
|
|
||||||
|
|
||||||
val nip65Infos = nip65Relays.map { AdvertisedRelayInfo(it, AdvertisedRelayType.BOTH) }
|
if (nip65Event == null && inboxEvent == null && keyPackageEvent == null) {
|
||||||
val nip65Event = AdvertisedRelayListEvent.create(nip65Infos, ctx.signer)
|
return Json.error(
|
||||||
val inboxEvent = ChatMessageRelayListEvent.create(inboxRelays, ctx.signer)
|
"no_relays",
|
||||||
val keyPackageListEvent = KeyPackageRelayListEvent.create(keyPackageRelays, ctx.signer)
|
"no relay lists in the local store; run `amy relay add` first or `amy create` to bootstrap defaults",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val targets = ctx.anyRelays()
|
val targets = ctx.anyRelays()
|
||||||
val nip65Result = ctx.publish(nip65Event, targets)
|
val nip65Result = nip65Event?.let { ctx.publish(it, targets) }.orEmpty()
|
||||||
val inboxResult = ctx.publish(inboxEvent, targets)
|
val inboxResult = inboxEvent?.let { ctx.publish(it, targets) }.orEmpty()
|
||||||
val keyPackageListResult = ctx.publish(keyPackageListEvent, targets)
|
val keyPackageResult = keyPackageEvent?.let { ctx.publish(it, targets) }.orEmpty()
|
||||||
|
|
||||||
Json.writeLine(
|
Json.writeLine(
|
||||||
mapOf(
|
mapOf(
|
||||||
"nip65_event_id" to nip65Event.id,
|
"nip65_event_id" to nip65Event?.id,
|
||||||
"inbox_event_id" to inboxEvent.id,
|
"inbox_event_id" to inboxEvent?.id,
|
||||||
"key_package_list_event_id" to keyPackageListEvent.id,
|
"key_package_list_event_id" to keyPackageEvent?.id,
|
||||||
"accepted_by" to
|
"accepted_by" to
|
||||||
mapOf(
|
mapOf(
|
||||||
"nip65" to nip65Result.filterValues { it }.keys.map { it.url },
|
"nip65" to nip65Result.filterValues { it }.keys.map { it.url },
|
||||||
"inbox" to inboxResult.filterValues { it }.keys.map { it.url },
|
"inbox" to inboxResult.filterValues { it }.keys.map { it.url },
|
||||||
"key_package_list" to keyPackageListResult.filterValues { it }.keys.map { it.url },
|
"key_package_list" to keyPackageResult.filterValues { it }.keys.map { it.url },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user