From 84bc76153d8a41b1ae8a33f2663526c332f380bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:46:03 +0000 Subject: [PATCH] refactor(cordn): keep coordinator announcements in the cache, not beside it The announced name was fetched by hand, parsed by hand, deduped by hand, kept in a second map on CordnRuntime and pruned by hand on stop/forget/purge -- and was still unverified, because verification is one more thing the cache does and a hand-rolled path does not. The reason given for all of it was that ServerAnnouncement is a parser over a raw Event "with no registered event class, so nothing caches one". That was backwards: nothing cached one BECAUSE no class was registered. EventCache says so itself, above the group this now joins -- "New kinds without custom consume logic go in one of the two groups below -- an unlisted kind falls into the else branch and is rejected as unsupported." So the kinds are typed and listed: CvmServerAnnouncementEvent (11316) and CvmToolsListEvent (11317), both BaseReplaceableEvent, registered in EventFactory and added to EventCache's replaceable group. From there the cache does what it does for every other kind -- stores it, keeps the newest per (kind, pubkey) because these kinds are replaceable, and verifies before anything trusts it (wasVerified || justVerify) -- and the screens read the name off the cached note through observeNoteEvent, the same event-finder data source the rest of the app uses. Deleted, because the cache replaces all of it: CordnRuntime.announcedNames and its three pruning sites, announcedServerName with its hand-rolled newest-wins, author filter and injectable verify, that function's test file, and the announced-name plumbing through both screens' call sites. The prefetch stays, but only to aim the request at the coordinator's own relays -- the one thing the generic data sources cannot know until its relay list is cached. Also drops the raw hex from Technical details. A profile shows a short npub with a button that copies the full one and then the same for the nprofile (DrawAdditionalInfo); nothing user-facing in this app shows 64 hex characters, and there was no reason for a coordinator to be the exception -- the argument that refs and device documents speak hex was about the wire encoding, not about what a person should read. The nprofile earns its place here more than on a profile, since its relay hints are how a coordinator is reached at all. Still to do: CordnCoordinatorDiscovery reads surfaces off a raw sweep for unknown servers and needs relay attribution, so it keeps its own path -- and its own verification, which it still lacks. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n --- .../amethyst/model/cordn/CordnRuntime.kt | 77 ++++------ .../chats/cordnGroup/CordnGroupInfoScreen.kt | 98 ++++++++++--- .../settings/cordn/CoordinatorIdentity.kt | 50 +++++-- .../settings/cordn/CordnCoordinatorsScreen.kt | 2 - amethyst/src/main/res/values/strings.xml | 2 + .../cordn/CordnCoordinatorDiscovery.kt | 33 ----- .../commons/model/cache/EventCache.kt | 4 + .../commons/cordn/AnnouncedServerNameTest.kt | 133 ------------------ .../CvmAnnouncementEvents.kt | 91 ++++++++++++ .../quartz/utils/EventFactory.kt | 4 + 10 files changed, 245 insertions(+), 249 deletions(-) delete mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/AnnouncedServerNameTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/CvmAnnouncementEvents.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt index 2d9e168695..f0308dfce6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt @@ -45,7 +45,6 @@ import com.vitorpamplona.amethyst.commons.cordn.FileCordnHandoffStore import com.vitorpamplona.amethyst.commons.cordn.FileCordnKeyPackageStore import com.vitorpamplona.amethyst.commons.cordn.KeyStoreCordnBlobCipher import com.vitorpamplona.amethyst.commons.cordn.OpenedWelcome -import com.vitorpamplona.amethyst.commons.cordn.announcedServerName import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupList import com.vitorpamplona.quartz.contextvm.core.CvmKinds import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode @@ -65,12 +64,9 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -172,23 +168,6 @@ class CordnRuntime( val coordinators = registry.coordinators - private val _announcedNames = MutableStateFlow>(emptyMap()) - - /** - * What each coordinator calls itself, per its CEP-6 announcement. - * - * Read by the screens as the fallback between the user's own label and the - * kind 0 -- a coordinator added from discovery was picked by this name, so - * showing it keeps the two places agreeing. Filled by the same one-shot - * fetch that collects the profile and the relay list when a coordinator is - * opened, because an announcement is a raw event with no registered class - * and nothing else caches one per coordinator. - * - * Absent rather than blank when a coordinator announces no name, so the - * display can fall through instead of rendering an empty line. - */ - val announcedNames: StateFlow> = _announcedNames.asStateFlow() - /** * The session for [config], opening and starting it if it is new. * @@ -226,6 +205,15 @@ class CordnRuntime( // own relays are where those live, so they are fetched here, // from the relays this account already talks to for cordn. // + // All three are ordinary public events about this pubkey, and now + // all three are typed, so the global cache connector files them and + // the cache does the keeping: newest-wins per (kind, pubkey) for the + // replaceable ones, and verification before anything trusts them. + // Nothing is parsed or stored by hand here any more. The fetch + // remains only to aim the request at the coordinator's OWN relays, + // which is where its announcement lives and is the one thing the + // generic data sources cannot know until its relay list is cached. + // // Not a nicety -- it is what keeps the *outbox discovery* for // this pubkey off this account's home relays. Rendering a // coordinator with UserPicture/observeUserNameByHex puts it in @@ -255,31 +243,22 @@ class CordnRuntime( val prefetch = scope.launch { runCatching { - val answered = - client.fetchAll( - filters = - config.relays.associateWith { - listOf( - Filter( - kinds = - listOf( - MetadataEvent.KIND, - AdvertisedRelayListEvent.KIND, - CvmKinds.SERVER_ANNOUNCEMENT, - ), - authors = listOf(config.pubKey), - ), - ) - }, - ) - - // The announcement is the one of the three the cache - // cannot keep: no registered event class, so it would be - // parsed by nobody and dropped. Read here, from the - // events this fetch returned, and kept for the screens. - announcedServerName(answered, config.pubKey)?.let { name -> - _announcedNames.update { it + (config.pubKey to name) } - } + client.fetchAll( + filters = + config.relays.associateWith { + listOf( + Filter( + kinds = + listOf( + MetadataEvent.KIND, + AdvertisedRelayListEvent.KIND, + CvmKinds.SERVER_ANNOUNCEMENT, + ), + authors = listOf(config.pubKey), + ), + ) + }, + ) } } // A coordinator that stops answering is otherwise invisible: @@ -602,7 +581,6 @@ class CordnRuntime( } registry.close() groups.clear() - _announcedNames.value = emptyMap() } /** Drops one coordinator, leaving its stored groups on disk. */ @@ -615,10 +593,6 @@ class CordnRuntime( } } registry.forget(coordinatorPubKey) - // A name learned about a coordinator this account no longer holds is - // nobody's to show, and keeping it would resurrect it on a re-add before - // the fresh announcement arrives. - _announcedNames.update { it - coordinatorPubKey } remember() } @@ -640,7 +614,6 @@ class CordnRuntime( suspend fun purge(coordinatorPubKey: HexKey) { forget(coordinatorPubKey) groups.forgetCoordinator(coordinatorPubKey) - _announcedNames.update { it - coordinatorPubKey } withContext(Dispatchers.IO) { CordnStorageLayout.directoryFor(filesDir, accountSigner.pubKey, coordinatorPubKey).deleteRecursively() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt index 6aefdb19a5..2a4cfa636b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt @@ -70,14 +70,17 @@ import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.copy_npub_to_clipboard import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.amethyst.model.cordn.CordnRuntime import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList @@ -176,18 +179,11 @@ private fun CordnGroupInfo( val runtime = accountViewModel.account.cordnRuntime val manager = runtime?.sessionOrNull(coordinatorPubKey)?.manager - // What to call this coordinator, cheapest source first. The label and the - // announced name are both already in hand -- neither costs a call to the - // coordinator, which matters because serverInfo() is a live MCP request over - // kind 25910 and a screen that fired one on open would tell the coordinator - // every time somebody glanced at a group (spec/00.md §8). - val announcedNames = - runtime - ?.announcedNames - ?.collectAsStateWithLifecycle() - ?.value - .orEmpty() - + // The user's own name for it, when they gave it one. Read from the stored + // config, never from the coordinator: serverInfo() is a live MCP request over + // kind 25910, and a screen that fired one on open would tell the coordinator + // every time somebody glanced at a group (spec/00.md §8). The announced name + // and the profile are read from the cache by CoordinatorIdentityRow itself. val coordinatorLabel = runtime ?.coordinators @@ -467,7 +463,6 @@ private fun CordnGroupInfo( CoordinatorIdentityRow( pubKey = coordinatorPubKey, label = coordinatorLabel, - announced = announcedNames[coordinatorPubKey], accountViewModel = accountViewModel, nav = nav, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), @@ -660,11 +655,78 @@ private fun TechnicalDetails( } AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) { - SelectionContainer { - Column { - InfoRow(stringRes(R.string.cordn_info_coordinator_key), coordinatorPubKey) - InfoRow(stringRes(R.string.cordn_info_gid), gid) - InfoRow(stringRes(R.string.cordn_info_epoch), epoch.toString()) + Column { + // Through the User, in the encodings the rest of the app uses for a + // person's key. A profile screen shows a short npub with a button that + // copies the full one, then does the same for the nprofile + // (DrawAdditionalInfo); nothing user-facing anywhere shows raw hex, + // and there was no reason for a coordinator to be the exception. The + // nprofile earns its place here more than on a profile, because it + // carries the relay hints that are how a coordinator is reached at all. + val coordinator = remember(coordinatorPubKey) { LocalCache.getOrCreateUser(coordinatorPubKey) } + + CopyableKeyRow( + label = stringRes(R.string.cordn_info_coordinator_key), + shown = coordinator.pubkeyDisplayHex(), + copied = coordinator.pubkeyNpub(), + copyDescription = stringRes(Res.string.copy_npub_to_clipboard), + ) + CopyableKeyRow( + label = stringRes(R.string.cordn_info_coordinator_nprofile), + shown = coordinator.toNProfile().toShortDisplay(6), + copied = coordinator.toNProfile(), + copyDescription = stringRes(R.string.cordn_info_copy_nprofile), + ) + + SelectionContainer { + Column { + InfoRow(stringRes(R.string.cordn_info_gid), gid) + InfoRow(stringRes(R.string.cordn_info_epoch), epoch.toString()) + } + } + } + } +} + +/** + * A key, short enough to read, with a button that copies the whole thing. + * + * The shape a profile uses for the same job: nobody reads a bech32 string off a + * screen, they copy it, so the visible half is there to confirm which key it is + * and the button is there to do the actual work. + */ +@Composable +private fun CopyableKeyRow( + label: String, + shown: String, + copied: String, + copyDescription: String, +) { + val clipboard = LocalClipboardManager.current + + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = shown, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + IconButton( + onClick = { clipboard.setText(AnnotatedString(copied)) }, + modifier = Modifier.size(24.dp).padding(start = 4.dp), + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = copyDescription, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CoordinatorIdentity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CoordinatorIdentity.kt index 41f3682fe5..2de55c63fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CoordinatorIdentity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CoordinatorIdentity.kt @@ -24,14 +24,20 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey /** @@ -48,10 +54,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey * 1. `CoordinatorConfig.label` -- documented as "what the user calls it. Never * a claim -- a coordinator cannot prove a name." The only name here that * means anything, so it wins. - * 2. The CEP-6 announcement's name, when one was heard. Before the profile - * because a coordinator added from discovery was *picked* by this name, and - * having the settings screen rename it afterwards would be its own small - * confusion. + * 2. The CEP-6 announcement's name, when the cache holds one. Before the + * profile because a coordinator added from discovery was *picked* by this + * name, and having the settings screen rename it afterwards would be its own + * small confusion. * 3. The kind 0's display name (CEP-23). * 4. The key's first characters. * @@ -65,19 +71,42 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey fun coordinatorDisplayName( pubKey: HexKey, label: String?, - announced: String?, accountViewModel: AccountViewModel, ): String { - // Unconditional: this is what subscribes for the kind 0 (observeUserName - // registers a UserFinder subscription), and it must not come and go with - // whether a label or an announcement happens to be present. + // Both unconditional: each one registers the subscription that fetches what + // it reads, so neither may come and go with whether an earlier source in the + // chain happens to have an answer. val fromProfile = observeUserNameByHex(pubKey, accountViewModel) + val announced = observeAnnouncedServerName(pubKey, accountViewModel) return label?.takeIf { it.isNotBlank() } - ?: announced?.takeIf { it.isNotBlank() } + ?: announced ?: fromProfile } +/** + * The name from the coordinator's CEP-6 announcement, as the cache holds it. + * + * A [CvmServerAnnouncementEvent] is a replaceable event like any other now, so + * reading it here gets the newest one per coordinator, already verified, and + * fetched by the same event-finder data source the rest of the app uses -- + * rather than a hand-rolled fetch, a hand-rolled newest-wins and a second copy + * of the name kept beside the cache. + */ +@Composable +fun observeAnnouncedServerName( + pubKey: HexKey, + accountViewModel: AccountViewModel, +): String? { + val note = + remember(pubKey) { + LocalCache.getOrCreateAddressableNote(Address(CvmServerAnnouncementEvent.KIND, pubKey, "")) + } + val announcement by observeNoteEvent(note, accountViewModel) + + return announcement?.serverName() +} + /** * A coordinator rendered as the user it is: avatar, name, and whatever acts on * it. @@ -96,7 +125,6 @@ fun coordinatorDisplayName( fun CoordinatorIdentityRow( pubKey: HexKey, label: String?, - announced: String?, accountViewModel: AccountViewModel, nav: INav, modifier: Modifier = Modifier, @@ -110,7 +138,7 @@ fun CoordinatorIdentityRow( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { UserPicture(userHex = pubKey, size = size, accountViewModel = accountViewModel, nav = nav) - name(coordinatorDisplayName(pubKey, label, announced, accountViewModel)) + name(coordinatorDisplayName(pubKey, label, accountViewModel)) trailing() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt index ddbe281d98..1b6a5c641e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt @@ -194,7 +194,6 @@ private fun CoordinatorCard( var renaming by remember(config.pubKey) { mutableStateOf(false) } val health = runtime.health(config.pubKey)?.collectAsStateWithLifecycle()?.value - val announced by runtime.announcedNames.collectAsStateWithLifecycle() Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { @@ -205,7 +204,6 @@ private fun CoordinatorCard( CoordinatorIdentityRow( pubKey = config.pubKey, label = config.label, - announced = announced[config.pubKey], accountViewModel = accountViewModel, nav = nav, trailing = { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a186a7d3f1..fda29d3b19 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -349,6 +349,8 @@ Send Coordinator Coordinator key + Coordinator link + Copy the coordinator\'s nprofile Group id Epoch Members diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscovery.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscovery.kt index 4f20604d80..7f1314397d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscovery.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscovery.kt @@ -35,39 +35,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -/** - * The name a coordinator announces for itself, or null if it announces none. - * - * The same rule [CordnCoordinatorDiscovery] applies when it builds a - * [DiscoveredCoordinator]: the CEP-6 announcement kinds are replaceable, so the - * newest per kind wins, and the name lives on the server announcement's - * discovery surface. Shared rather than re-derived, because "newest wins" is - * the part that is easy to get subtly wrong when a lagging relay answers late. - * - * Its own function because a coordinator this account already uses never goes - * through discovery again, and until this existed there was nowhere for its - * announced name to come from: the announcement is a raw [Event] with no - * registered event class, so nothing caches one per coordinator. - * - * ## [pubKey] is required, not optional - * - * A REQ's `authors` is what was asked for, never a guarantee of what came back: - * a relay may answer with anything. Discovery is safe from that because it - * groups by author before it reads a surface; a caller handing over a flat fetch - * result has done no such thing, and one relay in a coordinator's own list could - * otherwise choose the name shown for it. So the filter lives here rather than - * in each caller, where it is one line to forget. - */ -fun announcedServerName( - events: List, - pubKey: HexKey, -): String? = - ServerAnnouncement - .latestPerKind(events.filter { it.pubKey == pubKey })[CvmKinds.SERVER_ANNOUNCEMENT] - ?.discovery - ?.name - ?.takeIf { it.isNotBlank() } - /** * A coordinator found by listening for CEP-6 announcements. * diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 67d5dc3186..d4aaf2d354 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -139,6 +139,8 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmToolsListEvent import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent @@ -3780,6 +3782,8 @@ open class EventCache : // ============================================================ is AcceptedBadgeSetEvent, is AdvertisedRelayListEvent, + is CvmServerAnnouncementEvent, + is CvmToolsListEvent, is AppDefinitionEvent, is AppRecommendationEvent, is AppSpecificDataEvent, diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/AnnouncedServerNameTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/AnnouncedServerNameTest.kt deleted file mode 100644 index 43c5a43152..0000000000 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/AnnouncedServerNameTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.commons.cordn - -import com.vitorpamplona.quartz.contextvm.core.CvmKinds -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.Tag -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -/** - * The name a coordinator this account already uses calls itself. - * - * Discovery reads this from a live sweep, but a coordinator already in the list - * never goes through discovery again -- so the rule has to hold on whatever a - * one-shot fetch happens to return, including a lagging relay's stale copy. - */ -class AnnouncedServerNameTest { - private val coordinator = "c".repeat(64) - - @Test - fun `the name comes off the server announcement`() { - assertEquals("Cordn Demo", announcedServerName(listOf(announcement(name = "Cordn Demo")), coordinator)) - } - - @Test - fun `a coordinator that announces no name has none, rather than an empty one`() { - // Absent and blank have to read the same, or the display chain stops at - // a name that renders as nothing instead of falling through to the - // profile and then the key. - assertNull(announcedServerName(listOf(announcement(name = "")), coordinator)) - assertNull(announcedServerName(listOf(announcement(name = " ")), coordinator)) - assertNull(announcedServerName(emptyList(), coordinator)) - } - - @Test - fun `the newest announcement wins, whatever order the relays answered in`() { - // These kinds are replaceable. A relay that is behind will happily hand - // back last month's name after a fresher one already arrived, and taking - // the last event seen would show the old one. - val stale = announcement(name = "Old Name", createdAt = 1_000) - val fresh = announcement(name = "New Name", createdAt = 2_000) - - assertEquals("New Name", announcedServerName(listOf(fresh, stale), coordinator)) - assertEquals("New Name", announcedServerName(listOf(stale, fresh), coordinator)) - } - - @Test - fun `an announcement by anybody else is not this coordinator's name`() { - // A REQ's `authors` is what was asked for, not a promise about what came - // back. Without the filter, one relay in a coordinator's own list could - // choose the name shown for it -- and that name ranks above its kind 0. - val impostor = - event( - kind = CvmKinds.SERVER_ANNOUNCEMENT, - createdAt = 9_000, - content = """{"protocolVersion":"2025-11-25"}""", - tags = arrayOf(arrayOf("name", "Not This Server")), - pubKey = "d".repeat(64), - ) - - assertNull(announcedServerName(listOf(impostor), coordinator)) - - // And it does not outrank the real one by being newer, either. - assertEquals( - "Real Name", - announcedServerName(listOf(impostor, announcement(name = "Real Name")), coordinator), - ) - } - - @Test - fun `the other announcement kinds do not supply a name`() { - // CEP-6 has five announcement kinds and only the server announcement - // carries the surface. A tools list with a name tag is not this server - // saying what it is called. - val tools = - event( - kind = CvmKinds.TOOLS_LIST, - createdAt = 5_000, - content = """{"tools":[]}""", - tags = arrayOf(arrayOf("name", "Tools List")), - ) - - assertNull(announcedServerName(listOf(tools), coordinator)) - } - - private fun announcement( - name: String, - createdAt: Long = 1_000, - ) = event( - kind = CvmKinds.SERVER_ANNOUNCEMENT, - createdAt = createdAt, - content = """{"protocolVersion":"2025-11-25"}""", - tags = arrayOf(arrayOf("name", name)), - ) - - /** Unsigned: nothing here verifies a signature, and an announcement proves nothing either way. */ - private fun event( - kind: Int, - createdAt: Long, - content: String, - tags: Array = emptyArray(), - pubKey: HexKey = coordinator, - ) = Event( - id = "${kind}_$createdAt", - pubKey = pubKey, - createdAt = createdAt, - kind = kind, - tags = tags, - content = content, - sig = "00".repeat(32), - ) -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/CvmAnnouncementEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/CvmAnnouncementEvents.kt new file mode 100644 index 0000000000..b3fa0a6b74 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/CvmAnnouncementEvents.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep06Announcements + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A server's CEP-6 announcement of itself: kind [CvmKinds.SERVER_ANNOUNCEMENT]. + * + * A typed class so the announcement can live in the event cache like anything + * else. Without one the kind is unlisted, and an unlisted kind is rejected as + * unsupported on the way in -- which is why a coordinator's announced name had + * to be fetched and parsed by hand everywhere it was wanted, with the + * newest-wins rule, the author check and the signature check all re-derived per + * caller. Being replaceable (10000..19999), the cache now keeps the newest per + * (kind, pubkey) and verifies before trusting it. + * + * [discovery] is the surface the server advertises -- its name, its blurb, and + * what it says it supports. Every field of it is the server's own claim; + * [pubKey] is the only thing here that is not. + */ +@Immutable +class CvmServerAnnouncementEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * What the server says about itself, parsed from the tags. + * + * Not cached on the instance: the cache keeps one event per coordinator and + * the screens read a name off it, so the parse is rare and cheap next to + * holding a second copy of every surface in memory. + */ + fun discovery(): DiscoverySurface = DiscoverySurface.parse(tags) + + /** The server's own name for itself, or null when it publishes none. */ + fun serverName(): String? = discovery().name?.takeIf { it.isNotBlank() } + + companion object { + const val KIND = CvmKinds.SERVER_ANNOUNCEMENT + } +} + +/** + * A server's CEP-6 `tools/list` announcement: kind [CvmKinds.TOOLS_LIST]. + * + * Stored for the same reason as [CvmServerAnnouncementEvent], and needed + * alongside it because what makes a ContextVM server a *cordn coordinator* is + * the eleven tools it advertises here, not anything it says about itself. + */ +@Immutable +class CvmToolsListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The advertised tools, or null when the content does not parse as a list. */ + fun tools(): AnnouncedTools? = AnnouncedTools.parseOrNull(content) + + companion object { + const val KIND = CvmKinds.TOOLS_LIST + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index d597a994b0..7d412edb94 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -103,6 +103,8 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmToolsListEvent import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent @@ -461,6 +463,8 @@ class EventFactory { AcceptedBadgeSetEvent.KIND -> AcceptedBadgeSetEvent(id, pubKey, createdAt, tags, content, sig) ConcordChatEditEvent.KIND -> ConcordChatEditEvent(id, pubKey, createdAt, tags, content, sig) AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) + CvmServerAnnouncementEvent.KIND -> CvmServerAnnouncementEvent(id, pubKey, createdAt, tags, content, sig) + CvmToolsListEvent.KIND -> CvmToolsListEvent(id, pubKey, createdAt, tags, content, sig) AgentTurnMetricEvent.KIND -> AgentTurnMetricEvent(id, pubKey, createdAt, tags, content, sig) EngramEvent.KIND -> EngramEvent(id, pubKey, createdAt, tags, content, sig) AgentProfileEvent.KIND -> AgentProfileEvent(id, pubKey, createdAt, tags, content, sig)