mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'upstream/claude/kind-keller-itzy63' into test/kind-keller
This commit is contained in:
@@ -643,6 +643,26 @@ class CordnRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which identities [coordinatorPubKey] can deliver a Welcome to, or null.
|
||||
*
|
||||
* For annotating people you are *about* to invite. cordn can only add a
|
||||
* member by spending a KeyPackage they published to this coordinator, and
|
||||
* `kp_list` is the only non-destructive way to learn who did -- `kp_take`
|
||||
* by identity consumes one of their single-use packages
|
||||
* (`spec/00.md` §"Coordinator retrieval behavior"), so it can never be
|
||||
* used as a probe. One call answers for everybody, which is why this hands
|
||||
* back the whole set instead of taking a pubkey.
|
||||
*
|
||||
* Null is **not** an empty set. It means the question went unanswered, and
|
||||
* a caller that renders it as "cannot be added" turns a network failure
|
||||
* into a claim about a person. Distinguish them.
|
||||
*/
|
||||
suspend fun identitiesWithKeyPackages(coordinatorPubKey: HexKey): Set<HexKey>? {
|
||||
val session = registry.sessionOrNull(coordinatorPubKey) ?: return null
|
||||
return session.keyPackages.identitiesWithKeyPackages()
|
||||
}
|
||||
|
||||
/** Publishes one KeyPackage. An attributable act under the account key (§8.4). */
|
||||
suspend fun publishKeyPackage(
|
||||
coordinatorPubKey: HexKey,
|
||||
|
||||
+42
-11
@@ -411,9 +411,18 @@ private fun CordnGroupInfo(
|
||||
// everybody would build commits the rest of the group drops on
|
||||
// receipt.
|
||||
if (canAdminister) {
|
||||
// Asked only once the field is in use. `kp_list` is unpaginated,
|
||||
// so opening this screen must not download the coordinator's
|
||||
// whole table on behalf of somebody who never types.
|
||||
val searching = memberSearch.length > 2
|
||||
val reachable by
|
||||
produceState<Set<HexKey>?>(null, runtime, coordinatorPubKey, searching) {
|
||||
if (searching) value = runtime?.identitiesWithKeyPackages(coordinatorPubKey)
|
||||
}
|
||||
CordnAddMember(
|
||||
userSuggestions = userSuggestions,
|
||||
search = memberSearch,
|
||||
reachable = reachable,
|
||||
onSearchChange = {
|
||||
memberSearch = it
|
||||
adminError = null
|
||||
@@ -537,16 +546,28 @@ private fun CordnGroupInfo(
|
||||
* ## The search finding somebody is not a promise
|
||||
*
|
||||
* cordn can only add a member by spending a KeyPackage they published **to
|
||||
* this coordinator** (`spec/00.md`), and nothing on this device can know
|
||||
* whether they did until the coordinator is asked. So the note says so before
|
||||
* the attempt, and `CordnGroupManager.invite` says which of the two went wrong
|
||||
* after it -- "the coordinator holds no KeyPackage for ..." is a different
|
||||
* problem from a name that matched nobody, and they are worth telling apart.
|
||||
* this coordinator** (`spec/00.md`). [reachable] is who the coordinator says
|
||||
* did, so the common disappointment is visible before the tap rather than
|
||||
* after it, and `CordnGroupManager.invite` still says which of the two went
|
||||
* wrong when one does -- "the coordinator holds no KeyPackage for ..." is a
|
||||
* different problem from a name that matched nobody.
|
||||
*
|
||||
* ## Why the badge marks the yes and says nothing about the no
|
||||
*
|
||||
* [reachable] has three states, not two: a set, an empty set, and null for a
|
||||
* lookup that never came back (`CordnRuntime.identitiesWithKeyPackages`). Only
|
||||
* membership in it is something we were told; absence covers both "has not
|
||||
* published here" and "we could not ask". Marking the yes therefore asserts
|
||||
* exactly what we verified, and an unmarked row asserts nothing -- where a
|
||||
* "cannot be added" marker would turn an unreachable coordinator into a claim
|
||||
* about a person. Unmarked rows stay fully tappable for the same reason: the
|
||||
* listing can be up to a minute stale, and the coordinator gets the last word.
|
||||
*/
|
||||
@Composable
|
||||
private fun CordnAddMember(
|
||||
userSuggestions: UserSuggestionState,
|
||||
search: String,
|
||||
reachable: Set<HexKey>?,
|
||||
onSearchChange: (String) -> Unit,
|
||||
busy: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -587,12 +608,22 @@ private fun CordnAddMember(
|
||||
)
|
||||
},
|
||||
trailingContent = { user ->
|
||||
IconButton(onClick = { onInvite(user) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PersonAdd,
|
||||
contentDescription = stringRes(R.string.cordn_info_add_member),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (reachable?.contains(user.pubkeyHex) == true) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Key,
|
||||
contentDescription = stringRes(R.string.cordn_info_has_key_package),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onInvite(user) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PersonAdd,
|
||||
contentDescription = stringRes(R.string.cordn_info_add_member),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -414,6 +414,7 @@
|
||||
<string name="cordn_coordinators_stale_note">These have not announced in over a month. Creating a group on one that has gone away will fail.</string>
|
||||
<string name="cordn_info_add_member">Add someone</string>
|
||||
<string name="cordn_info_add_member_placeholder">Name, npub or name@domain</string>
|
||||
<string name="cordn_info_has_key_package">Can be added on this coordinator</string>
|
||||
<string name="cordn_info_add_member_note">They can only be added if they published a key package to this coordinator.</string>
|
||||
<string name="cordn_exposure_open">What this coordinator can see</string>
|
||||
<string name="cordn_exposure_close">Got it</string>
|
||||
|
||||
+103
-3
@@ -29,9 +29,13 @@ import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle
|
||||
import com.vitorpamplona.quartz.mls.messages.KeyPackageBundleCodec
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@@ -122,6 +126,89 @@ class CordnKeyPackages(
|
||||
val at: Long,
|
||||
)
|
||||
|
||||
private val snapshotLock = Mutex()
|
||||
private var snapshot: Snapshot? = null
|
||||
|
||||
/**
|
||||
* What `kp_list` reported, the last time we asked.
|
||||
*
|
||||
* `kp_list` takes no arguments and returns every KeyPackage the coordinator
|
||||
* holds for **every** identity (`spec/00.md` leaves retrieval scoping to the
|
||||
* coordinator; the reference implementation answers with the whole table).
|
||||
* So one call answers a question about anybody, and asking it once per
|
||||
* person would be the same download repeated.
|
||||
*
|
||||
* [identities] keeps only pubkeys, not the entries. Another account's
|
||||
* `kp_ref` is useless to us -- a Welcome is addressed to a ref we obtained
|
||||
* by *taking* the package, never to one we read out of a listing -- and on
|
||||
* a busy coordinator those refs are the bulk of the response. Dropping them
|
||||
* bounds what we retain even though nothing bounds what arrives.
|
||||
*/
|
||||
class Snapshot(
|
||||
/** Every identity the coordinator holds at least one KeyPackage for. */
|
||||
val identities: Set<HexKey>,
|
||||
/** The full entries for this account, which are the ones we act on. */
|
||||
val mine: List<AvailableKeyPackage>,
|
||||
/** When this was fetched, for [snapshot]'s age check. */
|
||||
val at: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Refetches unconditionally and replaces the snapshot.
|
||||
*
|
||||
* Every decision about whether to *publish* reads through here rather than
|
||||
* through [snapshot]. Minting against a stale listing is the one place
|
||||
* staleness does damage: the coordinator keeps a single last-resort package
|
||||
* per identity, so publishing a second silently evicts the first and
|
||||
* strands every invite that referenced it.
|
||||
*/
|
||||
suspend fun refresh(): Snapshot = snapshotLock.withLock { fetchLocked() }
|
||||
|
||||
/**
|
||||
* The snapshot, refetched if it is older than [maxAgeSeconds].
|
||||
*
|
||||
* For reads that only *describe* what the coordinator holds. A minute of
|
||||
* staleness costs at most a missing badge for somebody who published within
|
||||
* it, and the act itself still fails loudly at the coordinator if the
|
||||
* listing was wrong -- whereas every refresh is another full-table
|
||||
* download, so asking less often is the cheap side of the trade.
|
||||
*/
|
||||
suspend fun snapshot(maxAgeSeconds: Long = SNAPSHOT_TTL_SECONDS): Snapshot =
|
||||
snapshotLock.withLock {
|
||||
snapshot?.takeIf { TimeUtils.now() - it.at < maxAgeSeconds } ?: fetchLocked()
|
||||
}
|
||||
|
||||
/**
|
||||
* Which identities the coordinator can deliver a Welcome to, or null.
|
||||
*
|
||||
* Null means **we do not know** -- the coordinator is unreachable, throttled
|
||||
* us, or answered with more than the transport admits. It does not mean
|
||||
* nobody has published. Callers must keep those apart: rendering a failed
|
||||
* lookup as "this person cannot be added" states something about a person
|
||||
* on the strength of a network error.
|
||||
*/
|
||||
suspend fun identitiesWithKeyPackages(): Set<HexKey>? =
|
||||
try {
|
||||
snapshot().identities
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Must hold [snapshotLock]. */
|
||||
private suspend fun fetchLocked(): Snapshot {
|
||||
val all = coordinator.listKeyPackages()
|
||||
return Snapshot(
|
||||
identities = all.mapTo(mutableSetOf()) { it.pubKey },
|
||||
mine = all.filter { it.pubKey == accountPubKey },
|
||||
at = TimeUtils.now(),
|
||||
).also { snapshot = it }
|
||||
}
|
||||
|
||||
/** Forgets the snapshot after we changed what the coordinator holds. */
|
||||
private suspend fun invalidate() = snapshotLock.withLock { snapshot = null }
|
||||
|
||||
/** Reloads which refs we hold private halves for. Call at startup. */
|
||||
suspend fun restore() {
|
||||
_published.value = store.list().toSet()
|
||||
@@ -150,7 +237,7 @@ class CordnKeyPackages(
|
||||
* is actually available the moment anyone invites us — and it is what is
|
||||
* available that decides whether the next invitation can happen.
|
||||
*/
|
||||
suspend fun listPublished(): List<AvailableKeyPackage> = coordinator.listKeyPackages().filter { it.pubKey == accountPubKey }
|
||||
suspend fun listPublished(): List<AvailableKeyPackage> = refresh().mine
|
||||
|
||||
/**
|
||||
* Generates a KeyPackage, keeps its private half, and publishes it.
|
||||
@@ -184,6 +271,7 @@ class CordnKeyPackages(
|
||||
throw e
|
||||
}
|
||||
|
||||
invalidate()
|
||||
return Published(result.keyPackageRef, result.lastResort || lastResort, result.at)
|
||||
}
|
||||
|
||||
@@ -219,6 +307,7 @@ class CordnKeyPackages(
|
||||
val removed = coordinator.removeKeyPackages(keyPackageRefs)
|
||||
removed.forEach { store.delete(it) }
|
||||
_published.value = _published.value - removed.toSet()
|
||||
invalidate()
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -233,7 +322,7 @@ class CordnKeyPackages(
|
||||
*/
|
||||
suspend fun topUp(minimum: Int = DEFAULT_POOL): List<String> {
|
||||
require(minimum >= 0) { "a KeyPackage pool cannot be negative" }
|
||||
val theirs = coordinator.listKeyPackages().filter { it.pubKey == accountPubKey && !it.lastResort }
|
||||
val theirs = refresh().mine.filter { !it.lastResort }
|
||||
val missing = minimum - theirs.size
|
||||
if (missing <= 0) return emptyList()
|
||||
return List(missing) { publishNew().keyPackageRef }
|
||||
@@ -248,7 +337,7 @@ class CordnKeyPackages(
|
||||
* not the norm.
|
||||
*/
|
||||
suspend fun ensureLastResort(): String? {
|
||||
val existing = coordinator.listKeyPackages().filter { it.pubKey == accountPubKey && it.lastResort }
|
||||
val existing = refresh().mine.filter { it.lastResort }
|
||||
// Only one, and only one we can still open: a last-resort package whose
|
||||
// private half this device never had is useless to it.
|
||||
val usable = existing.firstOrNull { store.load(it.keyPackageRef) != null }
|
||||
@@ -292,5 +381,16 @@ class CordnKeyPackages(
|
||||
* last-resort package is the safety net.
|
||||
*/
|
||||
const val DEFAULT_POOL = 5
|
||||
|
||||
/**
|
||||
* How long a [Snapshot] may be reused by [snapshot].
|
||||
*
|
||||
* Bounded by what a refresh costs rather than by how fast the answer
|
||||
* changes: `kp_list` is unpaginated, so each one downloads the
|
||||
* coordinator's whole table. A minute is long enough that opening a
|
||||
* screen repeatedly costs one call and short enough that somebody who
|
||||
* has just published becomes visible while you are still looking.
|
||||
*/
|
||||
const val SNAPSHOT_TTL_SECONDS = 60L
|
||||
}
|
||||
}
|
||||
|
||||
+60
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.commons.cordn
|
||||
|
||||
import com.vitorpamplona.quartz.cordn.groups.CordnCredential
|
||||
import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage
|
||||
import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator
|
||||
import com.vitorpamplona.quartz.cordn.spec00Coordinator.KeyPackagePublication
|
||||
import com.vitorpamplona.quartz.mls.codec.TlsReader
|
||||
import com.vitorpamplona.quartz.mls.messages.KeyPackageBundleCodec
|
||||
@@ -335,4 +337,62 @@ class CordnKeyPackagesTest : CordnTransportHarness() {
|
||||
|
||||
assertTrue(driving { alice.keyPackages.listPublished() }.all { it.pubKey == alice.pubKey })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the identity set spans everybody, where listPublished is scoped to us`() =
|
||||
runTest {
|
||||
// The two read one `kp_list` response and keep different halves of
|
||||
// it. Scoping the identity set the way listPublished is scoped
|
||||
// would answer "can I add Bob" with our own packages, which is
|
||||
// always no.
|
||||
val alice = Account()
|
||||
val bob = Account()
|
||||
driving { alice.keyPackages.publishNew() }
|
||||
driving { bob.keyPackages.publishNew() }
|
||||
|
||||
val reachable = assertNotNull(driving { alice.keyPackages.identitiesWithKeyPackages() })
|
||||
assertTrue(alice.pubKey in reachable, "ourselves")
|
||||
assertTrue(bob.pubKey in reachable, "somebody else")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `publishing shows up straight away rather than at the end of the snapshot's life`() =
|
||||
runTest {
|
||||
// The snapshot is reused for a minute, so our own publish has to
|
||||
// drop it. Otherwise the account that just published a package is
|
||||
// told for the next minute that it has none.
|
||||
val alice = Account()
|
||||
assertTrue(driving { alice.keyPackages.identitiesWithKeyPackages() }?.contains(alice.pubKey) == false)
|
||||
|
||||
driving { alice.keyPackages.publishNew() }
|
||||
|
||||
assertTrue(driving { alice.keyPackages.identitiesWithKeyPackages() }?.contains(alice.pubKey) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a coordinator that cannot answer is unknown, not nobody`() =
|
||||
runTest {
|
||||
// The distinction the whole return type exists for. An empty set
|
||||
// says the coordinator holds nothing for anyone; null says we never
|
||||
// found out. A caller that folds them together renders a network
|
||||
// failure as a claim that somebody cannot be added.
|
||||
val alice = Account()
|
||||
driving { alice.keyPackages.publishNew() }
|
||||
|
||||
val blind =
|
||||
CordnKeyPackages(
|
||||
accountPubKey = alice.pubKey,
|
||||
coordinator = UnreachableList(alice.coordinatorClient),
|
||||
store = InMemoryCordnKeyPackageStore(),
|
||||
)
|
||||
|
||||
assertNull(driving { blind.identitiesWithKeyPackages() })
|
||||
}
|
||||
|
||||
/** A coordinator whose `kp_list` is the one call that fails. */
|
||||
private class UnreachableList(
|
||||
delegate: ICoordinator,
|
||||
) : ICoordinator by delegate {
|
||||
override suspend fun listKeyPackages(): List<AvailableKeyPackage> = throw IllegalStateException("kp_list unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user