From 8b822029e29f42a4881bb8cd7a87d3261f98e80e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:52:12 +0000 Subject: [PATCH] feat(cli): graperank refresh rename, unregister verb, operator keys rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `graperank update` -> `graperank refresh`: the verb says what it does (refresh the WoT record kinds from each author's outbox) and stops colliding with the sync/crawl naming tangle. `update` stays as an alias. - New `graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]`: the missing inverse of `register` — removes matching entries (public + private) from the account's kind:10040 and re-publishes it; without narrowing flags every entry for that provider key is dropped. - `graperank operator providers` -> `operator keys`: it lists the observer -> service-key map, and the old name collided with `graperank providers` (the kind:10040 read). `providers` stays as an alias; the JSON key `providers` becomes `keys` in both the listing and `operator status` (breaking, matches the rename). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56 --- cli/README.md | 8 +- cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 13 +- .../amethyst/cli/commands/GrapeRankCommand.kt | 120 ++++++++++++++++-- 4 files changed, 123 insertions(+), 20 deletions(-) diff --git a/cli/README.md b/cli/README.md index 91446e2e74..4a6cf5a7a7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -392,8 +392,10 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy graperank [OBSERVER] [--offline] [--min-rank N]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. **Every score run persists its result locally**: the ranks (cutoff `--min-rank`, default 2) are reconciled into the shared store as NIP-85 kind:30382 cards signed by a per-observer **service key** — changed ranks re-signed, unchanged skipped (no event-id churn), dropped targets retracted (kind:5). `graperank score` is the local-only variant (same as `--offline`). | | `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | Transport only: make the operator relay(s) converge to the locally persisted card set — one NIP-77 up-only reconcile per relay over the service key's kind:30382 + kind:5 (nothing is re-scored or re-signed; a relay that can't reconcile gets the full set published instead). Also refreshes the observer's kind:10040 pointer when we hold their key. | | `amy graperank rank USER [--provider PUBKEY] [--refresh]` | The consumer side: read the kind:30382 cards about USER — one rank per provider, newest card each. Local store first; `--refresh` (or a miss) drains the operator relays, the relays your kind:10040 declares, and the bootstrap set. | -| `amy graperank operator [status \| relay … \| providers]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where `publish` sends cards + retractions; `status` shows the master pubkey and relays; `providers` lists the observer → service-pubkey map. | +| `amy graperank refresh [--down] [--up]` | Refresh every locally-known author's WoT record kinds (0/3/10002/1984) from their own outbox: one NIP-77 negentropy reconcile per write relay scoped to its authors, so the next `score` runs on current data without a full re-crawl. (`update` is the pre-rename alias.) | +| `amy graperank operator [status \| relay … \| keys]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where `publish` sends cards + retractions; `status` shows the master pubkey and relays; `keys` lists the observer → service-pubkey map (`providers` is the pre-rename alias). | | `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL]` | Declare a NIP-85 provider in your kind:10040 so clients can discover it (default: self as the `30382:rank` provider). | +| `amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]` | The inverse of `register`: remove matching entries (public + private) from your kind:10040 and re-publish it. `--service`/`--relay` narrow the match; without them every entry for that provider key is dropped. | | `amy graperank providers [USER]` | List a user's declared NIP-85 trusted providers (public + your own private entries). | #### GrapeRank scores are persisted locally, then published (NIP-85) @@ -432,7 +434,7 @@ negentropy-reconcile gets the full set published event-by-event instead. When the observer is your own account (we hold the key), `publish` also writes their kind:10040 pointing `30382:rank → serviceKey @ operator relay` to their outbox, so clients can find the cards. For a third-party observer, `graperank operator -providers` prints the `observer → service-pubkey` mapping to wire their +keys` prints the `observer → service-pubkey` mapping to wire their kind:10040 out-of-band. ### Direct messages (NIP-17) @@ -553,7 +555,7 @@ the last facet removes R entirely. | `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. | -| `amy relay probe [--timeout SECS] [--concurrency N]` | The relay census: mass-connect every relay the local store knows (all stored kind:10002 relays + the reachability cache) in parallel waves and record live/dead + measured `rtt-open` into the NIP-66 reachability cache (kind:30166). Reachability-aware commands (`graperank crawl`/`update`) read it to skip dead relays and pre-connect live ones. (`amy graperank probe` remains as an alias.) | +| `amy relay probe [--timeout SECS] [--concurrency N]` | The relay census: mass-connect every relay the local store knows (all stored kind:10002 relays + the reachability cache) in parallel waves and record live/dead + measured `rtt-open` into the NIP-66 reachability cache (kind:30166). Reachability-aware commands (`graperank crawl`/`refresh`) read it to skip dead relays and pre-connect live ones. (`amy graperank probe` remains as an alias.) | ### Local store maintenance diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 6144320f7b..217e53814d 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -61,7 +61,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` | | NIP-57 zaps (send + verify) | 🆕 | Needs LN-URL plumbing; `amethyst/service/lnurl/`. | | NIP-65 outbox model queries | 🆕 | | -| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); every score run persists kind:30382 `ContactCardEvent` cards to the local store (diffed against prior ranks, kind:5 retractions), `publish` mirrors that set to the operator relays via NIP-77 up-sync, `rank` reads cards back, plus `register` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. | +| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); every score run persists kind:30382 `ContactCardEvent` cards to the local store (diffed against prior ranks, kind:5 retractions), `publish` mirrors that set to the operator relays via NIP-77 up-sync, `rank` reads cards back, plus `register` / `unregister` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. | | NIP-72 communities | 🆕 | | | NIP-78 app-specific data (settings sync) | 🆕 | | | Long-form (NIP-23) publish / read | 🆕 | | 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 f4b2d7a5ef..2c76144d85 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -646,7 +646,7 @@ private fun printUsage() { | storm (seeded from the reachability cache). | graperank probe alias for `relay probe` (the census moved there — | it feeds the shared NIP-66 reachability cache). - | graperank update [--down] [--up] refresh every locally-known author's WoT record kinds + | graperank refresh [--down] [--up] refresh every locally-known author's WoT record kinds | [--no-sync-deletions] [--timeout SECS] (0/3/10002/1984) from their own outbox: reads all | [--relay-concurrency N] [--author-chunk N] kind:10002 in the store, groups authors by write | [--min-authors N] [--report-limit N] relay, and runs one NIP-77 negentropy reconcile per @@ -654,14 +654,19 @@ private fun printUsage() { | the deletion settle downloads the relay's kind:5 when | an uploaded record was rejected (author retracted it). | Falls back to a full paged download when a relay - | can't reconcile via negentropy. + | can't reconcile via negentropy. (`update` is the + | pre-rename alias.) | graperank operator [status|relay … manage the machine's operator keys (~/.amy/operator/, - | |providers] independent of accounts): relay sets where cards + + | |keys] independent of accounts): relay sets where cards + | retractions publish; status shows master + relays; - | providers lists observer -> service-pubkey. + | keys lists observer -> service-pubkey. | graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040 so | [--service KIND:TAG] [--relay URL] clients can discover it (default: self as the | [--private] 30382:rank provider at your first outbox relay). + | graperank unregister PROVIDER remove matching provider entries (public + private) + | [--service KIND:TAG] [--relay URL] from your kind:10040 and re-publish it; --service/ + | --relay narrow the match, else every entry for + | that provider key is dropped. | graperank providers [USER] [--refresh] list a user's declared NIP-85 trusted providers | [--timeout SECS] (default: active account). | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt index ac836681fb..324945b1da 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt @@ -102,7 +102,8 @@ import kotlin.math.roundToInt * store first, `--refresh` to drain providers' relays): the consumer side. * - `amy graperank register` — advertise a `30382:rank` provider in the * account's kind:10040 [TrustProviderListEvent] (defaults to self, so a - * provider publishing ranks announces where to find them). + * provider publishing ranks announces where to find them); + * `amy graperank unregister PROVIDER` removes entries again. * - `amy graperank providers [USER]` — list a user's trusted providers. */ object GrapeRankCommand { @@ -196,16 +197,19 @@ object GrapeRankCommand { // NIP-05, or nothing) is the OBSERVER positional for a score computation. when (tail.firstOrNull()) { "register" -> register(dataDir, tail.drop(1).toTypedArray()) + "unregister" -> unregister(dataDir, tail.drop(1).toTypedArray()) "providers" -> providers(dataDir, tail.drop(1).toTypedArray()) "operator" -> operator(dataDir, tail.drop(1).toTypedArray()) // `sync` is the pre-rename name kept as a back-compat alias; `crawl` is - // canonical (disambiguates from negentropy `amy sync` / `graperank update`). + // canonical (disambiguates from negentropy `amy sync` / `graperank refresh`). "crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray()) // The relay census outgrew graperank (it feeds the shared NIP-66 // reachability cache every command reads) and moved to `amy relay // probe`; this alias keeps the old spelling working. "probe" -> RelayCommands.probe(dataDir, tail.drop(1).toTypedArray()) - "update" -> update(dataDir, tail.drop(1).toTypedArray()) + // `refresh` is canonical (it refreshes the WoT record kinds from each + // author's outbox); `update` is the pre-rename back-compat alias. + "refresh", "update" -> refresh(dataDir, tail.drop(1).toTypedArray()) "score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true) "publish" -> publish(dataDir, tail.drop(1).toTypedArray()) "rank" -> rank(dataDir, tail.drop(1).toTypedArray()) @@ -544,7 +548,7 @@ object GrapeRankCommand { } /** - * `amy graperank update [flags]` — refresh every locally-known author's WoT + * `amy graperank refresh [flags]` (alias: `update`) — refresh every locally-known author's WoT * record kinds (0 / 3 / 10002 / 1984) straight from their own outbox, so the * next `graperank score` runs on current data without a full follow-graph crawl. * @@ -563,7 +567,7 @@ object GrapeRankCommand { * `--report-limit N` (per-relay rows in the JSON, default 50), * `--down` / `--up` / `--no-sync-deletions`. */ - private suspend fun update( + private suspend fun refresh( dataDir: DataDir, rest: Array, ): Int { @@ -854,13 +858,15 @@ object GrapeRankCommand { } /** - * `amy graperank operator [status | relay … | providers]` + * `amy graperank operator [status | relay … | keys]` * * Manage the machine's operator keys used to sign trusted-assertion cards. - * - `status` (default): master pubkey, configured relay(s), provider count. + * - `status` (default): master pubkey, configured relay(s), service-key count. * - `relay …`: set the operator relay(s) the cards + retractions publish * to; creates the operator master on first use. - * - `providers`: the observer -> provider-pubkey mapping learned so far. + * - `keys` (alias: the pre-rename `providers`, which collided with + * `graperank providers`): the observer -> service-key mapping derived so + * far — what a third-party observer wires into their kind:10040. */ private fun operator( dataDir: DataDir, @@ -877,11 +883,11 @@ object GrapeRankCommand { 0 } - "providers" -> { + "keys", "providers" -> { Output.emit( mapOf( "master_pubkey" to if (opKeys.exists()) opKeys.masterPubKey() else null, - "providers" to opKeys.providers().map { (observer, rec) -> mapOf("observer" to observer, "provider_pubkey" to rec.providerPubKey) }, + "keys" to opKeys.providers().map { (observer, rec) -> mapOf("observer" to observer, "provider_pubkey" to rec.providerPubKey) }, ), ) 0 @@ -896,14 +902,14 @@ object GrapeRankCommand { "initialized" to true, "master_pubkey" to opKeys.masterPubKey(), "relays" to opKeys.operatorRelays().map { it.url }, - "providers" to opKeys.providers().size, + "keys" to opKeys.providers().size, ), ) } 0 } - else -> Output.error("bad_args", "unknown operator subcommand '${rest.first()}' (status | relay | providers)") + else -> Output.error("bad_args", "unknown operator subcommand '${rest.first()}' (status | relay | keys)") } } @@ -990,6 +996,96 @@ object GrapeRankCommand { } } + /** + * `amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL] [--timeout SECS]` + * + * The inverse of [register]: drop matching provider entries — public AND + * private — from the account's kind:10040 [TrustProviderListEvent] and + * re-publish it. PROVIDER is required; `--service` / `--relay` narrow the + * match when the same key is listed for several services or relays — without + * them, every entry for that provider key is removed. Fetches the freshest + * list first so the removal applies to the current provider set. + */ + private suspend fun unregister( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val providerArg = + args.positionalOrNull(0) + ?: args.flag("provider") + ?: return Output.error("bad_args", "usage: amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]") + val serviceArg = args.flag("service") + val relayArg = args.flag("relay") + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + val service = + serviceArg?.let { + ServiceType.parse(it) ?: return Output.error("bad_args", "--service must be KIND:TAG, e.g. 30382:rank") + } + val relay = + relayArg?.let { + RelayUrlNormalizer.normalizeOrNull(it) ?: return Output.error("bad_args", "--relay is not a valid relay URL") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val provider = ctx.requireUserHex(providerArg) + val outbox = ctx.outboxRelays() + + val latest = + fetchLatestProviderList(ctx, ctx.identity.pubKeyHex, outbox, timeoutMs) + ?: return Output.error("not_found", "no kind:10040 provider list found for this account") + + fun matches(tag: ServiceProviderTag) = + tag.pubkey == provider && + (service == null || tag.service == service) && + (relay == null || tag.relayUrl == relay) + + val publicMatches = latest.serviceProviders().filter(::matches) + val privateMatches = + latest + .privateTags(ctx.signer) + ?.serviceProviders() + .orEmpty() + .filter(::matches) + val toRemove = (publicMatches + privateMatches).distinct() + + if (toRemove.isEmpty()) { + Output.emit( + mapOf( + "provider" to provider, + "changed" to false, + "removed" to emptyList(), + "based_on" to latest.id, + ), + ) + return 0 + } + + // remove() strips the tag from both the public and the private set, + // re-signing each round; only the final version is published. + var event = latest + for (tag in toRemove) { + event = TrustProviderListEvent.remove(event, tag, ctx.signer) + } + + val ack = ctx.publish(event, outbox) + Output.emit( + mapOf( + "provider" to provider, + "changed" to true, + "removed" to toRemove.map { mapOf("service" to it.service.toValue(), "relay" to it.relayUrl.url) }, + "event_id" to event.id, + "based_on" to latest.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } + } + /** * `amy graperank providers [USER] [--refresh] [--timeout SECS]` *