mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
@@ -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<Map<HexKey, String>>(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<Map<HexKey, String>> = _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()
|
||||
}
|
||||
|
||||
+80
-18
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-11
@@ -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<CvmServerAnnouncementEvent>(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()
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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 = {
|
||||
|
||||
@@ -349,6 +349,8 @@
|
||||
<string name="cordn_send">Send</string>
|
||||
<string name="cordn_info_coordinator">Coordinator</string>
|
||||
<string name="cordn_info_coordinator_key">Coordinator key</string>
|
||||
<string name="cordn_info_coordinator_nprofile">Coordinator link</string>
|
||||
<string name="cordn_info_copy_nprofile">Copy the coordinator\'s nprofile</string>
|
||||
<string name="cordn_info_gid">Group id</string>
|
||||
<string name="cordn_info_epoch">Epoch</string>
|
||||
<string name="cordn_info_members">Members</string>
|
||||
|
||||
-33
@@ -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<Event>,
|
||||
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.
|
||||
*
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
-133
@@ -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<Tag> = emptyArray(),
|
||||
pubKey: HexKey = coordinator,
|
||||
) = Event(
|
||||
id = "${kind}_$createdAt",
|
||||
pubKey = pubKey,
|
||||
createdAt = createdAt,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
content = content,
|
||||
sig = "00".repeat(32),
|
||||
)
|
||||
}
|
||||
+91
@@ -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<Array<String>>,
|
||||
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<Array<String>>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user