mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
fix(cli): amy complies with a Kick against its account
The app leaves a community when an honored Kick names it (CORD-04 §6), but that check runs on its revision tick and amy has none, so a kicked amy kept the community and kept reading it. `channels`, `send` and `read` now fold the Guestbook against the roster and, when an honored Kick naming us postdates this membership, drop the community from the local store and stop with `kicked`. The store records `addedAt` on create, join and import, so a re-invite starts a new membership the old Kick no longer applies to. The rule itself moves to ConcordActions.honoredKickAgainst, shared with the app session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
87e2f5e945
commit
0ed87cd714
@@ -47,6 +47,7 @@ object ConcordChannelCommands {
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val state = foldState(ctx, sc)
|
||||
ConcordCommands.kickedGuard(ctx, dataDir, sc, state.authority)?.let { return it }
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"name" to state.metadata?.name,
|
||||
@@ -87,6 +88,7 @@ object ConcordChannelCommands {
|
||||
// CORD-02 §9: a dissolved community is sealed read-only — held keys still open history, but
|
||||
// nothing new is honored, so refuse to post before we ever build/publish a wrap.
|
||||
val state = foldState(ctx, sc)
|
||||
ConcordCommands.kickedGuard(ctx, dataDir, sc, state.authority)?.let { return it }
|
||||
if (state.dissolved) {
|
||||
return Output.error("dissolved", "community '$handle' has been dissolved and is read-only (CORD-02 §9)")
|
||||
}
|
||||
@@ -141,6 +143,7 @@ object ConcordChannelCommands {
|
||||
ctx.prepare()
|
||||
val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
|
||||
val state = foldState(ctx, sc)
|
||||
ConcordCommands.kickedGuard(ctx, dataDir, sc, state.authority)?.let { return it }
|
||||
// A Private Channel is read only on its own key's plane (CORD-03 §1); --root/--epoch pick a
|
||||
// root-derived plane and so apply to Public Channels only.
|
||||
val privatePlane =
|
||||
|
||||
@@ -42,6 +42,8 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.Guestbook
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring
|
||||
@@ -223,6 +225,7 @@ object ConcordCommands {
|
||||
controlRoot = community.controlRoot.toHexKey(),
|
||||
generalChannelId = community.generalChannelIdHex,
|
||||
relays = relays,
|
||||
addedAt = TimeUtils.nowMillis(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -313,6 +316,7 @@ object ConcordCommands {
|
||||
// unrecoverable, so a list entry without one must not clear ours.
|
||||
inviteRef = e.inviteRef ?: prior?.inviteRef ?: "",
|
||||
privateChannels = e.privateChannels.filter { it.key.isNotBlank() }.map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
|
||||
addedAt = maxOf(e.addedAt, prior?.addedAt ?: 0),
|
||||
),
|
||||
)
|
||||
mapOf(
|
||||
@@ -588,6 +592,8 @@ object ConcordCommands {
|
||||
// The stranded-recovery anchor; blank for a Direct Invite, which has no link.
|
||||
inviteRef = inviteRef,
|
||||
privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) },
|
||||
// A (re-)join starts a new membership: a Kick from before it no longer applies.
|
||||
addedAt = TimeUtils.nowMillis(),
|
||||
)
|
||||
ConcordStore(dataDir.concordFile).upsert(stored)
|
||||
|
||||
@@ -1134,6 +1140,43 @@ object ConcordCommands {
|
||||
return if (ctx.publish(event, relays).values.any { it.accepted }) merged else null
|
||||
}
|
||||
|
||||
/** Opens every Guestbook motion at [sc]'s current epoch (CORD-02 §5); empty when the plane can't be read. */
|
||||
suspend fun guestbookEntriesOf(
|
||||
ctx: Context,
|
||||
sc: StoredCommunity,
|
||||
): List<GuestbookEntry> =
|
||||
runCatching {
|
||||
val gb = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
|
||||
val relays = relaysFor(ctx, sc)
|
||||
ctx.registerConcordStreamKeys(relays, listOf(gb.secretKey))
|
||||
ctx
|
||||
.drain(relays.associateWith { listOf(ConcordActions.planeFilter(gb.publicKeyHex)) }, pendingOnAuthRequired = true)
|
||||
.mapNotNull { ConcordActions.guestbookEntry(it.second, gb) }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/**
|
||||
* Compliance with a Kick against this account (CORD-04 §6). Amethyst drains this on its
|
||||
* revision tick; amy has no tick, so a command that folds the community is the moment to
|
||||
* check. When the Guestbook, coalesced against [authority], carries an honored Kick naming us
|
||||
* that postdates this membership, the community is dropped locally (as a Leave does) and the
|
||||
* command stops with `kicked`. A re-invite re-joins. An unreadable Guestbook is no verdict.
|
||||
*/
|
||||
suspend fun kickedGuard(
|
||||
ctx: Context,
|
||||
dataDir: DataDir,
|
||||
sc: StoredCommunity,
|
||||
authority: AuthorityResolver,
|
||||
): Int? {
|
||||
val coalesced = Guestbook.coalesce(guestbookEntriesOf(ctx, sc), TimeUtils.nowMillis(), authority)
|
||||
val kick = ConcordActions.honoredKickAgainst(coalesced, ctx.signer.pubKey, sc.owner, sc.addedAt) ?: return null
|
||||
ConcordStore(dataDir.concordFile).remove(sc.communityId)
|
||||
return Output.error(
|
||||
"kicked",
|
||||
"${kick.author} kicked this account from '${sc.name}' (CORD-04 §6), so it left the community locally; a new invite re-joins",
|
||||
mapOf("community_id" to sc.communityId, "kicked_by" to kick.author),
|
||||
)
|
||||
}
|
||||
|
||||
fun notFound(handle: String): Int {
|
||||
Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`")
|
||||
return 1
|
||||
|
||||
@@ -740,14 +740,7 @@ object ConcordModCommands {
|
||||
private suspend fun guestbookMembersOf(
|
||||
ctx: Context,
|
||||
sc: StoredCommunity,
|
||||
): Set<String> =
|
||||
runCatching {
|
||||
val gb = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch)
|
||||
val relays = ConcordCommands.relaysFor(ctx, sc)
|
||||
ctx.registerConcordStreamKeys(relays, listOf(gb.secretKey))
|
||||
val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(gb.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second }
|
||||
ConcordActions.guestbookMembers(wraps, gb).mapTo(HashSet()) { it.lowercase() }
|
||||
}.getOrDefault(emptySet())
|
||||
): Set<String> = ConcordActions.projectGuestbook(ConcordCommands.guestbookEntriesOf(ctx, sc)).mapTo(HashSet()) { it.lowercase() }
|
||||
|
||||
/**
|
||||
* Authors of every channel message we can decrypt. Most members never send a Guestbook motion,
|
||||
|
||||
@@ -67,6 +67,10 @@ data class StoredCommunity(
|
||||
// Keys reserved for a Refounding this account started but has not yet adopted (CORD-06 §3): a
|
||||
// retried `refound` must re-deliver the SAME root, never mint a sibling that splits the members.
|
||||
val pendingRefounding: StoredPendingRefounding? = null,
|
||||
// When this membership began (ms, the Community List's `added_at`). A Kick older than this
|
||||
// judged an earlier membership that a re-join put behind us (CORD-04 §6). 0 = unknown, stored
|
||||
// before amy tracked it: then any honored Kick that is still our latest Guestbook motion counts.
|
||||
val addedAt: Long = 0,
|
||||
)
|
||||
|
||||
/** A held Private Channel key at its channel epoch, mirroring quartz `PrivateChannelKey`. */
|
||||
@@ -122,6 +126,9 @@ class ConcordStore(
|
||||
save(next)
|
||||
}
|
||||
|
||||
/** Drop [communityId] from the list, as a Leave or a complied-with Kick does locally. */
|
||||
fun remove(communityId: String) = save(load().filterNot { it.communityId == communityId })
|
||||
|
||||
/** Resolve a user-supplied handle: exact name, exact id, or a unique id/name prefix. */
|
||||
fun find(handle: String): StoredCommunity? {
|
||||
val all = load()
|
||||
|
||||
+16
@@ -948,6 +948,22 @@ object ConcordActions {
|
||||
?.rumor
|
||||
?.let { Guestbook.parse(it) }
|
||||
|
||||
/**
|
||||
* The honored Kick naming [me] in a [coalesced] Guestbook (CORD-04 §6), or null. Only a Kick
|
||||
* newer than [addedAtMs] — when this membership began — counts: an older one judged an earlier
|
||||
* membership that a re-join has already put behind us. The owner is never kicked.
|
||||
*/
|
||||
fun honoredKickAgainst(
|
||||
coalesced: Map<HexKey, GuestbookEntry>,
|
||||
me: HexKey,
|
||||
owner: HexKey,
|
||||
addedAtMs: Long,
|
||||
): GuestbookEntry? {
|
||||
val self = me.lowercase()
|
||||
if (self == owner.lowercase()) return null
|
||||
return coalesced[self]?.takeIf { it.action == GuestbookAction.KICK && it.ms > addedAtMs }
|
||||
}
|
||||
|
||||
/**
|
||||
* The CORD-02 §5 coalesce of already-opened [entries] (latest motion per npub, Kicks honored
|
||||
* against [authority]) down to the JOINed member set.
|
||||
|
||||
+1
-6
@@ -455,12 +455,7 @@ class ConcordCommunitySession(
|
||||
* A Kick older than the entry's `added_at` judged an earlier membership — a re-join (a later
|
||||
* Join, or a re-invite that re-added the entry) leaves it behind. Never for the owner.
|
||||
*/
|
||||
fun kickedMe(): GuestbookEntry? {
|
||||
val me = myPubKey.lowercase()
|
||||
if (me == entry.owner.lowercase()) return null
|
||||
val mine = _guestbook.value[me] ?: return null
|
||||
return mine.takeIf { it.action == GuestbookAction.KICK && it.ms > entry.addedAt }
|
||||
}
|
||||
fun kickedMe(): GuestbookEntry? = ConcordActions.honoredKickAgainst(_guestbook.value, myPubKey, entry.owner, entry.addedAt)
|
||||
|
||||
/** The size of [allMembers] — the community's true (best-effort) member count. */
|
||||
fun memberCount(): Int = allMembers().size
|
||||
|
||||
+19
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
@@ -185,4 +186,22 @@ class ConcordKickTest {
|
||||
val byOwner = ConcordActions.buildGuestbookKick(owner, gb, target.pubKey, citation = null, createdAt = 10L)
|
||||
assertNotNull(session(c, target.pubKey, w.controlWraps + join + byOwner).kickedMe())
|
||||
}
|
||||
|
||||
/** The shared rule both the app session and amy apply (CORD-04 §6). */
|
||||
@Test
|
||||
fun honoredKickAgainstOnlyCountsAKickNewerThanTheMembership() {
|
||||
val me = "a".repeat(64)
|
||||
val owner = "b".repeat(64)
|
||||
val kick = GuestbookEntry(member = me, action = GuestbookAction.KICK, createdAt = 100, inviteCreator = null, inviteLabel = null, author = owner)
|
||||
val coalesced = mapOf(me to kick)
|
||||
|
||||
assertNotNull(ConcordActions.honoredKickAgainst(coalesced, me.uppercase(), owner, addedAtMs = 0))
|
||||
assertNotNull(ConcordActions.honoredKickAgainst(coalesced, me, owner, addedAtMs = 99_999))
|
||||
// Re-joined after the Kick: it judged the earlier membership.
|
||||
assertNull(ConcordActions.honoredKickAgainst(coalesced, me, owner, addedAtMs = 100_000))
|
||||
// The owner is never kicked; a latest motion that is a Join is not a Kick.
|
||||
assertNull(ConcordActions.honoredKickAgainst(mapOf(owner to kick), owner, owner, addedAtMs = 0))
|
||||
val join = GuestbookEntry(member = me, action = GuestbookAction.JOIN, createdAt = 200, inviteCreator = null, inviteLabel = null)
|
||||
assertNull(ConcordActions.honoredKickAgainst(mapOf(me to join), me, owner, addedAtMs = 0))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user