diff --git a/cli/README.md b/cli/README.md index 2976bcd1b5..88eeeea65d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -384,7 +384,9 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. | | `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. | | `amy follow USER` / `amy unfollow USER` | Add/remove USER from your kind:3 contact list (fetches the freshest list first). | -| `amy graperank [OBSERVER] [--max-depth N] [--offline] [--publish]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph, crawled via the outbox model; optionally publish results as NIP-85 kind:30382 cards. | +| `amy graperank [OBSERVER] [--max-depth N] [--offline] [--publish]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph, crawled via the outbox model; optionally publish results as NIP-85 kind:30382 cards (unchanged ranks are skipped). | +| `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 providers [USER]` | List a user's declared NIP-85 trusted providers (public + your own private entries). | ### Direct messages (NIP-17) diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index d31deebf08..8fc7609b0b 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -58,7 +58,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`); publishes kind:30382 `ContactCardEvent`. | +| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); publishes kind:30382 `ContactCardEvent` (diffed against prior ranks), plus `register` / `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 a405f7dde6..08667592ad 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -215,7 +215,7 @@ private suspend fun dispatch(argv: Array): Int { "store" -> StoreCommands.dispatch(dataDir, tail) "follow" -> FollowCommand.follow(dataDir, tail) "unfollow" -> FollowCommand.unfollow(dataDir, tail) - "graperank" -> GrapeRankCommand.run(dataDir, tail) + "graperank" -> GrapeRankCommand.dispatch(dataDir, tail) "search" -> SearchCommand.dispatch(dataDir, tail) "zap" -> ZapCommand.dispatch(dataDir, tail) "offer" -> OfferCommands.dispatch(dataDir, tail) @@ -535,7 +535,12 @@ private fun printUsage() { | [--publish] [--min-rank N] the local store only. --publish writes NIP-85 | [--publish-limit N] [--publish-relay URL] kind:30382 trusted-assertion cards | (rank = round(score*100)) for each user at or - | above --min-rank. + | above --min-rank (unchanged ranks are skipped). + | 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 providers [USER] [--refresh] list a user's declared NIP-85 trusted providers + | [--timeout SECS] (default: active account). | |Zaps (NIP-57): | zap user USER SATS build a profile zap-request, fetch a BOLT11 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 0e1b2d2d18..07f71c7360 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 @@ -37,6 +37,11 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceType import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag import kotlinx.coroutines.async @@ -58,6 +63,13 @@ import kotlin.math.roundToInt * Prints a ranked list (text, or one JSON object under `--json`). With * `--publish`, results are also published as NIP-85 kind:30382 `ContactCardEvent` * trusted assertions (one per scored user, `rank = round(score*100)`). + * + * Sub-verbs complete the NIP-85 provider experience — the discovery layer that + * lets clients find and consume those assertions: + * - `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). + * - `amy graperank providers [USER]` — list a user's trusted providers. */ object GrapeRankCommand { // Authors per REQ filter — keeps individual subscriptions within relay limits. @@ -66,6 +78,18 @@ object GrapeRankCommand { // Concurrent publishes when writing NIP-85 cards. private const val PUBLISH_CONCURRENCY = 16 + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + // Sub-verbs are explicit words; anything else (npub / hex / nprofile / + // NIP-05, or nothing) is the OBSERVER positional for a score computation. + when (tail.firstOrNull()) { + "register" -> register(dataDir, tail.drop(1).toTypedArray()) + "providers" -> providers(dataDir, tail.drop(1).toTypedArray()) + else -> run(dataDir, tail) + } + suspend fun run( dataDir: DataDir, rest: Array, @@ -198,6 +222,178 @@ object GrapeRankCommand { } } + /** + * `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL] [--private]` + * + * Add a NIP-85 provider entry to the account's kind:10040 + * [TrustProviderListEvent] — the declaration a client reads to discover which + * key publishes which assertion, and where. Defaults to declaring *self* as + * the `30382:rank` provider at the account's first outbox relay, which is the + * self-advertisement a GrapeRank provider makes so its followers can find the + * cards it publishes. Fetches the freshest list first so existing providers + * are preserved. + */ + private suspend fun register( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val providerArg = args.positionalOrNull(0) ?: args.flag("provider") + val serviceArg = args.flag("service") + val relayArg = args.flag("relay") + val isPrivate = args.bool("private") + 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") + } ?: ProviderTypes.rank + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val self = ctx.identity.pubKeyHex + val provider = providerArg?.let { ctx.requireUserHex(it) } ?: self + + val outbox = ctx.outboxRelays() + val relay = + relayArg?.let { RelayUrlNormalizer.normalizeOrNull(it) } + ?: outbox.firstOrNull() + ?: return Output.error("no_relays", "no relay hint; pass --relay URL or configure outbox relays") + + val latest = fetchLatestProviderList(ctx, self, outbox, timeoutMs) + val alreadyListed = + latest?.serviceProviders()?.any { + it.service == service && it.pubkey == provider && it.relayUrl == relay + } ?: false + + if (alreadyListed) { + Output.emit( + mapOf( + "service" to service.toValue(), + "provider" to provider, + "relay" to relay.url, + "changed" to false, + "based_on" to latest?.id, + ), + ) + return 0 + } + + val tag = ServiceProviderTag(service, provider, relay) + val event = + if (latest == null) { + TrustProviderListEvent.create(tag, isPrivate = isPrivate, signer = ctx.signer) + } else { + TrustProviderListEvent.add(latest, tag, isPrivate = isPrivate, signer = ctx.signer) + } + + val ack = ctx.publish(event, outbox) + Output.emit( + mapOf( + "service" to service.toValue(), + "provider" to provider, + "relay" to relay.url, + "private" to isPrivate, + "changed" to true, + "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]` + * + * List the NIP-85 trusted providers a user declares in their kind:10040 + * (default: the active account). Cache-first; falls back to a relay drain on + * a miss or with `--refresh`. For the active account, private (NIP-44) + * provider entries are decrypted and included too. + */ + private suspend fun providers( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val userArg = args.positionalOrNull(0) + val refresh = args.bool("refresh") + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val user = userArg?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val isSelf = user == ctx.identity.pubKeyHex + + var event = if (refresh) null else providerListOf(ctx, user) + if (event == null) { + ctx.drain( + (ctx.bootstrapRelays() + Constants.eventFinderRelays).associateWith { + listOf(Filter(kinds = listOf(TrustProviderListEvent.KIND), authors = listOf(user), limit = 1)) + }, + timeoutMs, + ) + event = providerListOf(ctx, user) + } + + if (event == null) { + Output.emit(mapOf("user" to user, "found" to false, "providers" to emptyList())) + return 0 + } + + val public = event.serviceProviders() + val private = if (isSelf) event.privateTags(ctx.signer)?.serviceProviders().orEmpty() else emptyList() + + fun render( + tag: ServiceProviderTag, + scope: String, + ) = mapOf( + "service" to tag.service.toValue(), + "provider" to tag.pubkey, + "relay" to tag.relayUrl.url, + "scope" to scope, + ) + + Output.emit( + mapOf( + "user" to user, + "found" to true, + "event_id" to event.id, + "created_at" to event.createdAt, + "providers" to public.map { render(it, "public") } + private.map { render(it, "private") }, + ), + ) + return 0 + } + } + + /** Latest known kind:10040 provider list for [pubKey] from the local store. */ + private suspend fun providerListOf( + ctx: Context, + pubKey: HexKey, + ): TrustProviderListEvent? = + ctx.store + .query(Filter(kinds = listOf(TrustProviderListEvent.KIND), authors = listOf(pubKey), limit = 1)) + .firstOrNull() as? TrustProviderListEvent + + /** + * Fetch the freshest kind:10040 for [pubKey] from [relays] so a register + * builds on top of the current provider set instead of clobbering it. + */ + private suspend fun fetchLatestProviderList( + ctx: Context, + pubKey: HexKey, + relays: Set, + timeoutMs: Long, + ): TrustProviderListEvent? { + if (relays.isEmpty()) return providerListOf(ctx, pubKey) + val filter = Filter(kinds = listOf(TrustProviderListEvent.KIND), authors = listOf(pubKey), limit = 1) + ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + return providerListOf(ctx, pubKey) + } + /** * Fetch kind:10002 relay lists for any frontier member we don't already know, * so [routeByOutbox] can route their content query to their own write relays.